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