@capgo/cordova-updater 8.1.1

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 (76) hide show
  1. package/LICENSE +373 -0
  2. package/README.md +82 -0
  3. package/dist/esm/definitions.d.ts +2684 -0
  4. package/dist/esm/definitions.js +109 -0
  5. package/dist/esm/definitions.js.map +1 -0
  6. package/dist/esm/exec.d.ts +4 -0
  7. package/dist/esm/exec.js +25 -0
  8. package/dist/esm/exec.js.map +1 -0
  9. package/dist/esm/history.d.ts +1 -0
  10. package/dist/esm/history.js +283 -0
  11. package/dist/esm/history.js.map +1 -0
  12. package/dist/esm/index.d.ts +57 -0
  13. package/dist/esm/index.js +68 -0
  14. package/dist/esm/index.js.map +1 -0
  15. package/dist/esm/plugin.d.ts +57 -0
  16. package/dist/esm/plugin.js +15 -0
  17. package/dist/esm/plugin.js.map +1 -0
  18. package/dist/plugin.cjs.js +217 -0
  19. package/dist/plugin.cjs.js.map +1 -0
  20. package/dist/plugin.js +222 -0
  21. package/dist/plugin.js.map +1 -0
  22. package/package.json +96 -0
  23. package/plugin.xml +207 -0
  24. package/src/android/CordovaUpdaterPlugin.java +5662 -0
  25. package/src/android/UpdaterPathHandler.java +99 -0
  26. package/src/android/app/capgo/cordova/updater/AndroidAppExitReporter.java +92 -0
  27. package/src/android/app/capgo/cordova/updater/AppLifecycleObserver.java +115 -0
  28. package/src/android/app/capgo/cordova/updater/BundleInfo.java +246 -0
  29. package/src/android/app/capgo/cordova/updater/BundleStatus.java +45 -0
  30. package/src/android/app/capgo/cordova/updater/Callback.java +13 -0
  31. package/src/android/app/capgo/cordova/updater/CapgoUpdater.java +3404 -0
  32. package/src/android/app/capgo/cordova/updater/CordovaUpdaterConfig.java +56 -0
  33. package/src/android/app/capgo/cordova/updater/CryptoCipher.java +462 -0
  34. package/src/android/app/capgo/cordova/updater/DataManager.java +44 -0
  35. package/src/android/app/capgo/cordova/updater/DelayCondition.java +56 -0
  36. package/src/android/app/capgo/cordova/updater/DelayUntilNext.java +14 -0
  37. package/src/android/app/capgo/cordova/updater/DelayUpdateUtils.java +307 -0
  38. package/src/android/app/capgo/cordova/updater/DeviceIdHelper.java +230 -0
  39. package/src/android/app/capgo/cordova/updater/DownloadService.java +1219 -0
  40. package/src/android/app/capgo/cordova/updater/DownloadWorkerManager.java +260 -0
  41. package/src/android/app/capgo/cordova/updater/InternalUtils.java +54 -0
  42. package/src/android/app/capgo/cordova/updater/Logger.java +345 -0
  43. package/src/android/app/capgo/cordova/updater/ShakeDetector.java +72 -0
  44. package/src/android/app/capgo/cordova/updater/ShakeMenu.java +39 -0
  45. package/src/android/app/capgo/cordova/updater/ThreeFingerPinchDetector.java +323 -0
  46. package/src/android/app/capgo/cordova/updater/compat/JSArray.java +9 -0
  47. package/src/android/app/capgo/cordova/updater/compat/JSObject.java +83 -0
  48. package/src/android/app/capgo/cordova/updater/compat/PluginCall.java +165 -0
  49. package/src/android/capgo-cordova-updater.gradle +19 -0
  50. package/src/android/capgo-updater.xml +3 -0
  51. package/src/android/test/app/capgo/cordova/updater/CapacitorUpdaterUnitTest.java.skip +3039 -0
  52. package/src/android/test/app/capgo/cordova/updater/DelayUpdateUtilsTest.java +177 -0
  53. package/src/android/test/app/capgo/cordova/updater/NativeContractTest.java +105 -0
  54. package/src/ios/AES.swift +216 -0
  55. package/src/ios/AppHealthTracker.swift +82 -0
  56. package/src/ios/BundleInfo.swift +185 -0
  57. package/src/ios/BundleStatus.swift +127 -0
  58. package/src/ios/CapgoRawRsa.swift +36 -0
  59. package/src/ios/CapgoUpdater.swift +3544 -0
  60. package/src/ios/CordovaCompat.swift +11 -0
  61. package/src/ios/CordovaPluginCall.swift +190 -0
  62. package/src/ios/CordovaUpdaterCommands.swift +270 -0
  63. package/src/ios/CordovaUpdaterPlugin.swift +4706 -0
  64. package/src/ios/CryptoCipher.swift +326 -0
  65. package/src/ios/DelayCondition.swift +74 -0
  66. package/src/ios/DelayUntilNext.swift +30 -0
  67. package/src/ios/DelayUpdateUtils.swift +257 -0
  68. package/src/ios/DeviceIdHelper.swift +120 -0
  69. package/src/ios/Info.plist +28 -0
  70. package/src/ios/InternalUtils.swift +471 -0
  71. package/src/ios/Logger.swift +283 -0
  72. package/src/ios/RSA.swift +96 -0
  73. package/src/ios/ShakeMenu.swift +52 -0
  74. package/src/ios/UpdaterSchemeHandler.swift +81 -0
  75. package/src/ios/UserDefaultsExtension.swift +46 -0
  76. package/src/ios/WebViewStatsReporter.swift +303 -0
