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