@capgo/capacitor-updater 6.14.26 → 6.14.33
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 +3 -2
- package/Package.swift +2 -2
- package/README.md +350 -74
- package/android/build.gradle +20 -8
- package/android/proguard-rules.pro +22 -5
- package/android/src/main/java/ee/forgr/capacitor_updater/BundleInfo.java +52 -16
- package/android/src/main/java/ee/forgr/capacitor_updater/Callback.java +2 -2
- package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +1202 -510
- package/android/src/main/java/ee/forgr/capacitor_updater/{CapacitorUpdater.java → CapgoUpdater.java} +566 -154
- package/android/src/main/java/ee/forgr/capacitor_updater/{CryptoCipher.java → CryptoCipherV1.java} +17 -9
- package/android/src/main/java/ee/forgr/capacitor_updater/CryptoCipherV2.java +15 -26
- 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 +300 -119
- package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +63 -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 +652 -63
- package/dist/esm/definitions.d.ts +274 -15
- 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/{Plugin → Sources/CapacitorUpdaterPlugin}/AES.swift +6 -3
- package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +1578 -0
- package/ios/{Plugin/CapacitorUpdater.swift → Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift} +408 -139
- package/ios/{Plugin/CryptoCipher.swift → Sources/CapacitorUpdaterPlugin/CryptoCipherV1.swift} +13 -6
- package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/CryptoCipherV2.swift +33 -27
- package/ios/Sources/CapacitorUpdaterPlugin/DelayUpdateUtils.swift +220 -0
- package/ios/Sources/CapacitorUpdaterPlugin/DeviceIdHelper.swift +120 -0
- package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/InternalUtils.swift +47 -0
- package/ios/Sources/CapacitorUpdaterPlugin/Logger.swift +310 -0
- package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/RSA.swift +1 -0
- package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +112 -0
- package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/UserDefaultsExtension.swift +0 -2
- package/package.json +20 -16
- package/ios/Plugin/CapacitorUpdaterPlugin.swift +0 -1030
- /package/{LICENCE → LICENSE} +0 -0
- /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/BigInt.swift +0 -0
- /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/BundleInfo.swift +0 -0
- /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/BundleStatus.swift +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,7 +30,6 @@ 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 = ""
|
|
@@ -44,12 +44,34 @@ import UIKit
|
|
|
44
44
|
public var publicKey: String = ""
|
|
45
45
|
public var hasOldPrivateKeyPropertyInConfig: Bool = false
|
|
46
46
|
|
|
47
|
+
// Flag to track if we received a 429 response - stops requests until app restart
|
|
48
|
+
private static var rateLimitExceeded = false
|
|
49
|
+
|
|
50
|
+
// Flag to track if we've already sent the rate limit statistic - prevents infinite loop
|
|
51
|
+
private static var rateLimitStatisticSent = false
|
|
52
|
+
|
|
53
|
+
private var userAgent: String {
|
|
54
|
+
let safePluginVersion = PLUGIN_VERSION.isEmpty ? "unknown" : PLUGIN_VERSION
|
|
55
|
+
let safeAppId = appId.isEmpty ? "unknown" : appId
|
|
56
|
+
return "CapacitorUpdater/\(safePluginVersion) (\(safeAppId)) ios/\(versionOs)"
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private lazy var alamofireSession: Session = {
|
|
60
|
+
let configuration = URLSessionConfiguration.default
|
|
61
|
+
configuration.httpAdditionalHeaders = ["User-Agent": self.userAgent]
|
|
62
|
+
return Session(configuration: configuration)
|
|
63
|
+
}()
|
|
64
|
+
|
|
47
65
|
public var notifyDownloadRaw: (String, Int, Bool) -> Void = { _, _, _ in }
|
|
48
66
|
public func notifyDownload(id: String, percent: Int, ignoreMultipleOfTen: Bool = false) {
|
|
49
67
|
notifyDownloadRaw(id, percent, ignoreMultipleOfTen)
|
|
50
68
|
}
|
|
51
69
|
public var notifyDownload: (String, Int) -> Void = { _, _ in }
|
|
52
70
|
|
|
71
|
+
public func setLogger(_ logger: Logger) {
|
|
72
|
+
self.logger = logger
|
|
73
|
+
}
|
|
74
|
+
|
|
53
75
|
private func calcTotalPercent(percent: Int, min: Int, max: Int) -> Int {
|
|
54
76
|
return (percent * (max - min)) / 100 + min
|
|
55
77
|
}
|
|
@@ -71,6 +93,58 @@ import UIKit
|
|
|
71
93
|
return !self.isDevEnvironment && !self.isAppStoreReceiptSandbox() && !self.hasEmbeddedMobileProvision()
|
|
72
94
|
}
|
|
73
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Check if a 429 (Too Many Requests) response was received and set the flag
|
|
98
|
+
*/
|
|
99
|
+
private func checkAndHandleRateLimitResponse(statusCode: Int?) -> Bool {
|
|
100
|
+
if statusCode == 429 {
|
|
101
|
+
// Send a statistic about the rate limit BEFORE setting the flag
|
|
102
|
+
// Only send once to prevent infinite loop if the stat request itself gets rate limited
|
|
103
|
+
if !CapgoUpdater.rateLimitExceeded && !CapgoUpdater.rateLimitStatisticSent {
|
|
104
|
+
CapgoUpdater.rateLimitStatisticSent = true
|
|
105
|
+
self.sendRateLimitStatistic()
|
|
106
|
+
}
|
|
107
|
+
CapgoUpdater.rateLimitExceeded = true
|
|
108
|
+
logger.warn("Rate limit exceeded (429). Stopping all stats and channel requests until app restart.")
|
|
109
|
+
return true
|
|
110
|
+
}
|
|
111
|
+
return false
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Send a synchronous statistic about rate limiting
|
|
116
|
+
*/
|
|
117
|
+
private func sendRateLimitStatistic() {
|
|
118
|
+
guard !statsUrl.isEmpty else {
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let current = getCurrentBundle()
|
|
123
|
+
var parameters = createInfoObject()
|
|
124
|
+
parameters.action = "rate_limit_reached"
|
|
125
|
+
parameters.version_name = current.getVersionName()
|
|
126
|
+
parameters.old_version_name = ""
|
|
127
|
+
|
|
128
|
+
// Send synchronously to ensure it goes out before the flag is set
|
|
129
|
+
let semaphore = DispatchSemaphore(value: 0)
|
|
130
|
+
self.alamofireSession.request(
|
|
131
|
+
self.statsUrl,
|
|
132
|
+
method: .post,
|
|
133
|
+
parameters: parameters,
|
|
134
|
+
encoder: JSONParameterEncoder.default,
|
|
135
|
+
requestModifier: { $0.timeoutInterval = self.timeout }
|
|
136
|
+
).responseData { response in
|
|
137
|
+
switch response.result {
|
|
138
|
+
case .success:
|
|
139
|
+
self.logger.info("Rate limit statistic sent")
|
|
140
|
+
case let .failure(error):
|
|
141
|
+
self.logger.error("Error sending rate limit statistic: \(error.localizedDescription)")
|
|
142
|
+
}
|
|
143
|
+
semaphore.signal()
|
|
144
|
+
}
|
|
145
|
+
semaphore.wait()
|
|
146
|
+
}
|
|
147
|
+
|
|
74
148
|
// MARK: Private
|
|
75
149
|
private func hasEmbeddedMobileProvision() -> Bool {
|
|
76
150
|
guard Bundle.main.path(forResource: "embedded", ofType: "mobileprovision") == nil else {
|
|
@@ -110,7 +184,7 @@ import UIKit
|
|
|
110
184
|
do {
|
|
111
185
|
try FileManager.default.createDirectory(atPath: source.path, withIntermediateDirectories: true, attributes: nil)
|
|
112
186
|
} catch {
|
|
113
|
-
|
|
187
|
+
logger.error("Cannot createDirectory \(source.path)")
|
|
114
188
|
throw CustomError.cannotCreateDirectory
|
|
115
189
|
}
|
|
116
190
|
}
|
|
@@ -120,7 +194,7 @@ import UIKit
|
|
|
120
194
|
do {
|
|
121
195
|
try FileManager.default.removeItem(atPath: source.path)
|
|
122
196
|
} catch {
|
|
123
|
-
|
|
197
|
+
logger.error("File not removed. \(source.path)")
|
|
124
198
|
throw CustomError.cannotDeleteDirectory
|
|
125
199
|
}
|
|
126
200
|
}
|
|
@@ -137,14 +211,14 @@ import UIKit
|
|
|
137
211
|
return false
|
|
138
212
|
}
|
|
139
213
|
} catch {
|
|
140
|
-
|
|
214
|
+
logger.error("File not moved. source: \(source.path) dest: \(dest.path)")
|
|
141
215
|
throw CustomError.cannotUnflat
|
|
142
216
|
}
|
|
143
217
|
}
|
|
144
218
|
|
|
145
219
|
private func unzipProgressHandler(entry: String, zipInfo: unz_file_info, entryNumber: Int, total: Int, destUnZip: URL, id: String, unzipError: inout NSError?) {
|
|
146
220
|
if entry.contains("\\") {
|
|
147
|
-
|
|
221
|
+
logger.error("unzip: Windows path is not supported, please use unix path as required by zip RFC: \(entry)")
|
|
148
222
|
self.sendStats(action: "windows_path_fail")
|
|
149
223
|
}
|
|
150
224
|
|
|
@@ -227,7 +301,7 @@ import UIKit
|
|
|
227
301
|
try FileManager.default.removeItem(at: sourceZip)
|
|
228
302
|
}
|
|
229
303
|
} catch {
|
|
230
|
-
|
|
304
|
+
logger.error("Could not delete source zip at \(sourceZip.path): \(error)")
|
|
231
305
|
}
|
|
232
306
|
}
|
|
233
307
|
|
|
@@ -257,8 +331,8 @@ import UIKit
|
|
|
257
331
|
if let channel = channel {
|
|
258
332
|
parameters.defaultChannel = channel
|
|
259
333
|
}
|
|
260
|
-
|
|
261
|
-
let request =
|
|
334
|
+
logger.info("Auto-update parameters: \(parameters)")
|
|
335
|
+
let request = alamofireSession.request(url, method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
|
|
262
336
|
|
|
263
337
|
request.validate().responseDecodable(of: AppVersionDec.self) { response in
|
|
264
338
|
switch response.result {
|
|
@@ -275,6 +349,9 @@ import UIKit
|
|
|
275
349
|
if let major = response.value?.major {
|
|
276
350
|
latest.major = major
|
|
277
351
|
}
|
|
352
|
+
if let breaking = response.value?.breaking {
|
|
353
|
+
latest.breaking = breaking
|
|
354
|
+
}
|
|
278
355
|
if let error = response.value?.error {
|
|
279
356
|
latest.error = error
|
|
280
357
|
}
|
|
@@ -291,7 +368,7 @@ import UIKit
|
|
|
291
368
|
latest.manifest = manifest
|
|
292
369
|
}
|
|
293
370
|
case let .failure(error):
|
|
294
|
-
|
|
371
|
+
self.logger.error("Error getting Latest \(response.value.debugDescription) \(error)")
|
|
295
372
|
latest.message = "Error getting Latest \(String(describing: response.value))"
|
|
296
373
|
latest.error = "response_error"
|
|
297
374
|
}
|
|
@@ -304,7 +381,7 @@ import UIKit
|
|
|
304
381
|
private func setCurrentBundle(bundle: String) {
|
|
305
382
|
UserDefaults.standard.set(bundle, forKey: self.CAP_SERVER_PATH)
|
|
306
383
|
UserDefaults.standard.synchronize()
|
|
307
|
-
|
|
384
|
+
logger.info("Current bundle set to: \((bundle ).isEmpty ? BundleInfo.ID_BUILTIN : bundle)")
|
|
308
385
|
}
|
|
309
386
|
|
|
310
387
|
private var tempDataPath: URL {
|
|
@@ -323,7 +400,7 @@ import UIKit
|
|
|
323
400
|
|
|
324
401
|
public func downloadManifest(manifest: [ManifestEntry], version: String, sessionKey: String) throws -> BundleInfo {
|
|
325
402
|
let id = self.randomString(length: 10)
|
|
326
|
-
|
|
403
|
+
logger.info("downloadManifest start \(id)")
|
|
327
404
|
let destFolder = self.getBundleDirectory(id: id)
|
|
328
405
|
let builtinFolder = Bundle.main.bundleURL.appendingPathComponent("public")
|
|
329
406
|
|
|
@@ -351,12 +428,16 @@ import UIKit
|
|
|
351
428
|
}
|
|
352
429
|
|
|
353
430
|
if !self.hasOldPrivateKeyPropertyInConfig && !self.publicKey.isEmpty && !sessionKey.isEmpty {
|
|
431
|
+
// V2 Encryption (publicKey)
|
|
354
432
|
do {
|
|
355
|
-
fileHash = try CryptoCipherV2.decryptChecksum(checksum: fileHash, publicKey: self.publicKey
|
|
433
|
+
fileHash = try CryptoCipherV2.decryptChecksum(checksum: fileHash, publicKey: self.publicKey)
|
|
356
434
|
} catch {
|
|
357
435
|
downloadError = error
|
|
358
|
-
|
|
436
|
+
logger.error("CryptoCipherV2.decryptChecksum error \(id) \(fileName) error: \(error)")
|
|
359
437
|
}
|
|
438
|
+
} else if self.hasOldPrivateKeyPropertyInConfig {
|
|
439
|
+
// V1 Encryption (privateKey) - deprecated but supported
|
|
440
|
+
// V1 doesn't decrypt checksum, uses different method
|
|
360
441
|
}
|
|
361
442
|
|
|
362
443
|
let fileNameWithoutPath = (fileName as NSString).lastPathComponent
|
|
@@ -372,19 +453,19 @@ import UIKit
|
|
|
372
453
|
|
|
373
454
|
if FileManager.default.fileExists(atPath: builtinFilePath.path) && verifyChecksum(file: builtinFilePath, expectedHash: fileHash) {
|
|
374
455
|
try FileManager.default.copyItem(at: builtinFilePath, to: destFilePath)
|
|
375
|
-
|
|
456
|
+
logger.info("downloadManifest \(fileName) using builtin file \(id)")
|
|
376
457
|
completedFiles += 1
|
|
377
458
|
self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
|
|
378
459
|
dispatchGroup.leave()
|
|
379
460
|
} else if FileManager.default.fileExists(atPath: cacheFilePath.path) && verifyChecksum(file: cacheFilePath, expectedHash: fileHash) {
|
|
380
461
|
try FileManager.default.copyItem(at: cacheFilePath, to: destFilePath)
|
|
381
|
-
|
|
462
|
+
logger.info("downloadManifest \(fileName) copy from cache \(id)")
|
|
382
463
|
completedFiles += 1
|
|
383
464
|
self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
|
|
384
465
|
dispatchGroup.leave()
|
|
385
466
|
} else {
|
|
386
467
|
// File not in cache, download, decompress, and save to both cache and destination
|
|
387
|
-
|
|
468
|
+
self.alamofireSession.download(downloadUrl).responseData { response in
|
|
388
469
|
defer { dispatchGroup.leave() }
|
|
389
470
|
|
|
390
471
|
switch response.result {
|
|
@@ -399,9 +480,10 @@ import UIKit
|
|
|
399
480
|
}
|
|
400
481
|
}
|
|
401
482
|
|
|
402
|
-
// Add decryption step if
|
|
483
|
+
// Add decryption step if encryption keys are set
|
|
403
484
|
var finalData = data
|
|
404
|
-
if !self.publicKey.isEmpty && !sessionKey.isEmpty {
|
|
485
|
+
if !self.hasOldPrivateKeyPropertyInConfig && !self.publicKey.isEmpty && !sessionKey.isEmpty {
|
|
486
|
+
// V2 Encryption (publicKey)
|
|
405
487
|
let tempFile = self.cacheFolder.appendingPathComponent("temp_\(UUID().uuidString)")
|
|
406
488
|
try finalData.write(to: tempFile)
|
|
407
489
|
do {
|
|
@@ -410,19 +492,36 @@ import UIKit
|
|
|
410
492
|
self.sendStats(action: "decrypt_fail", versionName: version)
|
|
411
493
|
throw error
|
|
412
494
|
}
|
|
413
|
-
|
|
495
|
+
finalData = try Data(contentsOf: tempFile)
|
|
496
|
+
} else if self.hasOldPrivateKeyPropertyInConfig && !sessionKey.isEmpty {
|
|
497
|
+
// V1 Encryption (privateKey) - deprecated but supported
|
|
498
|
+
let tempFile = self.cacheFolder.appendingPathComponent("temp_\(UUID().uuidString)")
|
|
499
|
+
try finalData.write(to: tempFile)
|
|
500
|
+
do {
|
|
501
|
+
try CryptoCipherV1.decryptFile(filePath: tempFile, privateKey: self.privateKey, sessionKey: sessionKey, version: version)
|
|
502
|
+
} catch {
|
|
503
|
+
self.sendStats(action: "decrypt_fail", versionName: version)
|
|
504
|
+
throw error
|
|
505
|
+
}
|
|
414
506
|
finalData = try Data(contentsOf: tempFile)
|
|
415
507
|
try FileManager.default.removeItem(at: tempFile)
|
|
416
508
|
}
|
|
417
509
|
|
|
418
|
-
//
|
|
419
|
-
|
|
420
|
-
|
|
510
|
+
// Check if file has .br extension for Brotli decompression
|
|
511
|
+
let isBrotli = fileName.hasSuffix(".br")
|
|
512
|
+
let finalFileName = isBrotli ? String(fileName.dropLast(3)) : fileName
|
|
513
|
+
let destFilePath = destFolder.appendingPathComponent(finalFileName)
|
|
514
|
+
|
|
515
|
+
if isBrotli {
|
|
516
|
+
// Decompress the Brotli data
|
|
517
|
+
guard let decompressedData = self.decompressBrotli(data: finalData, fileName: fileName) else {
|
|
518
|
+
throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to decompress Brotli data for file \(fileName) at url \(downloadUrl)"])
|
|
519
|
+
}
|
|
520
|
+
finalData = decompressedData
|
|
421
521
|
}
|
|
422
|
-
finalData = decompressedData
|
|
423
522
|
|
|
424
523
|
try finalData.write(to: destFilePath)
|
|
425
|
-
if !self.
|
|
524
|
+
if !self.publicKey.isEmpty && !sessionKey.isEmpty {
|
|
426
525
|
// assume that calcChecksum != null
|
|
427
526
|
let calculatedChecksum = CryptoCipherV2.calcChecksum(filePath: destFilePath)
|
|
428
527
|
if calculatedChecksum != fileHash {
|
|
@@ -435,13 +534,13 @@ import UIKit
|
|
|
435
534
|
|
|
436
535
|
completedFiles += 1
|
|
437
536
|
self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
|
|
438
|
-
|
|
537
|
+
self.logger.info("downloadManifest \(id) \(fileName) downloaded\(isBrotli ? ", decompressed" : "")\(!self.publicKey.isEmpty && !sessionKey.isEmpty ? ", decrypted" : ""), and cached")
|
|
439
538
|
} catch {
|
|
440
539
|
downloadError = error
|
|
441
|
-
|
|
540
|
+
self.logger.error("downloadManifest \(id) \(fileName) error: \(error.localizedDescription)")
|
|
442
541
|
}
|
|
443
542
|
case .failure(let error):
|
|
444
|
-
|
|
543
|
+
self.logger.error("downloadManifest \(id) \(fileName) download error: \(error.localizedDescription). Debug response: \(response.debugDescription).")
|
|
445
544
|
}
|
|
446
545
|
}
|
|
447
546
|
}
|
|
@@ -460,7 +559,7 @@ import UIKit
|
|
|
460
559
|
let updatedBundle = bundleInfo.setStatus(status: BundleStatus.PENDING.localizedString)
|
|
461
560
|
self.saveBundleInfo(id: id, bundle: updatedBundle)
|
|
462
561
|
|
|
463
|
-
|
|
562
|
+
logger.info("downloadManifest done \(id)")
|
|
464
563
|
return updatedBundle
|
|
465
564
|
}
|
|
466
565
|
|
|
@@ -501,7 +600,7 @@ import UIKit
|
|
|
501
600
|
var status = compression_stream_init(streamPointer, COMPRESSION_STREAM_DECODE, COMPRESSION_BROTLI)
|
|
502
601
|
|
|
503
602
|
guard status != COMPRESSION_STATUS_ERROR else {
|
|
504
|
-
|
|
603
|
+
logger.error("Error: Failed to initialize Brotli stream for \(fileName). Status: \(status)")
|
|
505
604
|
return nil
|
|
506
605
|
}
|
|
507
606
|
|
|
@@ -523,7 +622,7 @@ import UIKit
|
|
|
523
622
|
if let baseAddress = rawBufferPointer.baseAddress {
|
|
524
623
|
streamPointer.pointee.src_ptr = baseAddress.assumingMemoryBound(to: UInt8.self)
|
|
525
624
|
} else {
|
|
526
|
-
|
|
625
|
+
logger.error("Error: Failed to get base address for \(fileName)")
|
|
527
626
|
status = COMPRESSION_STATUS_ERROR
|
|
528
627
|
return
|
|
529
628
|
}
|
|
@@ -533,7 +632,7 @@ import UIKit
|
|
|
533
632
|
if status == COMPRESSION_STATUS_ERROR {
|
|
534
633
|
let maxBytes = min(32, data.count)
|
|
535
634
|
let hexDump = data.prefix(maxBytes).map { String(format: "%02x", $0) }.joined(separator: " ")
|
|
536
|
-
|
|
635
|
+
logger.error("Error: Brotli decompression failed for \(fileName). First \(maxBytes) bytes: \(hexDump)")
|
|
537
636
|
break
|
|
538
637
|
}
|
|
539
638
|
|
|
@@ -547,18 +646,18 @@ import UIKit
|
|
|
547
646
|
if status == COMPRESSION_STATUS_END {
|
|
548
647
|
break
|
|
549
648
|
} else if status == COMPRESSION_STATUS_ERROR {
|
|
550
|
-
|
|
649
|
+
logger.error("Error: Brotli process failed for \(fileName). Status: \(status)")
|
|
551
650
|
if let text = String(data: data, encoding: .utf8) {
|
|
552
651
|
let asciiCount = text.unicodeScalars.filter { $0.isASCII }.count
|
|
553
652
|
let totalCount = text.unicodeScalars.count
|
|
554
653
|
if totalCount > 0 && Double(asciiCount) / Double(totalCount) >= 0.8 {
|
|
555
|
-
|
|
654
|
+
logger.error("Error: Input appears to be plain text: \(text)")
|
|
556
655
|
}
|
|
557
656
|
}
|
|
558
657
|
|
|
559
658
|
let maxBytes = min(32, data.count)
|
|
560
659
|
let hexDump = data.prefix(maxBytes).map { String(format: "%02x", $0) }.joined(separator: " ")
|
|
561
|
-
|
|
660
|
+
logger.error("Error: Raw data (\(fileName)): \(hexDump)")
|
|
562
661
|
|
|
563
662
|
return nil
|
|
564
663
|
}
|
|
@@ -569,7 +668,7 @@ import UIKit
|
|
|
569
668
|
}
|
|
570
669
|
|
|
571
670
|
if input.count == 0 {
|
|
572
|
-
|
|
671
|
+
logger.error("Error: Zero input size for \(fileName)")
|
|
573
672
|
break
|
|
574
673
|
}
|
|
575
674
|
}
|
|
@@ -598,11 +697,13 @@ import UIKit
|
|
|
598
697
|
let monitor = ClosureEventMonitor()
|
|
599
698
|
monitor.requestDidCompleteTaskWithError = { (_, _, error) in
|
|
600
699
|
if error != nil {
|
|
601
|
-
|
|
700
|
+
self.logger.error("Downloading failed - ClosureEventMonitor activated")
|
|
602
701
|
mainError = error as NSError?
|
|
603
702
|
}
|
|
604
703
|
}
|
|
605
|
-
let
|
|
704
|
+
let configuration = URLSessionConfiguration.default
|
|
705
|
+
configuration.httpAdditionalHeaders = ["User-Agent": self.userAgent]
|
|
706
|
+
let session = Session(configuration: configuration, eventMonitors: [monitor])
|
|
606
707
|
|
|
607
708
|
let request = session.streamRequest(url, headers: requestHeaders).validate().onHTTPResponse(perform: { response in
|
|
608
709
|
if let contentLength = response.headers.value(for: "Content-Length") {
|
|
@@ -629,11 +730,11 @@ import UIKit
|
|
|
629
730
|
}
|
|
630
731
|
|
|
631
732
|
} else {
|
|
632
|
-
|
|
733
|
+
self.logger.error("Download failed")
|
|
633
734
|
}
|
|
634
735
|
|
|
635
736
|
case .complete:
|
|
636
|
-
|
|
737
|
+
self.logger.info("Download complete, total received bytes: \(totalReceivedBytes)")
|
|
637
738
|
self.notifyDownload(id: id, percent: 70, ignoreMultipleOfTen: true)
|
|
638
739
|
semaphore.signal()
|
|
639
740
|
}
|
|
@@ -655,69 +756,68 @@ import UIKit
|
|
|
655
756
|
reachabilityManager?.stopListening()
|
|
656
757
|
|
|
657
758
|
if mainError != nil {
|
|
658
|
-
|
|
759
|
+
logger.error("Failed to download: \(String(describing: mainError))")
|
|
659
760
|
self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum))
|
|
660
761
|
throw mainError!
|
|
661
762
|
}
|
|
662
763
|
|
|
663
764
|
let finalPath = tempDataPath.deletingLastPathComponent().appendingPathComponent("\(id)")
|
|
664
765
|
do {
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
}
|
|
672
|
-
} catch {
|
|
673
|
-
self.sendStats(action: "decrypt_fail", versionName: version)
|
|
674
|
-
throw error
|
|
766
|
+
if !self.hasOldPrivateKeyPropertyInConfig {
|
|
767
|
+
// V2 Encryption (publicKey)
|
|
768
|
+
try CryptoCipherV2.decryptFile(filePath: tempDataPath, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
|
|
769
|
+
} else {
|
|
770
|
+
// V1 Encryption (privateKey) - deprecated but supported
|
|
771
|
+
try CryptoCipherV1.decryptFile(filePath: tempDataPath, privateKey: self.privateKey, sessionKey: sessionKey, version: version)
|
|
675
772
|
}
|
|
676
773
|
try FileManager.default.moveItem(at: tempDataPath, to: finalPath)
|
|
677
774
|
} catch {
|
|
678
|
-
|
|
775
|
+
logger.error("Failed decrypt file : \(error)")
|
|
679
776
|
self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum))
|
|
680
777
|
cleanDownloadData()
|
|
681
778
|
throw error
|
|
682
779
|
}
|
|
683
780
|
|
|
684
781
|
do {
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
} else {
|
|
688
|
-
checksum = CryptoCipher.calcChecksum(filePath: finalPath)
|
|
689
|
-
}
|
|
690
|
-
print("\(CapacitorUpdater.TAG) Downloading: 80% (unzipping)")
|
|
782
|
+
checksum = CryptoCipherV2.calcChecksum(filePath: finalPath)
|
|
783
|
+
logger.info("Downloading: 80% (unzipping)")
|
|
691
784
|
try self.saveDownloaded(sourceZip: finalPath, id: id, base: self.libraryDir.appendingPathComponent(self.bundleDirectory), notify: true)
|
|
692
785
|
|
|
693
786
|
} catch {
|
|
694
|
-
|
|
787
|
+
logger.error("Failed to unzip file: \(error)")
|
|
695
788
|
self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum))
|
|
789
|
+
// Best-effort cleanup of the decrypted zip file when unzip fails
|
|
790
|
+
do {
|
|
791
|
+
if FileManager.default.fileExists(atPath: finalPath.path) {
|
|
792
|
+
try FileManager.default.removeItem(at: finalPath)
|
|
793
|
+
}
|
|
794
|
+
} catch {
|
|
795
|
+
logger.error("Could not delete failed zip at \(finalPath.path): \(error)")
|
|
796
|
+
}
|
|
696
797
|
cleanDownloadData()
|
|
697
|
-
// todo: cleanup zip attempts
|
|
698
798
|
throw error
|
|
699
799
|
}
|
|
700
800
|
|
|
701
801
|
self.notifyDownload(id: id, percent: 90)
|
|
702
|
-
|
|
802
|
+
logger.info("Downloading: 90% (wrapping up)")
|
|
703
803
|
let info = BundleInfo(id: id, version: version, status: BundleStatus.PENDING, downloaded: Date(), checksum: checksum)
|
|
704
804
|
self.saveBundleInfo(id: id, bundle: info)
|
|
705
805
|
self.cleanDownloadData()
|
|
706
806
|
self.notifyDownload(id: id, percent: 100)
|
|
707
|
-
|
|
807
|
+
logger.info("Downloading: 100% (complete)")
|
|
708
808
|
return info
|
|
709
809
|
}
|
|
710
810
|
private func ensureResumableFilesExist() {
|
|
711
811
|
let fileManager = FileManager.default
|
|
712
812
|
if !fileManager.fileExists(atPath: tempDataPath.path) {
|
|
713
813
|
if !fileManager.createFile(atPath: tempDataPath.path, contents: Data()) {
|
|
714
|
-
|
|
814
|
+
logger.error("Cannot ensure that a file at \(tempDataPath.path) exists")
|
|
715
815
|
}
|
|
716
816
|
}
|
|
717
817
|
|
|
718
818
|
if !fileManager.fileExists(atPath: updateInfo.path) {
|
|
719
819
|
if !fileManager.createFile(atPath: updateInfo.path, contents: Data()) {
|
|
720
|
-
|
|
820
|
+
logger.error("Cannot ensure that a file at \(updateInfo.path) exists")
|
|
721
821
|
}
|
|
722
822
|
}
|
|
723
823
|
}
|
|
@@ -729,7 +829,7 @@ import UIKit
|
|
|
729
829
|
do {
|
|
730
830
|
try fileManager.removeItem(at: tempDataPath)
|
|
731
831
|
} catch {
|
|
732
|
-
|
|
832
|
+
logger.error("Could not delete file at \(tempDataPath): \(error)")
|
|
733
833
|
}
|
|
734
834
|
}
|
|
735
835
|
// Deleting update.dat
|
|
@@ -737,7 +837,7 @@ import UIKit
|
|
|
737
837
|
do {
|
|
738
838
|
try fileManager.removeItem(at: updateInfo)
|
|
739
839
|
} catch {
|
|
740
|
-
|
|
840
|
+
logger.error("Could not delete file at \(updateInfo): \(error)")
|
|
741
841
|
}
|
|
742
842
|
}
|
|
743
843
|
}
|
|
@@ -756,7 +856,7 @@ import UIKit
|
|
|
756
856
|
fileHandle.closeFile()
|
|
757
857
|
}
|
|
758
858
|
} catch {
|
|
759
|
-
|
|
859
|
+
logger.error("Failed to write data starting at byte \(byteOffset): \(error)")
|
|
760
860
|
}
|
|
761
861
|
self.tempData.removeAll() // Clearing tempData to avoid writing the same data multiple times
|
|
762
862
|
}
|
|
@@ -765,7 +865,7 @@ import UIKit
|
|
|
765
865
|
do {
|
|
766
866
|
try "\(version)".write(to: updateInfo, atomically: true, encoding: .utf8)
|
|
767
867
|
} catch {
|
|
768
|
-
|
|
868
|
+
logger.error("Failed to save progress: \(error)")
|
|
769
869
|
}
|
|
770
870
|
}
|
|
771
871
|
private func getLocalUpdateVersion() -> String { // Return the version that was tried to be downloaded on last download attempt
|
|
@@ -787,7 +887,7 @@ import UIKit
|
|
|
787
887
|
return fileSize.int64Value
|
|
788
888
|
}
|
|
789
889
|
} catch {
|
|
790
|
-
|
|
890
|
+
logger.error("Could not retrieve already downloaded data size : \(error)")
|
|
791
891
|
}
|
|
792
892
|
return 0
|
|
793
893
|
}
|
|
@@ -799,7 +899,7 @@ import UIKit
|
|
|
799
899
|
do {
|
|
800
900
|
let files: [String] = try FileManager.default.contentsOfDirectory(atPath: dest.path)
|
|
801
901
|
var res: [BundleInfo] = []
|
|
802
|
-
|
|
902
|
+
logger.info("list File : \(dest.path)")
|
|
803
903
|
if dest.exist {
|
|
804
904
|
for id: String in files {
|
|
805
905
|
res.append(self.getBundleInfo(id: id))
|
|
@@ -807,12 +907,12 @@ import UIKit
|
|
|
807
907
|
}
|
|
808
908
|
return res
|
|
809
909
|
} catch {
|
|
810
|
-
|
|
910
|
+
logger.info("No version available \(dest.path)")
|
|
811
911
|
return []
|
|
812
912
|
}
|
|
813
913
|
} else {
|
|
814
914
|
guard let regex = try? NSRegularExpression(pattern: "^[0-9A-Za-z]{10}_info$") else {
|
|
815
|
-
|
|
915
|
+
logger.error("Invalid regex ?????")
|
|
816
916
|
return []
|
|
817
917
|
}
|
|
818
918
|
return UserDefaults.standard.dictionaryRepresentation().keys.filter {
|
|
@@ -831,7 +931,7 @@ import UIKit
|
|
|
831
931
|
public func delete(id: String, removeInfo: Bool) -> Bool {
|
|
832
932
|
let deleted: BundleInfo = self.getBundleInfo(id: id)
|
|
833
933
|
if deleted.isBuiltin() || self.getCurrentBundleId() == id {
|
|
834
|
-
|
|
934
|
+
logger.info("Cannot delete \(id)")
|
|
835
935
|
return false
|
|
836
936
|
}
|
|
837
937
|
|
|
@@ -840,7 +940,7 @@ import UIKit
|
|
|
840
940
|
!next.isDeleted() &&
|
|
841
941
|
!next.isErrorStatus() &&
|
|
842
942
|
next.getId() == id {
|
|
843
|
-
|
|
943
|
+
logger.info("Cannot delete the next bundle \(id)")
|
|
844
944
|
return false
|
|
845
945
|
}
|
|
846
946
|
|
|
@@ -848,7 +948,7 @@ import UIKit
|
|
|
848
948
|
do {
|
|
849
949
|
try FileManager.default.removeItem(atPath: destPersist.path)
|
|
850
950
|
} catch {
|
|
851
|
-
|
|
951
|
+
logger.error("Folder \(destPersist.path), not removed.")
|
|
852
952
|
// even if, we don;t care. Android doesn't care
|
|
853
953
|
if removeInfo {
|
|
854
954
|
self.removeBundleInfo(id: id)
|
|
@@ -861,7 +961,7 @@ import UIKit
|
|
|
861
961
|
} else {
|
|
862
962
|
self.saveBundleInfo(id: id, bundle: deleted.setStatus(status: BundleStatus.DELETED.localizedString))
|
|
863
963
|
}
|
|
864
|
-
|
|
964
|
+
logger.info("bundle delete \(deleted.getVersionName())")
|
|
865
965
|
self.sendStats(action: "delete", versionName: deleted.getVersionName())
|
|
866
966
|
return true
|
|
867
967
|
}
|
|
@@ -870,6 +970,42 @@ import UIKit
|
|
|
870
970
|
return self.delete(id: id, removeInfo: true)
|
|
871
971
|
}
|
|
872
972
|
|
|
973
|
+
public func cleanupDownloadDirectories(allowedIds: Set<String>) {
|
|
974
|
+
let bundleRoot = libraryDir.appendingPathComponent(bundleDirectory)
|
|
975
|
+
let fileManager = FileManager.default
|
|
976
|
+
|
|
977
|
+
guard fileManager.fileExists(atPath: bundleRoot.path) else {
|
|
978
|
+
return
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
do {
|
|
982
|
+
let contents = try fileManager.contentsOfDirectory(at: bundleRoot, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles])
|
|
983
|
+
|
|
984
|
+
for url in contents {
|
|
985
|
+
let resourceValues = try url.resourceValues(forKeys: [.isDirectoryKey])
|
|
986
|
+
if resourceValues.isDirectory != true {
|
|
987
|
+
continue
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
let id = url.lastPathComponent
|
|
991
|
+
|
|
992
|
+
if allowedIds.contains(id) {
|
|
993
|
+
continue
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
do {
|
|
997
|
+
try fileManager.removeItem(at: url)
|
|
998
|
+
self.removeBundleInfo(id: id)
|
|
999
|
+
logger.info("Deleted orphan bundle directory: \(id)")
|
|
1000
|
+
} catch {
|
|
1001
|
+
logger.error("Failed to delete orphan bundle directory: \(id) \(error.localizedDescription)")
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
} catch {
|
|
1005
|
+
logger.error("Failed to enumerate bundle directory for cleanup: \(error.localizedDescription)")
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
873
1009
|
public func getBundleDirectory(id: String) -> URL {
|
|
874
1010
|
return libraryDir.appendingPathComponent(self.bundleDirectory).appendingPathComponent(id)
|
|
875
1011
|
}
|
|
@@ -914,7 +1050,7 @@ import UIKit
|
|
|
914
1050
|
public func autoReset() {
|
|
915
1051
|
let currentBundle: BundleInfo = self.getCurrentBundle()
|
|
916
1052
|
if !currentBundle.isBuiltin() && !self.bundleExists(id: currentBundle.getId()) {
|
|
917
|
-
|
|
1053
|
+
logger.info("Folder at bundle path does not exist. Triggering reset.")
|
|
918
1054
|
self.reset()
|
|
919
1055
|
}
|
|
920
1056
|
}
|
|
@@ -924,7 +1060,7 @@ import UIKit
|
|
|
924
1060
|
}
|
|
925
1061
|
|
|
926
1062
|
public func reset(isInternal: Bool) {
|
|
927
|
-
|
|
1063
|
+
logger.info("reset: \(isInternal)")
|
|
928
1064
|
let currentBundleName = self.getCurrentBundle().getVersionName()
|
|
929
1065
|
self.setCurrentBundle(bundle: "")
|
|
930
1066
|
self.setFallbackBundle(fallback: Optional<BundleInfo>.none)
|
|
@@ -937,14 +1073,14 @@ import UIKit
|
|
|
937
1073
|
public func setSuccess(bundle: BundleInfo, autoDeletePrevious: Bool) {
|
|
938
1074
|
self.setBundleStatus(id: bundle.getId(), status: BundleStatus.SUCCESS)
|
|
939
1075
|
let fallback: BundleInfo = self.getFallbackBundle()
|
|
940
|
-
|
|
941
|
-
|
|
1076
|
+
logger.info("Fallback bundle is: \(fallback.toString())")
|
|
1077
|
+
logger.info("Version successfully loaded: \(bundle.toString())")
|
|
942
1078
|
if autoDeletePrevious && !fallback.isBuiltin() && fallback.getId() != bundle.getId() {
|
|
943
1079
|
let res = self.delete(id: fallback.getId())
|
|
944
1080
|
if res {
|
|
945
|
-
|
|
1081
|
+
logger.info("Deleted previous bundle: \(fallback.toString())")
|
|
946
1082
|
} else {
|
|
947
|
-
|
|
1083
|
+
logger.error("Failed to delete previous bundle: \(fallback.toString())")
|
|
948
1084
|
}
|
|
949
1085
|
}
|
|
950
1086
|
self.setFallbackBundle(fallback: bundle)
|
|
@@ -956,8 +1092,17 @@ import UIKit
|
|
|
956
1092
|
|
|
957
1093
|
func unsetChannel() -> SetChannel {
|
|
958
1094
|
let setChannel: SetChannel = SetChannel()
|
|
1095
|
+
|
|
1096
|
+
// Check if rate limit was exceeded
|
|
1097
|
+
if CapgoUpdater.rateLimitExceeded {
|
|
1098
|
+
logger.debug("Skipping unsetChannel due to rate limit (429). Requests will resume after app restart.")
|
|
1099
|
+
setChannel.message = "Rate limit exceeded"
|
|
1100
|
+
setChannel.error = "rate_limit_exceeded"
|
|
1101
|
+
return setChannel
|
|
1102
|
+
}
|
|
1103
|
+
|
|
959
1104
|
if (self.channelUrl ).isEmpty {
|
|
960
|
-
|
|
1105
|
+
logger.error("Channel URL is not set")
|
|
961
1106
|
setChannel.message = "Channel URL is not set"
|
|
962
1107
|
setChannel.error = "missing_config"
|
|
963
1108
|
return setChannel
|
|
@@ -965,24 +1110,30 @@ import UIKit
|
|
|
965
1110
|
let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
|
|
966
1111
|
let parameters: InfoObject = self.createInfoObject()
|
|
967
1112
|
|
|
968
|
-
let request =
|
|
1113
|
+
let request = alamofireSession.request(self.channelUrl, method: .delete, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
|
|
969
1114
|
|
|
970
1115
|
request.validate().responseDecodable(of: SetChannelDec.self) { response in
|
|
1116
|
+
// Check for 429 rate limit
|
|
1117
|
+
if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
|
|
1118
|
+
setChannel.message = "Rate limit exceeded"
|
|
1119
|
+
setChannel.error = "rate_limit_exceeded"
|
|
1120
|
+
semaphore.signal()
|
|
1121
|
+
return
|
|
1122
|
+
}
|
|
1123
|
+
|
|
971
1124
|
switch response.result {
|
|
972
1125
|
case .success:
|
|
973
|
-
if let
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
setChannel.message = message
|
|
1126
|
+
if let responseValue = response.value {
|
|
1127
|
+
if let error = responseValue.error {
|
|
1128
|
+
setChannel.error = error
|
|
1129
|
+
} else {
|
|
1130
|
+
setChannel.status = responseValue.status ?? ""
|
|
1131
|
+
setChannel.message = responseValue.message ?? ""
|
|
1132
|
+
}
|
|
981
1133
|
}
|
|
982
1134
|
case let .failure(error):
|
|
983
|
-
|
|
984
|
-
setChannel.
|
|
985
|
-
setChannel.error = "response_error"
|
|
1135
|
+
self.logger.error("Error unset Channel \(error)")
|
|
1136
|
+
setChannel.error = "Request failed: \(error.localizedDescription)"
|
|
986
1137
|
}
|
|
987
1138
|
semaphore.signal()
|
|
988
1139
|
}
|
|
@@ -992,8 +1143,17 @@ import UIKit
|
|
|
992
1143
|
|
|
993
1144
|
func setChannel(channel: String) -> SetChannel {
|
|
994
1145
|
let setChannel: SetChannel = SetChannel()
|
|
1146
|
+
|
|
1147
|
+
// Check if rate limit was exceeded
|
|
1148
|
+
if CapgoUpdater.rateLimitExceeded {
|
|
1149
|
+
logger.debug("Skipping setChannel due to rate limit (429). Requests will resume after app restart.")
|
|
1150
|
+
setChannel.message = "Rate limit exceeded"
|
|
1151
|
+
setChannel.error = "rate_limit_exceeded"
|
|
1152
|
+
return setChannel
|
|
1153
|
+
}
|
|
1154
|
+
|
|
995
1155
|
if (self.channelUrl ).isEmpty {
|
|
996
|
-
|
|
1156
|
+
logger.error("Channel URL is not set")
|
|
997
1157
|
setChannel.message = "Channel URL is not set"
|
|
998
1158
|
setChannel.error = "missing_config"
|
|
999
1159
|
return setChannel
|
|
@@ -1002,24 +1162,30 @@ import UIKit
|
|
|
1002
1162
|
var parameters: InfoObject = self.createInfoObject()
|
|
1003
1163
|
parameters.channel = channel
|
|
1004
1164
|
|
|
1005
|
-
let request =
|
|
1165
|
+
let request = alamofireSession.request(self.channelUrl, method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
|
|
1006
1166
|
|
|
1007
1167
|
request.validate().responseDecodable(of: SetChannelDec.self) { response in
|
|
1168
|
+
// Check for 429 rate limit
|
|
1169
|
+
if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
|
|
1170
|
+
setChannel.message = "Rate limit exceeded"
|
|
1171
|
+
setChannel.error = "rate_limit_exceeded"
|
|
1172
|
+
semaphore.signal()
|
|
1173
|
+
return
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1008
1176
|
switch response.result {
|
|
1009
1177
|
case .success:
|
|
1010
|
-
if let
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
setChannel.message = message
|
|
1178
|
+
if let responseValue = response.value {
|
|
1179
|
+
if let error = responseValue.error {
|
|
1180
|
+
setChannel.error = error
|
|
1181
|
+
} else {
|
|
1182
|
+
setChannel.status = responseValue.status ?? ""
|
|
1183
|
+
setChannel.message = responseValue.message ?? ""
|
|
1184
|
+
}
|
|
1018
1185
|
}
|
|
1019
1186
|
case let .failure(error):
|
|
1020
|
-
|
|
1021
|
-
setChannel.
|
|
1022
|
-
setChannel.error = "response_error"
|
|
1187
|
+
self.logger.error("Error set Channel \(error)")
|
|
1188
|
+
setChannel.error = "Request failed: \(error.localizedDescription)"
|
|
1023
1189
|
}
|
|
1024
1190
|
semaphore.signal()
|
|
1025
1191
|
}
|
|
@@ -1029,36 +1195,48 @@ import UIKit
|
|
|
1029
1195
|
|
|
1030
1196
|
func getChannel() -> GetChannel {
|
|
1031
1197
|
let getChannel: GetChannel = GetChannel()
|
|
1198
|
+
|
|
1199
|
+
// Check if rate limit was exceeded
|
|
1200
|
+
if CapgoUpdater.rateLimitExceeded {
|
|
1201
|
+
logger.debug("Skipping getChannel due to rate limit (429). Requests will resume after app restart.")
|
|
1202
|
+
getChannel.message = "Rate limit exceeded"
|
|
1203
|
+
getChannel.error = "rate_limit_exceeded"
|
|
1204
|
+
return getChannel
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1032
1207
|
if (self.channelUrl ).isEmpty {
|
|
1033
|
-
|
|
1208
|
+
logger.error("Channel URL is not set")
|
|
1034
1209
|
getChannel.message = "Channel URL is not set"
|
|
1035
1210
|
getChannel.error = "missing_config"
|
|
1036
1211
|
return getChannel
|
|
1037
1212
|
}
|
|
1038
1213
|
let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
|
|
1039
1214
|
let parameters: InfoObject = self.createInfoObject()
|
|
1040
|
-
let request =
|
|
1215
|
+
let request = alamofireSession.request(self.channelUrl, method: .put, parameters: parameters, encoder: JSONParameterEncoder.default, requestModifier: { $0.timeoutInterval = self.timeout })
|
|
1041
1216
|
|
|
1042
1217
|
request.validate().responseDecodable(of: GetChannelDec.self) { response in
|
|
1043
1218
|
defer {
|
|
1044
1219
|
semaphore.signal()
|
|
1045
1220
|
}
|
|
1221
|
+
|
|
1222
|
+
// Check for 429 rate limit
|
|
1223
|
+
if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
|
|
1224
|
+
getChannel.message = "Rate limit exceeded"
|
|
1225
|
+
getChannel.error = "rate_limit_exceeded"
|
|
1226
|
+
return
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1046
1229
|
switch response.result {
|
|
1047
1230
|
case .success:
|
|
1048
|
-
if let
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
if let channel = response.value?.channel {
|
|
1058
|
-
getChannel.channel = channel
|
|
1059
|
-
}
|
|
1060
|
-
if let allowSet = response.value?.allowSet {
|
|
1061
|
-
getChannel.allowSet = allowSet
|
|
1231
|
+
if let responseValue = response.value {
|
|
1232
|
+
if let error = responseValue.error {
|
|
1233
|
+
getChannel.error = error
|
|
1234
|
+
} else {
|
|
1235
|
+
getChannel.status = responseValue.status ?? ""
|
|
1236
|
+
getChannel.message = responseValue.message ?? ""
|
|
1237
|
+
getChannel.channel = responseValue.channel ?? ""
|
|
1238
|
+
getChannel.allowSet = responseValue.allowSet ?? true
|
|
1239
|
+
}
|
|
1062
1240
|
}
|
|
1063
1241
|
case let .failure(error):
|
|
1064
1242
|
if let data = response.data, let bodyString = String(data: data, encoding: .utf8) {
|
|
@@ -1069,18 +1247,105 @@ import UIKit
|
|
|
1069
1247
|
}
|
|
1070
1248
|
}
|
|
1071
1249
|
|
|
1072
|
-
|
|
1073
|
-
getChannel.
|
|
1074
|
-
getChannel.error = "response_error"
|
|
1250
|
+
self.logger.error("Error get Channel \(error)")
|
|
1251
|
+
getChannel.error = "Request failed: \(error.localizedDescription)"
|
|
1075
1252
|
}
|
|
1076
1253
|
}
|
|
1077
1254
|
semaphore.wait()
|
|
1078
1255
|
return getChannel
|
|
1079
1256
|
}
|
|
1080
1257
|
|
|
1258
|
+
func listChannels() -> ListChannels {
|
|
1259
|
+
let listChannels: ListChannels = ListChannels()
|
|
1260
|
+
|
|
1261
|
+
// Check if rate limit was exceeded
|
|
1262
|
+
if CapgoUpdater.rateLimitExceeded {
|
|
1263
|
+
logger.debug("Skipping listChannels due to rate limit (429). Requests will resume after app restart.")
|
|
1264
|
+
listChannels.error = "rate_limit_exceeded"
|
|
1265
|
+
return listChannels
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
if (self.channelUrl).isEmpty {
|
|
1269
|
+
logger.error("Channel URL is not set")
|
|
1270
|
+
listChannels.error = "Channel URL is not set"
|
|
1271
|
+
return listChannels
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
|
|
1275
|
+
|
|
1276
|
+
// Auto-detect values
|
|
1277
|
+
let appId = self.appId
|
|
1278
|
+
let platform = "ios"
|
|
1279
|
+
let isEmulator = self.isEmulator()
|
|
1280
|
+
let isProd = self.isProd()
|
|
1281
|
+
|
|
1282
|
+
// Create query parameters
|
|
1283
|
+
var urlComponents = URLComponents(string: self.channelUrl)
|
|
1284
|
+
urlComponents?.queryItems = [
|
|
1285
|
+
URLQueryItem(name: "app_id", value: appId),
|
|
1286
|
+
URLQueryItem(name: "platform", value: platform),
|
|
1287
|
+
URLQueryItem(name: "is_emulator", value: String(isEmulator)),
|
|
1288
|
+
URLQueryItem(name: "is_prod", value: String(isProd))
|
|
1289
|
+
]
|
|
1290
|
+
|
|
1291
|
+
guard let url = urlComponents?.url else {
|
|
1292
|
+
logger.error("Invalid channel URL")
|
|
1293
|
+
listChannels.error = "Invalid channel URL"
|
|
1294
|
+
return listChannels
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
let request = alamofireSession.request(url, method: .get, requestModifier: { $0.timeoutInterval = self.timeout })
|
|
1298
|
+
|
|
1299
|
+
request.validate().responseDecodable(of: ListChannelsDec.self) { response in
|
|
1300
|
+
defer {
|
|
1301
|
+
semaphore.signal()
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
// Check for 429 rate limit
|
|
1305
|
+
if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
|
|
1306
|
+
listChannels.error = "rate_limit_exceeded"
|
|
1307
|
+
return
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
switch response.result {
|
|
1311
|
+
case .success:
|
|
1312
|
+
if let responseValue = response.value {
|
|
1313
|
+
// Check for server-side errors
|
|
1314
|
+
if let error = responseValue.error {
|
|
1315
|
+
listChannels.error = error
|
|
1316
|
+
return
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
// Backend returns direct array, so channels should be populated by our custom decoder
|
|
1320
|
+
if let channels = responseValue.channels {
|
|
1321
|
+
listChannels.channels = channels.map { channel in
|
|
1322
|
+
var channelDict: [String: Any] = [:]
|
|
1323
|
+
channelDict["id"] = channel.id ?? ""
|
|
1324
|
+
channelDict["name"] = channel.name ?? ""
|
|
1325
|
+
channelDict["public"] = channel.public ?? false
|
|
1326
|
+
channelDict["allow_self_set"] = channel.allow_self_set ?? false
|
|
1327
|
+
return channelDict
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
case let .failure(error):
|
|
1332
|
+
self.logger.error("Error list channels \(error)")
|
|
1333
|
+
listChannels.error = "Request failed: \(error.localizedDescription)"
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
semaphore.wait()
|
|
1337
|
+
return listChannels
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1081
1340
|
private let operationQueue = OperationQueue()
|
|
1082
1341
|
|
|
1083
1342
|
func sendStats(action: String, versionName: String? = nil, oldVersionName: String? = "") {
|
|
1343
|
+
// Check if rate limit was exceeded
|
|
1344
|
+
if CapgoUpdater.rateLimitExceeded {
|
|
1345
|
+
logger.debug("Skipping sendStats due to rate limit (429). Stats will resume after app restart.")
|
|
1346
|
+
return
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1084
1349
|
guard !statsUrl.isEmpty else {
|
|
1085
1350
|
return
|
|
1086
1351
|
}
|
|
@@ -1095,22 +1360,28 @@ import UIKit
|
|
|
1095
1360
|
|
|
1096
1361
|
let operation = BlockOperation {
|
|
1097
1362
|
let semaphore = DispatchSemaphore(value: 0)
|
|
1098
|
-
|
|
1363
|
+
self.alamofireSession.request(
|
|
1099
1364
|
self.statsUrl,
|
|
1100
1365
|
method: .post,
|
|
1101
1366
|
parameters: parameters,
|
|
1102
1367
|
encoder: JSONParameterEncoder.default,
|
|
1103
1368
|
requestModifier: { $0.timeoutInterval = self.timeout }
|
|
1104
1369
|
).responseData { response in
|
|
1370
|
+
// Check for 429 rate limit
|
|
1371
|
+
if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
|
|
1372
|
+
semaphore.signal()
|
|
1373
|
+
return
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1105
1376
|
switch response.result {
|
|
1106
1377
|
case .success:
|
|
1107
|
-
|
|
1378
|
+
self.logger.info("Stats sent for \(action), version \(versionName)")
|
|
1108
1379
|
case let .failure(error):
|
|
1109
|
-
|
|
1380
|
+
self.logger.error("Error sending stats: \(response.value?.debugDescription ?? "") \(error.localizedDescription)")
|
|
1110
1381
|
}
|
|
1111
1382
|
semaphore.signal()
|
|
1112
1383
|
}
|
|
1113
|
-
semaphore.
|
|
1384
|
+
semaphore.wait()
|
|
1114
1385
|
}
|
|
1115
1386
|
operationQueue.addOperation(operation)
|
|
1116
1387
|
|
|
@@ -1121,7 +1392,6 @@ import UIKit
|
|
|
1121
1392
|
if id != nil {
|
|
1122
1393
|
trueId = id!
|
|
1123
1394
|
}
|
|
1124
|
-
// print("\(CapacitorUpdater.TAG) Getting info for bundle [\(trueId)]")
|
|
1125
1395
|
let result: BundleInfo
|
|
1126
1396
|
if BundleInfo.ID_BUILTIN == trueId {
|
|
1127
1397
|
result = BundleInfo(id: trueId, version: "", status: BundleStatus.SUCCESS, checksum: "")
|
|
@@ -1131,11 +1401,10 @@ import UIKit
|
|
|
1131
1401
|
do {
|
|
1132
1402
|
result = try UserDefaults.standard.getObj(forKey: "\(trueId)\(self.INFO_SUFFIX)", castTo: BundleInfo.self)
|
|
1133
1403
|
} catch {
|
|
1134
|
-
|
|
1404
|
+
logger.error("Failed to parse info for bundle [\(trueId)] \(error.localizedDescription)")
|
|
1135
1405
|
result = BundleInfo(id: trueId, version: "", status: BundleStatus.PENDING, checksum: "")
|
|
1136
1406
|
}
|
|
1137
1407
|
}
|
|
1138
|
-
// print("\(CapacitorUpdater.TAG) Returning info bundle [\(result.toString())]")
|
|
1139
1408
|
return result
|
|
1140
1409
|
}
|
|
1141
1410
|
|
|
@@ -1155,26 +1424,26 @@ import UIKit
|
|
|
1155
1424
|
|
|
1156
1425
|
public func saveBundleInfo(id: String, bundle: BundleInfo?) {
|
|
1157
1426
|
if bundle != nil && (bundle!.isBuiltin() || bundle!.isUnknown()) {
|
|
1158
|
-
|
|
1427
|
+
logger.info("Not saving info for bundle [\(id)] \(bundle?.toString() ?? "")")
|
|
1159
1428
|
return
|
|
1160
1429
|
}
|
|
1161
1430
|
if bundle == nil {
|
|
1162
|
-
|
|
1431
|
+
logger.info("Removing info for bundle [\(id)]")
|
|
1163
1432
|
UserDefaults.standard.removeObject(forKey: "\(id)\(self.INFO_SUFFIX)")
|
|
1164
1433
|
} else {
|
|
1165
1434
|
let update = bundle!.setId(id: id)
|
|
1166
|
-
|
|
1435
|
+
logger.info("Storing info for bundle [\(id)] \(update.toString())")
|
|
1167
1436
|
do {
|
|
1168
1437
|
try UserDefaults.standard.setObj(update, forKey: "\(id)\(self.INFO_SUFFIX)")
|
|
1169
1438
|
} catch {
|
|
1170
|
-
|
|
1439
|
+
logger.error("Failed to save info for bundle [\(id)] \(error.localizedDescription)")
|
|
1171
1440
|
}
|
|
1172
1441
|
}
|
|
1173
1442
|
UserDefaults.standard.synchronize()
|
|
1174
1443
|
}
|
|
1175
1444
|
|
|
1176
1445
|
private func setBundleStatus(id: String, status: BundleStatus) {
|
|
1177
|
-
|
|
1446
|
+
logger.info("Setting status for bundle [\(id)] to \(status)")
|
|
1178
1447
|
let info = self.getBundleInfo(id: id)
|
|
1179
1448
|
self.saveBundleInfo(id: id, bundle: info.setStatus(status: status.localizedString))
|
|
1180
1449
|
}
|