@@ -0,0 +1,3544 @@
1
+ /*
2
+ * This Source Code Form is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
5
+ */
6
+
7
+ import Foundation
8
+ import ZIPFoundation
9
+ import Alamofire
10
+ import Compression
11
+ import UIKit
12
+
13
+ @objc public class CapgoUpdater: NSObject {
14
+ private var logger: Logger!
15
+
16
+ private let versionCode: String = Bundle.main.versionCode ?? ""
17
+ private let versionOs = UIDevice.current.systemVersion
18
+ private let libraryDir: URL = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first!
19
+ private let DEFAULT_FOLDER: String = ""
20
+ private let bundleDirectory: String = "NoCloud/ionic_built_snapshots"
21
+ private let INFO_SUFFIX: String = "_info"
22
+ private let FALLBACK_VERSION: String = "pastVersion"
23
+ private let NEXT_VERSION: String = "nextVersion"
24
+ private let PREVIEW_FALLBACK_VERSION: String = "previewFallbackVersion"
25
+ private let PENDING_DELETE_IDS: String = "pendingDeleteIds"
26
+ private var unzipPercent = 0
27
+ private let TEMP_UNZIP_PREFIX: String = "capgo_unzip_"
28
+ /// HTTP + decode share one pool. Cap by CPU: 8 on 4 cores, 16 on 8 cores, 64 max.
29
+ static let manifestMaxConcurrentFiles = clampedManifestConcurrency(processorCount: ProcessInfo.processInfo.processorCount)
30
+
31
+ static func clampedManifestConcurrency(processorCount: Int) -> Int {
32
+ min(64, max(8, max(1, processorCount) * 2))
33
+ }
34
+ private static let emptySha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
35
+ private let deletePaceSeconds: TimeInterval = 0.075
36
+ private let deleteLock = NSLock()
37
+
38
+ // Add this line to declare cacheFolder
39
+ private let cacheFolder: URL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!.appendingPathComponent("capgo_downloads")
40
+
41
+ public let CAP_SERVER_PATH: String = "serverBasePath"
42
+ public var versionBuild: String = ""
43
+ public var customId: String = ""
44
+ public var pluginVersion: String = ""
45
+ public var timeout: Double = 20
46
+ public var statsUrl: String = ""
47
+ /// Optional gate run before any download touches disk (e.g. wait for launch cleanup).
48
+ public var beforeDownload: (() throws -> Void)?
49
+ public var channelUrl: String = ""
50
+ public var defaultChannel: String = ""
51
+ public var appId: String = ""
52
+ public var deviceID = ""
53
+ public var previewSession = false
54
+ public var publicKey: String = ""
55
+
56
+ // Cached key ID calculated once from publicKey
57
+ private var cachedKeyId: String?
58
+
59
+ // Temporary 429 block until this epoch ms (Retry-After / rateLimitResetAt). No sticky latch.
60
+ // Guarded by rateLimitStateLock so concurrent 429s cannot shorten the window or mix metadata.
61
+ private static let rateLimitStateLock = NSLock()
62
+ private static var rateLimitBlockedUntilMs: Double = 0
63
+ private static var rateLimitBlockedError: String = "too_many_requests"
64
+ private static var rateLimitBlockedMessage: String = "Too many requests"
65
+
66
+ // Flag to track if we've already sent the rate limit statistic - prevents infinite loop.
67
+ // Released again when the send fails, so a later 429 can retry it.
68
+ private static var rateLimitStatisticSent = false
69
+
70
+ // Upper bound for a client-side 429 block, so a bogus Retry-After cannot block the app for days.
71
+ private static let maxRateLimitWindowMs: Double = 24 * 60 * 60 * 1000
72
+
73
+ // Stats batching - queue events and send max once per second
74
+ private var statsQueue: [QueuedStatsEvent] = []
75
+ private var statsInFlight: [QueuedStatsEvent] = []
76
+ private let statsQueueLock = NSLock()
77
+ private let statsPersistLock = NSLock()
78
+ private let statsStateLock = NSLock()
79
+ private var statsFlushTimer: Timer?
80
+ private var _statsStopped = false
81
+ private var statsStopped: Bool {
82
+ get {
83
+ statsStateLock.lock()
84
+ defer { statsStateLock.unlock() }
85
+ return _statsStopped
86
+ }
87
+ set {
88
+ statsStateLock.lock()
89
+ defer { statsStateLock.unlock() }
90
+ _statsStopped = newValue
91
+ }
92
+ }
93
+ private static let statsFlushInterval: TimeInterval = 1.0
94
+ private static let maxPendingStats = 200
95
+ private let pendingStatsFileName = "capgo_pending_stats.json"
96
+
97
+ private struct QueuedStatsEvent {
98
+ let event: StatsEvent
99
+ let onSent: (() -> Void)?
100
+ }
101
+
102
+ private static func sanitizeHeaderValue(_ value: String) -> String {
103
+ if value.isEmpty {
104
+ return "unknown"
105
+ }
106
+
107
+ let filteredScalars = value.unicodeScalars.filter { scalar in
108
+ let cp = scalar.value
109
+ let isVisibleAscii = (0x20...0x7E).contains(cp)
110
+ let isIso88591 = (0xA0...0xFF).contains(cp)
111
+ return isVisibleAscii || isIso88591
112
+ }
113
+
114
+ let sanitized = String(String.UnicodeScalarView(filteredScalars)).trimmingCharacters(in: .whitespacesAndNewlines)
115
+ return sanitized.isEmpty ? "unknown" : sanitized
116
+ }
117
+
118
+ static func buildUserAgent(appId: String, pluginVersion: String, versionOs: String) -> String {
119
+ let safePluginVersion = sanitizeHeaderValue(pluginVersion)
120
+ let safeAppId = sanitizeHeaderValue(appId)
121
+ let safeVersionOs = sanitizeHeaderValue(versionOs)
122
+ return "CapacitorUpdater/\(safePluginVersion) (\(safeAppId)) ios/\(safeVersionOs)"
123
+ }
124
+
125
+ private var userAgent: String {
126
+ CapgoUpdater.buildUserAgent(appId: appId, pluginVersion: pluginVersion, versionOs: versionOs)
127
+ }
128
+
129
+ struct RequestResult {
130
+ let data: Data?
131
+ let response: HTTPURLResponse?
132
+ let error: Error?
133
+ let timedOut: Bool
134
+ }
135
+
136
+ private struct DownloadRequestResult {
137
+ let fileURL: URL?
138
+ let response: HTTPURLResponse?
139
+ let error: Error?
140
+ let timedOut: Bool
141
+ }
142
+
143
+ enum SecurePathError: Error {
144
+ case emptyPath
145
+ case windowsPath
146
+ case absolutePath
147
+ case pathTraversal
148
+ }
149
+
150
+ static func resolvePathInsideDirectory(baseDirectory: URL, relativePath: String) throws -> URL {
151
+ if relativePath.isEmpty {
152
+ throw SecurePathError.emptyPath
153
+ }
154
+ if relativePath.contains("\\") || relativePath.contains("\0") {
155
+ throw SecurePathError.windowsPath
156
+ }
157
+ if (relativePath as NSString).isAbsolutePath {
158
+ throw SecurePathError.absolutePath
159
+ }
160
+
161
+ let canonicalBase = baseDirectory.standardizedFileURL
162
+ let canonicalBasePath = canonicalBase.path
163
+ let normalizedBasePath = canonicalBasePath.hasSuffix("/") ? canonicalBasePath : "\(canonicalBasePath)/"
164
+ let canonicalTarget = canonicalBase.appendingPathComponent(relativePath).standardizedFileURL
165
+ let canonicalTargetPath = canonicalTarget.path
166
+
167
+ if canonicalTargetPath != canonicalBasePath && !canonicalTargetPath.hasPrefix(normalizedBasePath) {
168
+ throw SecurePathError.pathTraversal
169
+ }
170
+
171
+ return canonicalTarget
172
+ }
173
+
174
+ static func resolveManifestTargetPath(baseDirectory: URL, fileName: String) throws -> URL {
175
+ let isBrotli = fileName.hasSuffix(".br")
176
+ let targetFileName = isBrotli ? String(fileName.dropLast(3)) : fileName
177
+ return try resolvePathInsideDirectory(baseDirectory: baseDirectory, relativePath: targetFileName)
178
+ }
179
+
180
+ private func isTimedOutError(_ error: Error?) -> Bool {
181
+ guard let nsError = error as NSError? else {
182
+ return false
183
+ }
184
+
185
+ return nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorTimedOut
186
+ }
187
+
188
+ private lazy var alamofireSession: Session = {
189
+ let configuration = URLSessionConfiguration.ephemeral
190
+ configuration.httpAdditionalHeaders = ["User-Agent": self.userAgent]
191
+ configuration.httpCookieStorage = nil
192
+ configuration.httpShouldSetCookies = false
193
+ configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
194
+ configuration.urlCache = nil
195
+ configuration.httpMaximumConnectionsPerHost = Self.manifestMaxConcurrentFiles
196
+ return Session(configuration: configuration)
197
+ }()
198
+ private let networkResponseQueue = DispatchQueue(label: "ee.forgr.capacitor-updater.network-response", qos: .utility)
199
+
200
+ public var notifyDownloadRaw: (String, Int, Bool, BundleInfo?) -> Void = { _, _, _, _ in }
201
+ public func notifyDownload(id: String, percent: Int, ignoreMultipleOfTen: Bool = false, bundle: BundleInfo? = nil) {
202
+ let emit = {
203
+ self.notifyDownloadRaw(id, percent, ignoreMultipleOfTen, bundle)
204
+ }
205
+ if Thread.isMainThread {
206
+ emit()
207
+ } else {
208
+ DispatchQueue.main.async {
209
+ emit()
210
+ }
211
+ }
212
+ }
213
+ public var notifyDownload: (String, Int) -> Void = { _, _ in }
214
+ public var notifyListeners: (String, [String: Any]) -> Void = { _, _ in }
215
+
216
+ public func setLogger(_ logger: Logger) {
217
+ self.logger = logger
218
+ }
219
+
220
+ private func createRequest(url: URL, method: String, parameters: [String: Any]? = nil, expectsJSONResponse: Bool = false) -> URLRequest? {
221
+ var request = URLRequest(url: url)
222
+ request.httpMethod = method
223
+ request.timeoutInterval = self.timeout
224
+ request.setValue(self.userAgent, forHTTPHeaderField: "User-Agent")
225
+ if expectsJSONResponse || parameters != nil {
226
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
227
+ }
228
+
229
+ guard let parameters else {
230
+ return request
231
+ }
232
+
233
+ guard JSONSerialization.isValidJSONObject(parameters) else {
234
+ logger.error("Invalid JSON body for \(method) \(url.absoluteString)")
235
+ return nil
236
+ }
237
+
238
+ do {
239
+ request.httpBody = try JSONSerialization.data(withJSONObject: parameters)
240
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
241
+ return request
242
+ } catch {
243
+ logger.error("Error encoding request body for \(method) \(url.absoluteString)")
244
+ logger.debug("Error: \(error.localizedDescription)")
245
+ return nil
246
+ }
247
+ }
248
+
249
+ func performRequest(_ request: URLRequest, label: String) -> RequestResult {
250
+ let waitTimeout = max(self.timeout + 5, 10)
251
+ let semaphore = DispatchSemaphore(value: 0)
252
+ var responseData: Data?
253
+ var httpResponse: HTTPURLResponse?
254
+ var requestError: Error?
255
+ let dataRequest = self.alamofireSession.request(request).responseData(queue: self.networkResponseQueue) { response in
256
+ responseData = response.data
257
+ httpResponse = response.response
258
+ requestError = response.error
259
+ semaphore.signal()
260
+ }
261
+ dataRequest.resume()
262
+
263
+ if semaphore.wait(timeout: .now() + waitTimeout) == .timedOut {
264
+ dataRequest.cancel()
265
+ logger.error("\(label) timed out after \(Int(waitTimeout))s")
266
+ return RequestResult(data: responseData, response: httpResponse, error: requestError, timedOut: true)
267
+ }
268
+
269
+ return RequestResult(data: responseData, response: httpResponse, error: requestError, timedOut: false)
270
+ }
271
+
272
+ private func performDownloadRequest(_ request: URLRequest, label: String) -> DownloadRequestResult {
273
+ let waitTimeout = max(self.timeout + 5, 10)
274
+ let semaphore = DispatchSemaphore(value: 0)
275
+ var tempFileURL: URL?
276
+ var httpResponse: HTTPURLResponse?
277
+ var requestError: Error?
278
+ let temporaryDownloadURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
279
+ let destination: DownloadRequest.Destination = { _, _ in
280
+ (temporaryDownloadURL, [.removePreviousFile, .createIntermediateDirectories])
281
+ }
282
+ let downloadRequest = self.alamofireSession.download(request, to: destination).response(queue: self.networkResponseQueue) { response in
283
+ tempFileURL = response.fileURL
284
+ httpResponse = response.response
285
+ requestError = response.error
286
+ semaphore.signal()
287
+ }
288
+ downloadRequest.resume()
289
+
290
+ if semaphore.wait(timeout: .now() + waitTimeout) == .timedOut {
291
+ downloadRequest.cancel()
292
+ logger.error("\(label) timed out after \(Int(waitTimeout))s")
293
+ return DownloadRequestResult(
294
+ fileURL: existingDownloadFileURL(tempFileURL, fallback: temporaryDownloadURL),
295
+ response: httpResponse,
296
+ error: requestError,
297
+ timedOut: true
298
+ )
299
+ }
300
+
301
+ if isTimedOutError(requestError) {
302
+ logger.error("\(label) timed out after \(Int(waitTimeout))s")
303
+ }
304
+
305
+ return DownloadRequestResult(
306
+ fileURL: existingDownloadFileURL(tempFileURL, fallback: temporaryDownloadURL),
307
+ response: httpResponse,
308
+ error: requestError,
309
+ timedOut: isTimedOutError(requestError)
310
+ )
311
+ }
312
+
313
+ private func existingDownloadFileURL(_ fileURL: URL?, fallback: URL) -> URL? {
314
+ let fileManager = FileManager.default
315
+ if let fileURL, fileManager.fileExists(atPath: fileURL.path) {
316
+ return fileURL
317
+ }
318
+ return fileManager.fileExists(atPath: fallback.path) ? fallback : nil
319
+ }
320
+
321
+ static func shouldAppendHttpBody(statusCode: Int, existingBytes: Int64) -> Bool {
322
+ existingBytes > 0 && statusCode == 206
323
+ }
324
+
325
+ static func safePartialToken(_ fileName: String) -> String {
326
+ CryptoCipher.shortPathKey(fileName)
327
+ }
328
+
329
+ static func manifestPartialURL(cacheFolder: URL, hash: String, fileName: String) -> URL {
330
+ let token = safePartialToken(fileName)
331
+ if isSafeCacheHash(hash) && hash.count == 64 {
332
+ return cacheFolder.appendingPathComponent("partial_\(hash)_\(token).tmp")
333
+ }
334
+ let digest = CryptoCipher.shortPathKey("\(hash)|\(fileName)")
335
+ return cacheFolder.appendingPathComponent("partial_\(digest)_\(token).tmp")
336
+ }
337
+
338
+ private func cleanupOldManifestPartials() {
339
+ let cutoff = Date().addingTimeInterval(-3600)
340
+ guard let files = try? FileManager.default.contentsOfDirectory(
341
+ at: cacheFolder,
342
+ includingPropertiesForKeys: [.contentModificationDateKey],
343
+ options: [.skipsHiddenFiles]
344
+ ) else {
345
+ return
346
+ }
347
+ for url in files {
348
+ let name = url.lastPathComponent
349
+ guard name.hasPrefix("partial_") && name.hasSuffix(".tmp") else {
350
+ continue
351
+ }
352
+ let modified = (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
353
+ if modified < cutoff {
354
+ try? FileManager.default.removeItem(at: url)
355
+ }
356
+ }
357
+ }
358
+
359
+ func storeDownloadedFile(_ downloadedFileURL: URL, at tempPath: URL, existingBytes: Int64, response: HTTPURLResponse?) throws {
360
+ let fileManager = FileManager.default
361
+ if Self.shouldAppendHttpBody(statusCode: response?.statusCode ?? 0, existingBytes: existingBytes) ||
362
+ (existingBytes > 0 && response == nil) {
363
+ let fileHandle = try FileHandle(forWritingTo: tempPath)
364
+ defer {
365
+ try? fileHandle.close()
366
+ }
367
+ fileHandle.seek(toFileOffset: UInt64(existingBytes))
368
+ let input = try FileHandle(forReadingFrom: downloadedFileURL)
369
+ defer {
370
+ try? input.close()
371
+ }
372
+ let chunkSize = CryptoCipher.copyBufferBytes()
373
+ while true {
374
+ let done: Bool = try autoreleasepool {
375
+ let chunk = try input.read(upToCount: chunkSize) ?? Data()
376
+ if chunk.isEmpty {
377
+ return true
378
+ }
379
+ fileHandle.write(chunk)
380
+ return false
381
+ }
382
+ if done {
383
+ break
384
+ }
385
+ }
386
+ try? fileManager.removeItem(at: downloadedFileURL)
387
+ return
388
+ }
389
+
390
+ if fileManager.fileExists(atPath: tempPath.path) {
391
+ try fileManager.removeItem(at: tempPath)
392
+ }
393
+ try fileManager.moveItem(at: downloadedFileURL, to: tempPath)
394
+ }
395
+
396
+ private func persistPartialDownload(_ downloadResult: DownloadRequestResult, id: String, tempPath: URL, existingBytes: Int64) {
397
+ guard let downloadedFileURL = downloadResult.fileURL else {
398
+ return
399
+ }
400
+ guard FileManager.default.fileExists(atPath: downloadedFileURL.path) else {
401
+ return
402
+ }
403
+ if let statusCode = downloadResult.response?.statusCode, statusCode < 200 || statusCode >= 300 {
404
+ return
405
+ }
406
+
407
+ do {
408
+ try storeDownloadedFile(downloadedFileURL, at: tempPath, existingBytes: existingBytes, response: downloadResult.response)
409
+ logger.info("Stored partial download for retry")
410
+ } catch {
411
+ logger.error("Failed to store partial download")
412
+ logger.debug("Path: \(downloadedFileURL.path), Error: \(error)")
413
+ }
414
+ }
415
+
416
+ deinit {
417
+ shutdown()
418
+ }
419
+
420
+ public func shutdown() {
421
+ statsStopped = true
422
+ statsFlushTimer?.invalidate()
423
+ statsFlushTimer = nil
424
+ persistStatsQueue(force: true)
425
+ }
426
+
427
+ private func calcTotalPercent(percent: Int, min: Int, max: Int) -> Int {
428
+ return (percent * (max - min)) / 100 + min
429
+ }
430
+
431
+ private func randomString(length: Int) -> String {
432
+ let letters: String = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
433
+ return String((0..<length).map { _ in letters.randomElement()! })
434
+ }
435
+
436
+ public func setPublicKey(_ publicKey: String) {
437
+ // Empty string means no encryption - proceed normally
438
+ if publicKey.isEmpty {
439
+ self.publicKey = ""
440
+ self.cachedKeyId = nil
441
+ return
442
+ }
443
+
444
+ // Non-empty: must be a valid RSA key or crash
445
+ guard RSAPublicKey.load(rsaPublicKey: publicKey) != nil else {
446
+ fatalError("Invalid public key in capacitor.config.json: failed to parse RSA key. Remove the key or provide a valid PEM-formatted RSA public key.")
447
+ }
448
+
449
+ self.publicKey = publicKey
450
+ self.cachedKeyId = CryptoCipher.calcKeyId(publicKey: publicKey)
451
+ }
452
+
453
+ public func getKeyId() -> String? {
454
+ return self.cachedKeyId
455
+ }
456
+
457
+ private var isDevEnvironment: Bool {
458
+ #if DEBUG
459
+ return true
460
+ #else
461
+ return false
462
+ #endif
463
+ }
464
+
465
+ private func isProd() -> Bool {
466
+ return !self.isDevEnvironment && !self.isAppStoreReceiptSandbox() && !self.hasEmbeddedMobileProvision()
467
+ }
468
+
469
+ private func installSource() -> String? {
470
+ if isEmulator() || self.isDevEnvironment || self.hasEmbeddedMobileProvision() {
471
+ return nil
472
+ }
473
+ guard let receiptURL = Bundle.main.appStoreReceiptURL else {
474
+ return nil
475
+ }
476
+ if receiptURL.lastPathComponent == "sandboxReceipt" {
477
+ return "testflight"
478
+ }
479
+ guard FileManager.default.fileExists(atPath: receiptURL.path) else {
480
+ return nil
481
+ }
482
+ return "app_store"
483
+ }
484
+
485
+ /**
486
+ * Checks if there is sufficient disk space for a download.
487
+ * Matches Android behavior: 2x safety margin, throws "insufficient_disk_space"
488
+ * - Parameter estimatedSize: The estimated size of the download in bytes. Defaults to 50MB.
489
+ */
490
+ private func checkDiskSpace(estimatedSize: Int64 = 50 * 1024 * 1024) throws {
491
+ let fileManager = FileManager.default
492
+ guard let documentDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
493
+ return
494
+ }
495
+
496
+ do {
497
+ let attributes = try fileManager.attributesOfFileSystem(forPath: documentDirectory.path)
498
+ guard let freeSpace = attributes[.systemFreeSize] as? Int64 else {
499
+ logger.warn("Could not determine free disk space, proceeding with download")
500
+ return
501
+ }
502
+
503
+ let requiredSpace = estimatedSize * 2 // 2x safety margin like Android
504
+
505
+ if freeSpace < requiredSpace {
506
+ logger.error("Insufficient disk space. Available: \(freeSpace), Required: \(requiredSpace)")
507
+ self.sendStats(action: "insufficient_disk_space")
508
+ throw CustomError.insufficientDiskSpace
509
+ }
510
+ } catch let error as CustomError {
511
+ throw error
512
+ } catch {
513
+ logger.warn("Error checking disk space: \(error.localizedDescription)")
514
+ }
515
+ }
516
+
517
+ private struct RemoteBlockResult {
518
+ let blocked: Bool
519
+ let error: String
520
+ let message: String
521
+ }
522
+
523
+ /**
524
+ * Handle HTTP 429 responses by honouring Retry-After / rateLimitResetAt.
525
+ * All 429s use the same temporary client block — no sticky latch until restart.
526
+ */
527
+ private func checkAndHandleRateLimitResponse(
528
+ statusCode: Int?,
529
+ data: Data? = nil,
530
+ response: HTTPURLResponse? = nil
531
+ ) -> RemoteBlockResult {
532
+ guard statusCode == 429 else {
533
+ return RemoteBlockResult(blocked: false, error: "", message: "")
534
+ }
535
+
536
+ let parsed = parseRemoteError(from: data)
537
+ let errorCode = parsed.error.isEmpty ? "too_many_requests" : parsed.error
538
+ let message = parsed.message.isEmpty ? "Too many requests" : parsed.message
539
+
540
+ let retryUntilMs = resolveRateLimitBlockedUntilMs(data: data, response: response)
541
+ CapgoUpdater.recordRateLimitBlock(untilMs: retryUntilMs, error: errorCode, message: message)
542
+
543
+ // Claim last, and only when there is somewhere to send it, so a 429 burst with no
544
+ // stats URL does not claim and release the latch once per response.
545
+ if errorCode == "too_many_requests" && !previewSession && !statsUrl.isEmpty && CapgoUpdater.claimRateLimitStatistic() {
546
+ DispatchQueue.global(qos: .utility).async {
547
+ self.sendRateLimitStatistic()
548
+ }
549
+ }
550
+
551
+ let nowMs = Date().timeIntervalSince1970 * 1000
552
+ let retryAfter = CapgoUpdater.retryAfterSecondsForLog(untilMs: retryUntilMs, nowMs: nowMs)
553
+ logger.warn("Received 429 (\(errorCode)). Honouring Retry-After: \(retryAfter)s.")
554
+ return RemoteBlockResult(blocked: true, error: errorCode, message: message)
555
+ }
556
+
557
+ /// Stores the block deadline and its metadata together, keeping the longest deadline
558
+ /// so a concurrent 429 with a shorter window cannot cut the block short.
559
+ private static func recordRateLimitBlock(untilMs: Double, error: String, message: String) {
560
+ rateLimitStateLock.lock()
561
+ defer { rateLimitStateLock.unlock() }
562
+ if untilMs > rateLimitBlockedUntilMs {
563
+ rateLimitBlockedUntilMs = untilMs
564
+ rateLimitBlockedError = error
565
+ rateLimitBlockedMessage = message
566
+ } else if rateLimitBlockedUntilMs <= 0 {
567
+ rateLimitBlockedError = error
568
+ rateLimitBlockedMessage = message
569
+ }
570
+ }
571
+
572
+ /// Seconds left in the block, clamped and finite so the Int conversion can never trap.
573
+ private static func retryAfterSecondsForLog(untilMs: Double, nowMs: Double) -> Int {
574
+ let seconds = ((untilMs - nowMs) / 1000).rounded(.up)
575
+ guard seconds.isFinite, seconds > 0 else {
576
+ return 0
577
+ }
578
+ return Int(min(seconds, maxRateLimitWindowMs / 1000))
579
+ }
580
+
581
+ /// Returns true for the first 429 only, so the rate-limit statistic is sent once.
582
+ private static func claimRateLimitStatistic() -> Bool {
583
+ rateLimitStateLock.lock()
584
+ defer { rateLimitStateLock.unlock() }
585
+ if rateLimitStatisticSent {
586
+ return false
587
+ }
588
+ rateLimitStatisticSent = true
589
+ return true
590
+ }
591
+
592
+ /// Gives the claim back when the statistic never made it out, so a later 429 can retry it.
593
+ private static func releaseRateLimitStatisticClaim() {
594
+ rateLimitStateLock.lock()
595
+ defer { rateLimitStateLock.unlock() }
596
+ rateLimitStatisticSent = false
597
+ }
598
+
599
+ private func parseRemoteError(from data: Data?) -> (error: String, message: String) {
600
+ guard let data = data,
601
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
602
+ return ("", "")
603
+ }
604
+ let error = json["error"] as? String ?? ""
605
+ let message = json["message"] as? String ?? ""
606
+ return (error, message)
607
+ }
608
+
609
+ private func resolveRateLimitBlockedUntilMs(data: Data?, response: HTTPURLResponse?) -> Double {
610
+ let nowMs = Date().timeIntervalSince1970 * 1000
611
+ let candidate = rawRateLimitDeadlineMs(data: data, response: response, nowMs: nowMs)
612
+ // NaN and past deadlines mean "no client-side block"; anything further out is capped.
613
+ guard candidate > nowMs else {
614
+ return 0
615
+ }
616
+ return min(candidate, nowMs + CapgoUpdater.maxRateLimitWindowMs)
617
+ }
618
+
619
+ private func rawRateLimitDeadlineMs(data: Data?, response: HTTPURLResponse?, nowMs: Double) -> Double {
620
+ if let header = response?.value(forHTTPHeaderField: "Retry-After")?.trimmingCharacters(in: .whitespacesAndNewlines),
621
+ let seconds = Double(header), seconds >= 0 {
622
+ return nowMs + seconds * 1000
623
+ }
624
+
625
+ if let data = data,
626
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
627
+ let moreInfo = json["moreInfo"] as? [String: Any]
628
+ if let retryAfter = (moreInfo?["retryAfterSeconds"] as? NSNumber)?.doubleValue
629
+ ?? (json["retryAfterSeconds"] as? NSNumber)?.doubleValue,
630
+ retryAfter >= 0 {
631
+ return nowMs + retryAfter * 1000
632
+ }
633
+ if let resetAt = (moreInfo?["rateLimitResetAt"] as? NSNumber)?.doubleValue
634
+ ?? (json["rateLimitResetAt"] as? NSNumber)?.doubleValue {
635
+ return resetAt
636
+ }
637
+ }
638
+
639
+ // No retry hint — do not hold a client-side block; allow immediate retry to the worker
640
+ return 0
641
+ }
642
+
643
+ private func isRemoteBlocked() -> Bool {
644
+ CapgoUpdater.rateLimitStateLock.lock()
645
+ defer { CapgoUpdater.rateLimitStateLock.unlock() }
646
+ if CapgoUpdater.rateLimitBlockedUntilMs <= 0 {
647
+ return false
648
+ }
649
+ if Date().timeIntervalSince1970 * 1000 >= CapgoUpdater.rateLimitBlockedUntilMs {
650
+ CapgoUpdater.rateLimitBlockedUntilMs = 0
651
+ return false
652
+ }
653
+ return true
654
+ }
655
+
656
+ private func remoteBlockedClientError() -> (error: String, message: String) {
657
+ CapgoUpdater.rateLimitStateLock.lock()
658
+ defer { CapgoUpdater.rateLimitStateLock.unlock() }
659
+ return (CapgoUpdater.rateLimitBlockedError, CapgoUpdater.rateLimitBlockedMessage)
660
+ }
661
+
662
+ /**
663
+ * Send a synchronous statistic about rate limiting
664
+ * Note: This method uses a semaphore to block until the request completes.
665
+ * It MUST be called from a background queue to avoid blocking the main thread.
666
+ */
667
+ private func sendRateLimitStatistic() {
668
+ guard !statsUrl.isEmpty else {
669
+ // The URL was cleared after the claim was taken; nothing went out, so hand it back.
670
+ CapgoUpdater.releaseRateLimitStatisticClaim()
671
+ return
672
+ }
673
+
674
+ let current = getCurrentBundle()
675
+ var parameters = createInfoObject()
676
+ parameters.action = "rate_limit_reached"
677
+ parameters.version_name = current.getVersionName()
678
+ parameters.old_version_name = ""
679
+
680
+ // Send synchronously using semaphore (safe because we're on a background queue)
681
+ let semaphore = DispatchSemaphore(value: 0)
682
+ self.alamofireSession.request(
683
+ self.statsUrl,
684
+ method: .post,
685
+ parameters: parameters.toParameters(),
686
+ encoding: JSONEncoding.default,
687
+ requestModifier: { $0.timeoutInterval = self.timeout }
688
+ ).responseData { response in
689
+ let statusCode = response.response?.statusCode
690
+ switch response.result {
691
+ case .success where (200...299).contains(statusCode ?? 0):
692
+ self.logger.info("Rate limit statistic sent")
693
+ case .success:
694
+ CapgoUpdater.releaseRateLimitStatisticClaim()
695
+ self.logger.error("Error sending rate limit statistic")
696
+ self.logger.debug("Response code: \(statusCode.map(String.init) ?? "nil")")
697
+ case let .failure(error):
698
+ CapgoUpdater.releaseRateLimitStatisticClaim()
699
+ self.logger.error("Error sending rate limit statistic")
700
+ self.logger.debug("Error: \(error.localizedDescription)")
701
+ }
702
+ semaphore.signal()
703
+ }
704
+ semaphore.wait()
705
+ }
706
+
707
+ // MARK: Private
708
+ private func hasEmbeddedMobileProvision() -> Bool {
709
+ guard Bundle.main.path(forResource: "embedded", ofType: "mobileprovision") == nil else {
710
+ return true
711
+ }
712
+ return false
713
+ }
714
+
715
+ private func isAppStoreReceiptSandbox() -> Bool {
716
+
717
+ if isEmulator() {
718
+ return false
719
+ } else {
720
+ guard let url: URL = Bundle.main.appStoreReceiptURL else {
721
+ return false
722
+ }
723
+ guard url.lastPathComponent == "sandboxReceipt" else {
724
+ return false
725
+ }
726
+ return true
727
+ }
728
+ }
729
+
730
+ private func isEmulator() -> Bool {
731
+ #if targetEnvironment(simulator)
732
+ return true
733
+ #else
734
+ return false
735
+ #endif
736
+ }
737
+ // Persistent path /var/mobile/Containers/Data/Application/8C0C07BE-0FD3-4FD4-B7DF-90A88E12B8C3/Library/NoCloud/ionic_built_snapshots/FOLDER
738
+ // Hot Reload path /var/mobile/Containers/Data/Application/8C0C07BE-0FD3-4FD4-B7DF-90A88E12B8C3/Documents/FOLDER
739
+ // Normal /private/var/containers/Bundle/Application/8C0C07BE-0FD3-4FD4-B7DF-90A88E12B8C3/App.app/public
740
+
741
+ private func prepareFolder(source: URL) throws {
742
+ if !FileManager.default.fileExists(atPath: source.path) {
743
+ do {
744
+ try FileManager.default.createDirectory(atPath: source.path, withIntermediateDirectories: true, attributes: nil)
745
+ } catch {
746
+ logger.error("Cannot create directory")
747
+ logger.debug("Directory path: \(source.path)")
748
+ throw CustomError.cannotCreateDirectory
749
+ }
750
+ }
751
+ }
752
+
753
+ private func deleteFolder(source: URL) throws {
754
+ do {
755
+ try FileManager.default.removeItem(atPath: source.path)
756
+ } catch {
757
+ logger.error("File not removed")
758
+ logger.debug("Path: \(source.path)")
759
+ throw CustomError.cannotDeleteDirectory
760
+ }
761
+ }
762
+
763
+ private func unflatFolder(source: URL, dest: URL) throws -> Bool {
764
+ let index: URL = source.appendingPathComponent("index.html")
765
+ do {
766
+ let files: [String] = try FileManager.default.contentsOfDirectory(atPath: source.path)
767
+ if files.count == 1 && source.appendingPathComponent(files[0]).isDirectory && !FileManager.default.fileExists(atPath: index.path) {
768
+ try FileManager.default.moveItem(at: source.appendingPathComponent(files[0]), to: dest)
769
+ return true
770
+ } else {
771
+ try FileManager.default.moveItem(at: source, to: dest)
772
+ return false
773
+ }
774
+ } catch {
775
+ logger.error("File not moved")
776
+ logger.debug("Source: \(source.path), Dest: \(dest.path)")
777
+ throw CustomError.cannotUnflat
778
+ }
779
+ }
780
+
781
+ private func resolveZipEntry(path: String, destUnZip: URL) throws -> URL {
782
+ do {
783
+ return try Self.resolvePathInsideDirectory(baseDirectory: destUnZip, relativePath: path)
784
+ } catch SecurePathError.windowsPath {
785
+ logger.error("Unzip failed: Windows path not supported")
786
+ logger.debug("Invalid path: \(path)")
787
+ self.sendStats(action: "windows_path_fail")
788
+ throw CustomError.cannotUnzip
789
+ } catch {
790
+ self.sendStats(action: "canonical_path_fail")
791
+ throw CustomError.cannotUnzip
792
+ }
793
+ }
794
+
795
+ private func extractZipEntry(_ archive: Archive, entry: Entry, to destPath: URL, bufferSize: Int = CryptoCipher.ioBufferBytes()) throws {
796
+ let fileManager = FileManager.default
797
+
798
+ switch entry.type {
799
+ case .directory:
800
+ try fileManager.createDirectory(at: destPath, withIntermediateDirectories: true, attributes: nil)
801
+ case .file:
802
+ let parentDir = destPath.deletingLastPathComponent()
803
+ try fileManager.createDirectory(at: parentDir, withIntermediateDirectories: true, attributes: nil)
804
+
805
+ if fileManager.fileExists(atPath: destPath.path) {
806
+ try fileManager.removeItem(at: destPath)
807
+ }
808
+
809
+ guard fileManager.createFile(atPath: destPath.path, contents: nil) else {
810
+ throw CustomError.cannotUnzip
811
+ }
812
+
813
+ let fileHandle = try FileHandle(forWritingTo: destPath)
814
+ defer {
815
+ fileHandle.closeFile()
816
+ }
817
+
818
+ _ = try archive.extract(entry, bufferSize: bufferSize, skipCRC32: true) { data in
819
+ if !data.isEmpty {
820
+ fileHandle.write(data)
821
+ }
822
+ }
823
+ case .symlink:
824
+ var linkData = Data()
825
+ _ = try archive.extract(entry, bufferSize: bufferSize, skipCRC32: true) { data in
826
+ linkData.append(data)
827
+ }
828
+
829
+ guard let linkPath = String(data: linkData, encoding: .utf8) else {
830
+ throw CustomError.cannotUnzip
831
+ }
832
+
833
+ let parentDir = destPath.deletingLastPathComponent()
834
+ try fileManager.createDirectory(at: parentDir, withIntermediateDirectories: true, attributes: nil)
835
+
836
+ let isAbsolutePath = (linkPath as NSString).isAbsolutePath
837
+ let linkURL = URL(fileURLWithPath: linkPath, relativeTo: isAbsolutePath ? nil : parentDir)
838
+ let canonicalPath = linkURL.standardizedFileURL.path
839
+ let canonicalDir = parentDir.standardizedFileURL.path
840
+ let normalizedDir = canonicalDir.hasSuffix("/") ? canonicalDir : "\(canonicalDir)/"
841
+
842
+ if canonicalPath != canonicalDir && !canonicalPath.hasPrefix(normalizedDir) {
843
+ throw CustomError.cannotUnzip
844
+ }
845
+
846
+ if fileManager.fileExists(atPath: destPath.path) {
847
+ try fileManager.removeItem(at: destPath)
848
+ }
849
+
850
+ try fileManager.createSymbolicLink(atPath: destPath.path, withDestinationPath: linkPath)
851
+ }
852
+ }
853
+
854
+ func saveDownloaded(sourceZip: URL, id: String, base: URL, notify: Bool, bufferSize: Int = CryptoCipher.ioBufferBytes()) throws {
855
+ try prepareFolder(source: base)
856
+ let destPersist: URL = base.appendingPathComponent(id)
857
+ let destUnZip: URL = libraryDir.appendingPathComponent(TEMP_UNZIP_PREFIX + randomString(length: 10))
858
+
859
+ self.unzipPercent = 0
860
+ self.notifyDownload(id: id, percent: 75)
861
+
862
+ // Open the archive
863
+ let archive: Archive
864
+ do {
865
+ archive = try Archive(url: sourceZip, accessMode: .read)
866
+ } catch {
867
+ self.sendStats(action: "unzip_fail")
868
+ throw CustomError.cannotUnzip
869
+ }
870
+
871
+ // Create destination directory
872
+ try FileManager.default.createDirectory(at: destUnZip, withIntermediateDirectories: true, attributes: nil)
873
+
874
+ // Count total entries for progress
875
+ let totalEntries = archive.reduce(0) { count, _ in count + 1 }
876
+ var processedEntries = 0
877
+
878
+ do {
879
+ for entry in archive {
880
+ let destPath = try resolveZipEntry(path: entry.path, destUnZip: destUnZip)
881
+
882
+ if entry.type == .directory {
883
+ try FileManager.default.createDirectory(at: destPath, withIntermediateDirectories: true, attributes: nil)
884
+ processedEntries += 1
885
+ if notify && totalEntries > 0 {
886
+ let newPercent = self.calcTotalPercent(percent: Int(Double(processedEntries) / Double(totalEntries) * 100), min: 75, max: 81)
887
+ if newPercent != self.unzipPercent {
888
+ self.unzipPercent = newPercent
889
+ self.notifyDownload(id: id, percent: newPercent)
890
+ }
891
+ }
892
+ continue
893
+ }
894
+
895
+ // Create parent directories if needed
896
+ let parentDir = destPath.deletingLastPathComponent()
897
+ if !FileManager.default.fileExists(atPath: parentDir.path) {
898
+ try FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true, attributes: nil)
899
+ }
900
+
901
+ try self.extractZipEntry(archive, entry: entry, to: destPath, bufferSize: bufferSize)
902
+
903
+ // Update progress
904
+ processedEntries += 1
905
+ if notify && totalEntries > 0 {
906
+ let newPercent = self.calcTotalPercent(percent: Int(Double(processedEntries) / Double(totalEntries) * 100), min: 75, max: 81)
907
+ if newPercent != self.unzipPercent {
908
+ self.unzipPercent = newPercent
909
+ self.notifyDownload(id: id, percent: newPercent)
910
+ }
911
+ }
912
+ }
913
+ } catch {
914
+ self.sendStats(action: "unzip_fail")
915
+ try? FileManager.default.removeItem(at: destUnZip)
916
+ throw error
917
+ }
918
+
919
+ if try unflatFolder(source: destUnZip, dest: destPersist) {
920
+ try deleteFolder(source: destUnZip)
921
+ }
922
+
923
+ // Cleanup: remove the downloaded/decrypted zip after successful extraction
924
+ do {
925
+ if FileManager.default.fileExists(atPath: sourceZip.path) {
926
+ try FileManager.default.removeItem(at: sourceZip)
927
+ }
928
+ } catch {
929
+ logger.error("Could not delete source zip")
930
+ logger.debug("Path: \(sourceZip.path), Error: \(error)")
931
+ }
932
+ }
933
+
934
+ private func populateDeltaCacheAsync(for id: String, manifest: [ManifestEntry]? = nil, sessionKey: String = "") {
935
+ DispatchQueue.global(qos: .utility).async { [weak self] in
936
+ self?.populateDeltaCache(for: id, manifest: manifest, sessionKey: sessionKey)
937
+ }
938
+ }
939
+
940
+ struct ManifestLookupEntry {
941
+ let hash: String
942
+ /// The manifest's own file name, `.br` suffix included when present. The
943
+ /// built-in bundle stores files under this exact name (see
944
+ /// isManifestEntryAvailableLocally), unlike the extracted/cached copy which
945
+ /// is always named without the suffix.
946
+ let originalFileName: String
947
+ }
948
+
949
+ /// Keys are stripped of the `.br` suffix so they match the extracted file names on
950
+ /// disk, not the manifest's (possibly brotli-compressed) original file names.
951
+ func manifestHashLookup(manifest: [ManifestEntry]?, sessionKey: String) -> [String: ManifestLookupEntry] {
952
+ guard let manifest else {
953
+ return [:]
954
+ }
955
+ var lookup: [String: ManifestLookupEntry] = [:]
956
+ for entry in manifest {
957
+ guard let fileName = entry.file_name,
958
+ let hash = resolveManifestFileHash(entry: entry, sessionKey: sessionKey) else {
959
+ continue
960
+ }
961
+ let destFileName = fileName.hasSuffix(".br") ? String(fileName.dropLast(3)) : fileName
962
+ lookup[destFileName] = ManifestLookupEntry(hash: hash, originalFileName: fileName)
963
+ }
964
+ return lookup
965
+ }
966
+
967
+ /// `manifest` must only contain entries the caller already checksum-verified
968
+ /// (as `downloadManifest` does) — the hashes are trusted as-is, not re-checked.
969
+ func populateDeltaCache(for id: String, manifest: [ManifestEntry]? = nil, sessionKey: String = "") {
970
+ let bundleDir = self.getBundleDirectory(id: id)
971
+ let fileManager = FileManager.default
972
+
973
+ guard fileManager.fileExists(atPath: bundleDir.path) else {
974
+ logger.debug("Skip delta cache population: bundle dir missing")
975
+ return
976
+ }
977
+
978
+ do {
979
+ try fileManager.createDirectory(at: cacheFolder, withIntermediateDirectories: true, attributes: nil)
980
+ } catch {
981
+ logger.debug("Skip delta cache population: failed to create cache dir")
982
+ return
983
+ }
984
+
985
+ guard let enumerator = fileManager.enumerator(at: bundleDir, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles]) else {
986
+ return
987
+ }
988
+
989
+ let knownEntries = manifestHashLookup(manifest: manifest, sessionKey: sessionKey)
990
+ let builtinFolder = self.builtinFolderURL()
991
+
992
+ for case let fileURL as URL in enumerator {
993
+ let resourceValues = try? fileURL.resourceValues(forKeys: [.isDirectoryKey])
994
+ if resourceValues?.isDirectory == true {
995
+ continue
996
+ }
997
+
998
+ let relativePath = String(fileURL.path.dropFirst(bundleDir.path.count + 1))
999
+ let knownEntry = knownEntries[relativePath]
1000
+
1001
+ let checksum = knownEntry?.hash ?? CryptoCipher.calcChecksum(filePath: fileURL)
1002
+ if checksum.isEmpty {
1003
+ continue
1004
+ }
1005
+
1006
+ // Builtin is already a permanent reuse source (see isManifestEntryAvailableLocally),
1007
+ // so there's no need to also duplicate this file into the delta cache
1008
+ let builtinRelativePath = knownEntry?.originalFileName ?? relativePath
1009
+ let builtinFilePath = builtinFolder.appendingPathComponent(builtinRelativePath)
1010
+ let isBuiltinOrigin = fileManager.fileExists(atPath: builtinFilePath.path) &&
1011
+ verifyChecksum(file: builtinFilePath, expectedHash: checksum)
1012
+ if isBuiltinOrigin {
1013
+ continue
1014
+ }
1015
+
1016
+ let cacheFile = cacheFolder.appendingPathComponent("\(checksum)_\(fileURL.lastPathComponent)")
1017
+ if fileManager.fileExists(atPath: cacheFile.path) {
1018
+ continue
1019
+ }
1020
+
1021
+ do {
1022
+ try copyItemAtomically(from: fileURL, to: cacheFile)
1023
+ } catch {
1024
+ logger.debug("Delta cache copy failed: \(fileURL.path)")
1025
+ }
1026
+ }
1027
+ }
1028
+
1029
+ private func createInfoObject(appIdOverride: String? = nil) -> InfoObject {
1030
+ return InfoObject(
1031
+ platform: "ios",
1032
+ device_id: self.deviceID,
1033
+ app_id: appIdOverride ?? self.appId,
1034
+ custom_id: self.customId,
1035
+ version_build: self.versionBuild,
1036
+ version_code: self.versionCode,
1037
+ version_os: self.versionOs,
1038
+ version_name: self.getCurrentBundle().getVersionName(),
1039
+ plugin_version: self.pluginVersion,
1040
+ is_emulator: self.isEmulator(),
1041
+ is_prod: self.isProd(),
1042
+ installSource: self.installSource(),
1043
+ action: nil,
1044
+ channel: nil,
1045
+ defaultChannel: self.defaultChannel,
1046
+ key_id: self.cachedKeyId
1047
+ )
1048
+ }
1049
+
1050
+ public func getLatest(url: URL, channel: String?, appIdOverride: String? = nil) -> AppVersion {
1051
+ let latest: AppVersion = AppVersion()
1052
+ if isRemoteBlocked() {
1053
+ let blocked = remoteBlockedClientError()
1054
+ logger.debug("Skipping getLatest due to remote block (\(blocked.error)).")
1055
+ latest.message = blocked.message
1056
+ latest.error = blocked.error
1057
+ latest.kind = "failed"
1058
+ return latest
1059
+ }
1060
+ func applyLatestResponse(_ value: AppVersionDec?) {
1061
+ if let url = value?.url {
1062
+ latest.url = url
1063
+ }
1064
+ if let checksum = value?.checksum {
1065
+ latest.checksum = checksum
1066
+ }
1067
+ if let version = value?.version {
1068
+ latest.version = version
1069
+ }
1070
+ if let major = value?.major {
1071
+ latest.major = major
1072
+ }
1073
+ if let breaking = value?.breaking {
1074
+ latest.breaking = breaking
1075
+ }
1076
+ if let error = value?.error {
1077
+ latest.error = error
1078
+ }
1079
+ if let kind = value?.kind {
1080
+ latest.kind = kind
1081
+ }
1082
+ if let message = value?.message {
1083
+ latest.message = message
1084
+ }
1085
+ if let sessionKey = value?.session_key {
1086
+ latest.sessionKey = sessionKey
1087
+ }
1088
+ if let data = value?.data {
1089
+ latest.data = data
1090
+ }
1091
+ if let manifest = value?.manifest {
1092
+ latest.manifest = manifest
1093
+ }
1094
+ if let link = value?.link {
1095
+ latest.link = link
1096
+ }
1097
+ if let comment = value?.comment {
1098
+ latest.comment = comment
1099
+ }
1100
+ }
1101
+
1102
+ var parameters: InfoObject = self.createInfoObject(appIdOverride: appIdOverride)
1103
+ if let channel = channel {
1104
+ parameters.defaultChannel = channel
1105
+ }
1106
+ guard let request = createRequest(url: url, method: "POST", parameters: parameters.toParameters()) else {
1107
+ latest.message = "Error getting Latest"
1108
+ latest.error = "request_error"
1109
+ latest.kind = "failed"
1110
+ return latest
1111
+ }
1112
+
1113
+ let result = performRequest(request, label: "getLatest")
1114
+ latest.statusCode = result.response?.statusCode ?? 0
1115
+
1116
+ if result.timedOut {
1117
+ latest.message = "Error getting Latest"
1118
+ latest.error = "timeout_error"
1119
+ latest.kind = "failed"
1120
+ return latest
1121
+ }
1122
+
1123
+ if let error = result.error {
1124
+ self.logger.error("Error getting latest version")
1125
+ self.logger.debug("Error: \(error.localizedDescription)")
1126
+ latest.message = "Error getting Latest"
1127
+ latest.error = "response_error"
1128
+ latest.kind = "failed"
1129
+ return latest
1130
+ }
1131
+
1132
+ guard let data = result.data else {
1133
+ self.logger.error("Missing latest version response data")
1134
+ latest.message = "Error getting Latest"
1135
+ latest.error = "response_error"
1136
+ latest.kind = "failed"
1137
+ return latest
1138
+ }
1139
+
1140
+ let rateLimit = self.checkAndHandleRateLimitResponse(
1141
+ statusCode: latest.statusCode,
1142
+ data: data,
1143
+ response: result.response
1144
+ )
1145
+ if rateLimit.blocked {
1146
+ latest.message = rateLimit.message
1147
+ latest.error = rateLimit.error
1148
+ latest.kind = "failed"
1149
+ return latest
1150
+ }
1151
+
1152
+ guard let responseValue = try? JSONDecoder().decode(AppVersionDec.self, from: data) else {
1153
+ self.logger.error("Error decoding latest version")
1154
+ latest.message = "Error getting Latest"
1155
+ latest.error = "decode_error"
1156
+ latest.kind = "failed"
1157
+ return latest
1158
+ }
1159
+
1160
+ applyLatestResponse(responseValue)
1161
+
1162
+ if latest.statusCode < 200 || latest.statusCode >= 300 {
1163
+ if latest.message == nil || latest.message?.isEmpty == true {
1164
+ latest.message = responseValue.message ?? "Server error: \(latest.statusCode)"
1165
+ }
1166
+ if latest.error == nil || latest.error?.isEmpty == true {
1167
+ latest.error = responseValue.error ?? "response_error"
1168
+ }
1169
+ if latest.kind == nil || latest.kind?.isEmpty == true {
1170
+ latest.kind = responseValue.kind ?? "failed"
1171
+ }
1172
+ return latest
1173
+ }
1174
+
1175
+ return latest
1176
+ }
1177
+
1178
+ private func setCurrentBundle(bundle: String) {
1179
+ UserDefaults.standard.set(bundle, forKey: self.CAP_SERVER_PATH)
1180
+ UserDefaults.standard.synchronize()
1181
+ logger.info("Current bundle set to: \((bundle ).isEmpty ? BundleInfo.ID_BUILTIN : bundle)")
1182
+ }
1183
+
1184
+ static func shouldResetForForeignBundle(bundlePath: String?, isBuiltin: Bool, hasStoredBundleInfo: Bool) -> Bool {
1185
+ guard let bundlePath, !bundlePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
1186
+ return false
1187
+ }
1188
+ return !isBuiltin && !hasStoredBundleInfo
1189
+ }
1190
+
1191
+ private func hasStoredBundleInfo(id: String) -> Bool {
1192
+ guard !id.isEmpty,
1193
+ id != BundleInfo.ID_BUILTIN,
1194
+ id != BundleInfo.VERSION_UNKNOWN else {
1195
+ return false
1196
+ }
1197
+ return UserDefaults.standard.object(forKey: "\(id)\(self.INFO_SUFFIX)") != nil
1198
+ }
1199
+
1200
+ // Per-download temp file paths to prevent collisions when multiple downloads run concurrently
1201
+ private func tempDataPath(for id: String) -> URL {
1202
+ return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("package_\(id).tmp")
1203
+ }
1204
+
1205
+ private func updateInfoPath(for id: String) -> URL {
1206
+ return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("update_\(id).dat")
1207
+ }
1208
+
1209
+ private var tempData = Data()
1210
+
1211
+ private func verifyChecksum(file: URL, expectedHash: String) -> Bool {
1212
+ let actualHash = CryptoCipher.calcChecksum(filePath: file)
1213
+ return actualHash == expectedHash
1214
+ }
1215
+
1216
+ /// Overridable so tests can point it at a writable directory instead of the
1217
+ /// real (read-only) app bundle.
1218
+ func builtinFolderURL() -> URL {
1219
+ Bundle.main.bundleURL.appendingPathComponent("public")
1220
+ }
1221
+
1222
+ private func resolveManifestFileHash(entry: ManifestEntry, sessionKey: String) -> String? {
1223
+ guard var fileHash = entry.file_hash, !fileHash.isEmpty else {
1224
+ return nil
1225
+ }
1226
+ if !self.publicKey.isEmpty && !sessionKey.isEmpty {
1227
+ do {
1228
+ fileHash = try CryptoCipher.decryptChecksum(checksum: fileHash, publicKey: self.publicKey)
1229
+ } catch {
1230
+ logger.error("Checksum decryption failed while checking missing manifest files")
1231
+ logger.debug("File: \(entry.file_name ?? "unknown"), Error: \(error.localizedDescription)")
1232
+ return nil
1233
+ }
1234
+ }
1235
+ return fileHash
1236
+ }
1237
+
1238
+ private func isManifestEntryAvailableLocally(entry: ManifestEntry, sessionKey: String) -> Bool {
1239
+ guard let fileName = entry.file_name,
1240
+ let fileHash = resolveManifestFileHash(entry: entry, sessionKey: sessionKey) else {
1241
+ return false
1242
+ }
1243
+
1244
+ let builtinFolder = self.builtinFolderURL()
1245
+ let builtinFilePath = builtinFolder.appendingPathComponent(fileName)
1246
+ if FileManager.default.fileExists(atPath: builtinFilePath.path) && verifyChecksum(file: builtinFilePath, expectedHash: fileHash) {
1247
+ return true
1248
+ }
1249
+
1250
+ let fileNameWithoutPath = (fileName as NSString).lastPathComponent
1251
+ let isBrotli = fileName.hasSuffix(".br")
1252
+ let cacheBaseName = isBrotli ? String(fileNameWithoutPath.dropLast(3)) : fileNameWithoutPath
1253
+ if Self.isSafeCacheHash(fileHash) {
1254
+ let cacheFilePath = cacheFolder.appendingPathComponent("\(fileHash)_\(cacheBaseName)")
1255
+ // Cache files are named `{hash}_{filename}` and were checksum-verified
1256
+ // when written. Re-hashing every hit re-reads the whole bundle and
1257
+ // OOMs/janks low-RAM devices during getMissing / delta apply.
1258
+ if isReusableCacheFile(cacheFilePath, expectedHash: fileHash) {
1259
+ return true
1260
+ }
1261
+
1262
+ if isBrotli {
1263
+ let legacyCacheFilePath = cacheFolder.appendingPathComponent("\(fileHash)_\(fileNameWithoutPath)")
1264
+ if isReusableCacheFile(legacyCacheFilePath, expectedHash: fileHash) {
1265
+ return true
1266
+ }
1267
+ }
1268
+ }
1269
+
1270
+ return false
1271
+ }
1272
+
1273
+ /// SHA-256 hash-named cache files were verified when written. Existence is
1274
+ /// enough for non-empty files; empty files are reused only for the empty SHA-256.
1275
+ /// CRC32 (8 hex) is too collision-prone to trust without a re-read.
1276
+ private func isReusableCacheFile(_ url: URL, expectedHash: String) -> Bool {
1277
+ guard Self.isSafeCacheHash(expectedHash), expectedHash.count == 64 else {
1278
+ return false
1279
+ }
1280
+ let size = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? -1
1281
+ if size > 0 {
1282
+ return true
1283
+ }
1284
+ return size == 0 && expectedHash.lowercased() == Self.emptySha256
1285
+ }
1286
+
1287
+ static func isSafeCacheHash(_ hash: String) -> Bool {
1288
+ let count = hash.count
1289
+ guard count == 64 || count == 8 else {
1290
+ return false
1291
+ }
1292
+ return hash.unicodeScalars.allSatisfy { scalar in
1293
+ (0x30...0x39).contains(scalar.value) ||
1294
+ (0x41...0x46).contains(scalar.value) ||
1295
+ (0x61...0x66).contains(scalar.value)
1296
+ }
1297
+ }
1298
+
1299
+ public func getMissingBundleFiles(manifest: [ManifestEntry], sessionKey: String) -> [ManifestEntry] {
1300
+ return manifest.filter { entry in
1301
+ !isManifestEntryAvailableLocally(entry: entry, sessionKey: sessionKey)
1302
+ }
1303
+ }
1304
+
1305
+ public func missingBundleFilesResult(manifest: [ManifestEntry], sessionKey: String) -> [String: Any] {
1306
+ let missing = getMissingBundleFiles(manifest: manifest, sessionKey: sessionKey)
1307
+ return [
1308
+ "missing": missing.map { $0.toDict() },
1309
+ "total": manifest.count,
1310
+ "missingCount": missing.count,
1311
+ "reusableCount": manifest.count - missing.count
1312
+ ]
1313
+ }
1314
+
1315
+ private func manifestSizeUrl(from updateUrl: URL) -> URL {
1316
+ var components = URLComponents(url: updateUrl, resolvingAgainstBaseURL: false)
1317
+ let path = components?.path ?? updateUrl.path
1318
+ let trimmedPath = path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
1319
+ components?.path = trimmedPath == ""
1320
+ ? "/manifest_size"
1321
+ : "/\(trimmedPath)/manifest_size"
1322
+ components?.query = nil
1323
+ return components?.url ?? updateUrl.appendingPathComponent("manifest_size")
1324
+ }
1325
+
1326
+ private func unavailableBundleSizeResult(manifest: [ManifestEntry], error: String) -> [String: Any] {
1327
+ return [
1328
+ "totalSize": 0,
1329
+ "knownFiles": 0,
1330
+ "unknownFiles": manifest.count,
1331
+ "files": manifest.map {
1332
+ var dict = $0.toDict()
1333
+ dict["error"] = error
1334
+ return dict
1335
+ }
1336
+ ]
1337
+ }
1338
+
1339
+ public func getBundleDownloadSize(updateUrl: URL, version: String?, manifest: [ManifestEntry]) -> [String: Any] {
1340
+ if manifest.isEmpty {
1341
+ return [
1342
+ "totalSize": 0,
1343
+ "knownFiles": 0,
1344
+ "unknownFiles": 0,
1345
+ "files": []
1346
+ ]
1347
+ }
1348
+
1349
+ var parameters = self.createInfoObject().toParameters()
1350
+ parameters["version"] = version ?? ""
1351
+ parameters["manifest"] = manifest.map { $0.toDict() }
1352
+
1353
+ guard let request = createRequest(url: manifestSizeUrl(from: updateUrl), method: "POST", parameters: parameters) else {
1354
+ return unavailableBundleSizeResult(manifest: manifest, error: "request_error")
1355
+ }
1356
+
1357
+ let result = performRequest(request, label: "getBundleDownloadSize")
1358
+ if result.timedOut {
1359
+ return unavailableBundleSizeResult(manifest: manifest, error: "timeout_error")
1360
+ }
1361
+ if let error = result.error {
1362
+ logger.error("Error getting bundle download size")
1363
+ logger.debug("Error: \(error.localizedDescription)")
1364
+ return unavailableBundleSizeResult(manifest: manifest, error: "response_error")
1365
+ }
1366
+ guard let data = result.data,
1367
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
1368
+ return unavailableBundleSizeResult(manifest: manifest, error: "parse_error")
1369
+ }
1370
+
1371
+ let statusCode = result.response?.statusCode ?? 0
1372
+ if statusCode < 200 || statusCode >= 300 {
1373
+ return unavailableBundleSizeResult(manifest: manifest, error: "response_error")
1374
+ }
1375
+
1376
+ return json
1377
+ }
1378
+
1379
+ private func runBeforeDownload() throws {
1380
+ try beforeDownload?()
1381
+ }
1382
+
1383
+ public func downloadManifest(manifest: [ManifestEntry], version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
1384
+ try self.runBeforeDownload()
1385
+ let id = self.randomString(length: 10)
1386
+ logger.info("downloadManifest start \(id)")
1387
+ let destFolder = self.getBundleDirectory(id: id)
1388
+ let builtinFolder = self.builtinFolderURL()
1389
+
1390
+ // Check disk space before starting manifest download (estimate 100KB per file, minimum 50MB)
1391
+ let estimatedSize = Int64(max(manifest.count * 100 * 1024, 50 * 1024 * 1024))
1392
+ try checkDiskSpace(estimatedSize: estimatedSize)
1393
+
1394
+ try FileManager.default.createDirectory(at: cacheFolder, withIntermediateDirectories: true, attributes: nil)
1395
+ cleanupOldManifestPartials()
1396
+ try FileManager.default.createDirectory(at: destFolder, withIntermediateDirectories: true, attributes: nil)
1397
+
1398
+ // Create and save BundleInfo before starting the download process
1399
+ let bundleInfo = BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: "", link: link, comment: comment)
1400
+ self.saveBundleInfo(id: id, bundle: bundleInfo)
1401
+
1402
+ // Send stats for manifest download start
1403
+ self.sendStats(action: "download_manifest_start", versionName: version)
1404
+
1405
+ // Notify the start of the download process
1406
+ self.notifyDownload(id: id, percent: 0, ignoreMultipleOfTen: true)
1407
+
1408
+ let totalFiles = manifest.count
1409
+
1410
+ // Thread-safe counters for concurrent operations
1411
+ let completedFiles = AtomicCounter()
1412
+ let hasError = AtomicBool(initialValue: false)
1413
+ var downloadError: Error?
1414
+ let errorLock = NSLock()
1415
+
1416
+ // Create operations for each file
1417
+ var operations: [Operation] = []
1418
+
1419
+ for entry in manifest {
1420
+ guard let fileName = entry.file_name,
1421
+ let downloadUrl = entry.download_url else {
1422
+ let error = NSError(
1423
+ domain: "ManifestEntryError",
1424
+ code: 1,
1425
+ userInfo: [
1426
+ NSLocalizedDescriptionKey: "Manifest entry is missing file_name or download_url"
1427
+ ]
1428
+ )
1429
+ errorLock.lock()
1430
+ if downloadError == nil {
1431
+ downloadError = error
1432
+ }
1433
+ errorLock.unlock()
1434
+ hasError.value = true
1435
+ logger.error("Manifest entry is missing file_name or download_url")
1436
+ continue
1437
+ }
1438
+ guard let entryFileHash = entry.file_hash, !entryFileHash.isEmpty else {
1439
+ logger.error("Missing file_hash for manifest entry: \(entry.file_name ?? "unknown")")
1440
+ let error = NSError(
1441
+ domain: "ManifestEntryError",
1442
+ code: 2,
1443
+ userInfo: [
1444
+ NSLocalizedDescriptionKey: "Manifest entry is missing file_hash for \(entry.file_name ?? "unknown")"
1445
+ ]
1446
+ )
1447
+ errorLock.lock()
1448
+ if downloadError == nil {
1449
+ downloadError = error
1450
+ }
1451
+ errorLock.unlock()
1452
+ hasError.value = true
1453
+ continue
1454
+ }
1455
+ var fileHash = entryFileHash
1456
+
1457
+ // Decrypt checksum if needed (done before creating operation)
1458
+ if !self.publicKey.isEmpty && !sessionKey.isEmpty {
1459
+ do {
1460
+ fileHash = try CryptoCipher.decryptChecksum(checksum: fileHash, publicKey: self.publicKey)
1461
+ } catch {
1462
+ errorLock.lock()
1463
+ downloadError = error
1464
+ errorLock.unlock()
1465
+ hasError.value = true
1466
+ logger.error("Checksum decryption failed")
1467
+ logger.debug("Bundle: \(id), File: \(fileName), Error: \(error)")
1468
+ continue
1469
+ }
1470
+ }
1471
+
1472
+ let finalFileHash = fileHash
1473
+ let fileNameWithoutPath = (fileName as NSString).lastPathComponent
1474
+ let isBrotli = fileName.hasSuffix(".br")
1475
+ let cacheBaseName = isBrotli ? String(fileNameWithoutPath.dropLast(3)) : fileNameWithoutPath
1476
+ let cacheFilePath: URL? = Self.isSafeCacheHash(finalFileHash)
1477
+ ? cacheFolder.appendingPathComponent("\(finalFileHash)_\(cacheBaseName)")
1478
+ : nil
1479
+ let legacyCacheFilePath: URL? = isBrotli && cacheFilePath != nil
1480
+ ? cacheFolder.appendingPathComponent("\(finalFileHash)_\(fileNameWithoutPath)")
1481
+ : nil
1482
+
1483
+ let destFileName = isBrotli ? String(fileName.dropLast(3)) : fileName
1484
+ let destFilePath: URL
1485
+ let builtinFilePath: URL
1486
+ do {
1487
+ destFilePath = try Self.resolveManifestTargetPath(baseDirectory: destFolder, fileName: fileName)
1488
+ builtinFilePath = try Self.resolvePathInsideDirectory(baseDirectory: builtinFolder, relativePath: fileName)
1489
+ } catch {
1490
+ logger.error("Invalid manifest file path: \(fileName)")
1491
+ self.sendStats(action: "manifest_path_fail", versionName: "\(version):\(fileName)")
1492
+ errorLock.lock()
1493
+ if downloadError == nil {
1494
+ downloadError = error
1495
+ }
1496
+ errorLock.unlock()
1497
+ hasError.value = true
1498
+ continue
1499
+ }
1500
+
1501
+ // Create parent directories synchronously (before operations start)
1502
+ try? FileManager.default.createDirectory(at: destFilePath.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
1503
+
1504
+ let operation = BlockOperation { [weak self] in
1505
+ guard let self = self else { return }
1506
+ guard !hasError.value else { return } // Skip if error already occurred
1507
+
1508
+ do {
1509
+ // Try builtin first
1510
+ if FileManager.default.fileExists(atPath: builtinFilePath.path) && self.verifyChecksum(file: builtinFilePath, expectedHash: finalFileHash) {
1511
+ try self.copyItemReplacing(from: builtinFilePath, to: destFilePath)
1512
+ self.logger.info("downloadManifest \(fileName) using builtin file \(id)")
1513
+ }
1514
+ // Try cache
1515
+ else if
1516
+ (cacheFilePath != nil && self.tryCopyFromCache(from: cacheFilePath!, to: destFilePath, expectedHash: finalFileHash)) ||
1517
+ (legacyCacheFilePath != nil && self.tryCopyFromCache(from: legacyCacheFilePath!, to: destFilePath, expectedHash: finalFileHash)) {
1518
+ self.logger.info("downloadManifest \(fileName) copy from cache \(id)")
1519
+ }
1520
+ // Download
1521
+ else {
1522
+ try self.downloadManifestFile(
1523
+ downloadUrl: downloadUrl,
1524
+ destFilePath: destFilePath,
1525
+ cacheFilePath: cacheFilePath,
1526
+ fileHash: finalFileHash,
1527
+ fileName: fileName,
1528
+ destFileName: destFileName,
1529
+ isBrotli: isBrotli,
1530
+ sessionKey: sessionKey,
1531
+ version: version,
1532
+ bundleId: id
1533
+ )
1534
+ }
1535
+
1536
+ let completed = completedFiles.increment()
1537
+ let percent = self.calcTotalPercent(percent: Int((Double(completed) / Double(totalFiles)) * 100), min: 10, max: 70)
1538
+ self.notifyDownload(id: id, percent: percent)
1539
+
1540
+ } catch {
1541
+ errorLock.lock()
1542
+ if downloadError == nil {
1543
+ downloadError = error
1544
+ }
1545
+ errorLock.unlock()
1546
+ hasError.value = true
1547
+ self.logger.error("Manifest file download failed: \(fileName)")
1548
+ self.logger.debug("Bundle: \(id), File: \(fileName), Error: \(error.localizedDescription)")
1549
+ }
1550
+ }
1551
+
1552
+ operations.append(operation)
1553
+ }
1554
+
1555
+ // Execute all operations concurrently and wait for completion
1556
+ manifestDownloadQueue.addOperations(operations, waitUntilFinished: true)
1557
+
1558
+ if hasError.value {
1559
+ let resolvedError = downloadError ?? NSError(
1560
+ domain: "ManifestDownloadError",
1561
+ code: 1,
1562
+ userInfo: [NSLocalizedDescriptionKey: "Manifest download failed due to invalid or missing entries"]
1563
+ )
1564
+ // Update bundle status to ERROR if download failed
1565
+ let errorBundle = bundleInfo.setStatus(status: BundleStatus.ERROR.storedValue)
1566
+ self.saveBundleInfo(id: id, bundle: errorBundle)
1567
+ throw resolvedError
1568
+ }
1569
+
1570
+ // Update bundle status to PENDING after successful download
1571
+ let updatedBundle = bundleInfo.setStatus(status: BundleStatus.PENDING.storedValue)
1572
+ self.saveBundleInfo(id: id, bundle: updatedBundle)
1573
+
1574
+ // Send stats for manifest download complete
1575
+ self.sendStats(action: "download_manifest_complete", versionName: version)
1576
+
1577
+ self.notifyDownload(id: id, percent: 100, bundle: updatedBundle)
1578
+ logger.info("downloadManifest done \(id)")
1579
+ return updatedBundle
1580
+ }
1581
+
1582
+ /// Downloads a single manifest file synchronously
1583
+ /// Used by downloadManifest for concurrent file downloads
1584
+ private func downloadManifestFile(
1585
+ downloadUrl: String,
1586
+ destFilePath: URL,
1587
+ cacheFilePath: URL?,
1588
+ fileHash: String,
1589
+ fileName: String,
1590
+ destFileName: String,
1591
+ isBrotli: Bool,
1592
+ sessionKey: String,
1593
+ version: String,
1594
+ bundleId: String
1595
+ ) throws {
1596
+ guard let url = URL(string: downloadUrl) else {
1597
+ throw NSError(
1598
+ domain: "ManifestDownloadError",
1599
+ code: 1,
1600
+ userInfo: [NSLocalizedDescriptionKey: "Invalid manifest download URL for file \(fileName): \(downloadUrl)"]
1601
+ )
1602
+ }
1603
+
1604
+ guard var request = createRequest(url: url, method: "GET") else {
1605
+ throw NSError(
1606
+ domain: "ManifestDownloadError",
1607
+ code: 2,
1608
+ userInfo: [NSLocalizedDescriptionKey: "Invalid manifest request for file \(fileName): \(downloadUrl)"]
1609
+ )
1610
+ }
1611
+
1612
+ try FileManager.default.createDirectory(at: cacheFolder, withIntermediateDirectories: true, attributes: nil)
1613
+ let partialURL = Self.manifestPartialURL(cacheFolder: cacheFolder, hash: fileHash, fileName: fileName)
1614
+ let existingBytes: Int64
1615
+ if FileManager.default.fileExists(atPath: partialURL.path) {
1616
+ existingBytes = Int64((try FileManager.default.attributesOfItem(atPath: partialURL.path)[.size] as? NSNumber)?.int64Value ?? 0)
1617
+ } else {
1618
+ existingBytes = 0
1619
+ }
1620
+ if existingBytes > 0 {
1621
+ request.setValue("bytes=\(existingBytes)-", forHTTPHeaderField: "Range")
1622
+ }
1623
+
1624
+ let result = performDownloadRequest(request, label: "downloadManifestFile \(fileName)")
1625
+ defer {
1626
+ if let fileURL = result.fileURL {
1627
+ try? FileManager.default.removeItem(at: fileURL)
1628
+ }
1629
+ }
1630
+
1631
+ if result.timedOut {
1632
+ persistPartialDownload(result, id: bundleId, tempPath: partialURL, existingBytes: existingBytes)
1633
+ self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
1634
+ throw NSError(
1635
+ domain: NSURLErrorDomain,
1636
+ code: NSURLErrorTimedOut,
1637
+ userInfo: [NSLocalizedDescriptionKey: "Timed out downloading manifest file \(fileName) at url \(downloadUrl)"]
1638
+ )
1639
+ }
1640
+
1641
+ if let error = result.error {
1642
+ persistPartialDownload(result, id: bundleId, tempPath: partialURL, existingBytes: existingBytes)
1643
+ self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
1644
+ self.logger.error("Manifest file download network error")
1645
+ self.logger.debug("Bundle: \(bundleId), File: \(fileName), Error: \(error.localizedDescription)")
1646
+ throw error
1647
+ }
1648
+
1649
+ let statusCode = result.response?.statusCode ?? 200
1650
+ if statusCode == 416 && existingBytes > 0 {
1651
+ logger.debug("Range not satisfiable, using existing partial \(partialURL.lastPathComponent)")
1652
+ } else if statusCode < 200 || statusCode >= 300 {
1653
+ self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
1654
+ throw NSError(domain: "StatusCodeError", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid for file \(fileName) at url \(downloadUrl)"])
1655
+ } else {
1656
+ guard let downloadedFileURL = result.fileURL, FileManager.default.fileExists(atPath: downloadedFileURL.path) else {
1657
+ self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
1658
+ throw NSError(
1659
+ domain: "ManifestDownloadError",
1660
+ code: 3,
1661
+ userInfo: [NSLocalizedDescriptionKey: "Manifest file response was empty for \(fileName) at url \(downloadUrl)"]
1662
+ )
1663
+ }
1664
+ try storeDownloadedFile(downloadedFileURL, at: partialURL, existingBytes: existingBytes, response: result.response)
1665
+ }
1666
+
1667
+ guard FileManager.default.fileExists(atPath: partialURL.path) else {
1668
+ self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
1669
+ throw NSError(
1670
+ domain: "ManifestDownloadError",
1671
+ code: 3,
1672
+ userInfo: [NSLocalizedDescriptionKey: "Manifest file response was empty for \(fileName) at url \(downloadUrl)"]
1673
+ )
1674
+ }
1675
+
1676
+ var workURL: URL?
1677
+ defer {
1678
+ if let workURL {
1679
+ try? FileManager.default.removeItem(at: workURL)
1680
+ }
1681
+ }
1682
+
1683
+ do {
1684
+ var source = partialURL
1685
+ if !self.publicKey.isEmpty && !sessionKey.isEmpty {
1686
+ let work = cacheFolder.appendingPathComponent("work_\(UUID().uuidString)_\((fileName as NSString).lastPathComponent)")
1687
+ try FileManager.default.copyItem(at: partialURL, to: work)
1688
+ workURL = work
1689
+ do {
1690
+ try CryptoCipher.decryptFile(filePath: work, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
1691
+ } catch {
1692
+ try? FileManager.default.removeItem(at: partialURL)
1693
+ self.sendStats(action: "decrypt_fail", versionName: version)
1694
+ throw error
1695
+ }
1696
+ source = work
1697
+ }
1698
+
1699
+ let calculatedChecksum: String
1700
+ if isBrotli {
1701
+ do {
1702
+ calculatedChecksum = try decompressBrotli(from: source, to: destFilePath, fileName: fileName)
1703
+ } catch {
1704
+ try? FileManager.default.removeItem(at: partialURL)
1705
+ self.sendStats(action: "download_manifest_brotli_fail", versionName: "\(version):\(destFileName)")
1706
+ throw error
1707
+ }
1708
+ } else {
1709
+ let handle = try FileHandle(forReadingFrom: source)
1710
+ defer {
1711
+ try? handle.close()
1712
+ }
1713
+ let length = (try FileManager.default.attributesOfItem(atPath: source.path)[.size] as? NSNumber)?.uint64Value ?? 0
1714
+ calculatedChecksum = try streamCopy(from: handle, count: length, to: destFilePath)
1715
+ }
1716
+
1717
+ CryptoCipher.logChecksumInfo(label: "Calculated checksum", hexChecksum: calculatedChecksum)
1718
+ CryptoCipher.logChecksumInfo(label: "Expected checksum", hexChecksum: fileHash)
1719
+ if calculatedChecksum != fileHash {
1720
+ try? FileManager.default.removeItem(at: destFilePath)
1721
+ try? FileManager.default.removeItem(at: partialURL)
1722
+ self.sendStats(action: "download_manifest_checksum_fail", versionName: "\(version):\(destFileName)")
1723
+ throw NSError(domain: "ChecksumError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Computed checksum is not equal to required checksum (\(calculatedChecksum) != \(fileHash)) for file \(fileName) at url \(downloadUrl)"])
1724
+ }
1725
+
1726
+ if let cacheFilePath {
1727
+ try copyItemAtomically(from: destFilePath, to: cacheFilePath)
1728
+ }
1729
+ try? FileManager.default.removeItem(at: partialURL)
1730
+
1731
+ self.logger.info("Manifest file downloaded and cached")
1732
+ self.logger.debug("Bundle: \(bundleId), File: \(fileName), Brotli: \(isBrotli), Encrypted: \(!self.publicKey.isEmpty && !sessionKey.isEmpty)")
1733
+ } catch {
1734
+ self.logger.error("Manifest file download failed")
1735
+ self.logger.debug("Bundle: \(bundleId), File: \(fileName), Error: \(error.localizedDescription)")
1736
+ throw error
1737
+ }
1738
+ }
1739
+
1740
+ /// Copy a file to the destination, replacing any existing file.
1741
+ private func copyItemReplacing(from source: URL, to destination: URL) throws {
1742
+ let fileManager = FileManager.default
1743
+ try fileManager.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
1744
+ if fileManager.fileExists(atPath: destination.path) {
1745
+ try fileManager.removeItem(at: destination)
1746
+ }
1747
+ try fileManager.copyItem(at: source, to: destination)
1748
+ }
1749
+
1750
+ func copyMatchingBuiltinFilesForTests(files: [(source: URL, dest: URL, hash: String)]) throws {
1751
+ let queue = OperationQueue()
1752
+ queue.maxConcurrentOperationCount = CapgoUpdater.manifestMaxConcurrentFiles
1753
+ let lock = NSLock()
1754
+ var firstError: Error?
1755
+ for file in files {
1756
+ queue.addOperation { [weak self] in
1757
+ guard let self else { return }
1758
+ do {
1759
+ guard self.verifyChecksum(file: file.source, expectedHash: file.hash) else {
1760
+ throw NSError(
1761
+ domain: "CapgoInstallPerf",
1762
+ code: 1,
1763
+ userInfo: [NSLocalizedDescriptionKey: "builtin checksum mismatch"]
1764
+ )
1765
+ }
1766
+ try self.copyItemReplacing(from: file.source, to: file.dest)
1767
+ } catch {
1768
+ lock.lock()
1769
+ if firstError == nil {
1770
+ firstError = error
1771
+ }
1772
+ lock.unlock()
1773
+ }
1774
+ }
1775
+ }
1776
+ queue.waitUntilAllOperationsAreFinished()
1777
+ if let firstError {
1778
+ throw firstError
1779
+ }
1780
+ }
1781
+
1782
+ /// Copy via a unique temp name then rename, so a crash cannot leave a
1783
+ /// non-empty partial file that `isReusableCacheFile` would trust.
1784
+ private func copyItemAtomically(from source: URL, to destination: URL) throws {
1785
+ let fileManager = FileManager.default
1786
+ try fileManager.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
1787
+ let tempURL = destination.deletingLastPathComponent().appendingPathComponent("\(destination.lastPathComponent).\(UUID().uuidString).tmp")
1788
+ defer {
1789
+ try? fileManager.removeItem(at: tempURL)
1790
+ }
1791
+ try fileManager.copyItem(at: source, to: tempURL)
1792
+ try replaceItemAtomically(at: destination, withItemAt: tempURL)
1793
+ }
1794
+
1795
+ /// One-step replace when dest exists, move when it does not. Avoids the
1796
+ /// fileExists/removeItem race that can fail a verified install.
1797
+ private func replaceItemAtomically(at destination: URL, withItemAt tempURL: URL) throws {
1798
+ let fileManager = FileManager.default
1799
+ do {
1800
+ _ = try fileManager.replaceItemAt(destination, withItemAt: tempURL)
1801
+ } catch {
1802
+ if fileManager.fileExists(atPath: destination.path) {
1803
+ throw error
1804
+ }
1805
+ try fileManager.moveItem(at: tempURL, to: destination)
1806
+ }
1807
+ }
1808
+
1809
+ /// Atomically try to copy a file from cache - returns true if successful, false if file doesn't exist or copy failed
1810
+ /// This handles the race condition where OS can delete cache files between exists() check and copy
1811
+ private func tryCopyFromCache(from source: URL, to destination: URL, expectedHash: String) -> Bool {
1812
+ // First quick check - if file doesn't exist or was truncated, don't bother
1813
+ guard isReusableCacheFile(source, expectedHash: expectedHash) else {
1814
+ return false
1815
+ }
1816
+
1817
+ // Hash is in the cache file name and was verified when written.
1818
+ // Re-hashing here would re-read every reused file on low-RAM devices.
1819
+ do {
1820
+ try copyItemAtomically(from: source, to: destination)
1821
+ return true
1822
+ } catch {
1823
+ // File was deleted between check and copy, or other IO error - caller should download instead
1824
+ logger.debug("Cache copy failed (likely OS eviction): \(error.localizedDescription)")
1825
+ return false
1826
+ }
1827
+ }
1828
+
1829
+ /// Stream Brotli from disk to disk. Peek only the 3-byte header and last byte
1830
+ /// for the empty/wrapper special cases; never load the whole file.
1831
+ func decompressBrotli(from source: URL, to dest: URL, fileName: String) throws -> String {
1832
+ let fileManager = FileManager.default
1833
+ try fileManager.createDirectory(at: dest.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
1834
+ let length = (try fileManager.attributesOfItem(atPath: source.path)[.size] as? NSNumber)?.uint64Value ?? 0
1835
+ if length == 0 {
1836
+ try Data().write(to: dest, options: .atomic)
1837
+ return CryptoCipher.RunningChecksum().hex()
1838
+ }
1839
+
1840
+ let handle = try FileHandle(forReadingFrom: source)
1841
+ defer {
1842
+ try? handle.close()
1843
+ }
1844
+
1845
+ let head = try handle.read(upToCount: 3) ?? Data()
1846
+ var last: UInt8 = 0
1847
+ if length >= 1 {
1848
+ try handle.seek(toOffset: length - 1)
1849
+ last = try handle.read(upToCount: 1)?.first ?? 0
1850
+ }
1851
+
1852
+ if length == 3 && head.count == 3 && head[0] == 0x1B && head[1] == 0x00 && head[2] == 0x06 {
1853
+ try Data().write(to: dest, options: .atomic)
1854
+ return CryptoCipher.RunningChecksum().hex()
1855
+ }
1856
+
1857
+ if length > 3 && head.count == 3 && last == 0x03 {
1858
+ let isEmptyWrapper = head[0] == 0x1B && head[1] == 0x00 && head[2] == 0x06
1859
+ let isQualityZeroWrapper = head[0] == 0x0b && head[1] == 0x02 && head[2] == 0x80
1860
+ if isEmptyWrapper || isQualityZeroWrapper {
1861
+ try handle.seek(toOffset: 3)
1862
+ return try streamCopy(from: handle, count: length - 4, to: dest)
1863
+ }
1864
+ }
1865
+
1866
+ try handle.seek(toOffset: 0)
1867
+ return try streamBrotliDecode(from: handle, to: dest, fileName: fileName)
1868
+ }
1869
+
1870
+ func streamCopy(from handle: FileHandle, count: UInt64, to dest: URL) throws -> String {
1871
+ let fileManager = FileManager.default
1872
+ let tempURL = dest.deletingLastPathComponent().appendingPathComponent("capgo-br-\(UUID().uuidString).tmp")
1873
+ fileManager.createFile(atPath: tempURL.path, contents: nil)
1874
+ let output = try FileHandle(forWritingTo: tempURL)
1875
+ defer {
1876
+ try? output.close()
1877
+ try? fileManager.removeItem(at: tempURL)
1878
+ }
1879
+
1880
+ let hasher = CryptoCipher.RunningChecksum()
1881
+ var remaining = count
1882
+ let chunkSize = CryptoCipher.ioBufferBytes()
1883
+ while remaining > 0 {
1884
+ let readCount: Int = try autoreleasepool {
1885
+ let toRead = Int(min(UInt64(chunkSize), remaining))
1886
+ let chunk = try handle.read(upToCount: toRead) ?? Data()
1887
+ if !chunk.isEmpty {
1888
+ hasher.update(chunk)
1889
+ try output.write(contentsOf: chunk)
1890
+ }
1891
+ return chunk.count
1892
+ }
1893
+ if readCount == 0 {
1894
+ break
1895
+ }
1896
+ remaining -= UInt64(readCount)
1897
+ }
1898
+ try output.close()
1899
+ try replaceItemAtomically(at: dest, withItemAt: tempURL)
1900
+ return hasher.hex()
1901
+ }
1902
+
1903
+ private func streamBrotliDecode(from handle: FileHandle, to dest: URL, fileName: String) throws -> String {
1904
+ let fileManager = FileManager.default
1905
+ let tempURL = dest.deletingLastPathComponent().appendingPathComponent("capgo-br-\(UUID().uuidString).tmp")
1906
+ fileManager.createFile(atPath: tempURL.path, contents: nil)
1907
+ let output = try FileHandle(forWritingTo: tempURL)
1908
+ defer {
1909
+ try? output.close()
1910
+ try? fileManager.removeItem(at: tempURL)
1911
+ }
1912
+ let hasher = CryptoCipher.RunningChecksum()
1913
+
1914
+ let chunkSize = max(CryptoCipher.ioBufferBytes(), 65536)
1915
+ var inputBuffer = [UInt8](repeating: 0, count: chunkSize)
1916
+ var outputBuffer = [UInt8](repeating: 0, count: chunkSize)
1917
+
1918
+ let streamPointer = UnsafeMutablePointer<compression_stream>.allocate(capacity: 1)
1919
+ var status = compression_stream_init(streamPointer, COMPRESSION_STREAM_DECODE, COMPRESSION_BROTLI)
1920
+ guard status != COMPRESSION_STATUS_ERROR else {
1921
+ logger.error("Failed to initialize Brotli stream")
1922
+ logger.debug("File: \(fileName), Status: \(status)")
1923
+ throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to initialize Brotli stream for \(fileName)"])
1924
+ }
1925
+ defer {
1926
+ compression_stream_destroy(streamPointer)
1927
+ streamPointer.deallocate()
1928
+ }
1929
+
1930
+ try inputBuffer.withUnsafeMutableBufferPointer { inBuf in
1931
+ try outputBuffer.withUnsafeMutableBufferPointer { outBuf in
1932
+ guard let inBase = inBuf.baseAddress, let outBase = outBuf.baseAddress else {
1933
+ throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to get buffer address for \(fileName)"])
1934
+ }
1935
+ streamPointer.pointee.src_size = 0
1936
+ streamPointer.pointee.dst_ptr = outBase
1937
+ streamPointer.pointee.dst_size = chunkSize
1938
+
1939
+ var flags: Int32 = 0
1940
+ var inputExhausted = false
1941
+ while true {
1942
+ if streamPointer.pointee.src_size == 0 && !inputExhausted {
1943
+ let chunk = try handle.read(upToCount: chunkSize) ?? Data()
1944
+ if chunk.isEmpty {
1945
+ inputExhausted = true
1946
+ flags = Int32(bitPattern: COMPRESSION_STREAM_FINALIZE.rawValue)
1947
+ } else {
1948
+ chunk.copyBytes(to: inBase, count: chunk.count)
1949
+ streamPointer.pointee.src_ptr = UnsafePointer(inBase)
1950
+ streamPointer.pointee.src_size = chunk.count
1951
+ }
1952
+ }
1953
+
1954
+ status = compression_stream_process(streamPointer, flags)
1955
+ let have = chunkSize - streamPointer.pointee.dst_size
1956
+ if have > 0 {
1957
+ let decoded = Data(bytes: outBase, count: have)
1958
+ hasher.update(decoded)
1959
+ try output.write(contentsOf: decoded)
1960
+ }
1961
+ streamPointer.pointee.dst_ptr = outBase
1962
+ streamPointer.pointee.dst_size = chunkSize
1963
+
1964
+ if status == COMPRESSION_STATUS_END {
1965
+ break
1966
+ }
1967
+ if status == COMPRESSION_STATUS_ERROR {
1968
+ logger.error("Brotli process failed")
1969
+ logger.debug("File: \(fileName), Status: \(status)")
1970
+ throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to decompress Brotli data for file \(fileName)"])
1971
+ }
1972
+ if inputExhausted && streamPointer.pointee.src_size == 0 && have == 0 {
1973
+ logger.error("Brotli decompression stalled")
1974
+ logger.debug("File: \(fileName)")
1975
+ throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to decompress Brotli data for file \(fileName)"])
1976
+ }
1977
+ }
1978
+ }
1979
+ }
1980
+
1981
+ try output.close()
1982
+ try replaceItemAtomically(at: dest, withItemAt: tempURL)
1983
+ return hasher.hex()
1984
+ }
1985
+
1986
+ public func download(url: URL, version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
1987
+ try self.runBeforeDownload()
1988
+ let id: String = self.randomString(length: 10)
1989
+ // Each download uses its own temp files keyed by bundle ID to prevent collisions
1990
+ if version != getLocalUpdateVersion(for: id) {
1991
+ cleanDownloadData(for: id)
1992
+ }
1993
+ ensureResumableFilesExist(for: id)
1994
+ saveDownloadInfo(version, for: id)
1995
+
1996
+ // Check disk space before starting download (matches Android behavior)
1997
+ try checkDiskSpace()
1998
+
1999
+ var checksum = ""
2000
+ var lastSentProgress = 0
2001
+ let totalReceivedBytes: Int64 = loadDownloadProgress(for: id) // Retrieving the amount of already downloaded data if exist, defined at 0 otherwise
2002
+ let tempPath = tempDataPath(for: id)
2003
+ let bundleInfo = BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: checksum, link: link, comment: comment)
2004
+ self.saveBundleInfo(id: id, bundle: bundleInfo)
2005
+
2006
+ // Send stats for zip download start
2007
+ self.sendStats(action: "download_zip_start", versionName: version)
2008
+
2009
+ // Opening connection for streaming the bytes
2010
+ if totalReceivedBytes == 0 {
2011
+ self.notifyDownload(id: id, percent: 0, ignoreMultipleOfTen: true)
2012
+ }
2013
+ var mainError: NSError?
2014
+
2015
+ guard var request = createRequest(url: url, method: "GET") else {
2016
+ self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum, link: link, comment: comment))
2017
+ throw NSError(
2018
+ domain: "DownloadError",
2019
+ code: 2,
2020
+ userInfo: [NSLocalizedDescriptionKey: "Invalid download request for \(url.absoluteString)"]
2021
+ )
2022
+ }
2023
+
2024
+ if totalReceivedBytes > 0 {
2025
+ request.setValue("bytes=\(totalReceivedBytes)-", forHTTPHeaderField: "Range")
2026
+ }
2027
+
2028
+ let downloadResult = performDownloadRequest(request, label: "download \(version)")
2029
+
2030
+ if downloadResult.timedOut {
2031
+ persistPartialDownload(downloadResult, id: id, tempPath: tempPath, existingBytes: totalReceivedBytes)
2032
+ mainError = NSError(
2033
+ domain: NSURLErrorDomain,
2034
+ code: NSURLErrorTimedOut,
2035
+ userInfo: [NSLocalizedDescriptionKey: "Timed out downloading bundle from \(url.absoluteString)"]
2036
+ )
2037
+ } else if let error = downloadResult.error {
2038
+ logger.error("Download failed")
2039
+ persistPartialDownload(downloadResult, id: id, tempPath: tempPath, existingBytes: totalReceivedBytes)
2040
+ mainError = error as NSError
2041
+ } else if let statusCode = downloadResult.response?.statusCode, statusCode < 200 || statusCode >= 300 {
2042
+ logger.error("Download failed")
2043
+ mainError = NSError(
2044
+ domain: "DownloadError",
2045
+ code: statusCode,
2046
+ userInfo: [NSLocalizedDescriptionKey: "Download request failed with status code \(statusCode)"]
2047
+ )
2048
+ } else if let downloadedFileURL = downloadResult.fileURL {
2049
+ do {
2050
+ try storeDownloadedFile(downloadedFileURL, at: tempPath, existingBytes: totalReceivedBytes, response: downloadResult.response)
2051
+
2052
+ if lastSentProgress < 70 {
2053
+ self.notifyDownload(id: id, percent: 70, ignoreMultipleOfTen: true)
2054
+ lastSentProgress = 70
2055
+ }
2056
+ self.logger.info("Download complete")
2057
+ } catch let error as NSError {
2058
+ mainError = error
2059
+ } catch {
2060
+ mainError = error as NSError
2061
+ }
2062
+ } else {
2063
+ mainError = NSError(
2064
+ domain: "DownloadError",
2065
+ code: 1,
2066
+ userInfo: [NSLocalizedDescriptionKey: "Downloaded file is missing at \(tempPath.path)"]
2067
+ )
2068
+ }
2069
+
2070
+ if mainError != nil {
2071
+ logger.error("Failed to download bundle")
2072
+ logger.debug("Error: \(String(describing: mainError))")
2073
+ self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum, link: link, comment: comment))
2074
+ throw mainError!
2075
+ }
2076
+
2077
+ let finalPath = tempPath.deletingLastPathComponent().appendingPathComponent("\(id)")
2078
+ do {
2079
+ try CryptoCipher.decryptFile(filePath: tempPath, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
2080
+ try FileManager.default.moveItem(at: tempPath, to: finalPath)
2081
+ } catch {
2082
+ logger.error("Failed to decrypt file")
2083
+ logger.debug("Error: \(error)")
2084
+ self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum, link: link, comment: comment))
2085
+ cleanDownloadData(for: id)
2086
+ throw error
2087
+ }
2088
+
2089
+ do {
2090
+ checksum = CryptoCipher.calcChecksum(filePath: finalPath)
2091
+ CryptoCipher.logChecksumInfo(label: "Calculated bundle checksum", hexChecksum: checksum)
2092
+ logger.info("Downloading: 80% (unzipping)")
2093
+ try self.saveDownloaded(sourceZip: finalPath, id: id, base: self.libraryDir.appendingPathComponent(self.bundleDirectory), notify: true)
2094
+ self.populateDeltaCacheAsync(for: id)
2095
+
2096
+ } catch {
2097
+ logger.error("Failed to unzip file")
2098
+ logger.debug("Error: \(error)")
2099
+ self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum, link: link, comment: comment))
2100
+ // Best-effort cleanup of the decrypted zip file when unzip fails
2101
+ do {
2102
+ if FileManager.default.fileExists(atPath: finalPath.path) {
2103
+ try FileManager.default.removeItem(at: finalPath)
2104
+ }
2105
+ } catch {
2106
+ logger.error("Could not delete failed zip")
2107
+ logger.debug("Path: \(finalPath.path), Error: \(error)")
2108
+ }
2109
+ cleanDownloadData(for: id)
2110
+ throw error
2111
+ }
2112
+
2113
+ self.notifyDownload(id: id, percent: 90)
2114
+ logger.info("Downloading: 90% (wrapping up)")
2115
+ let info = BundleInfo(id: id, version: version, status: BundleStatus.PENDING, downloaded: Date(), checksum: checksum, link: link, comment: comment)
2116
+ self.saveBundleInfo(id: id, bundle: info)
2117
+ self.cleanDownloadData(for: id)
2118
+
2119
+ // Send stats for zip download complete
2120
+ self.sendStats(action: "download_zip_complete", versionName: version)
2121
+
2122
+ self.notifyDownload(id: id, percent: 100, bundle: info)
2123
+ logger.info("Downloading: 100% (complete)")
2124
+ return info
2125
+ }
2126
+ private func ensureResumableFilesExist(for id: String) {
2127
+ let fileManager = FileManager.default
2128
+ let tempPath = tempDataPath(for: id)
2129
+ let infoPath = updateInfoPath(for: id)
2130
+ if !fileManager.fileExists(atPath: tempPath.path) {
2131
+ if !fileManager.createFile(atPath: tempPath.path, contents: Data()) {
2132
+ logger.error("Cannot ensure temp data file exists")
2133
+ logger.debug("Path: \(tempPath.path)")
2134
+ }
2135
+ }
2136
+
2137
+ if !fileManager.fileExists(atPath: infoPath.path) {
2138
+ if !fileManager.createFile(atPath: infoPath.path, contents: Data()) {
2139
+ logger.error("Cannot ensure update info file exists")
2140
+ logger.debug("Path: \(infoPath.path)")
2141
+ }
2142
+ }
2143
+ }
2144
+
2145
+ private func cleanDownloadData(for id: String) {
2146
+ let fileManager = FileManager.default
2147
+ let tempPath = tempDataPath(for: id)
2148
+ let infoPath = updateInfoPath(for: id)
2149
+ // Deleting package_<id>.tmp
2150
+ if fileManager.fileExists(atPath: tempPath.path) {
2151
+ do {
2152
+ try fileManager.removeItem(at: tempPath)
2153
+ } catch {
2154
+ logger.error("Could not delete temp data file")
2155
+ logger.debug("Path: \(tempPath), Error: \(error)")
2156
+ }
2157
+ }
2158
+ // Deleting update_<id>.dat
2159
+ if fileManager.fileExists(atPath: infoPath.path) {
2160
+ do {
2161
+ try fileManager.removeItem(at: infoPath)
2162
+ } catch {
2163
+ logger.error("Could not delete update info file")
2164
+ logger.debug("Path: \(infoPath), Error: \(error)")
2165
+ }
2166
+ }
2167
+ }
2168
+
2169
+ private func savePartialData(startingAt byteOffset: UInt64, for id: String) {
2170
+ let fileManager = FileManager.default
2171
+ let tempPath = tempDataPath(for: id)
2172
+ do {
2173
+ // Check if package_<id>.tmp exist
2174
+ if !fileManager.fileExists(atPath: tempPath.path) {
2175
+ try self.tempData.write(to: tempPath, options: .atomicWrite)
2176
+ } else {
2177
+ // If yes, it start writing on it
2178
+ let fileHandle = try FileHandle(forWritingTo: tempPath)
2179
+ fileHandle.seek(toFileOffset: byteOffset) // Moving at the specified position to start writing
2180
+ fileHandle.write(self.tempData)
2181
+ fileHandle.closeFile()
2182
+ }
2183
+ } catch {
2184
+ logger.error("Failed to write partial data")
2185
+ logger.debug("Byte offset: \(byteOffset), Error: \(error)")
2186
+ }
2187
+ self.tempData.removeAll() // Clearing tempData to avoid writing the same data multiple times
2188
+ }
2189
+
2190
+ private func saveDownloadInfo(_ version: String, for id: String) {
2191
+ let infoPath = updateInfoPath(for: id)
2192
+ do {
2193
+ try "\(version)".write(to: infoPath, atomically: true, encoding: .utf8)
2194
+ } catch {
2195
+ logger.error("Failed to save download progress")
2196
+ logger.debug("Error: \(error)")
2197
+ }
2198
+ }
2199
+
2200
+ private func getLocalUpdateVersion(for id: String) -> String { // Return the version that was tried to be downloaded on last download attempt
2201
+ let infoPath = updateInfoPath(for: id)
2202
+ if !FileManager.default.fileExists(atPath: infoPath.path) {
2203
+ return "nil"
2204
+ }
2205
+ guard let versionString = try? String(contentsOf: infoPath),
2206
+ let version = Optional(versionString) else {
2207
+ return "nil"
2208
+ }
2209
+ return version
2210
+ }
2211
+
2212
+ private func loadDownloadProgress(for id: String) -> Int64 {
2213
+ let fileManager = FileManager.default
2214
+ let tempPath = tempDataPath(for: id)
2215
+ do {
2216
+ let attributes = try fileManager.attributesOfItem(atPath: tempPath.path)
2217
+ if let fileSize = attributes[.size] as? NSNumber {
2218
+ return fileSize.int64Value
2219
+ }
2220
+ } catch {
2221
+ logger.error("Could not retrieve download progress size")
2222
+ logger.debug("Error: \(error)")
2223
+ }
2224
+ return 0
2225
+ }
2226
+
2227
+ public func list(raw: Bool = false) -> [BundleInfo] {
2228
+ if !raw {
2229
+ // UserDefaults.standard.dictionaryRepresentation().values
2230
+ let dest: URL = libraryDir.appendingPathComponent(bundleDirectory)
2231
+ do {
2232
+ let files: [String] = try FileManager.default.contentsOfDirectory(atPath: dest.path)
2233
+ var res: [BundleInfo] = []
2234
+ logger.info("list File : \(dest.path)")
2235
+ if dest.exist {
2236
+ for id: String in files {
2237
+ res.append(self.getBundleInfo(id: id))
2238
+ }
2239
+ }
2240
+ return res
2241
+ } catch {
2242
+ logger.info("No version available \(dest.path)")
2243
+ return []
2244
+ }
2245
+ } else {
2246
+ guard let regex = try? NSRegularExpression(pattern: "^[0-9A-Za-z]{10}_info$") else {
2247
+ logger.error("Invalid regex ?????")
2248
+ return []
2249
+ }
2250
+ return UserDefaults.standard.dictionaryRepresentation().keys.filter {
2251
+ let range = NSRange($0.startIndex..., in: $0)
2252
+ let matches = regex.matches(in: $0, range: range)
2253
+ return !matches.isEmpty
2254
+ }.map {
2255
+ $0.components(separatedBy: "_")[0]
2256
+ }.map {
2257
+ self.getBundleInfo(id: $0)
2258
+ }
2259
+ }
2260
+
2261
+ }
2262
+
2263
+ public func delete(id: String, removeInfo: Bool) -> Bool {
2264
+ self.deleteLock.lock()
2265
+ defer { self.deleteLock.unlock() }
2266
+
2267
+ let deleted: BundleInfo = self.getBundleInfo(id: id)
2268
+ if deleted.isBuiltin() || self.getCurrentBundleId() == id {
2269
+ logger.info("Cannot delete current or builtin bundle")
2270
+ logger.debug("Bundle ID: \(id)")
2271
+ return false
2272
+ }
2273
+
2274
+ if let previewFallback = self.getPreviewFallbackBundle(),
2275
+ !previewFallback.isDeleted(),
2276
+ !previewFallback.isErrorStatus(),
2277
+ !previewFallback.isDeleting(),
2278
+ previewFallback.getId() == id {
2279
+ logger.info("Cannot delete the preview fallback bundle")
2280
+ logger.debug("Bundle ID: \(id)")
2281
+ return false
2282
+ }
2283
+
2284
+ // Check if this is the next bundle and prevent deletion if it is
2285
+ if let next = self.getNextBundle(),
2286
+ !next.isDeleted() &&
2287
+ !next.isErrorStatus() &&
2288
+ !next.isDeleting() &&
2289
+ next.getId() == id {
2290
+ logger.info("Cannot delete the next bundle")
2291
+ logger.debug("Bundle ID: \(id)")
2292
+ return false
2293
+ }
2294
+
2295
+ let destPersist: URL = libraryDir.appendingPathComponent(bundleDirectory).appendingPathComponent(id)
2296
+ let hadRegistry = self.hasStoredBundleInfo(id: id)
2297
+ let hadFolder = FileManager.default.fileExists(atPath: destPersist.path)
2298
+ if !hadRegistry && !hadFolder {
2299
+ logger.error("Cannot delete unknown bundle")
2300
+ logger.debug("Bundle ID: \(id)")
2301
+ self.dequeuePendingDelete(id: id)
2302
+ return false
2303
+ }
2304
+
2305
+ // Persist DELETING before touching disk so kill/OOM can resume on next launch.
2306
+ if !deleted.isDeleting() {
2307
+ if !self.saveBundleInfo(id: id, bundle: deleted.setStatus(status: BundleStatus.DELETING.storedValue)) {
2308
+ logger.error("Failed to persist DELETING marker, aborting disk delete")
2309
+ logger.debug("Bundle ID: \(id)")
2310
+ return false
2311
+ }
2312
+ UserDefaults.standard.synchronize()
2313
+ }
2314
+
2315
+ if FileManager.default.fileExists(atPath: destPersist.path) {
2316
+ do {
2317
+ try FileManager.default.removeItem(atPath: destPersist.path)
2318
+ } catch {
2319
+ logger.error("Bundle folder not removed, will retry later")
2320
+ logger.debug("Path: \(destPersist.path), Error: \(error.localizedDescription)")
2321
+ return false
2322
+ }
2323
+ }
2324
+
2325
+ // Only drop registry after the folder is confirmed gone.
2326
+ if FileManager.default.fileExists(atPath: destPersist.path) {
2327
+ logger.error("Bundle folder still present after delete, will retry later")
2328
+ logger.debug("Bundle ID: \(id)")
2329
+ return false
2330
+ }
2331
+
2332
+ let finalized: Bool
2333
+ if removeInfo {
2334
+ finalized = self.saveBundleInfo(id: id, bundle: nil)
2335
+ } else {
2336
+ finalized = self.saveBundleInfo(id: id, bundle: deleted.setStatus(status: BundleStatus.DELETED.storedValue))
2337
+ }
2338
+ guard finalized else {
2339
+ logger.error("Failed to finalize delete registry update, will retry later")
2340
+ logger.debug("Bundle ID: \(id)")
2341
+ return false
2342
+ }
2343
+ UserDefaults.standard.synchronize()
2344
+ self.dequeuePendingDelete(id: id)
2345
+ logger.info("Bundle deleted and confirmed gone")
2346
+ logger.debug("Version: \(deleted.getVersionName())")
2347
+ self.sendStats(action: "delete", versionName: deleted.getVersionName())
2348
+ return true
2349
+ }
2350
+
2351
+ public func delete(id: String) -> Bool {
2352
+ return self.delete(id: id, removeInfo: true)
2353
+ }
2354
+
2355
+ /// Resume incomplete deletes one-by-one. Safe across app kill / OOM because
2356
+ /// delete() marks DELETING before disk work and only clears registry after confirm.
2357
+ public func drainPendingDeletes() {
2358
+ var pendingIds = Set(self.list(raw: true).filter { $0.isDeleting() }.map { $0.getId() }.filter { !$0.isEmpty })
2359
+ pendingIds.formUnion(self.getPendingDeleteIds())
2360
+ for id in pendingIds {
2361
+ if Thread.current.isCancelled {
2362
+ logger.warn("drainPendingDeletes was cancelled")
2363
+ return
2364
+ }
2365
+ logger.info("Resuming pending delete for bundle: \(id)")
2366
+ if self.delete(id: id, removeInfo: true) {
2367
+ self.dequeuePendingDelete(id: id)
2368
+ }
2369
+ Thread.sleep(forTimeInterval: self.deletePaceSeconds)
2370
+ }
2371
+ }
2372
+
2373
+ private func getPendingDeleteIds() -> Set<String> {
2374
+ guard let raw = UserDefaults.standard.string(forKey: self.PENDING_DELETE_IDS), !raw.isEmpty else {
2375
+ return []
2376
+ }
2377
+ return Set(raw.split(separator: ",").map { String($0) }.filter { !$0.isEmpty })
2378
+ }
2379
+
2380
+ private func enqueuePendingDelete(id: String) {
2381
+ guard !id.isEmpty else { return }
2382
+ var ids = self.getPendingDeleteIds()
2383
+ guard ids.insert(id).inserted else { return }
2384
+ UserDefaults.standard.set(ids.sorted().joined(separator: ","), forKey: self.PENDING_DELETE_IDS)
2385
+ UserDefaults.standard.synchronize()
2386
+ }
2387
+
2388
+ private func dequeuePendingDelete(id: String) {
2389
+ guard !id.isEmpty else { return }
2390
+ var ids = self.getPendingDeleteIds()
2391
+ guard ids.remove(id) != nil else { return }
2392
+ if ids.isEmpty {
2393
+ UserDefaults.standard.removeObject(forKey: self.PENDING_DELETE_IDS)
2394
+ } else {
2395
+ UserDefaults.standard.set(ids.sorted().joined(separator: ","), forKey: self.PENDING_DELETE_IDS)
2396
+ }
2397
+ UserDefaults.standard.synchronize()
2398
+ }
2399
+
2400
+ public func cleanupDeltaCache() {
2401
+ cleanupDeltaCache(threadToCheck: nil)
2402
+ }
2403
+
2404
+ public func cleanupDeltaCache(threadToCheck: Thread?) {
2405
+ // Check if thread was cancelled
2406
+ if let thread = threadToCheck, thread.isCancelled {
2407
+ logger.warn("cleanupDeltaCache was cancelled before starting")
2408
+ return
2409
+ }
2410
+
2411
+ let fileManager = FileManager.default
2412
+ guard fileManager.fileExists(atPath: cacheFolder.path) else {
2413
+ return
2414
+ }
2415
+ do {
2416
+ try fileManager.removeItem(at: cacheFolder)
2417
+ logger.info("Cleaned up delta cache folder")
2418
+ } catch {
2419
+ logger.error("Failed to cleanup delta cache")
2420
+ logger.debug("Error: \(error.localizedDescription)")
2421
+ }
2422
+ }
2423
+
2424
+ public func cleanupDownloadDirectories(allowedIds: Set<String>) {
2425
+ cleanupDownloadDirectories(allowedIds: allowedIds, threadToCheck: nil)
2426
+ }
2427
+
2428
+ public func cleanupDownloadDirectories(allowedIds: Set<String>, threadToCheck: Thread?) {
2429
+ let bundleRoot = libraryDir.appendingPathComponent(bundleDirectory)
2430
+ let fileManager = FileManager.default
2431
+
2432
+ guard fileManager.fileExists(atPath: bundleRoot.path) else {
2433
+ return
2434
+ }
2435
+
2436
+ do {
2437
+ let contents = try fileManager.contentsOfDirectory(at: bundleRoot, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles])
2438
+
2439
+ for url in contents {
2440
+ // Check if thread was cancelled
2441
+ if let thread = threadToCheck, thread.isCancelled {
2442
+ logger.warn("cleanupDownloadDirectories was cancelled")
2443
+ return
2444
+ }
2445
+
2446
+ let resourceValues = try url.resourceValues(forKeys: [.isDirectoryKey])
2447
+ if resourceValues.isDirectory != true {
2448
+ continue
2449
+ }
2450
+
2451
+ let id = url.lastPathComponent
2452
+
2453
+ if allowedIds.contains(id) {
2454
+ continue
2455
+ }
2456
+
2457
+ do {
2458
+ try fileManager.removeItem(at: url)
2459
+ if fileManager.fileExists(atPath: url.path) {
2460
+ logger.error("Orphan bundle directory still present after delete")
2461
+ logger.debug("Bundle ID: \(id)")
2462
+ continue
2463
+ }
2464
+ self.removeBundleInfo(id: id)
2465
+ logger.info("Deleted orphan bundle directory")
2466
+ logger.debug("Bundle ID: \(id)")
2467
+ } catch {
2468
+ logger.error("Failed to delete orphan bundle directory")
2469
+ logger.debug("Bundle ID: \(id), Error: \(error.localizedDescription)")
2470
+ }
2471
+ }
2472
+ } catch {
2473
+ logger.error("Failed to enumerate bundle directory for cleanup")
2474
+ logger.debug("Error: \(error.localizedDescription)")
2475
+ }
2476
+ }
2477
+
2478
+ public func allowedBundleIdsForCleanup() -> Set<String> {
2479
+ var allowedIds = Set(self.list(raw: true).compactMap { info -> String? in
2480
+ let id = info.getId()
2481
+ // DELETED tombstones must not protect leftover folders.
2482
+ // DELETING stays protected so drainPendingDeletes owns the removal.
2483
+ if id.isEmpty || info.isDeleted() {
2484
+ return nil
2485
+ }
2486
+ return id
2487
+ })
2488
+ let currentId = self.getCurrentBundleId()
2489
+ if !currentId.isEmpty {
2490
+ allowedIds.insert(currentId)
2491
+ }
2492
+ let fallback = self.getFallbackBundle()
2493
+ let fallbackId = fallback.getId()
2494
+ if !fallbackId.isEmpty && !fallback.isDeleting() {
2495
+ allowedIds.insert(fallbackId)
2496
+ }
2497
+ if let next = self.getNextBundle() {
2498
+ let nextId = next.getId()
2499
+ if !nextId.isEmpty && !next.isDeleting() {
2500
+ allowedIds.insert(nextId)
2501
+ }
2502
+ }
2503
+ if let previewFallback = self.getPreviewFallbackBundle() {
2504
+ let previewId = previewFallback.getId()
2505
+ if !previewId.isEmpty && !previewFallback.isDeleting() {
2506
+ allowedIds.insert(previewId)
2507
+ }
2508
+ }
2509
+ return allowedIds
2510
+ }
2511
+
2512
+ public func cleanupOrphanedTempFolders(threadToCheck: Thread?) {
2513
+ let fileManager = FileManager.default
2514
+
2515
+ do {
2516
+ let contents = try fileManager.contentsOfDirectory(at: libraryDir, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles])
2517
+
2518
+ for url in contents {
2519
+ // Check if thread was cancelled
2520
+ if let thread = threadToCheck, thread.isCancelled {
2521
+ logger.warn("cleanupOrphanedTempFolders was cancelled")
2522
+ return
2523
+ }
2524
+
2525
+ let resourceValues = try url.resourceValues(forKeys: [.isDirectoryKey])
2526
+ if resourceValues.isDirectory != true {
2527
+ continue
2528
+ }
2529
+
2530
+ let folderName = url.lastPathComponent
2531
+
2532
+ // Only delete folders with the temp unzip prefix
2533
+ if !folderName.hasPrefix(TEMP_UNZIP_PREFIX) {
2534
+ continue
2535
+ }
2536
+
2537
+ do {
2538
+ try fileManager.removeItem(at: url)
2539
+ logger.info("Deleted orphaned temp unzip folder")
2540
+ logger.debug("Folder: \(folderName)")
2541
+ } catch {
2542
+ logger.error("Failed to delete orphaned temp folder")
2543
+ logger.debug("Folder: \(folderName), Error: \(error.localizedDescription)")
2544
+ }
2545
+ }
2546
+ } catch {
2547
+ logger.error("Failed to enumerate library directory for temp folder cleanup")
2548
+ logger.debug("Error: \(error.localizedDescription)")
2549
+ }
2550
+
2551
+ if let thread = threadToCheck, thread.isCancelled {
2552
+ logger.warn("cleanupOrphanedTempFolders was cancelled")
2553
+ return
2554
+ }
2555
+
2556
+ // Also cleanup old download temp files (package_*.tmp and update_*.dat)
2557
+ cleanupOldDownloadTempFiles(threadToCheck: threadToCheck)
2558
+ }
2559
+
2560
+ private func cleanupOldDownloadTempFiles(threadToCheck: Thread? = nil) {
2561
+ let fileManager = FileManager.default
2562
+ guard let documentsDir = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
2563
+ return
2564
+ }
2565
+
2566
+ do {
2567
+ let contents = try fileManager.contentsOfDirectory(at: documentsDir, includingPropertiesForKeys: [.contentModificationDateKey], options: [.skipsHiddenFiles])
2568
+ let oneHourAgo = Date().addingTimeInterval(-3600)
2569
+
2570
+ for url in contents {
2571
+ if let thread = threadToCheck, thread.isCancelled {
2572
+ logger.warn("cleanupOldDownloadTempFiles was cancelled")
2573
+ return
2574
+ }
2575
+ let fileName = url.lastPathComponent
2576
+ // Only cleanup package_*.tmp and update_*.dat files
2577
+ let isDownloadTemp = (fileName.hasPrefix("package_") && fileName.hasSuffix(".tmp")) ||
2578
+ (fileName.hasPrefix("update_") && fileName.hasSuffix(".dat"))
2579
+ if !isDownloadTemp {
2580
+ continue
2581
+ }
2582
+
2583
+ // Only delete files older than 1 hour
2584
+ if let modDate = try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate,
2585
+ modDate < oneHourAgo {
2586
+ do {
2587
+ try fileManager.removeItem(at: url)
2588
+ logger.debug("Deleted old download temp file: \(fileName)")
2589
+ } catch {
2590
+ logger.debug("Failed to delete old download temp file: \(fileName), Error: \(error.localizedDescription)")
2591
+ }
2592
+ }
2593
+ }
2594
+ } catch {
2595
+ logger.debug("Failed to enumerate documents directory for temp file cleanup: \(error.localizedDescription)")
2596
+ }
2597
+ }
2598
+
2599
+ public func getBundleDirectory(id: String) -> URL {
2600
+ return libraryDir.appendingPathComponent(self.bundleDirectory).appendingPathComponent(id)
2601
+ }
2602
+
2603
+ struct ResetState {
2604
+ let currentBundlePath: String
2605
+ let fallbackBundleId: String
2606
+ let nextBundleId: String?
2607
+ }
2608
+
2609
+ func captureResetState() -> ResetState {
2610
+ ResetState(
2611
+ currentBundlePath: UserDefaults.standard.string(forKey: self.CAP_SERVER_PATH) ?? self.DEFAULT_FOLDER,
2612
+ fallbackBundleId: UserDefaults.standard.string(forKey: self.FALLBACK_VERSION) ?? BundleInfo.ID_BUILTIN,
2613
+ nextBundleId: UserDefaults.standard.string(forKey: self.NEXT_VERSION)
2614
+ )
2615
+ }
2616
+
2617
+ func restoreResetState(_ state: ResetState) {
2618
+ let currentBundlePath = state.currentBundlePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
2619
+ ? self.DEFAULT_FOLDER
2620
+ : state.currentBundlePath
2621
+ let fallbackBundleId = state.fallbackBundleId.isEmpty ? BundleInfo.ID_BUILTIN : state.fallbackBundleId
2622
+
2623
+ self.setCurrentBundle(bundle: currentBundlePath)
2624
+ UserDefaults.standard.set(fallbackBundleId, forKey: self.FALLBACK_VERSION)
2625
+ if let nextBundleId = state.nextBundleId, !nextBundleId.isEmpty {
2626
+ UserDefaults.standard.set(nextBundleId, forKey: self.NEXT_VERSION)
2627
+ } else {
2628
+ UserDefaults.standard.removeObject(forKey: self.NEXT_VERSION)
2629
+ }
2630
+ UserDefaults.standard.synchronize()
2631
+ }
2632
+
2633
+ func prepareResetStateForTransition() {
2634
+ self.setCurrentBundle(bundle: "")
2635
+ self.setFallbackBundle(fallback: Optional<BundleInfo>.none)
2636
+ _ = self.setNextBundle(next: Optional<String>.none)
2637
+ }
2638
+
2639
+ func finalizeResetTransition(previousBundleName: String, isInternal: Bool) {
2640
+ if !isInternal {
2641
+ self.sendStats(action: "reset", versionName: self.getCurrentBundle().getVersionName(), oldVersionName: previousBundleName)
2642
+ }
2643
+ }
2644
+
2645
+ func canSet(bundle: BundleInfo) -> Bool {
2646
+ bundle.isBuiltin() || self.bundleExists(id: bundle.getId())
2647
+ }
2648
+
2649
+ public func set(bundle: BundleInfo) -> Bool {
2650
+ return self.set(id: bundle.getId())
2651
+ }
2652
+
2653
+ private func bundleExists(id: String) -> Bool {
2654
+ let destPersist: URL = self.getBundleDirectory(id: id)
2655
+ let indexPersist: URL = destPersist.appendingPathComponent("index.html")
2656
+ let bundleIndo: BundleInfo = self.getBundleInfo(id: id)
2657
+ if
2658
+ destPersist.exist &&
2659
+ destPersist.isDirectory &&
2660
+ !indexPersist.isDirectory &&
2661
+ indexPersist.exist &&
2662
+ !bundleIndo.isDeleted() &&
2663
+ !bundleIndo.isDeleting() {
2664
+ return true
2665
+ }
2666
+ return false
2667
+ }
2668
+
2669
+ public func set(id: String) -> Bool {
2670
+ let newBundle: BundleInfo = self.getBundleInfo(id: id)
2671
+ if newBundle.isBuiltin() {
2672
+ self.reset()
2673
+ return true
2674
+ }
2675
+ if bundleExists(id: id) {
2676
+ let currentBundleName = self.getCurrentBundle().getVersionName()
2677
+ self.setCurrentBundle(bundle: self.getBundleDirectory(id: id).path)
2678
+ self.setBundleStatus(id: id, status: BundleStatus.PENDING)
2679
+ self.sendStats(action: "set", versionName: newBundle.getVersionName(), oldVersionName: currentBundleName)
2680
+ return true
2681
+ }
2682
+ self.setBundleStatus(id: id, status: BundleStatus.ERROR)
2683
+ self.sendStats(action: "set_fail", versionName: newBundle.getVersionName())
2684
+ return false
2685
+ }
2686
+
2687
+ func stagePendingReload(bundle: BundleInfo) -> Bool {
2688
+ guard !bundle.isBuiltin(), bundleExists(id: bundle.getId()) else {
2689
+ return false
2690
+ }
2691
+ self.setCurrentBundle(bundle: self.getBundleDirectory(id: bundle.getId()).path)
2692
+ return true
2693
+ }
2694
+
2695
+ func stagePreviewFallbackReload(bundle: BundleInfo) -> Bool {
2696
+ guard !bundle.isErrorStatus() else {
2697
+ return false
2698
+ }
2699
+ if bundle.isBuiltin() {
2700
+ self.setCurrentBundle(bundle: self.DEFAULT_FOLDER)
2701
+ return true
2702
+ }
2703
+ guard bundleExists(id: bundle.getId()) else {
2704
+ return false
2705
+ }
2706
+ self.setCurrentBundle(bundle: self.getBundleDirectory(id: bundle.getId()).path)
2707
+ return true
2708
+ }
2709
+
2710
+ func finalizePendingReload(bundle: BundleInfo, previousBundleName: String) {
2711
+ guard !bundle.isBuiltin() else {
2712
+ return
2713
+ }
2714
+ self.sendStats(action: "set", versionName: bundle.getVersionName(), oldVersionName: previousBundleName)
2715
+ }
2716
+
2717
+ public func autoReset() {
2718
+ let currentBundle: BundleInfo = self.getCurrentBundle()
2719
+ if !currentBundle.isBuiltin() && !self.bundleExists(id: currentBundle.getId()) {
2720
+ logger.info("Folder at bundle path does not exist. Triggering reset.")
2721
+ self.reset()
2722
+ return
2723
+ }
2724
+ let bundlePath = UserDefaults.standard.string(forKey: self.CAP_SERVER_PATH)
2725
+ if Self.shouldResetForForeignBundle(
2726
+ bundlePath: bundlePath,
2727
+ isBuiltin: currentBundle.isBuiltin(),
2728
+ hasStoredBundleInfo: self.hasStoredBundleInfo(id: currentBundle.getId())
2729
+ ) {
2730
+ logger.info("Current bundle id is not one of the bundle ids stored by this plugin. Triggering reset.")
2731
+ self.reset()
2732
+ }
2733
+ }
2734
+
2735
+ public func reset() {
2736
+ self.reset(isInternal: false)
2737
+ }
2738
+
2739
+ public func reset(isInternal: Bool) {
2740
+ logger.info("reset: \(isInternal)")
2741
+ let currentBundleName = self.getCurrentBundle().getVersionName()
2742
+ self.prepareResetStateForTransition()
2743
+ self.finalizeResetTransition(previousBundleName: currentBundleName, isInternal: isInternal)
2744
+ }
2745
+
2746
+ public func setSuccess(bundle: BundleInfo, autoDeletePrevious: Bool) {
2747
+ self.setBundleStatus(id: bundle.getId(), status: BundleStatus.SUCCESS)
2748
+ let fallback: BundleInfo = self.getFallbackBundle()
2749
+ let previewFallback = self.getPreviewFallbackBundle()
2750
+ let fallbackIsPreviewFallback = previewFallback?.getId() == fallback.getId()
2751
+ logger.info("Fallback bundle is: \(fallback.toString())")
2752
+ logger.info("Version successfully loaded: \(bundle.toString())")
2753
+ let previousFallbackId = fallback.getId()
2754
+ let nextBundle = self.getNextBundle()
2755
+ let previousIsNext = nextBundle?.getId() == previousFallbackId &&
2756
+ !(nextBundle?.isDeleted() ?? true) &&
2757
+ !(nextBundle?.isErrorStatus() ?? true) &&
2758
+ !(nextBundle?.isDeleting() ?? true)
2759
+ let shouldDeletePrevious = autoDeletePrevious &&
2760
+ !fallback.isBuiltin() &&
2761
+ previousFallbackId != bundle.getId() &&
2762
+ !fallbackIsPreviewFallback &&
2763
+ !previousIsNext
2764
+ if shouldDeletePrevious {
2765
+ // Mark durable intent before fallback switch so a kill mid-flight still retries.
2766
+ if !self.saveBundleInfo(id: previousFallbackId, bundle: fallback.setStatus(status: BundleStatus.DELETING.storedValue)) {
2767
+ self.logger.error("Failed to persist DELETING for previous bundle; queueing durable retry")
2768
+ self.logger.debug("Bundle ID: \(previousFallbackId)")
2769
+ self.enqueuePendingDelete(id: previousFallbackId)
2770
+ }
2771
+ UserDefaults.standard.synchronize()
2772
+ }
2773
+ self.setFallbackBundle(fallback: bundle)
2774
+ if shouldDeletePrevious {
2775
+ DispatchQueue.global(qos: .utility).async {
2776
+ let res = self.delete(id: previousFallbackId)
2777
+ if res {
2778
+ self.logger.info("Deleted previous bundle")
2779
+ self.logger.debug("Bundle ID: \(previousFallbackId)")
2780
+ } else {
2781
+ self.logger.info("Previous bundle delete incomplete, will retry")
2782
+ self.logger.debug("Bundle ID: \(previousFallbackId)")
2783
+ }
2784
+ }
2785
+ }
2786
+ }
2787
+
2788
+ public func setError(bundle: BundleInfo) {
2789
+ self.setBundleStatus(id: bundle.getId(), status: BundleStatus.ERROR)
2790
+ }
2791
+
2792
+ func unsetChannel(defaultChannelKey: String, configDefaultChannel: String) -> SetChannel {
2793
+ let setChannel: SetChannel = SetChannel()
2794
+
2795
+ // Clear persisted defaultChannel and revert to config value
2796
+ UserDefaults.standard.removeObject(forKey: defaultChannelKey)
2797
+ UserDefaults.standard.synchronize()
2798
+ self.defaultChannel = configDefaultChannel
2799
+ self.logger.info("Persisted defaultChannel cleared, reverted to config value: \(configDefaultChannel)")
2800
+
2801
+ setChannel.status = "ok"
2802
+ setChannel.message = "Channel override removed"
2803
+ return setChannel
2804
+ }
2805
+
2806
+ func setChannel(
2807
+ channel: String,
2808
+ defaultChannelKey: String,
2809
+ allowSetDefaultChannel: Bool,
2810
+ configDefaultChannel: String = ""
2811
+ ) -> SetChannel {
2812
+ let setChannel: SetChannel = SetChannel()
2813
+
2814
+ // Check if setting defaultChannel is allowed
2815
+ if !allowSetDefaultChannel {
2816
+ logger.error("setChannel is disabled by allowSetDefaultChannel config")
2817
+ setChannel.message = "setChannel is disabled by configuration"
2818
+ setChannel.error = "disabled_by_config"
2819
+ return setChannel
2820
+ }
2821
+
2822
+ if isRemoteBlocked() {
2823
+ let blocked = remoteBlockedClientError()
2824
+ logger.debug("Skipping setChannel due to remote block (\(blocked.error)).")
2825
+ setChannel.message = blocked.message
2826
+ setChannel.error = blocked.error
2827
+ return setChannel
2828
+ }
2829
+
2830
+ if (self.channelUrl ).isEmpty {
2831
+ logger.error("Channel URL is not set")
2832
+ setChannel.message = "Channel URL is not set"
2833
+ setChannel.error = "missing_config"
2834
+ return setChannel
2835
+ }
2836
+ guard let channelURL = URL(string: self.channelUrl) else {
2837
+ logger.error("Invalid channel URL")
2838
+ setChannel.message = "Channel URL is invalid"
2839
+ setChannel.error = "invalid_config"
2840
+ return setChannel
2841
+ }
2842
+ var parameters: InfoObject = self.createInfoObject()
2843
+ parameters.channel = channel
2844
+ guard let request = createRequest(url: channelURL, method: "POST", parameters: parameters.toParameters()) else {
2845
+ setChannel.error = "Request failed: invalid request"
2846
+ return setChannel
2847
+ }
2848
+
2849
+ let result = performRequest(request, label: "setChannel")
2850
+
2851
+ let rateLimit = self.checkAndHandleRateLimitResponse(
2852
+ statusCode: result.response?.statusCode,
2853
+ data: result.data,
2854
+ response: result.response
2855
+ )
2856
+ if rateLimit.blocked {
2857
+ setChannel.message = rateLimit.message
2858
+ setChannel.error = rateLimit.error
2859
+ return setChannel
2860
+ }
2861
+
2862
+ if result.timedOut {
2863
+ setChannel.error = "Request timed out"
2864
+ return setChannel
2865
+ }
2866
+
2867
+ if let error = result.error {
2868
+ self.logger.error("Error setting channel")
2869
+ self.logger.debug("Error: \(error.localizedDescription)")
2870
+ setChannel.error = "Request failed: \(error.localizedDescription)"
2871
+ return setChannel
2872
+ }
2873
+
2874
+ guard let data = result.data else {
2875
+ setChannel.error = "Request failed: empty response"
2876
+ return setChannel
2877
+ }
2878
+
2879
+ guard let responseValue = try? JSONDecoder().decode(SetChannelDec.self, from: data) else {
2880
+ setChannel.error = "decode_error"
2881
+ return setChannel
2882
+ }
2883
+
2884
+ let statusCode = result.response?.statusCode ?? 0
2885
+ if statusCode < 200 || statusCode >= 300 {
2886
+ setChannel.message = responseValue.message ?? "Server error: \(statusCode)"
2887
+ setChannel.error = responseValue.error ?? "response_error"
2888
+ return setChannel
2889
+ }
2890
+
2891
+ if let error = responseValue.error {
2892
+ setChannel.error = error
2893
+ } else if responseValue.unset == true {
2894
+ UserDefaults.standard.removeObject(forKey: defaultChannelKey)
2895
+ UserDefaults.standard.synchronize()
2896
+ self.defaultChannel = configDefaultChannel
2897
+ self.logger.info("Public channel requested, channel override removed")
2898
+
2899
+ setChannel.status = responseValue.status ?? "ok"
2900
+ setChannel.message = responseValue.message
2901
+ ?? "Public channel requested, channel override removed. Device will use public channel automatically."
2902
+ } else {
2903
+ self.defaultChannel = channel
2904
+ UserDefaults.standard.set(channel, forKey: defaultChannelKey)
2905
+ UserDefaults.standard.synchronize()
2906
+ self.logger.info("defaultChannel persisted locally: \(channel)")
2907
+
2908
+ setChannel.status = responseValue.status ?? ""
2909
+ setChannel.message = responseValue.message ?? ""
2910
+ }
2911
+ return setChannel
2912
+ }
2913
+
2914
+ func getChannel(defaultChannelKey: String? = nil) -> GetChannel {
2915
+ let getChannel: GetChannel = GetChannel()
2916
+ // Check if rate limit was exceeded
2917
+ if isRemoteBlocked() {
2918
+ let blocked = remoteBlockedClientError()
2919
+ logger.debug("Skipping getChannel due to remote block (\(blocked.error)).")
2920
+ getChannel.message = blocked.message
2921
+ getChannel.error = blocked.error
2922
+ return getChannel
2923
+ }
2924
+
2925
+ if (self.channelUrl ).isEmpty {
2926
+ logger.error("Channel URL is not set")
2927
+ getChannel.message = "Channel URL is not set"
2928
+ getChannel.error = "missing_config"
2929
+ return getChannel
2930
+ }
2931
+ guard let channelURL = URL(string: self.channelUrl) else {
2932
+ logger.error("Invalid channel URL")
2933
+ getChannel.message = "Channel URL is invalid"
2934
+ getChannel.error = "invalid_config"
2935
+ return getChannel
2936
+ }
2937
+ let parameters: InfoObject = self.createInfoObject()
2938
+ guard let request = createRequest(url: channelURL, method: "PUT", parameters: parameters.toParameters()) else {
2939
+ getChannel.error = "Request failed: invalid request"
2940
+ return getChannel
2941
+ }
2942
+
2943
+ let result = performRequest(request, label: "getChannel")
2944
+
2945
+ let rateLimit = self.checkAndHandleRateLimitResponse(
2946
+ statusCode: result.response?.statusCode,
2947
+ data: result.data,
2948
+ response: result.response
2949
+ )
2950
+ if rateLimit.blocked {
2951
+ getChannel.message = rateLimit.message
2952
+ getChannel.error = rateLimit.error
2953
+ return getChannel
2954
+ }
2955
+
2956
+ if result.timedOut {
2957
+ getChannel.error = "Request timed out"
2958
+ return getChannel
2959
+ }
2960
+
2961
+ if let error = result.error {
2962
+ if let data = result.data, let bodyString = String(data: data, encoding: .utf8) {
2963
+ if bodyString.contains("channel_not_found") && result.response?.statusCode == 400 && !self.defaultChannel.isEmpty {
2964
+ getChannel.channel = self.defaultChannel
2965
+ getChannel.status = "default"
2966
+ return getChannel
2967
+ }
2968
+ }
2969
+
2970
+ self.logger.error("Error getting channel")
2971
+ self.logger.debug("Error: \(error.localizedDescription)")
2972
+ getChannel.error = "Request failed: \(error.localizedDescription)"
2973
+ return getChannel
2974
+ }
2975
+
2976
+ guard let data = result.data else {
2977
+ getChannel.error = "Request failed: empty response"
2978
+ return getChannel
2979
+ }
2980
+
2981
+ guard let responseValue = try? JSONDecoder().decode(GetChannelDec.self, from: data) else {
2982
+ getChannel.error = "decode_error"
2983
+ return getChannel
2984
+ }
2985
+
2986
+ let statusCode = result.response?.statusCode ?? 0
2987
+ if let error = responseValue.error {
2988
+ if error == "channel_not_found", statusCode == 400, !self.defaultChannel.isEmpty {
2989
+ getChannel.channel = self.defaultChannel
2990
+ getChannel.status = "default"
2991
+ return getChannel
2992
+ }
2993
+ getChannel.error = error
2994
+ getChannel.message = responseValue.message ?? ""
2995
+ return getChannel
2996
+ }
2997
+
2998
+ if statusCode < 200 || statusCode >= 300 {
2999
+ getChannel.message = responseValue.message ?? "Server error: \(statusCode)"
3000
+ getChannel.error = "response_error"
3001
+ } else {
3002
+ getChannel.status = responseValue.status ?? ""
3003
+ getChannel.message = responseValue.message ?? ""
3004
+ getChannel.channel = responseValue.channel ?? ""
3005
+ getChannel.allowSet = responseValue.allowSet ?? true
3006
+ persistDefaultChannelFromResponse(channel: responseValue.channel, defaultChannelKey: defaultChannelKey)
3007
+ }
3008
+ return getChannel
3009
+ }
3010
+
3011
+ func persistDefaultChannelFromResponse(channel: String?, defaultChannelKey: String?) {
3012
+ guard let channelName = channel?.trimmingCharacters(in: .whitespacesAndNewlines),
3013
+ !channelName.isEmpty,
3014
+ channelName != BundleInfo.ID_BUILTIN else {
3015
+ return
3016
+ }
3017
+
3018
+ self.defaultChannel = channelName
3019
+ if let defaultChannelKey, !defaultChannelKey.isEmpty {
3020
+ UserDefaults.standard.set(channelName, forKey: defaultChannelKey)
3021
+ UserDefaults.standard.synchronize()
3022
+ }
3023
+ logger.info("defaultChannel synchronized from getChannel(): \(channelName)")
3024
+ }
3025
+
3026
+ func listChannels() -> ListChannels {
3027
+ let listChannels: ListChannels = ListChannels()
3028
+
3029
+ // Check if rate limit was exceeded
3030
+ if isRemoteBlocked() {
3031
+ let blocked = remoteBlockedClientError()
3032
+ logger.debug("Skipping listChannels due to remote block (\(blocked.error)).")
3033
+ listChannels.error = blocked.error
3034
+ return listChannels
3035
+ }
3036
+
3037
+ if (self.channelUrl).isEmpty {
3038
+ logger.error("Channel URL is not set")
3039
+ listChannels.error = "Channel URL is not set"
3040
+ return listChannels
3041
+ }
3042
+
3043
+ // Create info object and convert to query parameters
3044
+ let infoObject = self.createInfoObject()
3045
+ var urlComponents = URLComponents(string: self.channelUrl)
3046
+ var queryItems: [URLQueryItem] = urlComponents?.queryItems ?? []
3047
+
3048
+ for (key, value) in infoObject.toParameters() {
3049
+ queryItems.append(URLQueryItem(name: key, value: String(describing: value)))
3050
+ }
3051
+
3052
+ urlComponents?.queryItems = queryItems
3053
+
3054
+ guard let url = urlComponents?.url else {
3055
+ logger.error("Invalid channel URL")
3056
+ listChannels.error = "Invalid channel URL"
3057
+ return listChannels
3058
+ }
3059
+
3060
+ guard let request = createRequest(url: url, method: "GET", expectsJSONResponse: true) else {
3061
+ listChannels.error = "Invalid channel URL"
3062
+ return listChannels
3063
+ }
3064
+
3065
+ let result = performRequest(request, label: "listChannels")
3066
+
3067
+ let rateLimit = self.checkAndHandleRateLimitResponse(
3068
+ statusCode: result.response?.statusCode,
3069
+ data: result.data,
3070
+ response: result.response
3071
+ )
3072
+ if rateLimit.blocked {
3073
+ listChannels.error = rateLimit.error
3074
+ return listChannels
3075
+ }
3076
+
3077
+ if result.timedOut {
3078
+ listChannels.error = "Request timed out"
3079
+ return listChannels
3080
+ }
3081
+
3082
+ if let error = result.error {
3083
+ self.logger.error("Error listing channels")
3084
+ self.logger.debug("Error: \(error.localizedDescription)")
3085
+ listChannels.error = "Request failed: \(error.localizedDescription)"
3086
+ return listChannels
3087
+ }
3088
+
3089
+ guard let data = result.data else {
3090
+ listChannels.error = "Request failed: empty response"
3091
+ return listChannels
3092
+ }
3093
+
3094
+ guard let responseValue = try? JSONDecoder().decode(ListChannelsDec.self, from: data) else {
3095
+ listChannels.error = "decode_error"
3096
+ return listChannels
3097
+ }
3098
+
3099
+ let statusCode = result.response?.statusCode ?? 0
3100
+ if let error = responseValue.error {
3101
+ listChannels.error = error
3102
+ return listChannels
3103
+ }
3104
+
3105
+ if statusCode < 200 || statusCode >= 300 {
3106
+ listChannels.error = "response_error"
3107
+ return listChannels
3108
+ }
3109
+
3110
+ if let channels = responseValue.channels {
3111
+ listChannels.channels = channels.map { channel in
3112
+ var channelDict: [String: Any] = [:]
3113
+ channelDict["id"] = channel.id
3114
+ channelDict["name"] = channel.name ?? ""
3115
+ channelDict["public"] = channel.public ?? false
3116
+ channelDict["allow_self_set"] = channel.allow_self_set ?? false
3117
+ return channelDict
3118
+ }
3119
+ }
3120
+
3121
+ return listChannels
3122
+ }
3123
+
3124
+ private let operationQueue = OperationQueue()
3125
+
3126
+ private let manifestDownloadQueue: OperationQueue = {
3127
+ let queue = OperationQueue()
3128
+ queue.name = "com.capgo.manifestDownload"
3129
+ queue.qualityOfService = .userInitiated
3130
+ queue.maxConcurrentOperationCount = CapgoUpdater.manifestMaxConcurrentFiles
3131
+ return queue
3132
+ }()
3133
+
3134
+ func sendStats(action: String, versionName: String? = nil, oldVersionName: String? = "") {
3135
+ sendStatsWithMetadata(action: action, versionName: versionName, oldVersionName: oldVersionName, metadata: nil, onSent: nil)
3136
+ }
3137
+
3138
+ func sendStats(action: String, versionName: String?, oldVersionName: String?, metadata: [String: String]) {
3139
+ sendStatsWithMetadata(action: action, versionName: versionName, oldVersionName: oldVersionName, metadata: metadata, onSent: nil)
3140
+ }
3141
+
3142
+ func sendStats(action: String, versionName: String?, oldVersionName: String?, metadata: [String: String], onSent: @escaping () -> Void) {
3143
+ sendStatsWithMetadata(action: action, versionName: versionName, oldVersionName: oldVersionName, metadata: metadata, onSent: onSent)
3144
+ }
3145
+
3146
+ private func sendStatsWithMetadata(
3147
+ action: String,
3148
+ versionName: String?,
3149
+ oldVersionName: String?,
3150
+ metadata: [String: String]?,
3151
+ onSent: (() -> Void)?
3152
+ ) {
3153
+ if statsStopped {
3154
+ return
3155
+ }
3156
+
3157
+ if previewSession {
3158
+ logger.debug("Skipping sendStats during preview session.")
3159
+ return
3160
+ }
3161
+
3162
+ guard !statsUrl.isEmpty else {
3163
+ return
3164
+ }
3165
+
3166
+ let resolvedVersionName = versionName ?? getCurrentBundle().getVersionName()
3167
+ let info = createInfoObject()
3168
+
3169
+ let event = StatsEvent(
3170
+ platform: info.platform,
3171
+ device_id: info.device_id,
3172
+ app_id: info.app_id,
3173
+ custom_id: info.custom_id,
3174
+ version_build: info.version_build,
3175
+ version_code: info.version_code,
3176
+ version_os: info.version_os,
3177
+ version_name: resolvedVersionName,
3178
+ old_version_name: oldVersionName ?? "",
3179
+ plugin_version: info.plugin_version,
3180
+ is_emulator: info.is_emulator,
3181
+ is_prod: info.is_prod,
3182
+ installSource: info.installSource,
3183
+ action: action,
3184
+ channel: info.channel,
3185
+ defaultChannel: info.defaultChannel,
3186
+ key_id: info.key_id,
3187
+ metadata: metadata,
3188
+ timestamp: Int64(Date().timeIntervalSince1970 * 1000)
3189
+ )
3190
+
3191
+ statsQueueLock.lock()
3192
+ if statsStopped {
3193
+ statsQueueLock.unlock()
3194
+ return
3195
+ }
3196
+ if statsQueue.count >= CapgoUpdater.maxPendingStats {
3197
+ statsQueue.removeFirst(statsQueue.count - CapgoUpdater.maxPendingStats + 1)
3198
+ }
3199
+ statsQueue.append(QueuedStatsEvent(event: event, onSent: onSent))
3200
+ statsQueueLock.unlock()
3201
+
3202
+ ensureStatsTimerStarted()
3203
+ }
3204
+
3205
+ func restorePendingStats() {
3206
+ let fileURL = pendingStatsFileURL()
3207
+ guard FileManager.default.fileExists(atPath: fileURL.path),
3208
+ let data = try? Data(contentsOf: fileURL),
3209
+ let events = try? JSONDecoder().decode([StatsEvent].self, from: data) else {
3210
+ return
3211
+ }
3212
+
3213
+ statsQueueLock.lock()
3214
+ for event in events {
3215
+ if statsQueue.count >= CapgoUpdater.maxPendingStats {
3216
+ break
3217
+ }
3218
+ statsQueue.append(QueuedStatsEvent(event: event, onSent: nil))
3219
+ }
3220
+ let restoredCount = statsQueue.count
3221
+ statsQueueLock.unlock()
3222
+
3223
+ if restoredCount > 0 {
3224
+ logger.info("Restored \(restoredCount) pending stats events")
3225
+ ensureStatsTimerStarted()
3226
+ }
3227
+ }
3228
+
3229
+ func persistPendingStats() {
3230
+ persistStatsQueue()
3231
+ }
3232
+
3233
+ private func pendingStatsFileURL() -> URL {
3234
+ libraryDir.appendingPathComponent(pendingStatsFileName)
3235
+ }
3236
+
3237
+ private func persistStatsQueue(force: Bool = false) {
3238
+ statsPersistLock.lock()
3239
+ defer { statsPersistLock.unlock() }
3240
+ if statsStopped && !force {
3241
+ return
3242
+ }
3243
+
3244
+ statsQueueLock.lock()
3245
+ var events = statsInFlight.map(\.event) + statsQueue.map(\.event)
3246
+ statsQueueLock.unlock()
3247
+ if events.count > CapgoUpdater.maxPendingStats {
3248
+ events = Array(events.suffix(CapgoUpdater.maxPendingStats))
3249
+ }
3250
+
3251
+ let fileURL = pendingStatsFileURL()
3252
+ if events.isEmpty {
3253
+ try? FileManager.default.removeItem(at: fileURL)
3254
+ return
3255
+ }
3256
+
3257
+ do {
3258
+ let data = try JSONEncoder().encode(events)
3259
+ try data.write(to: fileURL, options: .atomic)
3260
+ var resourceURL = fileURL
3261
+ var values = URLResourceValues()
3262
+ values.isExcludedFromBackup = true
3263
+ try resourceURL.setResourceValues(values)
3264
+ } catch {
3265
+ logger.error("Failed to persist stats queue")
3266
+ logger.debug("Error: \(error.localizedDescription)")
3267
+ }
3268
+ }
3269
+
3270
+ private func ensureStatsTimerStarted() {
3271
+ if statsStopped {
3272
+ return
3273
+ }
3274
+ DispatchQueue.main.async { [weak self] in
3275
+ guard let self = self, !self.statsStopped else { return }
3276
+ if self.statsFlushTimer == nil || !self.statsFlushTimer!.isValid {
3277
+ // Use closure-based timer to avoid strong reference cycle
3278
+ self.statsFlushTimer = Timer.scheduledTimer(
3279
+ withTimeInterval: CapgoUpdater.statsFlushInterval,
3280
+ repeats: true
3281
+ ) { [weak self] _ in
3282
+ self?.flushStatsQueue()
3283
+ }
3284
+ }
3285
+ }
3286
+ }
3287
+
3288
+ private func flushStatsQueue() {
3289
+ if statsStopped {
3290
+ return
3291
+ }
3292
+ // While Retry-After is active, keep stats queued and skip the network call.
3293
+ if isRemoteBlocked() {
3294
+ logger.debug("Deferring stats flush until Retry-After expires.")
3295
+ return
3296
+ }
3297
+
3298
+ statsQueueLock.lock()
3299
+ guard statsInFlight.isEmpty, !statsQueue.isEmpty else {
3300
+ statsQueueLock.unlock()
3301
+ return
3302
+ }
3303
+ let queuedEvents = statsQueue
3304
+ statsQueue.removeAll()
3305
+ statsInFlight = queuedEvents
3306
+ statsQueueLock.unlock()
3307
+ persistStatsQueue()
3308
+
3309
+ let eventsToSend = queuedEvents.map(\.event)
3310
+
3311
+ operationQueue.maxConcurrentOperationCount = 1
3312
+
3313
+ let operation = BlockOperation {
3314
+ let semaphore = DispatchSemaphore(value: 0)
3315
+ self.alamofireSession.request(
3316
+ self.statsUrl,
3317
+ method: .post,
3318
+ parameters: eventsToSend,
3319
+ encoder: JSONParameterEncoder.default,
3320
+ requestModifier: { $0.timeoutInterval = self.timeout }
3321
+ ).responseData { response in
3322
+ if self.abandonStoppedStatsFlush() {
3323
+ semaphore.signal()
3324
+ return
3325
+ }
3326
+ if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode, data: response.data, response: response.response).blocked {
3327
+ self.requeueStatsEvents(queuedEvents)
3328
+ semaphore.signal()
3329
+ return
3330
+ }
3331
+
3332
+ if let statusCode = response.response?.statusCode, !(200...299).contains(statusCode) {
3333
+ if CapgoUpdater.isTransientStatsFailure(statusCode) {
3334
+ self.requeueStatsEvents(queuedEvents)
3335
+ self.logger.error("Error sending stats batch")
3336
+ self.logger.debug("Retrying later, response code: \(statusCode)")
3337
+ } else {
3338
+ self.clearStatsInFlight()
3339
+ self.logger.error("Dropping stats batch after permanent error")
3340
+ self.logger.debug("Response code: \(statusCode)")
3341
+ }
3342
+ semaphore.signal()
3343
+ return
3344
+ }
3345
+
3346
+ switch response.result {
3347
+ case .success:
3348
+ self.clearStatsInFlight()
3349
+ self.logger.info("Stats batch sent successfully")
3350
+ self.logger.debug("Sent \(eventsToSend.count) events")
3351
+ self.runStatsCallbacks(queuedEvents)
3352
+ case let .failure(error):
3353
+ self.requeueStatsEvents(queuedEvents)
3354
+ self.logger.error("Error sending stats batch")
3355
+ self.logger.debug("Response: \(response.value?.debugDescription ?? "nil"), Error: \(error.localizedDescription)")
3356
+ }
3357
+ semaphore.signal()
3358
+ }
3359
+ semaphore.wait()
3360
+ if !self.statsStopped {
3361
+ self.persistStatsQueue()
3362
+ }
3363
+ }
3364
+ operationQueue.addOperation(operation)
3365
+ }
3366
+
3367
+ private func abandonStoppedStatsFlush() -> Bool {
3368
+ statsStopped
3369
+ }
3370
+
3371
+ /// Only 429, request timeout and 5xx are worth retrying; other 4xx are permanent rejections.
3372
+ private static func isTransientStatsFailure(_ statusCode: Int) -> Bool {
3373
+ return statusCode == 429 || statusCode == 408 || statusCode >= 500
3374
+ }
3375
+
3376
+ private func runStatsCallbacks(_ sentEvents: [QueuedStatsEvent]) {
3377
+ for sentEvent in sentEvents {
3378
+ sentEvent.onSent?()
3379
+ }
3380
+ }
3381
+
3382
+ private func requeueStatsEvents(_ events: [QueuedStatsEvent]) {
3383
+ guard !statsStopped, !events.isEmpty else { return }
3384
+ statsQueueLock.lock()
3385
+ statsInFlight.removeAll()
3386
+ statsQueue.insert(contentsOf: events, at: 0)
3387
+ if statsQueue.count > CapgoUpdater.maxPendingStats {
3388
+ statsQueue.removeFirst(statsQueue.count - CapgoUpdater.maxPendingStats)
3389
+ }
3390
+ statsQueueLock.unlock()
3391
+ persistStatsQueue()
3392
+ ensureStatsTimerStarted()
3393
+ }
3394
+
3395
+ private func clearStatsInFlight() {
3396
+ statsQueueLock.lock()
3397
+ statsInFlight.removeAll()
3398
+ statsQueueLock.unlock()
3399
+ }
3400
+
3401
+ public func getBundleInfo(id: String?) -> BundleInfo {
3402
+ var trueId = BundleInfo.VERSION_UNKNOWN
3403
+ if id != nil {
3404
+ trueId = id!
3405
+ }
3406
+ let result: BundleInfo
3407
+ if BundleInfo.ID_BUILTIN == trueId {
3408
+ result = BundleInfo(id: trueId, version: self.versionBuild, status: BundleStatus.SUCCESS, checksum: "")
3409
+ } else if BundleInfo.VERSION_UNKNOWN == trueId {
3410
+ result = BundleInfo(id: trueId, version: "", status: BundleStatus.ERROR, checksum: "")
3411
+ } else {
3412
+ do {
3413
+ result = try UserDefaults.standard.getObj(forKey: "\(trueId)\(self.INFO_SUFFIX)", castTo: BundleInfo.self)
3414
+ } catch {
3415
+ logger.error("Failed to parse bundle info")
3416
+ logger.debug("Bundle ID: \(trueId), Error: \(error.localizedDescription)")
3417
+ result = BundleInfo(id: trueId, version: "", status: BundleStatus.PENDING, checksum: "")
3418
+ }
3419
+ }
3420
+ return result
3421
+ }
3422
+
3423
+ public func getBundleInfoByVersionName(version: String) -> BundleInfo? {
3424
+ let installed: [BundleInfo] = self.list()
3425
+ for i in installed {
3426
+ if i.getVersionName() == version {
3427
+ return i
3428
+ }
3429
+ }
3430
+ return nil
3431
+ }
3432
+
3433
+ private func removeBundleInfo(id: String) {
3434
+ self.saveBundleInfo(id: id, bundle: nil)
3435
+ }
3436
+
3437
+ @discardableResult
3438
+ public func saveBundleInfo(id: String, bundle: BundleInfo?) -> Bool {
3439
+ if bundle != nil && (bundle!.isBuiltin() || bundle!.isUnknown()) {
3440
+ logger.info("Not saving info for bundle [\(id)] \(bundle?.toString() ?? "")")
3441
+ return false
3442
+ }
3443
+ if bundle == nil {
3444
+ logger.info("Removing info for bundle [\(id)]")
3445
+ UserDefaults.standard.removeObject(forKey: "\(id)\(self.INFO_SUFFIX)")
3446
+ return true
3447
+ }
3448
+ let update = bundle!.setId(id: id)
3449
+ logger.info("Storing info for bundle [\(id)] \(update.toString())")
3450
+ do {
3451
+ try UserDefaults.standard.setObj(update, forKey: "\(id)\(self.INFO_SUFFIX)")
3452
+ return true
3453
+ } catch {
3454
+ logger.error("Failed to save bundle info")
3455
+ logger.debug("Bundle ID: \(id), Error: \(error.localizedDescription)")
3456
+ return false
3457
+ }
3458
+ }
3459
+
3460
+ private func setBundleStatus(id: String, status: BundleStatus) {
3461
+ logger.info("Setting status for bundle [\(id)] to \(status)")
3462
+ let info = self.getBundleInfo(id: id)
3463
+ self.saveBundleInfo(id: id, bundle: info.setStatus(status: status.storedValue))
3464
+ }
3465
+
3466
+ public func getCurrentBundle() -> BundleInfo {
3467
+ return self.getBundleInfo(id: self.getCurrentBundleId())
3468
+ }
3469
+
3470
+ public func getCurrentBundleId() -> String {
3471
+ guard let bundlePath: String = UserDefaults.standard.string(forKey: self.CAP_SERVER_PATH) else {
3472
+ return BundleInfo.ID_BUILTIN
3473
+ }
3474
+ if (bundlePath).isEmpty {
3475
+ return BundleInfo.ID_BUILTIN
3476
+ }
3477
+ let bundleID: String = bundlePath.components(separatedBy: "/").last ?? bundlePath
3478
+ return bundleID
3479
+ }
3480
+
3481
+ public func isUsingBuiltin() -> Bool {
3482
+ return (UserDefaults.standard.string(forKey: self.CAP_SERVER_PATH) ?? "") == self.DEFAULT_FOLDER
3483
+ }
3484
+
3485
+ public func getFallbackBundle() -> BundleInfo {
3486
+ let id: String = UserDefaults.standard.string(forKey: self.FALLBACK_VERSION) ?? BundleInfo.ID_BUILTIN
3487
+ return self.getBundleInfo(id: id)
3488
+ }
3489
+
3490
+ private func setFallbackBundle(fallback: BundleInfo?) {
3491
+ UserDefaults.standard.set(fallback == nil ? BundleInfo.ID_BUILTIN : fallback!.getId(), forKey: self.FALLBACK_VERSION)
3492
+ UserDefaults.standard.synchronize()
3493
+ }
3494
+
3495
+ public func getNextBundle() -> BundleInfo? {
3496
+ let id: String? = UserDefaults.standard.string(forKey: self.NEXT_VERSION)
3497
+ return self.getBundleInfo(id: id)
3498
+ }
3499
+
3500
+ public func getPreviewFallbackBundle() -> BundleInfo? {
3501
+ guard let id = UserDefaults.standard.string(forKey: self.PREVIEW_FALLBACK_VERSION) else {
3502
+ return nil
3503
+ }
3504
+ let bundle = self.getBundleInfo(id: id)
3505
+ if !bundle.isBuiltin() && !self.bundleExists(id: id) {
3506
+ _ = self.setPreviewFallbackBundle(fallback: nil)
3507
+ return nil
3508
+ }
3509
+ return bundle
3510
+ }
3511
+
3512
+ public func setPreviewFallbackBundle(fallback: String?) -> Bool {
3513
+ guard let fallbackId = fallback else {
3514
+ UserDefaults.standard.removeObject(forKey: self.PREVIEW_FALLBACK_VERSION)
3515
+ UserDefaults.standard.synchronize()
3516
+ return true
3517
+ }
3518
+ let newBundle: BundleInfo = self.getBundleInfo(id: fallbackId)
3519
+ if !newBundle.isBuiltin() && !self.bundleExists(id: fallbackId) {
3520
+ return false
3521
+ }
3522
+ UserDefaults.standard.set(fallbackId, forKey: self.PREVIEW_FALLBACK_VERSION)
3523
+ UserDefaults.standard.synchronize()
3524
+ return true
3525
+ }
3526
+
3527
+ public func setNextBundle(next: String?) -> Bool {
3528
+ guard let nextId: String = next else {
3529
+ UserDefaults.standard.removeObject(forKey: self.NEXT_VERSION)
3530
+ UserDefaults.standard.synchronize()
3531
+ return false
3532
+ }
3533
+ let newBundle: BundleInfo = self.getBundleInfo(id: nextId)
3534
+ if !newBundle.isBuiltin() && !self.bundleExists(id: nextId) {
3535
+ return false
3536
+ }
3537
+ UserDefaults.standard.set(nextId, forKey: self.NEXT_VERSION)
3538
+ UserDefaults.standard.synchronize()
3539
+ self.setBundleStatus(id: nextId, status: BundleStatus.PENDING)
3540
+ self.sendStats(action: "set_next", versionName: newBundle.getVersionName(), oldVersionName: self.getCurrentBundle().getVersionName())
3541
+ self.notifyListeners("setNext", ["bundle": newBundle.toJSON()])
3542
+ return true
3543
+ }
3544
+ }