@capgo/capacitor-updater 8.0.1 → 8.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CapgoCapacitorUpdater.podspec +7 -5
- package/Package.swift +9 -7
- package/README.md +984 -215
- package/android/build.gradle +24 -12
- package/android/proguard-rules.pro +22 -5
- package/android/src/main/java/ee/forgr/capacitor_updater/BundleInfo.java +110 -22
- package/android/src/main/java/ee/forgr/capacitor_updater/Callback.java +2 -2
- package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +1310 -488
- package/android/src/main/java/ee/forgr/capacitor_updater/{CapacitorUpdater.java → CapgoUpdater.java} +640 -203
- package/android/src/main/java/ee/forgr/capacitor_updater/{CryptoCipherV2.java → CryptoCipher.java} +119 -33
- package/android/src/main/java/ee/forgr/capacitor_updater/DelayCondition.java +0 -3
- package/android/src/main/java/ee/forgr/capacitor_updater/DelayUpdateUtils.java +260 -0
- package/android/src/main/java/ee/forgr/capacitor_updater/DeviceIdHelper.java +221 -0
- package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +497 -133
- package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +80 -25
- package/android/src/main/java/ee/forgr/capacitor_updater/Logger.java +338 -0
- package/android/src/main/java/ee/forgr/capacitor_updater/ShakeDetector.java +72 -0
- package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +169 -0
- package/dist/docs.json +873 -154
- package/dist/esm/definitions.d.ts +881 -114
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/history.d.ts +1 -0
- package/dist/esm/history.js +283 -0
- package/dist/esm/history.js.map +1 -0
- package/dist/esm/index.d.ts +1 -0
- package/dist/esm/index.js +1 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/web.d.ts +12 -1
- package/dist/esm/web.js +29 -2
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +311 -2
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +311 -2
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/CapacitorUpdaterPlugin/AES.swift +69 -0
- package/ios/Sources/CapacitorUpdaterPlugin/BigInt.swift +55 -0
- package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/BundleInfo.swift +37 -10
- package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/BundleStatus.swift +1 -1
- package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +1605 -0
- package/ios/{Plugin/CapacitorUpdater.swift → Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift} +523 -230
- package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +267 -0
- package/ios/Sources/CapacitorUpdaterPlugin/DelayUpdateUtils.swift +220 -0
- package/ios/Sources/CapacitorUpdaterPlugin/DeviceIdHelper.swift +120 -0
- package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/InternalUtils.swift +53 -0
- package/ios/Sources/CapacitorUpdaterPlugin/Logger.swift +310 -0
- package/ios/Sources/CapacitorUpdaterPlugin/RSA.swift +274 -0
- package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +112 -0
- package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/UserDefaultsExtension.swift +0 -2
- package/package.json +21 -19
- package/ios/Plugin/CapacitorUpdaterPlugin.swift +0 -975
- package/ios/Plugin/CryptoCipherV2.swift +0 -310
- /package/{LICENCE → LICENSE} +0 -0
- /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/DelayCondition.swift +0 -0
- /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/DelayUntilNext.swift +0 -0
- /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/Info.plist +0 -0
package/ios/{Plugin/CapacitorUpdater.swift → Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift}
RENAMED
|
@@ -14,7 +14,8 @@ import Alamofire
|
|
|
14
14
|
import Compression
|
|
15
15
|
import UIKit
|
|
16
16
|
|
|
17
|
-
@objc public class
|
|
17
|
+
@objc public class CapgoUpdater: NSObject {
|
|
18
|
+
private var logger: Logger!
|
|
18
19
|
|
|
19
20
|
private let versionCode: String = Bundle.main.versionCode ?? ""
|
|
20
21
|
private let versionOs = UIDevice.current.systemVersion
|
|
@@ -29,11 +30,10 @@ import UIKit
|
|
|
29
30
|
// Add this line to declare cacheFolder
|
|
30
31
|
private let cacheFolder: URL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!.appendingPathComponent("capgo_downloads")
|
|
31
32
|
|
|
32
|
-
public static let TAG: String = "✨ Capacitor-updater:"
|
|
33
33
|
public let CAP_SERVER_PATH: String = "serverBasePath"
|
|
34
34
|
public var versionBuild: String = ""
|
|
35
35
|
public var customId: String = ""
|
|
36
|
-
public var
|
|
36
|
+
public var pluginVersion: String = ""
|
|
37
37
|
public var timeout: Double = 20
|
|
38
38
|
public var statsUrl: String = ""
|
|
39
39
|
public var channelUrl: String = ""
|
|
@@ -42,12 +42,34 @@ import UIKit
|
|
|
42
42
|
public var deviceID = ""
|
|
43
43
|
public var publicKey: String = ""
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
// Flag to track if we received a 429 response - stops requests until app restart
|
|
46
|
+
private static var rateLimitExceeded = false
|
|
47
|
+
|
|
48
|
+
// Flag to track if we've already sent the rate limit statistic - prevents infinite loop
|
|
49
|
+
private static var rateLimitStatisticSent = false
|
|
50
|
+
|
|
51
|
+
private var userAgent: String {
|
|
52
|
+
let safePluginVersion = pluginVersion.isEmpty ? "unknown" : pluginVersion
|
|
53
|
+
let safeAppId = appId.isEmpty ? "unknown" : appId
|
|
54
|
+
return "CapacitorUpdater/\(safePluginVersion) (\(safeAppId)) ios/\(versionOs)"
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private lazy var alamofireSession: Session = {
|
|
58
|
+
let configuration = URLSessionConfiguration.default
|
|
59
|
+
configuration.httpAdditionalHeaders = ["User-Agent": self.userAgent]
|
|
60
|
+
return Session(configuration: configuration)
|
|
61
|
+
}()
|
|
62
|
+
|
|
63
|
+
public var notifyDownloadRaw: (String, Int, Bool, BundleInfo?) -> Void = { _, _, _, _ in }
|
|
64
|
+
public func notifyDownload(id: String, percent: Int, ignoreMultipleOfTen: Bool = false, bundle: BundleInfo? = nil) {
|
|
65
|
+
notifyDownloadRaw(id, percent, ignoreMultipleOfTen, bundle)
|
|
48
66
|
}
|
|
49
67
|
public var notifyDownload: (String, Int) -> Void = { _, _ in }
|
|
50
68
|
|
|
69
|
+
public func setLogger(_ logger: Logger) {
|
|
70
|
+
self.logger = logger
|
|
71
|
+
}
|
|
72
|
+
|
|
51
73
|
private func calcTotalPercent(percent: Int, min: Int, max: Int) -> Int {
|
|
52
74
|
return (percent * (max - min)) / 100 + min
|
|
53
75
|
}
|
|
@@ -69,6 +91,64 @@ import UIKit
|
|
|
69
91
|
return !self.isDevEnvironment && !self.isAppStoreReceiptSandbox() && !self.hasEmbeddedMobileProvision()
|
|
70
92
|
}
|
|
71
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Check if a 429 (Too Many Requests) response was received and set the flag
|
|
96
|
+
*/
|
|
97
|
+
private func checkAndHandleRateLimitResponse(statusCode: Int?) -> Bool {
|
|
98
|
+
if statusCode == 429 {
|
|
99
|
+
// Send a statistic about the rate limit BEFORE setting the flag
|
|
100
|
+
// Only send once to prevent infinite loop if the stat request itself gets rate limited
|
|
101
|
+
if !CapgoUpdater.rateLimitExceeded && !CapgoUpdater.rateLimitStatisticSent {
|
|
102
|
+
CapgoUpdater.rateLimitStatisticSent = true
|
|
103
|
+
|
|
104
|
+
// Dispatch to background queue to avoid blocking the main thread
|
|
105
|
+
DispatchQueue.global(qos: .utility).async {
|
|
106
|
+
self.sendRateLimitStatistic()
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
CapgoUpdater.rateLimitExceeded = true
|
|
110
|
+
logger.warn("Rate limit exceeded (429). Stopping all stats and channel requests until app restart.")
|
|
111
|
+
return true
|
|
112
|
+
}
|
|
113
|
+
return false
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Send a synchronous statistic about rate limiting
|
|
118
|
+
* Note: This method uses a semaphore to block until the request completes.
|
|
119
|
+
* It MUST be called from a background queue to avoid blocking the main thread.
|
|
120
|
+
*/
|
|
121
|
+
private func sendRateLimitStatistic() {
|
|
122
|
+
guard !statsUrl.isEmpty else {
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let current = getCurrentBundle()
|
|
127
|
+
var parameters = createInfoObject()
|
|
128
|
+
parameters.action = "rate_limit_reached"
|
|
129
|
+
parameters.version_name = current.getVersionName()
|
|
130
|
+
parameters.old_version_name = ""
|
|
131
|
+
|
|
132
|
+
// Send synchronously using semaphore (safe because we're on a background queue)
|
|
133
|
+
let semaphore = DispatchSemaphore(value: 0)
|
|
134
|
+
self.alamofireSession.request(
|
|
135
|
+
self.statsUrl,
|
|
136
|
+
method: .post,
|
|
137
|
+
parameters: parameters,
|
|
138
|
+
encoder: JSONParameterEncoder.default,
|
|
139
|
+
requestModifier: { $0.timeoutInterval = self.timeout }
|
|
140
|
+
).responseData { response in
|
|
141
|
+
switch response.result {
|
|
142
|
+
case .success:
|
|
143
|
+
self.logger.info("Rate limit statistic sent")
|
|
144
|
+
case let .failure(error):
|
|
145
|
+
self.logger.error("Error sending rate limit statistic: \(error.localizedDescription)")
|
|
146
|
+
}
|
|
147
|
+
semaphore.signal()
|
|
148
|
+
}
|
|
149
|
+
semaphore.wait()
|
|
150
|
+
}
|
|
151
|
+
|
|
72
152
|
// MARK: Private
|
|
73
153
|
private func hasEmbeddedMobileProvision() -> Bool {
|
|
74
154
|
guard Bundle.main.path(forResource: "embedded", ofType: "mobileprovision") == nil else {
|
|
@@ -108,7 +188,7 @@ import UIKit
|
|
|
108
188
|
do {
|
|
109
189
|
try FileManager.default.createDirectory(atPath: source.path, withIntermediateDirectories: true, attributes: nil)
|
|
110
190
|
} catch {
|
|
111
|
-
|
|
191
|
+
logger.error("Cannot createDirectory \(source.path)")
|
|
112
192
|
throw CustomError.cannotCreateDirectory
|
|
113
193
|
}
|
|
114
194
|
}
|
|
@@ -118,7 +198,7 @@ import UIKit
|
|
|
118
198
|
do {
|
|
119
199
|
try FileManager.default.removeItem(atPath: source.path)
|
|
120
200
|
} catch {
|
|
121
|
-
|
|
201
|
+
logger.error("File not removed. \(source.path)")
|
|
122
202
|
throw CustomError.cannotDeleteDirectory
|
|
123
203
|
}
|
|
124
204
|
}
|
|
@@ -135,58 +215,14 @@ import UIKit
|
|
|
135
215
|
return false
|
|
136
216
|
}
|
|
137
217
|
} catch {
|
|
138
|
-
|
|
218
|
+
logger.error("File not moved. source: \(source.path) dest: \(dest.path)")
|
|
139
219
|
throw CustomError.cannotUnflat
|
|
140
220
|
}
|
|
141
221
|
}
|
|
142
222
|
|
|
143
|
-
private func decryptFileV2(filePath: URL, sessionKey: String, version: String) throws {
|
|
144
|
-
if self.publicKey.isEmpty || sessionKey.isEmpty || sessionKey.components(separatedBy: ":").count != 2 {
|
|
145
|
-
print("\(CapacitorUpdater.TAG) Cannot find public key or sessionKey")
|
|
146
|
-
return
|
|
147
|
-
}
|
|
148
|
-
do {
|
|
149
|
-
guard let rsaPublicKey: RSAPublicKey = .load(rsaPublicKey: self.publicKey) else {
|
|
150
|
-
print("cannot decode publicKey", self.publicKey)
|
|
151
|
-
throw CustomError.cannotDecode
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
let sessionKeyArray: [String] = sessionKey.components(separatedBy: ":")
|
|
155
|
-
guard let ivData: Data = Data(base64Encoded: sessionKeyArray[0]) else {
|
|
156
|
-
print("cannot decode sessionKey", sessionKey)
|
|
157
|
-
throw CustomError.cannotDecode
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
guard let sessionKeyDataEncrypted = Data(base64Encoded: sessionKeyArray[1]) else {
|
|
161
|
-
throw NSError(domain: "Invalid session key data", code: 1, userInfo: nil)
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
guard let sessionKeyDataDecrypted = rsaPublicKey.decrypt(data: sessionKeyDataEncrypted) else {
|
|
165
|
-
throw NSError(domain: "Failed to decrypt session key data", code: 2, userInfo: nil)
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
let aesPrivateKey = AES128Key(iv: ivData, aes128Key: sessionKeyDataDecrypted)
|
|
169
|
-
|
|
170
|
-
guard let encryptedData = try? Data(contentsOf: filePath) else {
|
|
171
|
-
throw NSError(domain: "Failed to read encrypted data", code: 3, userInfo: nil)
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
guard let decryptedData = aesPrivateKey.decrypt(data: encryptedData) else {
|
|
175
|
-
throw NSError(domain: "Failed to decrypt data", code: 4, userInfo: nil)
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
try decryptedData.write(to: filePath)
|
|
179
|
-
|
|
180
|
-
} catch {
|
|
181
|
-
print("\(CapacitorUpdater.TAG) Cannot decode: \(filePath.path)", error)
|
|
182
|
-
self.sendStats(action: "decrypt_fail", versionName: version)
|
|
183
|
-
throw CustomError.cannotDecode
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
223
|
private func unzipProgressHandler(entry: String, zipInfo: unz_file_info, entryNumber: Int, total: Int, destUnZip: URL, id: String, unzipError: inout NSError?) {
|
|
188
224
|
if entry.contains("\\") {
|
|
189
|
-
|
|
225
|
+
logger.error("unzip: Windows path is not supported, please use unix path as required by zip RFC: \(entry)")
|
|
190
226
|
self.sendStats(action: "windows_path_fail")
|
|
191
227
|
}
|
|
192
228
|
|
|
@@ -255,12 +291,22 @@ import UIKit
|
|
|
255
291
|
|
|
256
292
|
if !success || unzipError != nil {
|
|
257
293
|
self.sendStats(action: "unzip_fail")
|
|
294
|
+
try? FileManager.default.removeItem(at: destUnZip)
|
|
258
295
|
throw unzipError ?? CustomError.cannotUnzip
|
|
259
296
|
}
|
|
260
297
|
|
|
261
298
|
if try unflatFolder(source: destUnZip, dest: destPersist) {
|
|
262
299
|
try deleteFolder(source: destUnZip)
|
|
263
300
|
}
|
|
301
|
+
|
|
302
|
+
// Cleanup: remove the downloaded/decrypted zip after successful extraction
|
|
303
|
+
do {
|
|
304
|
+
if FileManager.default.fileExists(atPath: sourceZip.path) {
|
|
305
|
+
try FileManager.default.removeItem(at: sourceZip)
|
|
306
|
+
}
|
|
307
|
+
} catch {
|
|
308
|
+
logger.error("Could not delete source zip at \(sourceZip.path): \(error)")
|
|
309
|
+
}
|
|
264
310
|
}
|
|
265
311
|
|
|
266
312
|
private func createInfoObject() -> InfoObject {
|
|
@@ -273,7 +319,7 @@ import UIKit
|
|
|
273
319
|
version_code: self.versionCode,
|
|
274
320
|
version_os: self.versionOs,
|
|
275
321
|
version_name: self.getCurrentBundle().getVersionName(),
|
|
276
|
-
plugin_version: self.
|
|
322
|
+
plugin_version: self.pluginVersion,
|
|
277
323
|
is_emulator: self.isEmulator(),
|
|
278
324
|
is_prod: self.isProd(),
|
|
279
325
|
action: nil,
|
|
@@ -289,12 +335,13 @@ import UIKit
|
|
|
289
335
|
if let channel = channel {
|
|
290
336
|
parameters.defaultChannel = channel
|
|
291
337
|
}
|
|
292
|
-
|
|
293
|
-
let request =
|
|
338
|
+
logger.info("Auto-update parameters: \(parameters)")
|
|
339
|
+
let request = alamofireSession.request(url, method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
|
|
294
340
|
|
|
295
341
|
request.validate().responseDecodable(of: AppVersionDec.self) { response in
|
|
296
342
|
switch response.result {
|
|
297
343
|
case .success:
|
|
344
|
+
latest.statusCode = response.response?.statusCode ?? 0
|
|
298
345
|
if let url = response.value?.url {
|
|
299
346
|
latest.url = url
|
|
300
347
|
}
|
|
@@ -307,6 +354,9 @@ import UIKit
|
|
|
307
354
|
if let major = response.value?.major {
|
|
308
355
|
latest.major = major
|
|
309
356
|
}
|
|
357
|
+
if let breaking = response.value?.breaking {
|
|
358
|
+
latest.breaking = breaking
|
|
359
|
+
}
|
|
310
360
|
if let error = response.value?.error {
|
|
311
361
|
latest.error = error
|
|
312
362
|
}
|
|
@@ -322,10 +372,17 @@ import UIKit
|
|
|
322
372
|
if let manifest = response.value?.manifest {
|
|
323
373
|
latest.manifest = manifest
|
|
324
374
|
}
|
|
375
|
+
if let link = response.value?.link {
|
|
376
|
+
latest.link = link
|
|
377
|
+
}
|
|
378
|
+
if let comment = response.value?.comment {
|
|
379
|
+
latest.comment = comment
|
|
380
|
+
}
|
|
325
381
|
case let .failure(error):
|
|
326
|
-
|
|
382
|
+
self.logger.error("Error getting Latest \(response.value.debugDescription) \(error)")
|
|
327
383
|
latest.message = "Error getting Latest \(String(describing: response.value))"
|
|
328
384
|
latest.error = "response_error"
|
|
385
|
+
latest.statusCode = response.response?.statusCode ?? 0
|
|
329
386
|
}
|
|
330
387
|
semaphore.signal()
|
|
331
388
|
}
|
|
@@ -336,7 +393,7 @@ import UIKit
|
|
|
336
393
|
private func setCurrentBundle(bundle: String) {
|
|
337
394
|
UserDefaults.standard.set(bundle, forKey: self.CAP_SERVER_PATH)
|
|
338
395
|
UserDefaults.standard.synchronize()
|
|
339
|
-
|
|
396
|
+
logger.info("Current bundle set to: \((bundle ).isEmpty ? BundleInfo.ID_BUILTIN : bundle)")
|
|
340
397
|
}
|
|
341
398
|
|
|
342
399
|
private var tempDataPath: URL {
|
|
@@ -347,36 +404,15 @@ import UIKit
|
|
|
347
404
|
return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("update.dat")
|
|
348
405
|
}
|
|
349
406
|
private var tempData = Data()
|
|
350
|
-
|
|
407
|
+
|
|
351
408
|
private func verifyChecksum(file: URL, expectedHash: String) -> Bool {
|
|
352
|
-
let actualHash =
|
|
409
|
+
let actualHash = CryptoCipher.calcChecksum(filePath: file)
|
|
353
410
|
return actualHash == expectedHash
|
|
354
411
|
}
|
|
355
412
|
|
|
356
|
-
public func
|
|
357
|
-
if self.publicKey.isEmpty {
|
|
358
|
-
return checksum
|
|
359
|
-
}
|
|
360
|
-
do {
|
|
361
|
-
let checksumBytes: Data = Data(base64Encoded: checksum)!
|
|
362
|
-
guard let rsaPublicKey: RSAPublicKey = .load(rsaPublicKey: self.publicKey) else {
|
|
363
|
-
print("cannot decode publicKey", self.publicKey)
|
|
364
|
-
throw CustomError.cannotDecode
|
|
365
|
-
}
|
|
366
|
-
guard let decryptedChecksum = rsaPublicKey.decrypt(data: checksumBytes) else {
|
|
367
|
-
throw NSError(domain: "Failed to decrypt session key data", code: 2, userInfo: nil)
|
|
368
|
-
}
|
|
369
|
-
return decryptedChecksum.base64EncodedString()
|
|
370
|
-
} catch {
|
|
371
|
-
print("\(CapacitorUpdater.TAG) Cannot decrypt checksum: \(checksum)", error)
|
|
372
|
-
self.sendStats(action: "decrypt_fail", versionName: version)
|
|
373
|
-
throw CustomError.cannotDecode
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
public func downloadManifest(manifest: [ManifestEntry], version: String, sessionKey: String) throws -> BundleInfo {
|
|
413
|
+
public func downloadManifest(manifest: [ManifestEntry], version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
|
|
378
414
|
let id = self.randomString(length: 10)
|
|
379
|
-
|
|
415
|
+
logger.info("downloadManifest start \(id)")
|
|
380
416
|
let destFolder = self.getBundleDirectory(id: id)
|
|
381
417
|
let builtinFolder = Bundle.main.bundleURL.appendingPathComponent("public")
|
|
382
418
|
|
|
@@ -384,9 +420,12 @@ import UIKit
|
|
|
384
420
|
try FileManager.default.createDirectory(at: destFolder, withIntermediateDirectories: true, attributes: nil)
|
|
385
421
|
|
|
386
422
|
// Create and save BundleInfo before starting the download process
|
|
387
|
-
let bundleInfo = BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: "")
|
|
423
|
+
let bundleInfo = BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: "", link: link, comment: comment)
|
|
388
424
|
self.saveBundleInfo(id: id, bundle: bundleInfo)
|
|
389
425
|
|
|
426
|
+
// Send stats for manifest download start
|
|
427
|
+
self.sendStats(action: "download_manifest_start", versionName: version)
|
|
428
|
+
|
|
390
429
|
// Notify the start of the download process
|
|
391
430
|
self.notifyDownload(id: id, percent: 0, ignoreMultipleOfTen: true)
|
|
392
431
|
|
|
@@ -405,17 +444,23 @@ import UIKit
|
|
|
405
444
|
|
|
406
445
|
if !self.publicKey.isEmpty && !sessionKey.isEmpty {
|
|
407
446
|
do {
|
|
408
|
-
fileHash = try
|
|
447
|
+
fileHash = try CryptoCipher.decryptChecksum(checksum: fileHash, publicKey: self.publicKey)
|
|
409
448
|
} catch {
|
|
410
449
|
downloadError = error
|
|
411
|
-
|
|
450
|
+
logger.error("CryptoCipher.decryptChecksum error \(id) \(fileName) error: \(error)")
|
|
412
451
|
}
|
|
413
452
|
}
|
|
414
453
|
|
|
454
|
+
// Check if file has .br extension for Brotli decompression
|
|
415
455
|
let fileNameWithoutPath = (fileName as NSString).lastPathComponent
|
|
416
456
|
let cacheFileName = "\(fileHash)_\(fileNameWithoutPath)"
|
|
417
457
|
let cacheFilePath = cacheFolder.appendingPathComponent(cacheFileName)
|
|
418
|
-
|
|
458
|
+
|
|
459
|
+
// Check if file is Brotli compressed and remove .br extension from destination
|
|
460
|
+
let isBrotli = fileName.hasSuffix(".br")
|
|
461
|
+
let destFileName = isBrotli ? String(fileName.dropLast(3)) : fileName
|
|
462
|
+
|
|
463
|
+
let destFilePath = destFolder.appendingPathComponent(destFileName)
|
|
419
464
|
let builtinFilePath = builtinFolder.appendingPathComponent(fileName)
|
|
420
465
|
|
|
421
466
|
// Create necessary subdirectories in the destination folder
|
|
@@ -424,20 +469,30 @@ import UIKit
|
|
|
424
469
|
dispatchGroup.enter()
|
|
425
470
|
|
|
426
471
|
if FileManager.default.fileExists(atPath: builtinFilePath.path) && verifyChecksum(file: builtinFilePath, expectedHash: fileHash) {
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
472
|
+
do {
|
|
473
|
+
try FileManager.default.copyItem(at: builtinFilePath, to: destFilePath)
|
|
474
|
+
logger.info("downloadManifest \(fileName) using builtin file \(id)")
|
|
475
|
+
completedFiles += 1
|
|
476
|
+
self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
|
|
477
|
+
} catch {
|
|
478
|
+
downloadError = error
|
|
479
|
+
logger.error("Failed to copy builtin file \(fileName): \(error.localizedDescription)")
|
|
480
|
+
}
|
|
431
481
|
dispatchGroup.leave()
|
|
432
482
|
} else if FileManager.default.fileExists(atPath: cacheFilePath.path) && verifyChecksum(file: cacheFilePath, expectedHash: fileHash) {
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
483
|
+
do {
|
|
484
|
+
try FileManager.default.copyItem(at: cacheFilePath, to: destFilePath)
|
|
485
|
+
logger.info("downloadManifest \(fileName) copy from cache \(id)")
|
|
486
|
+
completedFiles += 1
|
|
487
|
+
self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
|
|
488
|
+
} catch {
|
|
489
|
+
downloadError = error
|
|
490
|
+
logger.error("Failed to copy cached file \(fileName): \(error.localizedDescription)")
|
|
491
|
+
}
|
|
437
492
|
dispatchGroup.leave()
|
|
438
493
|
} else {
|
|
439
494
|
// File not in cache, download, decompress, and save to both cache and destination
|
|
440
|
-
|
|
495
|
+
self.alamofireSession.download(downloadUrl).responseData { response in
|
|
441
496
|
defer { dispatchGroup.leave() }
|
|
442
497
|
|
|
443
498
|
switch response.result {
|
|
@@ -445,10 +500,11 @@ import UIKit
|
|
|
445
500
|
do {
|
|
446
501
|
let statusCode = response.response?.statusCode ?? 200
|
|
447
502
|
if statusCode < 200 || statusCode >= 300 {
|
|
503
|
+
self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
|
|
448
504
|
if let stringData = String(data: data, encoding: .utf8) {
|
|
449
|
-
throw NSError(domain: "StatusCodeError", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid. Data: \(stringData)"])
|
|
505
|
+
throw NSError(domain: "StatusCodeError", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid. Data: \(stringData) for file \(fileName) at url \(downloadUrl)"])
|
|
450
506
|
} else {
|
|
451
|
-
throw NSError(domain: "StatusCodeError", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid"])
|
|
507
|
+
throw NSError(domain: "StatusCodeError", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid for file \(fileName) at url \(downloadUrl)"])
|
|
452
508
|
}
|
|
453
509
|
}
|
|
454
510
|
|
|
@@ -458,7 +514,7 @@ import UIKit
|
|
|
458
514
|
let tempFile = self.cacheFolder.appendingPathComponent("temp_\(UUID().uuidString)")
|
|
459
515
|
try finalData.write(to: tempFile)
|
|
460
516
|
do {
|
|
461
|
-
try
|
|
517
|
+
try CryptoCipher.decryptFile(filePath: tempFile, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
|
|
462
518
|
} catch {
|
|
463
519
|
self.sendStats(action: "decrypt_fail", versionName: version)
|
|
464
520
|
throw error
|
|
@@ -468,18 +524,27 @@ import UIKit
|
|
|
468
524
|
try FileManager.default.removeItem(at: tempFile)
|
|
469
525
|
}
|
|
470
526
|
|
|
471
|
-
//
|
|
472
|
-
|
|
473
|
-
|
|
527
|
+
// Use the isBrotli and destFilePath already computed above
|
|
528
|
+
if isBrotli {
|
|
529
|
+
// Decompress the Brotli data
|
|
530
|
+
guard let decompressedData = self.decompressBrotli(data: finalData, fileName: fileName) else {
|
|
531
|
+
self.sendStats(action: "download_manifest_brotli_fail", versionName: "\(version):\(destFileName)")
|
|
532
|
+
throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to decompress Brotli data for file \(fileName) at url \(downloadUrl)"])
|
|
533
|
+
}
|
|
534
|
+
finalData = decompressedData
|
|
474
535
|
}
|
|
475
|
-
finalData = decompressedData
|
|
476
536
|
|
|
477
537
|
try finalData.write(to: destFilePath)
|
|
478
538
|
if !self.publicKey.isEmpty && !sessionKey.isEmpty {
|
|
479
539
|
// assume that calcChecksum != null
|
|
480
|
-
let calculatedChecksum =
|
|
540
|
+
let calculatedChecksum = CryptoCipher.calcChecksum(filePath: destFilePath)
|
|
541
|
+
CryptoCipher.logChecksumInfo(label: "Calculated checksum", hexChecksum: calculatedChecksum)
|
|
542
|
+
CryptoCipher.logChecksumInfo(label: "Expected checksum", hexChecksum: fileHash)
|
|
481
543
|
if calculatedChecksum != fileHash {
|
|
482
|
-
|
|
544
|
+
// Delete the corrupt file before throwing error
|
|
545
|
+
try? FileManager.default.removeItem(at: destFilePath)
|
|
546
|
+
self.sendStats(action: "download_manifest_checksum_fail", versionName: "\(version):\(destFileName)")
|
|
547
|
+
throw NSError(domain: "ChecksumError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Computed checksum is not equal to required checksum (\(calculatedChecksum) != \(fileHash)) for file \(fileName) at url \(downloadUrl)"])
|
|
483
548
|
}
|
|
484
549
|
}
|
|
485
550
|
|
|
@@ -488,13 +553,15 @@ import UIKit
|
|
|
488
553
|
|
|
489
554
|
completedFiles += 1
|
|
490
555
|
self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
|
|
491
|
-
|
|
556
|
+
self.logger.info("downloadManifest \(id) \(fileName) downloaded\(isBrotli ? ", decompressed" : "")\(!self.publicKey.isEmpty && !sessionKey.isEmpty ? ", decrypted" : ""), and cached")
|
|
492
557
|
} catch {
|
|
493
558
|
downloadError = error
|
|
494
|
-
|
|
559
|
+
self.logger.error("downloadManifest \(id) \(fileName) error: \(error.localizedDescription)")
|
|
495
560
|
}
|
|
496
561
|
case .failure(let error):
|
|
497
|
-
|
|
562
|
+
downloadError = error
|
|
563
|
+
self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
|
|
564
|
+
self.logger.error("downloadManifest \(id) \(fileName) download error: \(error.localizedDescription). Debug response: \(response.debugDescription).")
|
|
498
565
|
}
|
|
499
566
|
}
|
|
500
567
|
}
|
|
@@ -513,19 +580,52 @@ import UIKit
|
|
|
513
580
|
let updatedBundle = bundleInfo.setStatus(status: BundleStatus.PENDING.localizedString)
|
|
514
581
|
self.saveBundleInfo(id: id, bundle: updatedBundle)
|
|
515
582
|
|
|
516
|
-
|
|
583
|
+
// Send stats for manifest download complete
|
|
584
|
+
self.sendStats(action: "download_manifest_complete", versionName: version)
|
|
585
|
+
|
|
586
|
+
self.notifyDownload(id: id, percent: 100, bundle: updatedBundle)
|
|
587
|
+
logger.info("downloadManifest done \(id)")
|
|
517
588
|
return updatedBundle
|
|
518
589
|
}
|
|
519
590
|
|
|
520
591
|
private func decompressBrotli(data: Data, fileName: String) -> Data? {
|
|
592
|
+
// Handle empty files
|
|
593
|
+
if data.count == 0 {
|
|
594
|
+
return data
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Handle the special EMPTY_BROTLI_STREAM case
|
|
598
|
+
if data.count == 3 && data[0] == 0x1B && data[1] == 0x00 && data[2] == 0x06 {
|
|
599
|
+
return Data()
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// For small files, check if it's a minimal Brotli wrapper
|
|
603
|
+
if data.count > 3 {
|
|
604
|
+
let maxBytes = min(32, data.count)
|
|
605
|
+
let hexDump = data.prefix(maxBytes).map { String(format: "%02x", $0) }.joined(separator: " ")
|
|
606
|
+
// Handle our minimal wrapper pattern
|
|
607
|
+
if data[0] == 0x1B && data[1] == 0x00 && data[2] == 0x06 && data.last == 0x03 {
|
|
608
|
+
let range = data.index(data.startIndex, offsetBy: 3)..<data.index(data.endIndex, offsetBy: -1)
|
|
609
|
+
return data[range]
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Handle brotli.compress minimal wrapper (quality 0)
|
|
613
|
+
if data[0] == 0x0b && data[1] == 0x02 && data[2] == 0x80 && data.last == 0x03 {
|
|
614
|
+
let range = data.index(data.startIndex, offsetBy: 3)..<data.index(data.endIndex, offsetBy: -1)
|
|
615
|
+
return data[range]
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// For all other cases, try standard decompression
|
|
521
620
|
let outputBufferSize = 65536
|
|
522
621
|
var outputBuffer = [UInt8](repeating: 0, count: outputBufferSize)
|
|
523
622
|
var decompressedData = Data()
|
|
524
623
|
|
|
525
624
|
let streamPointer = UnsafeMutablePointer<compression_stream>.allocate(capacity: 1)
|
|
526
625
|
var status = compression_stream_init(streamPointer, COMPRESSION_STREAM_DECODE, COMPRESSION_BROTLI)
|
|
626
|
+
|
|
527
627
|
guard status != COMPRESSION_STATUS_ERROR else {
|
|
528
|
-
|
|
628
|
+
logger.error("Error: Failed to initialize Brotli stream for \(fileName). Status: \(status)")
|
|
529
629
|
return nil
|
|
530
630
|
}
|
|
531
631
|
|
|
@@ -547,7 +647,7 @@ import UIKit
|
|
|
547
647
|
if let baseAddress = rawBufferPointer.baseAddress {
|
|
548
648
|
streamPointer.pointee.src_ptr = baseAddress.assumingMemoryBound(to: UInt8.self)
|
|
549
649
|
} else {
|
|
550
|
-
|
|
650
|
+
logger.error("Error: Failed to get base address for \(fileName)")
|
|
551
651
|
status = COMPRESSION_STATUS_ERROR
|
|
552
652
|
return
|
|
553
653
|
}
|
|
@@ -555,6 +655,9 @@ import UIKit
|
|
|
555
655
|
}
|
|
556
656
|
|
|
557
657
|
if status == COMPRESSION_STATUS_ERROR {
|
|
658
|
+
let maxBytes = min(32, data.count)
|
|
659
|
+
let hexDump = data.prefix(maxBytes).map { String(format: "%02x", $0) }.joined(separator: " ")
|
|
660
|
+
logger.error("Error: Brotli decompression failed for \(fileName). First \(maxBytes) bytes: \(hexDump)")
|
|
558
661
|
break
|
|
559
662
|
}
|
|
560
663
|
|
|
@@ -568,15 +671,19 @@ import UIKit
|
|
|
568
671
|
if status == COMPRESSION_STATUS_END {
|
|
569
672
|
break
|
|
570
673
|
} else if status == COMPRESSION_STATUS_ERROR {
|
|
571
|
-
|
|
572
|
-
// Try to decode as text if mostly ASCII
|
|
674
|
+
logger.error("Error: Brotli process failed for \(fileName). Status: \(status)")
|
|
573
675
|
if let text = String(data: data, encoding: .utf8) {
|
|
574
676
|
let asciiCount = text.unicodeScalars.filter { $0.isASCII }.count
|
|
575
677
|
let totalCount = text.unicodeScalars.count
|
|
576
678
|
if totalCount > 0 && Double(asciiCount) / Double(totalCount) >= 0.8 {
|
|
577
|
-
|
|
679
|
+
logger.error("Error: Input appears to be plain text: \(text)")
|
|
578
680
|
}
|
|
579
681
|
}
|
|
682
|
+
|
|
683
|
+
let maxBytes = min(32, data.count)
|
|
684
|
+
let hexDump = data.prefix(maxBytes).map { String(format: "%02x", $0) }.joined(separator: " ")
|
|
685
|
+
logger.error("Error: Raw data (\(fileName)): \(hexDump)")
|
|
686
|
+
|
|
580
687
|
return nil
|
|
581
688
|
}
|
|
582
689
|
|
|
@@ -586,6 +693,7 @@ import UIKit
|
|
|
586
693
|
}
|
|
587
694
|
|
|
588
695
|
if input.count == 0 {
|
|
696
|
+
logger.error("Error: Zero input size for \(fileName)")
|
|
589
697
|
break
|
|
590
698
|
}
|
|
591
699
|
}
|
|
@@ -593,7 +701,7 @@ import UIKit
|
|
|
593
701
|
return status == COMPRESSION_STATUS_END ? decompressedData : nil
|
|
594
702
|
}
|
|
595
703
|
|
|
596
|
-
public func download(url: URL, version: String, sessionKey: String) throws -> BundleInfo {
|
|
704
|
+
public func download(url: URL, version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
|
|
597
705
|
let id: String = self.randomString(length: 10)
|
|
598
706
|
let semaphore = DispatchSemaphore(value: 0)
|
|
599
707
|
if version != getLocalUpdateVersion() {
|
|
@@ -606,6 +714,10 @@ import UIKit
|
|
|
606
714
|
var lastSentProgress = 0
|
|
607
715
|
var totalReceivedBytes: Int64 = loadDownloadProgress() // Retrieving the amount of already downloaded data if exist, defined at 0 otherwise
|
|
608
716
|
let requestHeaders: HTTPHeaders = ["Range": "bytes=\(totalReceivedBytes)-"]
|
|
717
|
+
|
|
718
|
+
// Send stats for zip download start
|
|
719
|
+
self.sendStats(action: "download_zip_start", versionName: version)
|
|
720
|
+
|
|
609
721
|
// Opening connection for streaming the bytes
|
|
610
722
|
if totalReceivedBytes == 0 {
|
|
611
723
|
self.notifyDownload(id: id, percent: 0, ignoreMultipleOfTen: true)
|
|
@@ -614,11 +726,13 @@ import UIKit
|
|
|
614
726
|
let monitor = ClosureEventMonitor()
|
|
615
727
|
monitor.requestDidCompleteTaskWithError = { (_, _, error) in
|
|
616
728
|
if error != nil {
|
|
617
|
-
|
|
729
|
+
self.logger.error("Downloading failed - ClosureEventMonitor activated")
|
|
618
730
|
mainError = error as NSError?
|
|
619
731
|
}
|
|
620
732
|
}
|
|
621
|
-
let
|
|
733
|
+
let configuration = URLSessionConfiguration.default
|
|
734
|
+
configuration.httpAdditionalHeaders = ["User-Agent": self.userAgent]
|
|
735
|
+
let session = Session(configuration: configuration, eventMonitors: [monitor])
|
|
622
736
|
|
|
623
737
|
let request = session.streamRequest(url, headers: requestHeaders).validate().onHTTPResponse(perform: { response in
|
|
624
738
|
if let contentLength = response.headers.value(for: "Content-Length") {
|
|
@@ -645,16 +759,16 @@ import UIKit
|
|
|
645
759
|
}
|
|
646
760
|
|
|
647
761
|
} else {
|
|
648
|
-
|
|
762
|
+
self.logger.error("Download failed")
|
|
649
763
|
}
|
|
650
764
|
|
|
651
765
|
case .complete:
|
|
652
|
-
|
|
766
|
+
self.logger.info("Download complete, total received bytes: \(totalReceivedBytes)")
|
|
653
767
|
self.notifyDownload(id: id, percent: 70, ignoreMultipleOfTen: true)
|
|
654
768
|
semaphore.signal()
|
|
655
769
|
}
|
|
656
770
|
}
|
|
657
|
-
self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: checksum))
|
|
771
|
+
self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: checksum, link: link, comment: comment))
|
|
658
772
|
let reachabilityManager = NetworkReachabilityManager()
|
|
659
773
|
reachabilityManager?.startListening { status in
|
|
660
774
|
switch status {
|
|
@@ -671,55 +785,67 @@ import UIKit
|
|
|
671
785
|
reachabilityManager?.stopListening()
|
|
672
786
|
|
|
673
787
|
if mainError != nil {
|
|
674
|
-
|
|
675
|
-
self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum))
|
|
788
|
+
logger.error("Failed to download: \(String(describing: mainError))")
|
|
789
|
+
self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum, link: link, comment: comment))
|
|
676
790
|
throw mainError!
|
|
677
791
|
}
|
|
678
792
|
|
|
679
793
|
let finalPath = tempDataPath.deletingLastPathComponent().appendingPathComponent("\(id)")
|
|
680
794
|
do {
|
|
681
|
-
try
|
|
795
|
+
try CryptoCipher.decryptFile(filePath: tempDataPath, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
|
|
682
796
|
try FileManager.default.moveItem(at: tempDataPath, to: finalPath)
|
|
683
797
|
} catch {
|
|
684
|
-
|
|
685
|
-
self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum))
|
|
798
|
+
logger.error("Failed decrypt file : \(error)")
|
|
799
|
+
self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum, link: link, comment: comment))
|
|
686
800
|
cleanDownloadData()
|
|
687
801
|
throw error
|
|
688
802
|
}
|
|
689
803
|
|
|
690
804
|
do {
|
|
691
|
-
checksum =
|
|
692
|
-
|
|
805
|
+
checksum = CryptoCipher.calcChecksum(filePath: finalPath)
|
|
806
|
+
CryptoCipher.logChecksumInfo(label: "Calculated bundle checksum", hexChecksum: checksum)
|
|
807
|
+
logger.info("Downloading: 80% (unzipping)")
|
|
693
808
|
try self.saveDownloaded(sourceZip: finalPath, id: id, base: self.libraryDir.appendingPathComponent(self.bundleDirectory), notify: true)
|
|
694
809
|
|
|
695
810
|
} catch {
|
|
696
|
-
|
|
697
|
-
self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum))
|
|
811
|
+
logger.error("Failed to unzip file: \(error)")
|
|
812
|
+
self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum, link: link, comment: comment))
|
|
813
|
+
// Best-effort cleanup of the decrypted zip file when unzip fails
|
|
814
|
+
do {
|
|
815
|
+
if FileManager.default.fileExists(atPath: finalPath.path) {
|
|
816
|
+
try FileManager.default.removeItem(at: finalPath)
|
|
817
|
+
}
|
|
818
|
+
} catch {
|
|
819
|
+
logger.error("Could not delete failed zip at \(finalPath.path): \(error)")
|
|
820
|
+
}
|
|
698
821
|
cleanDownloadData()
|
|
699
|
-
// todo: cleanup zip attempts
|
|
700
822
|
throw error
|
|
701
823
|
}
|
|
702
824
|
|
|
703
825
|
self.notifyDownload(id: id, percent: 90)
|
|
704
|
-
|
|
705
|
-
let info = BundleInfo(id: id, version: version, status: BundleStatus.PENDING, downloaded: Date(), checksum: checksum)
|
|
826
|
+
logger.info("Downloading: 90% (wrapping up)")
|
|
827
|
+
let info = BundleInfo(id: id, version: version, status: BundleStatus.PENDING, downloaded: Date(), checksum: checksum, link: link, comment: comment)
|
|
706
828
|
self.saveBundleInfo(id: id, bundle: info)
|
|
707
829
|
self.cleanDownloadData()
|
|
708
|
-
|
|
709
|
-
|
|
830
|
+
|
|
831
|
+
// Send stats for zip download complete
|
|
832
|
+
self.sendStats(action: "download_zip_complete", versionName: version)
|
|
833
|
+
|
|
834
|
+
self.notifyDownload(id: id, percent: 100, bundle: info)
|
|
835
|
+
logger.info("Downloading: 100% (complete)")
|
|
710
836
|
return info
|
|
711
837
|
}
|
|
712
838
|
private func ensureResumableFilesExist() {
|
|
713
839
|
let fileManager = FileManager.default
|
|
714
840
|
if !fileManager.fileExists(atPath: tempDataPath.path) {
|
|
715
841
|
if !fileManager.createFile(atPath: tempDataPath.path, contents: Data()) {
|
|
716
|
-
|
|
842
|
+
logger.error("Cannot ensure that a file at \(tempDataPath.path) exists")
|
|
717
843
|
}
|
|
718
844
|
}
|
|
719
845
|
|
|
720
846
|
if !fileManager.fileExists(atPath: updateInfo.path) {
|
|
721
847
|
if !fileManager.createFile(atPath: updateInfo.path, contents: Data()) {
|
|
722
|
-
|
|
848
|
+
logger.error("Cannot ensure that a file at \(updateInfo.path) exists")
|
|
723
849
|
}
|
|
724
850
|
}
|
|
725
851
|
}
|
|
@@ -731,7 +857,7 @@ import UIKit
|
|
|
731
857
|
do {
|
|
732
858
|
try fileManager.removeItem(at: tempDataPath)
|
|
733
859
|
} catch {
|
|
734
|
-
|
|
860
|
+
logger.error("Could not delete file at \(tempDataPath): \(error)")
|
|
735
861
|
}
|
|
736
862
|
}
|
|
737
863
|
// Deleting update.dat
|
|
@@ -739,7 +865,7 @@ import UIKit
|
|
|
739
865
|
do {
|
|
740
866
|
try fileManager.removeItem(at: updateInfo)
|
|
741
867
|
} catch {
|
|
742
|
-
|
|
868
|
+
logger.error("Could not delete file at \(updateInfo): \(error)")
|
|
743
869
|
}
|
|
744
870
|
}
|
|
745
871
|
}
|
|
@@ -758,7 +884,7 @@ import UIKit
|
|
|
758
884
|
fileHandle.closeFile()
|
|
759
885
|
}
|
|
760
886
|
} catch {
|
|
761
|
-
|
|
887
|
+
logger.error("Failed to write data starting at byte \(byteOffset): \(error)")
|
|
762
888
|
}
|
|
763
889
|
self.tempData.removeAll() // Clearing tempData to avoid writing the same data multiple times
|
|
764
890
|
}
|
|
@@ -767,7 +893,7 @@ import UIKit
|
|
|
767
893
|
do {
|
|
768
894
|
try "\(version)".write(to: updateInfo, atomically: true, encoding: .utf8)
|
|
769
895
|
} catch {
|
|
770
|
-
|
|
896
|
+
logger.error("Failed to save progress: \(error)")
|
|
771
897
|
}
|
|
772
898
|
}
|
|
773
899
|
private func getLocalUpdateVersion() -> String { // Return the version that was tried to be downloaded on last download attempt
|
|
@@ -789,7 +915,7 @@ import UIKit
|
|
|
789
915
|
return fileSize.int64Value
|
|
790
916
|
}
|
|
791
917
|
} catch {
|
|
792
|
-
|
|
918
|
+
logger.error("Could not retrieve already downloaded data size : \(error)")
|
|
793
919
|
}
|
|
794
920
|
return 0
|
|
795
921
|
}
|
|
@@ -801,7 +927,7 @@ import UIKit
|
|
|
801
927
|
do {
|
|
802
928
|
let files: [String] = try FileManager.default.contentsOfDirectory(atPath: dest.path)
|
|
803
929
|
var res: [BundleInfo] = []
|
|
804
|
-
|
|
930
|
+
logger.info("list File : \(dest.path)")
|
|
805
931
|
if dest.exist {
|
|
806
932
|
for id: String in files {
|
|
807
933
|
res.append(self.getBundleInfo(id: id))
|
|
@@ -809,12 +935,12 @@ import UIKit
|
|
|
809
935
|
}
|
|
810
936
|
return res
|
|
811
937
|
} catch {
|
|
812
|
-
|
|
938
|
+
logger.info("No version available \(dest.path)")
|
|
813
939
|
return []
|
|
814
940
|
}
|
|
815
941
|
} else {
|
|
816
942
|
guard let regex = try? NSRegularExpression(pattern: "^[0-9A-Za-z]{10}_info$") else {
|
|
817
|
-
|
|
943
|
+
logger.error("Invalid regex ?????")
|
|
818
944
|
return []
|
|
819
945
|
}
|
|
820
946
|
return UserDefaults.standard.dictionaryRepresentation().keys.filter {
|
|
@@ -833,7 +959,7 @@ import UIKit
|
|
|
833
959
|
public func delete(id: String, removeInfo: Bool) -> Bool {
|
|
834
960
|
let deleted: BundleInfo = self.getBundleInfo(id: id)
|
|
835
961
|
if deleted.isBuiltin() || self.getCurrentBundleId() == id {
|
|
836
|
-
|
|
962
|
+
logger.info("Cannot delete \(id)")
|
|
837
963
|
return false
|
|
838
964
|
}
|
|
839
965
|
|
|
@@ -842,7 +968,7 @@ import UIKit
|
|
|
842
968
|
!next.isDeleted() &&
|
|
843
969
|
!next.isErrorStatus() &&
|
|
844
970
|
next.getId() == id {
|
|
845
|
-
|
|
971
|
+
logger.info("Cannot delete the next bundle \(id)")
|
|
846
972
|
return false
|
|
847
973
|
}
|
|
848
974
|
|
|
@@ -850,7 +976,7 @@ import UIKit
|
|
|
850
976
|
do {
|
|
851
977
|
try FileManager.default.removeItem(atPath: destPersist.path)
|
|
852
978
|
} catch {
|
|
853
|
-
|
|
979
|
+
logger.error("Folder \(destPersist.path), not removed.")
|
|
854
980
|
// even if, we don;t care. Android doesn't care
|
|
855
981
|
if removeInfo {
|
|
856
982
|
self.removeBundleInfo(id: id)
|
|
@@ -863,7 +989,7 @@ import UIKit
|
|
|
863
989
|
} else {
|
|
864
990
|
self.saveBundleInfo(id: id, bundle: deleted.setStatus(status: BundleStatus.DELETED.localizedString))
|
|
865
991
|
}
|
|
866
|
-
|
|
992
|
+
logger.info("bundle delete \(deleted.getVersionName())")
|
|
867
993
|
self.sendStats(action: "delete", versionName: deleted.getVersionName())
|
|
868
994
|
return true
|
|
869
995
|
}
|
|
@@ -872,6 +998,55 @@ import UIKit
|
|
|
872
998
|
return self.delete(id: id, removeInfo: true)
|
|
873
999
|
}
|
|
874
1000
|
|
|
1001
|
+
public func cleanupDeltaCache() {
|
|
1002
|
+
let fileManager = FileManager.default
|
|
1003
|
+
guard fileManager.fileExists(atPath: cacheFolder.path) else {
|
|
1004
|
+
return
|
|
1005
|
+
}
|
|
1006
|
+
do {
|
|
1007
|
+
try fileManager.removeItem(at: cacheFolder)
|
|
1008
|
+
logger.info("Cleaned up delta cache folder")
|
|
1009
|
+
} catch {
|
|
1010
|
+
logger.error("Failed to cleanup delta cache: \(error.localizedDescription)")
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
public func cleanupDownloadDirectories(allowedIds: Set<String>) {
|
|
1015
|
+
let bundleRoot = libraryDir.appendingPathComponent(bundleDirectory)
|
|
1016
|
+
let fileManager = FileManager.default
|
|
1017
|
+
|
|
1018
|
+
guard fileManager.fileExists(atPath: bundleRoot.path) else {
|
|
1019
|
+
return
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
do {
|
|
1023
|
+
let contents = try fileManager.contentsOfDirectory(at: bundleRoot, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles])
|
|
1024
|
+
|
|
1025
|
+
for url in contents {
|
|
1026
|
+
let resourceValues = try url.resourceValues(forKeys: [.isDirectoryKey])
|
|
1027
|
+
if resourceValues.isDirectory != true {
|
|
1028
|
+
continue
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
let id = url.lastPathComponent
|
|
1032
|
+
|
|
1033
|
+
if allowedIds.contains(id) {
|
|
1034
|
+
continue
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
do {
|
|
1038
|
+
try fileManager.removeItem(at: url)
|
|
1039
|
+
self.removeBundleInfo(id: id)
|
|
1040
|
+
logger.info("Deleted orphan bundle directory: \(id)")
|
|
1041
|
+
} catch {
|
|
1042
|
+
logger.error("Failed to delete orphan bundle directory: \(id) \(error.localizedDescription)")
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
} catch {
|
|
1046
|
+
logger.error("Failed to enumerate bundle directory for cleanup: \(error.localizedDescription)")
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
875
1050
|
public func getBundleDirectory(id: String) -> URL {
|
|
876
1051
|
return libraryDir.appendingPathComponent(self.bundleDirectory).appendingPathComponent(id)
|
|
877
1052
|
}
|
|
@@ -916,7 +1091,7 @@ import UIKit
|
|
|
916
1091
|
public func autoReset() {
|
|
917
1092
|
let currentBundle: BundleInfo = self.getCurrentBundle()
|
|
918
1093
|
if !currentBundle.isBuiltin() && !self.bundleExists(id: currentBundle.getId()) {
|
|
919
|
-
|
|
1094
|
+
logger.info("Folder at bundle path does not exist. Triggering reset.")
|
|
920
1095
|
self.reset()
|
|
921
1096
|
}
|
|
922
1097
|
}
|
|
@@ -926,7 +1101,7 @@ import UIKit
|
|
|
926
1101
|
}
|
|
927
1102
|
|
|
928
1103
|
public func reset(isInternal: Bool) {
|
|
929
|
-
|
|
1104
|
+
logger.info("reset: \(isInternal)")
|
|
930
1105
|
let currentBundleName = self.getCurrentBundle().getVersionName()
|
|
931
1106
|
self.setCurrentBundle(bundle: "")
|
|
932
1107
|
self.setFallbackBundle(fallback: Optional<BundleInfo>.none)
|
|
@@ -939,14 +1114,14 @@ import UIKit
|
|
|
939
1114
|
public func setSuccess(bundle: BundleInfo, autoDeletePrevious: Bool) {
|
|
940
1115
|
self.setBundleStatus(id: bundle.getId(), status: BundleStatus.SUCCESS)
|
|
941
1116
|
let fallback: BundleInfo = self.getFallbackBundle()
|
|
942
|
-
|
|
943
|
-
|
|
1117
|
+
logger.info("Fallback bundle is: \(fallback.toString())")
|
|
1118
|
+
logger.info("Version successfully loaded: \(bundle.toString())")
|
|
944
1119
|
if autoDeletePrevious && !fallback.isBuiltin() && fallback.getId() != bundle.getId() {
|
|
945
1120
|
let res = self.delete(id: fallback.getId())
|
|
946
1121
|
if res {
|
|
947
|
-
|
|
1122
|
+
logger.info("Deleted previous bundle: \(fallback.toString())")
|
|
948
1123
|
} else {
|
|
949
|
-
|
|
1124
|
+
logger.error("Failed to delete previous bundle: \(fallback.toString())")
|
|
950
1125
|
}
|
|
951
1126
|
}
|
|
952
1127
|
self.setFallbackBundle(fallback: bundle)
|
|
@@ -956,46 +1131,41 @@ import UIKit
|
|
|
956
1131
|
self.setBundleStatus(id: bundle.getId(), status: BundleStatus.ERROR)
|
|
957
1132
|
}
|
|
958
1133
|
|
|
959
|
-
func unsetChannel() -> SetChannel {
|
|
1134
|
+
func unsetChannel(defaultChannelKey: String, configDefaultChannel: String) -> SetChannel {
|
|
960
1135
|
let setChannel: SetChannel = SetChannel()
|
|
961
|
-
if (self.channelUrl ).isEmpty {
|
|
962
|
-
print("\(CapacitorUpdater.TAG) Channel URL is not set")
|
|
963
|
-
setChannel.message = "Channel URL is not set"
|
|
964
|
-
setChannel.error = "missing_config"
|
|
965
|
-
return setChannel
|
|
966
|
-
}
|
|
967
|
-
let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
|
|
968
|
-
let parameters: InfoObject = self.createInfoObject()
|
|
969
1136
|
|
|
970
|
-
|
|
1137
|
+
// Clear persisted defaultChannel and revert to config value
|
|
1138
|
+
UserDefaults.standard.removeObject(forKey: defaultChannelKey)
|
|
1139
|
+
UserDefaults.standard.synchronize()
|
|
1140
|
+
self.defaultChannel = configDefaultChannel
|
|
1141
|
+
self.logger.info("Persisted defaultChannel cleared, reverted to config value: \(configDefaultChannel)")
|
|
971
1142
|
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
case .success:
|
|
975
|
-
if let status = response.value?.status {
|
|
976
|
-
setChannel.status = status
|
|
977
|
-
}
|
|
978
|
-
if let error = response.value?.error {
|
|
979
|
-
setChannel.error = error
|
|
980
|
-
}
|
|
981
|
-
if let message = response.value?.message {
|
|
982
|
-
setChannel.message = message
|
|
983
|
-
}
|
|
984
|
-
case let .failure(error):
|
|
985
|
-
print("\(CapacitorUpdater.TAG) Error unset Channel", response.value ?? "", error)
|
|
986
|
-
setChannel.message = "Error unset Channel \(String(describing: response.value))"
|
|
987
|
-
setChannel.error = "response_error"
|
|
988
|
-
}
|
|
989
|
-
semaphore.signal()
|
|
990
|
-
}
|
|
991
|
-
semaphore.wait()
|
|
1143
|
+
setChannel.status = "ok"
|
|
1144
|
+
setChannel.message = "Channel override removed"
|
|
992
1145
|
return setChannel
|
|
993
1146
|
}
|
|
994
1147
|
|
|
995
|
-
func setChannel(channel: String) -> SetChannel {
|
|
1148
|
+
func setChannel(channel: String, defaultChannelKey: String, allowSetDefaultChannel: Bool) -> SetChannel {
|
|
996
1149
|
let setChannel: SetChannel = SetChannel()
|
|
1150
|
+
|
|
1151
|
+
// Check if setting defaultChannel is allowed
|
|
1152
|
+
if !allowSetDefaultChannel {
|
|
1153
|
+
logger.error("setChannel is disabled by allowSetDefaultChannel config")
|
|
1154
|
+
setChannel.message = "setChannel is disabled by configuration"
|
|
1155
|
+
setChannel.error = "disabled_by_config"
|
|
1156
|
+
return setChannel
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// Check if rate limit was exceeded
|
|
1160
|
+
if CapgoUpdater.rateLimitExceeded {
|
|
1161
|
+
logger.debug("Skipping setChannel due to rate limit (429). Requests will resume after app restart.")
|
|
1162
|
+
setChannel.message = "Rate limit exceeded"
|
|
1163
|
+
setChannel.error = "rate_limit_exceeded"
|
|
1164
|
+
return setChannel
|
|
1165
|
+
}
|
|
1166
|
+
|
|
997
1167
|
if (self.channelUrl ).isEmpty {
|
|
998
|
-
|
|
1168
|
+
logger.error("Channel URL is not set")
|
|
999
1169
|
setChannel.message = "Channel URL is not set"
|
|
1000
1170
|
setChannel.error = "missing_config"
|
|
1001
1171
|
return setChannel
|
|
@@ -1004,24 +1174,36 @@ import UIKit
|
|
|
1004
1174
|
var parameters: InfoObject = self.createInfoObject()
|
|
1005
1175
|
parameters.channel = channel
|
|
1006
1176
|
|
|
1007
|
-
let request =
|
|
1177
|
+
let request = alamofireSession.request(self.channelUrl, method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
|
|
1008
1178
|
|
|
1009
1179
|
request.validate().responseDecodable(of: SetChannelDec.self) { response in
|
|
1180
|
+
// Check for 429 rate limit
|
|
1181
|
+
if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
|
|
1182
|
+
setChannel.message = "Rate limit exceeded"
|
|
1183
|
+
setChannel.error = "rate_limit_exceeded"
|
|
1184
|
+
semaphore.signal()
|
|
1185
|
+
return
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1010
1188
|
switch response.result {
|
|
1011
1189
|
case .success:
|
|
1012
|
-
if let
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1190
|
+
if let responseValue = response.value {
|
|
1191
|
+
if let error = responseValue.error {
|
|
1192
|
+
setChannel.error = error
|
|
1193
|
+
} else {
|
|
1194
|
+
// Success - persist defaultChannel
|
|
1195
|
+
self.defaultChannel = channel
|
|
1196
|
+
UserDefaults.standard.set(channel, forKey: defaultChannelKey)
|
|
1197
|
+
UserDefaults.standard.synchronize()
|
|
1198
|
+
self.logger.info("defaultChannel persisted locally: \(channel)")
|
|
1199
|
+
|
|
1200
|
+
setChannel.status = responseValue.status ?? ""
|
|
1201
|
+
setChannel.message = responseValue.message ?? ""
|
|
1202
|
+
}
|
|
1020
1203
|
}
|
|
1021
1204
|
case let .failure(error):
|
|
1022
|
-
|
|
1023
|
-
setChannel.
|
|
1024
|
-
setChannel.error = "response_error"
|
|
1205
|
+
self.logger.error("Error set Channel \(error)")
|
|
1206
|
+
setChannel.error = "Request failed: \(error.localizedDescription)"
|
|
1025
1207
|
}
|
|
1026
1208
|
semaphore.signal()
|
|
1027
1209
|
}
|
|
@@ -1031,36 +1213,48 @@ import UIKit
|
|
|
1031
1213
|
|
|
1032
1214
|
func getChannel() -> GetChannel {
|
|
1033
1215
|
let getChannel: GetChannel = GetChannel()
|
|
1216
|
+
|
|
1217
|
+
// Check if rate limit was exceeded
|
|
1218
|
+
if CapgoUpdater.rateLimitExceeded {
|
|
1219
|
+
logger.debug("Skipping getChannel due to rate limit (429). Requests will resume after app restart.")
|
|
1220
|
+
getChannel.message = "Rate limit exceeded"
|
|
1221
|
+
getChannel.error = "rate_limit_exceeded"
|
|
1222
|
+
return getChannel
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1034
1225
|
if (self.channelUrl ).isEmpty {
|
|
1035
|
-
|
|
1226
|
+
logger.error("Channel URL is not set")
|
|
1036
1227
|
getChannel.message = "Channel URL is not set"
|
|
1037
1228
|
getChannel.error = "missing_config"
|
|
1038
1229
|
return getChannel
|
|
1039
1230
|
}
|
|
1040
1231
|
let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
|
|
1041
1232
|
let parameters: InfoObject = self.createInfoObject()
|
|
1042
|
-
let request =
|
|
1233
|
+
let request = alamofireSession.request(self.channelUrl, method: .put, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
|
|
1043
1234
|
|
|
1044
1235
|
request.validate().responseDecodable(of: GetChannelDec.self) { response in
|
|
1045
1236
|
defer {
|
|
1046
1237
|
semaphore.signal()
|
|
1047
1238
|
}
|
|
1239
|
+
|
|
1240
|
+
// Check for 429 rate limit
|
|
1241
|
+
if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
|
|
1242
|
+
getChannel.message = "Rate limit exceeded"
|
|
1243
|
+
getChannel.error = "rate_limit_exceeded"
|
|
1244
|
+
return
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1048
1247
|
switch response.result {
|
|
1049
1248
|
case .success:
|
|
1050
|
-
if let
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
if let channel = response.value?.channel {
|
|
1060
|
-
getChannel.channel = channel
|
|
1061
|
-
}
|
|
1062
|
-
if let allowSet = response.value?.allowSet {
|
|
1063
|
-
getChannel.allowSet = allowSet
|
|
1249
|
+
if let responseValue = response.value {
|
|
1250
|
+
if let error = responseValue.error {
|
|
1251
|
+
getChannel.error = error
|
|
1252
|
+
} else {
|
|
1253
|
+
getChannel.status = responseValue.status ?? ""
|
|
1254
|
+
getChannel.message = responseValue.message ?? ""
|
|
1255
|
+
getChannel.channel = responseValue.channel ?? ""
|
|
1256
|
+
getChannel.allowSet = responseValue.allowSet ?? true
|
|
1257
|
+
}
|
|
1064
1258
|
}
|
|
1065
1259
|
case let .failure(error):
|
|
1066
1260
|
if let data = response.data, let bodyString = String(data: data, encoding: .utf8) {
|
|
@@ -1071,18 +1265,113 @@ import UIKit
|
|
|
1071
1265
|
}
|
|
1072
1266
|
}
|
|
1073
1267
|
|
|
1074
|
-
|
|
1075
|
-
getChannel.
|
|
1076
|
-
getChannel.error = "response_error"
|
|
1268
|
+
self.logger.error("Error get Channel \(error)")
|
|
1269
|
+
getChannel.error = "Request failed: \(error.localizedDescription)"
|
|
1077
1270
|
}
|
|
1078
1271
|
}
|
|
1079
1272
|
semaphore.wait()
|
|
1080
1273
|
return getChannel
|
|
1081
1274
|
}
|
|
1082
1275
|
|
|
1276
|
+
func listChannels() -> ListChannels {
|
|
1277
|
+
let listChannels: ListChannels = ListChannels()
|
|
1278
|
+
|
|
1279
|
+
// Check if rate limit was exceeded
|
|
1280
|
+
if CapgoUpdater.rateLimitExceeded {
|
|
1281
|
+
logger.debug("Skipping listChannels due to rate limit (429). Requests will resume after app restart.")
|
|
1282
|
+
listChannels.error = "rate_limit_exceeded"
|
|
1283
|
+
return listChannels
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
if (self.channelUrl).isEmpty {
|
|
1287
|
+
logger.error("Channel URL is not set")
|
|
1288
|
+
listChannels.error = "Channel URL is not set"
|
|
1289
|
+
return listChannels
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
|
|
1293
|
+
|
|
1294
|
+
// Create info object and convert to query parameters
|
|
1295
|
+
let infoObject = self.createInfoObject()
|
|
1296
|
+
|
|
1297
|
+
// Create query parameters from InfoObject
|
|
1298
|
+
var urlComponents = URLComponents(string: self.channelUrl)
|
|
1299
|
+
var queryItems: [URLQueryItem] = []
|
|
1300
|
+
|
|
1301
|
+
// Convert InfoObject to dictionary using Mirror
|
|
1302
|
+
let mirror = Mirror(reflecting: infoObject)
|
|
1303
|
+
for child in mirror.children {
|
|
1304
|
+
if let key = child.label, let value = child.value as? CustomStringConvertible {
|
|
1305
|
+
queryItems.append(URLQueryItem(name: key, value: String(describing: value)))
|
|
1306
|
+
} else if let key = child.label {
|
|
1307
|
+
// Handle optional values
|
|
1308
|
+
let mirror = Mirror(reflecting: child.value)
|
|
1309
|
+
if let value = mirror.children.first?.value {
|
|
1310
|
+
queryItems.append(URLQueryItem(name: key, value: String(describing: value)))
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
urlComponents?.queryItems = queryItems
|
|
1316
|
+
|
|
1317
|
+
guard let url = urlComponents?.url else {
|
|
1318
|
+
logger.error("Invalid channel URL")
|
|
1319
|
+
listChannels.error = "Invalid channel URL"
|
|
1320
|
+
return listChannels
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
let request = alamofireSession.request(url, method: .get, requestModifier: { $0.timeoutInterval = self.timeout })
|
|
1324
|
+
|
|
1325
|
+
request.validate().responseDecodable(of: ListChannelsDec.self) { response in
|
|
1326
|
+
defer {
|
|
1327
|
+
semaphore.signal()
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// Check for 429 rate limit
|
|
1331
|
+
if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
|
|
1332
|
+
listChannels.error = "rate_limit_exceeded"
|
|
1333
|
+
return
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
switch response.result {
|
|
1337
|
+
case .success:
|
|
1338
|
+
if let responseValue = response.value {
|
|
1339
|
+
// Check for server-side errors
|
|
1340
|
+
if let error = responseValue.error {
|
|
1341
|
+
listChannels.error = error
|
|
1342
|
+
return
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
// Backend returns direct array, so channels should be populated by our custom decoder
|
|
1346
|
+
if let channels = responseValue.channels {
|
|
1347
|
+
listChannels.channels = channels.map { channel in
|
|
1348
|
+
var channelDict: [String: Any] = [:]
|
|
1349
|
+
channelDict["id"] = channel.id ?? ""
|
|
1350
|
+
channelDict["name"] = channel.name ?? ""
|
|
1351
|
+
channelDict["public"] = channel.public ?? false
|
|
1352
|
+
channelDict["allow_self_set"] = channel.allow_self_set ?? false
|
|
1353
|
+
return channelDict
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
case let .failure(error):
|
|
1358
|
+
self.logger.error("Error list channels \(error)")
|
|
1359
|
+
listChannels.error = "Request failed: \(error.localizedDescription)"
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
semaphore.wait()
|
|
1363
|
+
return listChannels
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1083
1366
|
private let operationQueue = OperationQueue()
|
|
1084
1367
|
|
|
1085
1368
|
func sendStats(action: String, versionName: String? = nil, oldVersionName: String? = "") {
|
|
1369
|
+
// Check if rate limit was exceeded
|
|
1370
|
+
if CapgoUpdater.rateLimitExceeded {
|
|
1371
|
+
logger.debug("Skipping sendStats due to rate limit (429). Stats will resume after app restart.")
|
|
1372
|
+
return
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1086
1375
|
guard !statsUrl.isEmpty else {
|
|
1087
1376
|
return
|
|
1088
1377
|
}
|
|
@@ -1097,22 +1386,28 @@ import UIKit
|
|
|
1097
1386
|
|
|
1098
1387
|
let operation = BlockOperation {
|
|
1099
1388
|
let semaphore = DispatchSemaphore(value: 0)
|
|
1100
|
-
|
|
1389
|
+
self.alamofireSession.request(
|
|
1101
1390
|
self.statsUrl,
|
|
1102
1391
|
method: .post,
|
|
1103
1392
|
parameters: parameters,
|
|
1104
1393
|
encoder: JSONParameterEncoder.default,
|
|
1105
1394
|
requestModifier: { $0.timeoutInterval = self.timeout }
|
|
1106
1395
|
).responseData { response in
|
|
1396
|
+
// Check for 429 rate limit
|
|
1397
|
+
if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
|
|
1398
|
+
semaphore.signal()
|
|
1399
|
+
return
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1107
1402
|
switch response.result {
|
|
1108
1403
|
case .success:
|
|
1109
|
-
|
|
1404
|
+
self.logger.info("Stats sent for \(action), version \(versionName)")
|
|
1110
1405
|
case let .failure(error):
|
|
1111
|
-
|
|
1406
|
+
self.logger.error("Error sending stats: \(response.value?.debugDescription ?? "") \(error.localizedDescription)")
|
|
1112
1407
|
}
|
|
1113
1408
|
semaphore.signal()
|
|
1114
1409
|
}
|
|
1115
|
-
semaphore.
|
|
1410
|
+
semaphore.wait()
|
|
1116
1411
|
}
|
|
1117
1412
|
operationQueue.addOperation(operation)
|
|
1118
1413
|
|
|
@@ -1123,7 +1418,6 @@ import UIKit
|
|
|
1123
1418
|
if id != nil {
|
|
1124
1419
|
trueId = id!
|
|
1125
1420
|
}
|
|
1126
|
-
// print("\(CapacitorUpdater.TAG) Getting info for bundle [\(trueId)]")
|
|
1127
1421
|
let result: BundleInfo
|
|
1128
1422
|
if BundleInfo.ID_BUILTIN == trueId {
|
|
1129
1423
|
result = BundleInfo(id: trueId, version: "", status: BundleStatus.SUCCESS, checksum: "")
|
|
@@ -1133,11 +1427,10 @@ import UIKit
|
|
|
1133
1427
|
do {
|
|
1134
1428
|
result = try UserDefaults.standard.getObj(forKey: "\(trueId)\(self.INFO_SUFFIX)", castTo: BundleInfo.self)
|
|
1135
1429
|
} catch {
|
|
1136
|
-
|
|
1430
|
+
logger.error("Failed to parse info for bundle [\(trueId)] \(error.localizedDescription)")
|
|
1137
1431
|
result = BundleInfo(id: trueId, version: "", status: BundleStatus.PENDING, checksum: "")
|
|
1138
1432
|
}
|
|
1139
1433
|
}
|
|
1140
|
-
// print("\(CapacitorUpdater.TAG) Returning info bundle [\(result.toString())]")
|
|
1141
1434
|
return result
|
|
1142
1435
|
}
|
|
1143
1436
|
|
|
@@ -1157,26 +1450,26 @@ import UIKit
|
|
|
1157
1450
|
|
|
1158
1451
|
public func saveBundleInfo(id: String, bundle: BundleInfo?) {
|
|
1159
1452
|
if bundle != nil && (bundle!.isBuiltin() || bundle!.isUnknown()) {
|
|
1160
|
-
|
|
1453
|
+
logger.info("Not saving info for bundle [\(id)] \(bundle?.toString() ?? "")")
|
|
1161
1454
|
return
|
|
1162
1455
|
}
|
|
1163
1456
|
if bundle == nil {
|
|
1164
|
-
|
|
1457
|
+
logger.info("Removing info for bundle [\(id)]")
|
|
1165
1458
|
UserDefaults.standard.removeObject(forKey: "\(id)\(self.INFO_SUFFIX)")
|
|
1166
1459
|
} else {
|
|
1167
1460
|
let update = bundle!.setId(id: id)
|
|
1168
|
-
|
|
1461
|
+
logger.info("Storing info for bundle [\(id)] \(update.toString())")
|
|
1169
1462
|
do {
|
|
1170
1463
|
try UserDefaults.standard.setObj(update, forKey: "\(id)\(self.INFO_SUFFIX)")
|
|
1171
1464
|
} catch {
|
|
1172
|
-
|
|
1465
|
+
logger.error("Failed to save info for bundle [\(id)] \(error.localizedDescription)")
|
|
1173
1466
|
}
|
|
1174
1467
|
}
|
|
1175
1468
|
UserDefaults.standard.synchronize()
|
|
1176
1469
|
}
|
|
1177
1470
|
|
|
1178
1471
|
private func setBundleStatus(id: String, status: BundleStatus) {
|
|
1179
|
-
|
|
1472
|
+
logger.info("Setting status for bundle [\(id)] to \(status)")
|
|
1180
1473
|
let info = self.getBundleInfo(id: id)
|
|
1181
1474
|
self.saveBundleInfo(id: id, bundle: info.setStatus(status: status.localizedString))
|
|
1182
1475
|
}
|