@capgo/capacitor-updater 7.43.3 → 7.50.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Package.swift +5 -2
- package/README.md +643 -115
- package/android/build.gradle +3 -3
- package/android/src/main/java/ee/forgr/capacitor_updater/AndroidAppExitReporter.java +92 -0
- package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +3121 -353
- package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +924 -290
- package/android/src/main/java/ee/forgr/capacitor_updater/DelayUpdateUtils.java +49 -13
- package/android/src/main/java/ee/forgr/capacitor_updater/DeviceIdHelper.java +45 -30
- package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +75 -32
- package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +2 -0
- package/android/src/main/java/ee/forgr/capacitor_updater/ShakeDetector.java +3 -3
- package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +401 -27
- package/android/src/main/java/ee/forgr/capacitor_updater/ThreeFingerPinchDetector.java +323 -0
- package/dist/docs.json +1590 -196
- package/dist/esm/definitions.d.ts +755 -66
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/web.d.ts +11 -2
- package/dist/esm/web.js +59 -5
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +59 -5
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +59 -5
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/CapacitorUpdaterPlugin/AES.swift +0 -1
- package/ios/Sources/CapacitorUpdaterPlugin/AppHealthTracker.swift +82 -0
- package/ios/Sources/CapacitorUpdaterPlugin/BigInt.swift +0 -16
- package/ios/Sources/CapacitorUpdaterPlugin/BundleInfo.swift +2 -2
- package/ios/Sources/CapacitorUpdaterPlugin/BundleStatus.swift +78 -2
- package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +2732 -417
- package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +1191 -363
- package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +0 -1
- package/ios/Sources/CapacitorUpdaterPlugin/DelayUpdateUtils.swift +37 -16
- package/ios/Sources/CapacitorUpdaterPlugin/InternalUtils.swift +80 -1
- package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +438 -39
- package/ios/Sources/CapacitorUpdaterPlugin/WebViewStatsReporter.swift +276 -0
- package/package.json +24 -9
|
@@ -21,6 +21,7 @@ import UIKit
|
|
|
21
21
|
private let INFO_SUFFIX: String = "_info"
|
|
22
22
|
private let FALLBACK_VERSION: String = "pastVersion"
|
|
23
23
|
private let NEXT_VERSION: String = "nextVersion"
|
|
24
|
+
private let PREVIEW_FALLBACK_VERSION: String = "previewFallbackVersion"
|
|
24
25
|
private var unzipPercent = 0
|
|
25
26
|
private let TEMP_UNZIP_PREFIX: String = "capgo_unzip_"
|
|
26
27
|
|
|
@@ -37,6 +38,7 @@ import UIKit
|
|
|
37
38
|
public var defaultChannel: String = ""
|
|
38
39
|
public var appId: String = ""
|
|
39
40
|
public var deviceID = ""
|
|
41
|
+
public var previewSession = false
|
|
40
42
|
public var publicKey: String = ""
|
|
41
43
|
|
|
42
44
|
// Cached key ID calculated once from publicKey
|
|
@@ -49,33 +51,272 @@ import UIKit
|
|
|
49
51
|
private static var rateLimitStatisticSent = false
|
|
50
52
|
|
|
51
53
|
// Stats batching - queue events and send max once per second
|
|
52
|
-
private var statsQueue: [
|
|
54
|
+
private var statsQueue: [QueuedStatsEvent] = []
|
|
53
55
|
private let statsQueueLock = NSLock()
|
|
54
56
|
private var statsFlushTimer: Timer?
|
|
55
57
|
private static let statsFlushInterval: TimeInterval = 1.0
|
|
56
58
|
|
|
59
|
+
private struct QueuedStatsEvent {
|
|
60
|
+
let event: StatsEvent
|
|
61
|
+
let onSent: (() -> Void)?
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private static func sanitizeHeaderValue(_ value: String) -> String {
|
|
65
|
+
if value.isEmpty {
|
|
66
|
+
return "unknown"
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let filteredScalars = value.unicodeScalars.filter { scalar in
|
|
70
|
+
let cp = scalar.value
|
|
71
|
+
let isVisibleAscii = (0x20...0x7E).contains(cp)
|
|
72
|
+
let isIso88591 = (0xA0...0xFF).contains(cp)
|
|
73
|
+
return isVisibleAscii || isIso88591
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let sanitized = String(String.UnicodeScalarView(filteredScalars)).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
77
|
+
return sanitized.isEmpty ? "unknown" : sanitized
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
static func buildUserAgent(appId: String, pluginVersion: String, versionOs: String) -> String {
|
|
81
|
+
let safePluginVersion = sanitizeHeaderValue(pluginVersion)
|
|
82
|
+
let safeAppId = sanitizeHeaderValue(appId)
|
|
83
|
+
let safeVersionOs = sanitizeHeaderValue(versionOs)
|
|
84
|
+
return "CapacitorUpdater/\(safePluginVersion) (\(safeAppId)) ios/\(safeVersionOs)"
|
|
85
|
+
}
|
|
86
|
+
|
|
57
87
|
private var userAgent: String {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
88
|
+
CapgoUpdater.buildUserAgent(appId: appId, pluginVersion: pluginVersion, versionOs: versionOs)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
struct RequestResult {
|
|
92
|
+
let data: Data?
|
|
93
|
+
let response: HTTPURLResponse?
|
|
94
|
+
let error: Error?
|
|
95
|
+
let timedOut: Bool
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private struct DownloadRequestResult {
|
|
99
|
+
let fileURL: URL?
|
|
100
|
+
let response: HTTPURLResponse?
|
|
101
|
+
let error: Error?
|
|
102
|
+
let timedOut: Bool
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
enum SecurePathError: Error {
|
|
106
|
+
case emptyPath
|
|
107
|
+
case windowsPath
|
|
108
|
+
case absolutePath
|
|
109
|
+
case pathTraversal
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
static func resolvePathInsideDirectory(baseDirectory: URL, relativePath: String) throws -> URL {
|
|
113
|
+
if relativePath.isEmpty {
|
|
114
|
+
throw SecurePathError.emptyPath
|
|
115
|
+
}
|
|
116
|
+
if relativePath.contains("\\") || relativePath.contains("\0") {
|
|
117
|
+
throw SecurePathError.windowsPath
|
|
118
|
+
}
|
|
119
|
+
if (relativePath as NSString).isAbsolutePath {
|
|
120
|
+
throw SecurePathError.absolutePath
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let canonicalBase = baseDirectory.standardizedFileURL
|
|
124
|
+
let canonicalBasePath = canonicalBase.path
|
|
125
|
+
let normalizedBasePath = canonicalBasePath.hasSuffix("/") ? canonicalBasePath : "\(canonicalBasePath)/"
|
|
126
|
+
let canonicalTarget = canonicalBase.appendingPathComponent(relativePath).standardizedFileURL
|
|
127
|
+
let canonicalTargetPath = canonicalTarget.path
|
|
128
|
+
|
|
129
|
+
if canonicalTargetPath != canonicalBasePath && !canonicalTargetPath.hasPrefix(normalizedBasePath) {
|
|
130
|
+
throw SecurePathError.pathTraversal
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return canonicalTarget
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
static func resolveManifestTargetPath(baseDirectory: URL, fileName: String) throws -> URL {
|
|
137
|
+
let isBrotli = fileName.hasSuffix(".br")
|
|
138
|
+
let targetFileName = isBrotli ? String(fileName.dropLast(3)) : fileName
|
|
139
|
+
return try resolvePathInsideDirectory(baseDirectory: baseDirectory, relativePath: targetFileName)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private func isTimedOutError(_ error: Error?) -> Bool {
|
|
143
|
+
guard let nsError = error as NSError? else {
|
|
144
|
+
return false
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorTimedOut
|
|
61
148
|
}
|
|
62
149
|
|
|
63
150
|
private lazy var alamofireSession: Session = {
|
|
64
|
-
let configuration = URLSessionConfiguration.
|
|
151
|
+
let configuration = URLSessionConfiguration.ephemeral
|
|
65
152
|
configuration.httpAdditionalHeaders = ["User-Agent": self.userAgent]
|
|
153
|
+
configuration.httpCookieStorage = nil
|
|
154
|
+
configuration.httpShouldSetCookies = false
|
|
155
|
+
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
|
156
|
+
configuration.urlCache = nil
|
|
66
157
|
return Session(configuration: configuration)
|
|
67
158
|
}()
|
|
159
|
+
private let networkResponseQueue = DispatchQueue(label: "ee.forgr.capacitor-updater.network-response", qos: .utility)
|
|
68
160
|
|
|
69
161
|
public var notifyDownloadRaw: (String, Int, Bool, BundleInfo?) -> Void = { _, _, _, _ in }
|
|
70
162
|
public func notifyDownload(id: String, percent: Int, ignoreMultipleOfTen: Bool = false, bundle: BundleInfo? = nil) {
|
|
71
|
-
|
|
163
|
+
let emit = {
|
|
164
|
+
self.notifyDownloadRaw(id, percent, ignoreMultipleOfTen, bundle)
|
|
165
|
+
}
|
|
166
|
+
if Thread.isMainThread {
|
|
167
|
+
emit()
|
|
168
|
+
} else {
|
|
169
|
+
DispatchQueue.main.async {
|
|
170
|
+
emit()
|
|
171
|
+
}
|
|
172
|
+
}
|
|
72
173
|
}
|
|
73
174
|
public var notifyDownload: (String, Int) -> Void = { _, _ in }
|
|
175
|
+
public var notifyListeners: (String, [String: Any]) -> Void = { _, _ in }
|
|
74
176
|
|
|
75
177
|
public func setLogger(_ logger: Logger) {
|
|
76
178
|
self.logger = logger
|
|
77
179
|
}
|
|
78
180
|
|
|
181
|
+
private func createRequest(url: URL, method: String, parameters: [String: Any]? = nil, expectsJSONResponse: Bool = false) -> URLRequest? {
|
|
182
|
+
var request = URLRequest(url: url)
|
|
183
|
+
request.httpMethod = method
|
|
184
|
+
request.timeoutInterval = self.timeout
|
|
185
|
+
request.setValue(self.userAgent, forHTTPHeaderField: "User-Agent")
|
|
186
|
+
if expectsJSONResponse || parameters != nil {
|
|
187
|
+
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
guard let parameters else {
|
|
191
|
+
return request
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
guard JSONSerialization.isValidJSONObject(parameters) else {
|
|
195
|
+
logger.error("Invalid JSON body for \(method) \(url.absoluteString)")
|
|
196
|
+
return nil
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
do {
|
|
200
|
+
request.httpBody = try JSONSerialization.data(withJSONObject: parameters)
|
|
201
|
+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
202
|
+
return request
|
|
203
|
+
} catch {
|
|
204
|
+
logger.error("Error encoding request body for \(method) \(url.absoluteString)")
|
|
205
|
+
logger.debug("Error: \(error.localizedDescription)")
|
|
206
|
+
return nil
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
func performRequest(_ request: URLRequest, label: String) -> RequestResult {
|
|
211
|
+
let waitTimeout = max(self.timeout + 5, 10)
|
|
212
|
+
let semaphore = DispatchSemaphore(value: 0)
|
|
213
|
+
var responseData: Data?
|
|
214
|
+
var httpResponse: HTTPURLResponse?
|
|
215
|
+
var requestError: Error?
|
|
216
|
+
let dataRequest = self.alamofireSession.request(request).responseData(queue: self.networkResponseQueue) { response in
|
|
217
|
+
responseData = response.data
|
|
218
|
+
httpResponse = response.response
|
|
219
|
+
requestError = response.error
|
|
220
|
+
semaphore.signal()
|
|
221
|
+
}
|
|
222
|
+
dataRequest.resume()
|
|
223
|
+
|
|
224
|
+
if semaphore.wait(timeout: .now() + waitTimeout) == .timedOut {
|
|
225
|
+
dataRequest.cancel()
|
|
226
|
+
logger.error("\(label) timed out after \(Int(waitTimeout))s")
|
|
227
|
+
return RequestResult(data: responseData, response: httpResponse, error: requestError, timedOut: true)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return RequestResult(data: responseData, response: httpResponse, error: requestError, timedOut: false)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private func performDownloadRequest(_ request: URLRequest, label: String) -> DownloadRequestResult {
|
|
234
|
+
let waitTimeout = max(self.timeout + 5, 10)
|
|
235
|
+
let semaphore = DispatchSemaphore(value: 0)
|
|
236
|
+
var tempFileURL: URL?
|
|
237
|
+
var httpResponse: HTTPURLResponse?
|
|
238
|
+
var requestError: Error?
|
|
239
|
+
let temporaryDownloadURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
240
|
+
let destination: DownloadRequest.Destination = { _, _ in
|
|
241
|
+
(temporaryDownloadURL, [.removePreviousFile, .createIntermediateDirectories])
|
|
242
|
+
}
|
|
243
|
+
let downloadRequest = self.alamofireSession.download(request, to: destination).response(queue: self.networkResponseQueue) { response in
|
|
244
|
+
tempFileURL = response.fileURL
|
|
245
|
+
httpResponse = response.response
|
|
246
|
+
requestError = response.error
|
|
247
|
+
semaphore.signal()
|
|
248
|
+
}
|
|
249
|
+
downloadRequest.resume()
|
|
250
|
+
|
|
251
|
+
if semaphore.wait(timeout: .now() + waitTimeout) == .timedOut {
|
|
252
|
+
downloadRequest.cancel()
|
|
253
|
+
logger.error("\(label) timed out after \(Int(waitTimeout))s")
|
|
254
|
+
return DownloadRequestResult(
|
|
255
|
+
fileURL: existingDownloadFileURL(tempFileURL, fallback: temporaryDownloadURL),
|
|
256
|
+
response: httpResponse,
|
|
257
|
+
error: requestError,
|
|
258
|
+
timedOut: true
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if isTimedOutError(requestError) {
|
|
263
|
+
logger.error("\(label) timed out after \(Int(waitTimeout))s")
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return DownloadRequestResult(
|
|
267
|
+
fileURL: existingDownloadFileURL(tempFileURL, fallback: temporaryDownloadURL),
|
|
268
|
+
response: httpResponse,
|
|
269
|
+
error: requestError,
|
|
270
|
+
timedOut: isTimedOutError(requestError)
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private func existingDownloadFileURL(_ fileURL: URL?, fallback: URL) -> URL? {
|
|
275
|
+
let fileManager = FileManager.default
|
|
276
|
+
if let fileURL, fileManager.fileExists(atPath: fileURL.path) {
|
|
277
|
+
return fileURL
|
|
278
|
+
}
|
|
279
|
+
return fileManager.fileExists(atPath: fallback.path) ? fallback : nil
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private func storeDownloadedFile(_ downloadedFileURL: URL, at tempPath: URL, existingBytes: Int64, response: HTTPURLResponse?) throws {
|
|
283
|
+
let fileManager = FileManager.default
|
|
284
|
+
if existingBytes > 0 && (response?.statusCode == 206 || response == nil) {
|
|
285
|
+
let resumedData = try Data(contentsOf: downloadedFileURL)
|
|
286
|
+
let fileHandle = try FileHandle(forWritingTo: tempPath)
|
|
287
|
+
fileHandle.seek(toFileOffset: UInt64(existingBytes))
|
|
288
|
+
fileHandle.write(resumedData)
|
|
289
|
+
try fileHandle.close()
|
|
290
|
+
try? fileManager.removeItem(at: downloadedFileURL)
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if fileManager.fileExists(atPath: tempPath.path) {
|
|
295
|
+
try fileManager.removeItem(at: tempPath)
|
|
296
|
+
}
|
|
297
|
+
try fileManager.moveItem(at: downloadedFileURL, to: tempPath)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private func persistPartialDownload(_ downloadResult: DownloadRequestResult, id: String, tempPath: URL, existingBytes: Int64) {
|
|
301
|
+
guard let downloadedFileURL = downloadResult.fileURL else {
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
guard FileManager.default.fileExists(atPath: downloadedFileURL.path) else {
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
if let statusCode = downloadResult.response?.statusCode, statusCode < 200 || statusCode >= 300 {
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
do {
|
|
312
|
+
try storeDownloadedFile(downloadedFileURL, at: tempPath, existingBytes: existingBytes, response: downloadResult.response)
|
|
313
|
+
logger.info("Stored partial download for retry")
|
|
314
|
+
} catch {
|
|
315
|
+
logger.error("Failed to store partial download")
|
|
316
|
+
logger.debug("Path: \(downloadedFileURL.path), Error: \(error)")
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
79
320
|
deinit {
|
|
80
321
|
// Invalidate the stats timer to prevent memory leaks
|
|
81
322
|
statsFlushTimer?.invalidate()
|
|
@@ -127,6 +368,22 @@ import UIKit
|
|
|
127
368
|
return !self.isDevEnvironment && !self.isAppStoreReceiptSandbox() && !self.hasEmbeddedMobileProvision()
|
|
128
369
|
}
|
|
129
370
|
|
|
371
|
+
private func installSource() -> String? {
|
|
372
|
+
if isEmulator() || self.isDevEnvironment || self.hasEmbeddedMobileProvision() {
|
|
373
|
+
return nil
|
|
374
|
+
}
|
|
375
|
+
guard let receiptURL = Bundle.main.appStoreReceiptURL else {
|
|
376
|
+
return nil
|
|
377
|
+
}
|
|
378
|
+
if receiptURL.lastPathComponent == "sandboxReceipt" {
|
|
379
|
+
return "testflight"
|
|
380
|
+
}
|
|
381
|
+
guard FileManager.default.fileExists(atPath: receiptURL.path) else {
|
|
382
|
+
return nil
|
|
383
|
+
}
|
|
384
|
+
return "app_store"
|
|
385
|
+
}
|
|
386
|
+
|
|
130
387
|
/**
|
|
131
388
|
* Checks if there is sufficient disk space for a download.
|
|
132
389
|
* Matches Android behavior: 2x safety margin, throws "insufficient_disk_space"
|
|
@@ -166,7 +423,7 @@ import UIKit
|
|
|
166
423
|
if statusCode == 429 {
|
|
167
424
|
// Send a statistic about the rate limit BEFORE setting the flag
|
|
168
425
|
// Only send once to prevent infinite loop if the stat request itself gets rate limited
|
|
169
|
-
if !CapgoUpdater.rateLimitExceeded && !CapgoUpdater.rateLimitStatisticSent {
|
|
426
|
+
if !previewSession && !CapgoUpdater.rateLimitExceeded && !CapgoUpdater.rateLimitStatisticSent {
|
|
170
427
|
CapgoUpdater.rateLimitStatisticSent = true
|
|
171
428
|
|
|
172
429
|
// Dispatch to background queue to avoid blocking the main thread
|
|
@@ -202,8 +459,8 @@ import UIKit
|
|
|
202
459
|
self.alamofireSession.request(
|
|
203
460
|
self.statsUrl,
|
|
204
461
|
method: .post,
|
|
205
|
-
parameters: parameters,
|
|
206
|
-
|
|
462
|
+
parameters: parameters.toParameters(),
|
|
463
|
+
encoding: JSONEncoding.default,
|
|
207
464
|
requestModifier: { $0.timeoutInterval = self.timeout }
|
|
208
465
|
).responseData { response in
|
|
209
466
|
switch response.result {
|
|
@@ -292,23 +549,76 @@ import UIKit
|
|
|
292
549
|
}
|
|
293
550
|
}
|
|
294
551
|
|
|
295
|
-
private func
|
|
296
|
-
|
|
297
|
-
|
|
552
|
+
private func resolveZipEntry(path: String, destUnZip: URL) throws -> URL {
|
|
553
|
+
do {
|
|
554
|
+
return try Self.resolvePathInsideDirectory(baseDirectory: destUnZip, relativePath: path)
|
|
555
|
+
} catch SecurePathError.windowsPath {
|
|
298
556
|
logger.error("Unzip failed: Windows path not supported")
|
|
299
557
|
logger.debug("Invalid path: \(path)")
|
|
300
558
|
self.sendStats(action: "windows_path_fail")
|
|
301
559
|
throw CustomError.cannotUnzip
|
|
560
|
+
} catch {
|
|
561
|
+
self.sendStats(action: "canonical_path_fail")
|
|
562
|
+
throw CustomError.cannotUnzip
|
|
302
563
|
}
|
|
564
|
+
}
|
|
303
565
|
|
|
304
|
-
|
|
305
|
-
let
|
|
306
|
-
let canonicalPath = fileURL.standardizedFileURL.path
|
|
307
|
-
let canonicalDir = destUnZip.standardizedFileURL.path
|
|
566
|
+
private func extractZipEntry(_ archive: Archive, entry: Entry, to destPath: URL) throws {
|
|
567
|
+
let fileManager = FileManager.default
|
|
308
568
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
569
|
+
switch entry.type {
|
|
570
|
+
case .directory:
|
|
571
|
+
try fileManager.createDirectory(at: destPath, withIntermediateDirectories: true, attributes: nil)
|
|
572
|
+
case .file:
|
|
573
|
+
let parentDir = destPath.deletingLastPathComponent()
|
|
574
|
+
try fileManager.createDirectory(at: parentDir, withIntermediateDirectories: true, attributes: nil)
|
|
575
|
+
|
|
576
|
+
if fileManager.fileExists(atPath: destPath.path) {
|
|
577
|
+
try fileManager.removeItem(at: destPath)
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
guard fileManager.createFile(atPath: destPath.path, contents: nil) else {
|
|
581
|
+
throw CustomError.cannotUnzip
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
let fileHandle = try FileHandle(forWritingTo: destPath)
|
|
585
|
+
defer {
|
|
586
|
+
fileHandle.closeFile()
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
_ = try archive.extract(entry, bufferSize: 16 * 1024, skipCRC32: true) { data in
|
|
590
|
+
if !data.isEmpty {
|
|
591
|
+
fileHandle.write(data)
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
case .symlink:
|
|
595
|
+
var linkData = Data()
|
|
596
|
+
_ = try archive.extract(entry, bufferSize: 16 * 1024, skipCRC32: true) { data in
|
|
597
|
+
linkData.append(data)
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
guard let linkPath = String(data: linkData, encoding: .utf8) else {
|
|
601
|
+
throw CustomError.cannotUnzip
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
let parentDir = destPath.deletingLastPathComponent()
|
|
605
|
+
try fileManager.createDirectory(at: parentDir, withIntermediateDirectories: true, attributes: nil)
|
|
606
|
+
|
|
607
|
+
let isAbsolutePath = (linkPath as NSString).isAbsolutePath
|
|
608
|
+
let linkURL = URL(fileURLWithPath: linkPath, relativeTo: isAbsolutePath ? nil : parentDir)
|
|
609
|
+
let canonicalPath = linkURL.standardizedFileURL.path
|
|
610
|
+
let canonicalDir = parentDir.standardizedFileURL.path
|
|
611
|
+
let normalizedDir = canonicalDir.hasSuffix("/") ? canonicalDir : "\(canonicalDir)/"
|
|
612
|
+
|
|
613
|
+
if canonicalPath != canonicalDir && !canonicalPath.hasPrefix(normalizedDir) {
|
|
614
|
+
throw CustomError.cannotUnzip
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if fileManager.fileExists(atPath: destPath.path) {
|
|
618
|
+
try fileManager.removeItem(at: destPath)
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
try fileManager.createSymbolicLink(atPath: destPath.path, withDestinationPath: linkPath)
|
|
312
622
|
}
|
|
313
623
|
}
|
|
314
624
|
|
|
@@ -338,10 +648,20 @@ import UIKit
|
|
|
338
648
|
|
|
339
649
|
do {
|
|
340
650
|
for entry in archive {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
651
|
+
let destPath = try resolveZipEntry(path: entry.path, destUnZip: destUnZip)
|
|
652
|
+
|
|
653
|
+
if entry.type == .directory {
|
|
654
|
+
try FileManager.default.createDirectory(at: destPath, withIntermediateDirectories: true, attributes: nil)
|
|
655
|
+
processedEntries += 1
|
|
656
|
+
if notify && totalEntries > 0 {
|
|
657
|
+
let newPercent = self.calcTotalPercent(percent: Int(Double(processedEntries) / Double(totalEntries) * 100), min: 75, max: 81)
|
|
658
|
+
if newPercent != self.unzipPercent {
|
|
659
|
+
self.unzipPercent = newPercent
|
|
660
|
+
self.notifyDownload(id: id, percent: newPercent)
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
continue
|
|
664
|
+
}
|
|
345
665
|
|
|
346
666
|
// Create parent directories if needed
|
|
347
667
|
let parentDir = destPath.deletingLastPathComponent()
|
|
@@ -349,8 +669,7 @@ import UIKit
|
|
|
349
669
|
try FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true, attributes: nil)
|
|
350
670
|
}
|
|
351
671
|
|
|
352
|
-
|
|
353
|
-
_ = try archive.extract(entry, to: destPath, skipCRC32: true)
|
|
672
|
+
try self.extractZipEntry(archive, entry: entry, to: destPath)
|
|
354
673
|
|
|
355
674
|
// Update progress
|
|
356
675
|
processedEntries += 1
|
|
@@ -433,11 +752,11 @@ import UIKit
|
|
|
433
752
|
}
|
|
434
753
|
}
|
|
435
754
|
|
|
436
|
-
private func createInfoObject() -> InfoObject {
|
|
755
|
+
private func createInfoObject(appIdOverride: String? = nil) -> InfoObject {
|
|
437
756
|
return InfoObject(
|
|
438
757
|
platform: "ios",
|
|
439
758
|
device_id: self.deviceID,
|
|
440
|
-
app_id: self.appId,
|
|
759
|
+
app_id: appIdOverride ?? self.appId,
|
|
441
760
|
custom_id: self.customId,
|
|
442
761
|
version_build: self.versionBuild,
|
|
443
762
|
version_code: self.versionCode,
|
|
@@ -446,6 +765,7 @@ import UIKit
|
|
|
446
765
|
plugin_version: self.pluginVersion,
|
|
447
766
|
is_emulator: self.isEmulator(),
|
|
448
767
|
is_prod: self.isProd(),
|
|
768
|
+
installSource: self.installSource(),
|
|
449
769
|
action: nil,
|
|
450
770
|
channel: nil,
|
|
451
771
|
defaultChannel: self.defaultChannel,
|
|
@@ -453,66 +773,118 @@ import UIKit
|
|
|
453
773
|
)
|
|
454
774
|
}
|
|
455
775
|
|
|
456
|
-
public func getLatest(url: URL, channel: String?) -> AppVersion {
|
|
457
|
-
let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
|
|
776
|
+
public func getLatest(url: URL, channel: String?, appIdOverride: String? = nil) -> AppVersion {
|
|
458
777
|
let latest: AppVersion = AppVersion()
|
|
459
|
-
|
|
778
|
+
func applyLatestResponse(_ value: AppVersionDec?) {
|
|
779
|
+
if let url = value?.url {
|
|
780
|
+
latest.url = url
|
|
781
|
+
}
|
|
782
|
+
if let checksum = value?.checksum {
|
|
783
|
+
latest.checksum = checksum
|
|
784
|
+
}
|
|
785
|
+
if let version = value?.version {
|
|
786
|
+
latest.version = version
|
|
787
|
+
}
|
|
788
|
+
if let major = value?.major {
|
|
789
|
+
latest.major = major
|
|
790
|
+
}
|
|
791
|
+
if let breaking = value?.breaking {
|
|
792
|
+
latest.breaking = breaking
|
|
793
|
+
}
|
|
794
|
+
if let error = value?.error {
|
|
795
|
+
latest.error = error
|
|
796
|
+
}
|
|
797
|
+
if let kind = value?.kind {
|
|
798
|
+
latest.kind = kind
|
|
799
|
+
}
|
|
800
|
+
if let message = value?.message {
|
|
801
|
+
latest.message = message
|
|
802
|
+
}
|
|
803
|
+
if let sessionKey = value?.session_key {
|
|
804
|
+
latest.sessionKey = sessionKey
|
|
805
|
+
}
|
|
806
|
+
if let data = value?.data {
|
|
807
|
+
latest.data = data
|
|
808
|
+
}
|
|
809
|
+
if let manifest = value?.manifest {
|
|
810
|
+
latest.manifest = manifest
|
|
811
|
+
}
|
|
812
|
+
if let link = value?.link {
|
|
813
|
+
latest.link = link
|
|
814
|
+
}
|
|
815
|
+
if let comment = value?.comment {
|
|
816
|
+
latest.comment = comment
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
var parameters: InfoObject = self.createInfoObject(appIdOverride: appIdOverride)
|
|
460
821
|
if let channel = channel {
|
|
461
822
|
parameters.defaultChannel = channel
|
|
462
823
|
}
|
|
463
|
-
|
|
464
|
-
|
|
824
|
+
guard let request = createRequest(url: url, method: "POST", parameters: parameters.toParameters()) else {
|
|
825
|
+
latest.message = "Error getting Latest"
|
|
826
|
+
latest.error = "request_error"
|
|
827
|
+
latest.kind = "failed"
|
|
828
|
+
return latest
|
|
829
|
+
}
|
|
465
830
|
|
|
466
|
-
request
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
831
|
+
let result = performRequest(request, label: "getLatest")
|
|
832
|
+
latest.statusCode = result.response?.statusCode ?? 0
|
|
833
|
+
|
|
834
|
+
if result.timedOut {
|
|
835
|
+
latest.message = "Error getting Latest"
|
|
836
|
+
latest.error = "timeout_error"
|
|
837
|
+
latest.kind = "failed"
|
|
838
|
+
return latest
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
if let error = result.error {
|
|
842
|
+
self.logger.error("Error getting latest version")
|
|
843
|
+
self.logger.debug("Error: \(error.localizedDescription)")
|
|
844
|
+
latest.message = "Error getting Latest"
|
|
845
|
+
latest.error = "response_error"
|
|
846
|
+
latest.kind = "failed"
|
|
847
|
+
return latest
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
guard let data = result.data else {
|
|
851
|
+
self.logger.error("Missing latest version response data")
|
|
852
|
+
latest.message = "Error getting Latest"
|
|
853
|
+
latest.error = "response_error"
|
|
854
|
+
latest.kind = "failed"
|
|
855
|
+
return latest
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
if self.checkAndHandleRateLimitResponse(statusCode: latest.statusCode) {
|
|
859
|
+
latest.message = "Rate limit exceeded"
|
|
860
|
+
latest.error = "rate_limit_exceeded"
|
|
861
|
+
latest.kind = "failed"
|
|
862
|
+
return latest
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
guard let responseValue = try? JSONDecoder().decode(AppVersionDec.self, from: data) else {
|
|
866
|
+
self.logger.error("Error decoding latest version")
|
|
867
|
+
latest.message = "Error getting Latest"
|
|
868
|
+
latest.error = "decode_error"
|
|
869
|
+
latest.kind = "failed"
|
|
870
|
+
return latest
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
applyLatestResponse(responseValue)
|
|
874
|
+
|
|
875
|
+
if latest.statusCode < 200 || latest.statusCode >= 300 {
|
|
876
|
+
if latest.message == nil || latest.message?.isEmpty == true {
|
|
877
|
+
latest.message = responseValue.message ?? "Server error: \(latest.statusCode)"
|
|
512
878
|
}
|
|
513
|
-
|
|
879
|
+
if latest.error == nil || latest.error?.isEmpty == true {
|
|
880
|
+
latest.error = responseValue.error ?? "response_error"
|
|
881
|
+
}
|
|
882
|
+
if latest.kind == nil || latest.kind?.isEmpty == true {
|
|
883
|
+
latest.kind = responseValue.kind ?? "failed"
|
|
884
|
+
}
|
|
885
|
+
return latest
|
|
514
886
|
}
|
|
515
|
-
|
|
887
|
+
|
|
516
888
|
return latest
|
|
517
889
|
}
|
|
518
890
|
|
|
@@ -522,6 +894,22 @@ import UIKit
|
|
|
522
894
|
logger.info("Current bundle set to: \((bundle ).isEmpty ? BundleInfo.ID_BUILTIN : bundle)")
|
|
523
895
|
}
|
|
524
896
|
|
|
897
|
+
static func shouldResetForForeignBundle(bundlePath: String?, isBuiltin: Bool, hasStoredBundleInfo: Bool) -> Bool {
|
|
898
|
+
guard let bundlePath, !bundlePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
899
|
+
return false
|
|
900
|
+
}
|
|
901
|
+
return !isBuiltin && !hasStoredBundleInfo
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
private func hasStoredBundleInfo(id: String) -> Bool {
|
|
905
|
+
guard !id.isEmpty,
|
|
906
|
+
id != BundleInfo.ID_BUILTIN,
|
|
907
|
+
id != BundleInfo.VERSION_UNKNOWN else {
|
|
908
|
+
return false
|
|
909
|
+
}
|
|
910
|
+
return UserDefaults.standard.object(forKey: "\(id)\(self.INFO_SUFFIX)") != nil
|
|
911
|
+
}
|
|
912
|
+
|
|
525
913
|
// Per-download temp file paths to prevent collisions when multiple downloads run concurrently
|
|
526
914
|
private func tempDataPath(for id: String) -> URL {
|
|
527
915
|
return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("package_\(id).tmp")
|
|
@@ -538,6 +926,132 @@ import UIKit
|
|
|
538
926
|
return actualHash == expectedHash
|
|
539
927
|
}
|
|
540
928
|
|
|
929
|
+
private func resolveManifestFileHash(entry: ManifestEntry, sessionKey: String) -> String? {
|
|
930
|
+
guard var fileHash = entry.file_hash, !fileHash.isEmpty else {
|
|
931
|
+
return nil
|
|
932
|
+
}
|
|
933
|
+
if !self.publicKey.isEmpty && !sessionKey.isEmpty {
|
|
934
|
+
do {
|
|
935
|
+
fileHash = try CryptoCipher.decryptChecksum(checksum: fileHash, publicKey: self.publicKey)
|
|
936
|
+
} catch {
|
|
937
|
+
logger.error("Checksum decryption failed while checking missing manifest files")
|
|
938
|
+
logger.debug("File: \(entry.file_name ?? "unknown"), Error: \(error.localizedDescription)")
|
|
939
|
+
return nil
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
return fileHash
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
private func isManifestEntryAvailableLocally(entry: ManifestEntry, sessionKey: String) -> Bool {
|
|
946
|
+
guard let fileName = entry.file_name,
|
|
947
|
+
let fileHash = resolveManifestFileHash(entry: entry, sessionKey: sessionKey) else {
|
|
948
|
+
return false
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
let builtinFolder = Bundle.main.bundleURL.appendingPathComponent("public")
|
|
952
|
+
let builtinFilePath = builtinFolder.appendingPathComponent(fileName)
|
|
953
|
+
if FileManager.default.fileExists(atPath: builtinFilePath.path) && verifyChecksum(file: builtinFilePath, expectedHash: fileHash) {
|
|
954
|
+
return true
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
let fileNameWithoutPath = (fileName as NSString).lastPathComponent
|
|
958
|
+
let isBrotli = fileName.hasSuffix(".br")
|
|
959
|
+
let cacheBaseName = isBrotli ? String(fileNameWithoutPath.dropLast(3)) : fileNameWithoutPath
|
|
960
|
+
let cacheFilePath = cacheFolder.appendingPathComponent("\(fileHash)_\(cacheBaseName)")
|
|
961
|
+
if FileManager.default.fileExists(atPath: cacheFilePath.path) && verifyChecksum(file: cacheFilePath, expectedHash: fileHash) {
|
|
962
|
+
return true
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
if isBrotli {
|
|
966
|
+
let legacyCacheFilePath = cacheFolder.appendingPathComponent("\(fileHash)_\(fileNameWithoutPath)")
|
|
967
|
+
if FileManager.default.fileExists(atPath: legacyCacheFilePath.path) && verifyChecksum(file: legacyCacheFilePath, expectedHash: fileHash) {
|
|
968
|
+
return true
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
return false
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
public func getMissingBundleFiles(manifest: [ManifestEntry], sessionKey: String) -> [ManifestEntry] {
|
|
976
|
+
return manifest.filter { entry in
|
|
977
|
+
!isManifestEntryAvailableLocally(entry: entry, sessionKey: sessionKey)
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
public func missingBundleFilesResult(manifest: [ManifestEntry], sessionKey: String) -> [String: Any] {
|
|
982
|
+
let missing = getMissingBundleFiles(manifest: manifest, sessionKey: sessionKey)
|
|
983
|
+
return [
|
|
984
|
+
"missing": missing.map { $0.toDict() },
|
|
985
|
+
"total": manifest.count,
|
|
986
|
+
"missingCount": missing.count,
|
|
987
|
+
"reusableCount": manifest.count - missing.count
|
|
988
|
+
]
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
private func manifestSizeUrl(from updateUrl: URL) -> URL {
|
|
992
|
+
var components = URLComponents(url: updateUrl, resolvingAgainstBaseURL: false)
|
|
993
|
+
let path = components?.path ?? updateUrl.path
|
|
994
|
+
let trimmedPath = path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
|
995
|
+
components?.path = trimmedPath == ""
|
|
996
|
+
? "/manifest_size"
|
|
997
|
+
: "/\(trimmedPath)/manifest_size"
|
|
998
|
+
components?.query = nil
|
|
999
|
+
return components?.url ?? updateUrl.appendingPathComponent("manifest_size")
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
private func unavailableBundleSizeResult(manifest: [ManifestEntry], error: String) -> [String: Any] {
|
|
1003
|
+
return [
|
|
1004
|
+
"totalSize": 0,
|
|
1005
|
+
"knownFiles": 0,
|
|
1006
|
+
"unknownFiles": manifest.count,
|
|
1007
|
+
"files": manifest.map {
|
|
1008
|
+
var dict = $0.toDict()
|
|
1009
|
+
dict["error"] = error
|
|
1010
|
+
return dict
|
|
1011
|
+
}
|
|
1012
|
+
]
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
public func getBundleDownloadSize(updateUrl: URL, version: String?, manifest: [ManifestEntry]) -> [String: Any] {
|
|
1016
|
+
if manifest.isEmpty {
|
|
1017
|
+
return [
|
|
1018
|
+
"totalSize": 0,
|
|
1019
|
+
"knownFiles": 0,
|
|
1020
|
+
"unknownFiles": 0,
|
|
1021
|
+
"files": []
|
|
1022
|
+
]
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
var parameters = self.createInfoObject().toParameters()
|
|
1026
|
+
parameters["version"] = version ?? ""
|
|
1027
|
+
parameters["manifest"] = manifest.map { $0.toDict() }
|
|
1028
|
+
|
|
1029
|
+
guard let request = createRequest(url: manifestSizeUrl(from: updateUrl), method: "POST", parameters: parameters) else {
|
|
1030
|
+
return unavailableBundleSizeResult(manifest: manifest, error: "request_error")
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
let result = performRequest(request, label: "getBundleDownloadSize")
|
|
1034
|
+
if result.timedOut {
|
|
1035
|
+
return unavailableBundleSizeResult(manifest: manifest, error: "timeout_error")
|
|
1036
|
+
}
|
|
1037
|
+
if let error = result.error {
|
|
1038
|
+
logger.error("Error getting bundle download size")
|
|
1039
|
+
logger.debug("Error: \(error.localizedDescription)")
|
|
1040
|
+
return unavailableBundleSizeResult(manifest: manifest, error: "response_error")
|
|
1041
|
+
}
|
|
1042
|
+
guard let data = result.data,
|
|
1043
|
+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
1044
|
+
return unavailableBundleSizeResult(manifest: manifest, error: "parse_error")
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
let statusCode = result.response?.statusCode ?? 0
|
|
1048
|
+
if statusCode < 200 || statusCode >= 300 {
|
|
1049
|
+
return unavailableBundleSizeResult(manifest: manifest, error: "response_error")
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
return json
|
|
1053
|
+
}
|
|
1054
|
+
|
|
541
1055
|
public func downloadManifest(manifest: [ManifestEntry], version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
|
|
542
1056
|
let id = self.randomString(length: 10)
|
|
543
1057
|
logger.info("downloadManifest start \(id)")
|
|
@@ -563,8 +1077,8 @@ import UIKit
|
|
|
563
1077
|
|
|
564
1078
|
let totalFiles = manifest.count
|
|
565
1079
|
|
|
566
|
-
//
|
|
567
|
-
manifestDownloadQueue.maxConcurrentOperationCount = min(
|
|
1080
|
+
// Keep this bounded because each manifest operation waits on a URLSession callback.
|
|
1081
|
+
manifestDownloadQueue.maxConcurrentOperationCount = min(8, max(1, totalFiles))
|
|
568
1082
|
|
|
569
1083
|
// Thread-safe counters for concurrent operations
|
|
570
1084
|
let completedFiles = AtomicCounter()
|
|
@@ -578,10 +1092,36 @@ import UIKit
|
|
|
578
1092
|
for entry in manifest {
|
|
579
1093
|
guard let fileName = entry.file_name,
|
|
580
1094
|
let downloadUrl = entry.download_url else {
|
|
1095
|
+
let error = NSError(
|
|
1096
|
+
domain: "ManifestEntryError",
|
|
1097
|
+
code: 1,
|
|
1098
|
+
userInfo: [
|
|
1099
|
+
NSLocalizedDescriptionKey: "Manifest entry is missing file_name or download_url"
|
|
1100
|
+
]
|
|
1101
|
+
)
|
|
1102
|
+
errorLock.lock()
|
|
1103
|
+
if downloadError == nil {
|
|
1104
|
+
downloadError = error
|
|
1105
|
+
}
|
|
1106
|
+
errorLock.unlock()
|
|
1107
|
+
hasError.value = true
|
|
1108
|
+
logger.error("Manifest entry is missing file_name or download_url")
|
|
581
1109
|
continue
|
|
582
1110
|
}
|
|
583
1111
|
guard let entryFileHash = entry.file_hash, !entryFileHash.isEmpty else {
|
|
584
1112
|
logger.error("Missing file_hash for manifest entry: \(entry.file_name ?? "unknown")")
|
|
1113
|
+
let error = NSError(
|
|
1114
|
+
domain: "ManifestEntryError",
|
|
1115
|
+
code: 2,
|
|
1116
|
+
userInfo: [
|
|
1117
|
+
NSLocalizedDescriptionKey: "Manifest entry is missing file_hash for \(entry.file_name ?? "unknown")"
|
|
1118
|
+
]
|
|
1119
|
+
)
|
|
1120
|
+
errorLock.lock()
|
|
1121
|
+
if downloadError == nil {
|
|
1122
|
+
downloadError = error
|
|
1123
|
+
}
|
|
1124
|
+
errorLock.unlock()
|
|
585
1125
|
hasError.value = true
|
|
586
1126
|
continue
|
|
587
1127
|
}
|
|
@@ -610,8 +1150,22 @@ import UIKit
|
|
|
610
1150
|
let legacyCacheFilePath: URL? = isBrotli ? cacheFolder.appendingPathComponent("\(finalFileHash)_\(fileNameWithoutPath)") : nil
|
|
611
1151
|
|
|
612
1152
|
let destFileName = isBrotli ? String(fileName.dropLast(3)) : fileName
|
|
613
|
-
let destFilePath
|
|
614
|
-
let builtinFilePath
|
|
1153
|
+
let destFilePath: URL
|
|
1154
|
+
let builtinFilePath: URL
|
|
1155
|
+
do {
|
|
1156
|
+
destFilePath = try Self.resolveManifestTargetPath(baseDirectory: destFolder, fileName: fileName)
|
|
1157
|
+
builtinFilePath = try Self.resolvePathInsideDirectory(baseDirectory: builtinFolder, relativePath: fileName)
|
|
1158
|
+
} catch {
|
|
1159
|
+
logger.error("Invalid manifest file path: \(fileName)")
|
|
1160
|
+
self.sendStats(action: "manifest_path_fail", versionName: "\(version):\(fileName)")
|
|
1161
|
+
errorLock.lock()
|
|
1162
|
+
if downloadError == nil {
|
|
1163
|
+
downloadError = error
|
|
1164
|
+
}
|
|
1165
|
+
errorLock.unlock()
|
|
1166
|
+
hasError.value = true
|
|
1167
|
+
continue
|
|
1168
|
+
}
|
|
615
1169
|
|
|
616
1170
|
// Create parent directories synchronously (before operations start)
|
|
617
1171
|
try? FileManager.default.createDirectory(at: destFilePath.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
|
|
@@ -623,7 +1177,7 @@ import UIKit
|
|
|
623
1177
|
do {
|
|
624
1178
|
// Try builtin first
|
|
625
1179
|
if FileManager.default.fileExists(atPath: builtinFilePath.path) && self.verifyChecksum(file: builtinFilePath, expectedHash: finalFileHash) {
|
|
626
|
-
try
|
|
1180
|
+
try self.copyItemReplacing(from: builtinFilePath, to: destFilePath)
|
|
627
1181
|
self.logger.info("downloadManifest \(fileName) using builtin file \(id)")
|
|
628
1182
|
}
|
|
629
1183
|
// Try cache
|
|
@@ -670,15 +1224,20 @@ import UIKit
|
|
|
670
1224
|
// Execute all operations concurrently and wait for completion
|
|
671
1225
|
manifestDownloadQueue.addOperations(operations, waitUntilFinished: true)
|
|
672
1226
|
|
|
673
|
-
if hasError.value
|
|
1227
|
+
if hasError.value {
|
|
1228
|
+
let resolvedError = downloadError ?? NSError(
|
|
1229
|
+
domain: "ManifestDownloadError",
|
|
1230
|
+
code: 1,
|
|
1231
|
+
userInfo: [NSLocalizedDescriptionKey: "Manifest download failed due to invalid or missing entries"]
|
|
1232
|
+
)
|
|
674
1233
|
// Update bundle status to ERROR if download failed
|
|
675
|
-
let errorBundle = bundleInfo.setStatus(status: BundleStatus.ERROR.
|
|
1234
|
+
let errorBundle = bundleInfo.setStatus(status: BundleStatus.ERROR.storedValue)
|
|
676
1235
|
self.saveBundleInfo(id: id, bundle: errorBundle)
|
|
677
|
-
throw
|
|
1236
|
+
throw resolvedError
|
|
678
1237
|
}
|
|
679
1238
|
|
|
680
1239
|
// Update bundle status to PENDING after successful download
|
|
681
|
-
let updatedBundle = bundleInfo.setStatus(status: BundleStatus.PENDING.
|
|
1240
|
+
let updatedBundle = bundleInfo.setStatus(status: BundleStatus.PENDING.storedValue)
|
|
682
1241
|
self.saveBundleInfo(id: id, bundle: updatedBundle)
|
|
683
1242
|
|
|
684
1243
|
// Send stats for manifest download complete
|
|
@@ -703,105 +1262,152 @@ import UIKit
|
|
|
703
1262
|
version: String,
|
|
704
1263
|
bundleId: String
|
|
705
1264
|
) throws {
|
|
706
|
-
let
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
1265
|
+
guard let url = URL(string: downloadUrl) else {
|
|
1266
|
+
throw NSError(
|
|
1267
|
+
domain: "ManifestDownloadError",
|
|
1268
|
+
code: 1,
|
|
1269
|
+
userInfo: [NSLocalizedDescriptionKey: "Invalid manifest download URL for file \(fileName): \(downloadUrl)"]
|
|
1270
|
+
)
|
|
1271
|
+
}
|
|
711
1272
|
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
throw NSError(domain: "StatusCodeError", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid. Data: \(stringData) for file \(fileName) at url \(downloadUrl)"])
|
|
720
|
-
} else {
|
|
721
|
-
throw NSError(domain: "StatusCodeError", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid for file \(fileName) at url \(downloadUrl)"])
|
|
722
|
-
}
|
|
723
|
-
}
|
|
1273
|
+
guard let request = createRequest(url: url, method: "GET") else {
|
|
1274
|
+
throw NSError(
|
|
1275
|
+
domain: "ManifestDownloadError",
|
|
1276
|
+
code: 2,
|
|
1277
|
+
userInfo: [NSLocalizedDescriptionKey: "Invalid manifest request for file \(fileName): \(downloadUrl)"]
|
|
1278
|
+
)
|
|
1279
|
+
}
|
|
724
1280
|
|
|
725
|
-
|
|
726
|
-
var finalData = data
|
|
727
|
-
if !self.publicKey.isEmpty && !sessionKey.isEmpty {
|
|
728
|
-
let tempFile = self.cacheFolder.appendingPathComponent("temp_\(UUID().uuidString)")
|
|
729
|
-
try finalData.write(to: tempFile)
|
|
730
|
-
do {
|
|
731
|
-
try CryptoCipher.decryptFile(filePath: tempFile, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
|
|
732
|
-
} catch {
|
|
733
|
-
self.sendStats(action: "decrypt_fail", versionName: version)
|
|
734
|
-
throw error
|
|
735
|
-
}
|
|
736
|
-
finalData = try Data(contentsOf: tempFile)
|
|
737
|
-
try FileManager.default.removeItem(at: tempFile)
|
|
738
|
-
}
|
|
1281
|
+
let result = performRequest(request, label: "downloadManifestFile \(fileName)")
|
|
739
1282
|
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
1283
|
+
if result.timedOut {
|
|
1284
|
+
self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
|
|
1285
|
+
throw NSError(
|
|
1286
|
+
domain: NSURLErrorDomain,
|
|
1287
|
+
code: NSURLErrorTimedOut,
|
|
1288
|
+
userInfo: [NSLocalizedDescriptionKey: "Timed out downloading manifest file \(fileName) at url \(downloadUrl)"]
|
|
1289
|
+
)
|
|
1290
|
+
}
|
|
748
1291
|
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
CryptoCipher.logChecksumInfo(label: "Expected checksum", hexChecksum: fileHash)
|
|
756
|
-
if calculatedChecksum != fileHash {
|
|
757
|
-
try? FileManager.default.removeItem(at: destFilePath)
|
|
758
|
-
self.sendStats(action: "download_manifest_checksum_fail", versionName: "\(version):\(destFileName)")
|
|
759
|
-
throw NSError(domain: "ChecksumError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Computed checksum is not equal to required checksum (\(calculatedChecksum) != \(fileHash)) for file \(fileName) at url \(downloadUrl)"])
|
|
760
|
-
}
|
|
1292
|
+
if let error = result.error {
|
|
1293
|
+
self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
|
|
1294
|
+
self.logger.error("Manifest file download network error")
|
|
1295
|
+
self.logger.debug("Bundle: \(bundleId), File: \(fileName), Error: \(error.localizedDescription)")
|
|
1296
|
+
throw error
|
|
1297
|
+
}
|
|
761
1298
|
|
|
762
|
-
|
|
763
|
-
|
|
1299
|
+
guard let data = result.data else {
|
|
1300
|
+
self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
|
|
1301
|
+
throw NSError(
|
|
1302
|
+
domain: "ManifestDownloadError",
|
|
1303
|
+
code: 3,
|
|
1304
|
+
userInfo: [NSLocalizedDescriptionKey: "Manifest file response was empty for \(fileName) at url \(downloadUrl)"]
|
|
1305
|
+
)
|
|
1306
|
+
}
|
|
764
1307
|
|
|
765
|
-
|
|
766
|
-
|
|
1308
|
+
let statusCode = result.response?.statusCode ?? 200
|
|
1309
|
+
if statusCode < 200 || statusCode >= 300 {
|
|
1310
|
+
self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
|
|
1311
|
+
if let stringData = String(data: data, encoding: .utf8) {
|
|
1312
|
+
throw NSError(domain: "StatusCodeError", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid. Data: \(stringData) for file \(fileName) at url \(downloadUrl)"])
|
|
1313
|
+
} else {
|
|
1314
|
+
throw NSError(domain: "StatusCodeError", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid for file \(fileName) at url \(downloadUrl)"])
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
767
1317
|
|
|
1318
|
+
do {
|
|
1319
|
+
// Add decryption step if public key is set and sessionKey is provided
|
|
1320
|
+
var finalData = data
|
|
1321
|
+
if !self.publicKey.isEmpty && !sessionKey.isEmpty {
|
|
1322
|
+
let tempFile = self.cacheFolder.appendingPathComponent("temp_\(UUID().uuidString)")
|
|
1323
|
+
try finalData.write(to: tempFile)
|
|
1324
|
+
do {
|
|
1325
|
+
try CryptoCipher.decryptFile(filePath: tempFile, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
|
|
768
1326
|
} catch {
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
self.logger.debug("Bundle: \(bundleId), File: \(fileName), Error: \(error.localizedDescription)")
|
|
1327
|
+
self.sendStats(action: "decrypt_fail", versionName: version)
|
|
1328
|
+
throw error
|
|
772
1329
|
}
|
|
1330
|
+
finalData = try Data(contentsOf: tempFile)
|
|
1331
|
+
try FileManager.default.removeItem(at: tempFile)
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
// Decompress Brotli if needed
|
|
1335
|
+
if isBrotli {
|
|
1336
|
+
guard let decompressedData = self.decompressBrotli(data: finalData, fileName: fileName) else {
|
|
1337
|
+
self.sendStats(action: "download_manifest_brotli_fail", versionName: "\(version):\(destFileName)")
|
|
1338
|
+
throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to decompress Brotli data for file \(fileName) at url \(downloadUrl)"])
|
|
1339
|
+
}
|
|
1340
|
+
finalData = decompressedData
|
|
1341
|
+
}
|
|
773
1342
|
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
1343
|
+
// Write to destination (replace if leftover from a previous failed download)
|
|
1344
|
+
try writeDataAtomically(finalData, to: destFilePath)
|
|
1345
|
+
|
|
1346
|
+
// Always verify checksum when file_hash is present
|
|
1347
|
+
let calculatedChecksum = CryptoCipher.calcChecksum(filePath: destFilePath)
|
|
1348
|
+
CryptoCipher.logChecksumInfo(label: "Calculated checksum", hexChecksum: calculatedChecksum)
|
|
1349
|
+
CryptoCipher.logChecksumInfo(label: "Expected checksum", hexChecksum: fileHash)
|
|
1350
|
+
if calculatedChecksum != fileHash {
|
|
1351
|
+
try? FileManager.default.removeItem(at: destFilePath)
|
|
1352
|
+
self.sendStats(action: "download_manifest_checksum_fail", versionName: "\(version):\(destFileName)")
|
|
1353
|
+
throw NSError(domain: "ChecksumError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Computed checksum is not equal to required checksum (\(calculatedChecksum) != \(fileHash)) for file \(fileName) at url \(downloadUrl)"])
|
|
779
1354
|
}
|
|
780
|
-
}
|
|
781
1355
|
|
|
782
|
-
|
|
1356
|
+
// Save to cache (replace stale cache entries from partial or concurrent downloads)
|
|
1357
|
+
try writeDataAtomically(finalData, to: cacheFilePath)
|
|
783
1358
|
|
|
784
|
-
|
|
1359
|
+
self.logger.info("Manifest file downloaded and cached")
|
|
1360
|
+
self.logger.debug("Bundle: \(bundleId), File: \(fileName), Brotli: \(isBrotli), Encrypted: \(!self.publicKey.isEmpty && !sessionKey.isEmpty)")
|
|
1361
|
+
} catch {
|
|
1362
|
+
self.logger.error("Manifest file download failed")
|
|
1363
|
+
self.logger.debug("Bundle: \(bundleId), File: \(fileName), Error: \(error.localizedDescription)")
|
|
785
1364
|
throw error
|
|
786
1365
|
}
|
|
787
1366
|
}
|
|
788
1367
|
|
|
1368
|
+
/// Atomically write data to a file, replacing any existing file at the destination.
|
|
1369
|
+
private func writeDataAtomically(_ data: Data, to destination: URL) throws {
|
|
1370
|
+
let fileManager = FileManager.default
|
|
1371
|
+
let tempURL = destination.deletingLastPathComponent().appendingPathComponent("\(destination.lastPathComponent).\(UUID().uuidString).tmp")
|
|
1372
|
+
defer {
|
|
1373
|
+
try? fileManager.removeItem(at: tempURL)
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
try data.write(to: tempURL, options: .atomic)
|
|
1377
|
+
if fileManager.fileExists(atPath: destination.path) {
|
|
1378
|
+
try fileManager.removeItem(at: destination)
|
|
1379
|
+
}
|
|
1380
|
+
try fileManager.moveItem(at: tempURL, to: destination)
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
/// Copy a file to the destination, replacing any existing file.
|
|
1384
|
+
private func copyItemReplacing(from source: URL, to destination: URL) throws {
|
|
1385
|
+
let fileManager = FileManager.default
|
|
1386
|
+
if fileManager.fileExists(atPath: destination.path) {
|
|
1387
|
+
try fileManager.removeItem(at: destination)
|
|
1388
|
+
}
|
|
1389
|
+
try fileManager.copyItem(at: source, to: destination)
|
|
1390
|
+
}
|
|
1391
|
+
|
|
789
1392
|
/// Atomically try to copy a file from cache - returns true if successful, false if file doesn't exist or copy failed
|
|
790
1393
|
/// This handles the race condition where OS can delete cache files between exists() check and copy
|
|
791
1394
|
private func tryCopyFromCache(from source: URL, to destination: URL, expectedHash: String) -> Bool {
|
|
1395
|
+
let fileManager = FileManager.default
|
|
1396
|
+
|
|
792
1397
|
// First quick check - if file doesn't exist, don't bother
|
|
793
|
-
guard
|
|
1398
|
+
guard fileManager.fileExists(atPath: source.path) else {
|
|
794
1399
|
return false
|
|
795
1400
|
}
|
|
796
1401
|
|
|
797
|
-
// Verify checksum before copy
|
|
1402
|
+
// Verify checksum before copy; remove stale cache entries that would block re-download
|
|
798
1403
|
guard verifyChecksum(file: source, expectedHash: expectedHash) else {
|
|
1404
|
+
try? fileManager.removeItem(at: source)
|
|
799
1405
|
return false
|
|
800
1406
|
}
|
|
801
1407
|
|
|
802
1408
|
// Try to copy - if it fails (file deleted by OS between check and copy), return false
|
|
803
1409
|
do {
|
|
804
|
-
try
|
|
1410
|
+
try copyItemReplacing(from: source, to: destination)
|
|
805
1411
|
return true
|
|
806
1412
|
} catch {
|
|
807
1413
|
// File was deleted between check and copy, or other IO error - caller should download instead
|
|
@@ -930,7 +1536,6 @@ import UIKit
|
|
|
930
1536
|
|
|
931
1537
|
public func download(url: URL, version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
|
|
932
1538
|
let id: String = self.randomString(length: 10)
|
|
933
|
-
let semaphore = DispatchSemaphore(value: 0)
|
|
934
1539
|
// Each download uses its own temp files keyed by bundle ID to prevent collisions
|
|
935
1540
|
if version != getLocalUpdateVersion(for: id) {
|
|
936
1541
|
cleanDownloadData(for: id)
|
|
@@ -942,10 +1547,11 @@ import UIKit
|
|
|
942
1547
|
try checkDiskSpace()
|
|
943
1548
|
|
|
944
1549
|
var checksum = ""
|
|
945
|
-
var targetSize = -1
|
|
946
1550
|
var lastSentProgress = 0
|
|
947
|
-
|
|
948
|
-
let
|
|
1551
|
+
let totalReceivedBytes: Int64 = loadDownloadProgress(for: id) // Retrieving the amount of already downloaded data if exist, defined at 0 otherwise
|
|
1552
|
+
let tempPath = tempDataPath(for: id)
|
|
1553
|
+
let bundleInfo = BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: checksum, link: link, comment: comment)
|
|
1554
|
+
self.saveBundleInfo(id: id, bundle: bundleInfo)
|
|
949
1555
|
|
|
950
1556
|
// Send stats for zip download start
|
|
951
1557
|
self.sendStats(action: "download_zip_start", versionName: version)
|
|
@@ -955,66 +1561,61 @@ import UIKit
|
|
|
955
1561
|
self.notifyDownload(id: id, percent: 0, ignoreMultipleOfTen: true)
|
|
956
1562
|
}
|
|
957
1563
|
var mainError: NSError?
|
|
958
|
-
let monitor = ClosureEventMonitor()
|
|
959
|
-
monitor.requestDidCompleteTaskWithError = { (_, _, error) in
|
|
960
|
-
if error != nil {
|
|
961
|
-
self.logger.error("Downloading failed - ClosureEventMonitor activated")
|
|
962
|
-
mainError = error as NSError?
|
|
963
|
-
}
|
|
964
|
-
}
|
|
965
|
-
let configuration = URLSessionConfiguration.default
|
|
966
|
-
configuration.httpAdditionalHeaders = ["User-Agent": self.userAgent]
|
|
967
|
-
let session = Session(configuration: configuration, eventMonitors: [monitor])
|
|
968
|
-
|
|
969
|
-
let request = session.streamRequest(url, headers: requestHeaders).validate().onHTTPResponse(perform: { response in
|
|
970
|
-
if let contentLength = response.headers.value(for: "Content-Length") {
|
|
971
|
-
targetSize = (Int(contentLength) ?? -1) + Int(totalReceivedBytes)
|
|
972
|
-
}
|
|
973
|
-
}).responseStream { [weak self] streamResponse in
|
|
974
|
-
guard let self = self else { return }
|
|
975
|
-
switch streamResponse.event {
|
|
976
|
-
case .stream(let result):
|
|
977
|
-
if case .success(let data) = result {
|
|
978
|
-
self.tempData.append(data)
|
|
979
|
-
|
|
980
|
-
self.savePartialData(startingAt: UInt64(totalReceivedBytes), for: id) // Saving the received data in the package_<id>.tmp file
|
|
981
|
-
totalReceivedBytes += Int64(data.count)
|
|
982
1564
|
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
1565
|
+
guard var request = createRequest(url: url, method: "GET") else {
|
|
1566
|
+
self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum, link: link, comment: comment))
|
|
1567
|
+
throw NSError(
|
|
1568
|
+
domain: "DownloadError",
|
|
1569
|
+
code: 2,
|
|
1570
|
+
userInfo: [NSLocalizedDescriptionKey: "Invalid download request for \(url.absoluteString)"]
|
|
1571
|
+
)
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
if totalReceivedBytes > 0 {
|
|
1575
|
+
request.setValue("bytes=\(totalReceivedBytes)-", forHTTPHeaderField: "Range")
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
let downloadResult = performDownloadRequest(request, label: "download \(version)")
|
|
1579
|
+
|
|
1580
|
+
if downloadResult.timedOut {
|
|
1581
|
+
persistPartialDownload(downloadResult, id: id, tempPath: tempPath, existingBytes: totalReceivedBytes)
|
|
1582
|
+
mainError = NSError(
|
|
1583
|
+
domain: NSURLErrorDomain,
|
|
1584
|
+
code: NSURLErrorTimedOut,
|
|
1585
|
+
userInfo: [NSLocalizedDescriptionKey: "Timed out downloading bundle from \(url.absoluteString)"]
|
|
1586
|
+
)
|
|
1587
|
+
} else if let error = downloadResult.error {
|
|
1588
|
+
logger.error("Download failed")
|
|
1589
|
+
persistPartialDownload(downloadResult, id: id, tempPath: tempPath, existingBytes: totalReceivedBytes)
|
|
1590
|
+
mainError = error as NSError
|
|
1591
|
+
} else if let statusCode = downloadResult.response?.statusCode, statusCode < 200 || statusCode >= 300 {
|
|
1592
|
+
logger.error("Download failed")
|
|
1593
|
+
mainError = NSError(
|
|
1594
|
+
domain: "DownloadError",
|
|
1595
|
+
code: statusCode,
|
|
1596
|
+
userInfo: [NSLocalizedDescriptionKey: "Download request failed with status code \(statusCode)"]
|
|
1597
|
+
)
|
|
1598
|
+
} else if let downloadedFileURL = downloadResult.fileURL {
|
|
1599
|
+
do {
|
|
1600
|
+
try storeDownloadedFile(downloadedFileURL, at: tempPath, existingBytes: totalReceivedBytes, response: downloadResult.response)
|
|
992
1601
|
|
|
993
|
-
|
|
994
|
-
self.
|
|
1602
|
+
if lastSentProgress < 70 {
|
|
1603
|
+
self.notifyDownload(id: id, percent: 70, ignoreMultipleOfTen: true)
|
|
1604
|
+
lastSentProgress = 70
|
|
995
1605
|
}
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
}
|
|
1002
|
-
}
|
|
1003
|
-
self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: checksum, link: link, comment: comment))
|
|
1004
|
-
let reachabilityManager = NetworkReachabilityManager()
|
|
1005
|
-
reachabilityManager?.startListening { status in
|
|
1006
|
-
switch status {
|
|
1007
|
-
case .notReachable:
|
|
1008
|
-
// Stop the download request if the network is not reachable
|
|
1009
|
-
request.cancel()
|
|
1010
|
-
mainError = NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet, userInfo: nil)
|
|
1011
|
-
semaphore.signal()
|
|
1012
|
-
default:
|
|
1013
|
-
break
|
|
1606
|
+
self.logger.info("Download complete")
|
|
1607
|
+
} catch let error as NSError {
|
|
1608
|
+
mainError = error
|
|
1609
|
+
} catch {
|
|
1610
|
+
mainError = error as NSError
|
|
1014
1611
|
}
|
|
1612
|
+
} else {
|
|
1613
|
+
mainError = NSError(
|
|
1614
|
+
domain: "DownloadError",
|
|
1615
|
+
code: 1,
|
|
1616
|
+
userInfo: [NSLocalizedDescriptionKey: "Downloaded file is missing at \(tempPath.path)"]
|
|
1617
|
+
)
|
|
1015
1618
|
}
|
|
1016
|
-
semaphore.wait()
|
|
1017
|
-
reachabilityManager?.stopListening()
|
|
1018
1619
|
|
|
1019
1620
|
if mainError != nil {
|
|
1020
1621
|
logger.error("Failed to download bundle")
|
|
@@ -1023,7 +1624,6 @@ import UIKit
|
|
|
1023
1624
|
throw mainError!
|
|
1024
1625
|
}
|
|
1025
1626
|
|
|
1026
|
-
let tempPath = tempDataPath(for: id)
|
|
1027
1627
|
let finalPath = tempPath.deletingLastPathComponent().appendingPathComponent("\(id)")
|
|
1028
1628
|
do {
|
|
1029
1629
|
try CryptoCipher.decryptFile(filePath: tempPath, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
|
|
@@ -1218,6 +1818,15 @@ import UIKit
|
|
|
1218
1818
|
return false
|
|
1219
1819
|
}
|
|
1220
1820
|
|
|
1821
|
+
if let previewFallback = self.getPreviewFallbackBundle(),
|
|
1822
|
+
!previewFallback.isDeleted(),
|
|
1823
|
+
!previewFallback.isErrorStatus(),
|
|
1824
|
+
previewFallback.getId() == id {
|
|
1825
|
+
logger.info("Cannot delete the preview fallback bundle")
|
|
1826
|
+
logger.debug("Bundle ID: \(id)")
|
|
1827
|
+
return false
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1221
1830
|
// Check if this is the next bundle and prevent deletion if it is
|
|
1222
1831
|
if let next = self.getNextBundle(),
|
|
1223
1832
|
!next.isDeleted() &&
|
|
@@ -1244,7 +1853,7 @@ import UIKit
|
|
|
1244
1853
|
if removeInfo {
|
|
1245
1854
|
self.removeBundleInfo(id: id)
|
|
1246
1855
|
} else {
|
|
1247
|
-
self.saveBundleInfo(id: id, bundle: deleted.setStatus(status: BundleStatus.DELETED.
|
|
1856
|
+
self.saveBundleInfo(id: id, bundle: deleted.setStatus(status: BundleStatus.DELETED.storedValue))
|
|
1248
1857
|
}
|
|
1249
1858
|
logger.info("Bundle deleted successfully")
|
|
1250
1859
|
logger.debug("Version: \(deleted.getVersionName())")
|
|
@@ -1411,6 +2020,52 @@ import UIKit
|
|
|
1411
2020
|
return libraryDir.appendingPathComponent(self.bundleDirectory).appendingPathComponent(id)
|
|
1412
2021
|
}
|
|
1413
2022
|
|
|
2023
|
+
struct ResetState {
|
|
2024
|
+
let currentBundlePath: String
|
|
2025
|
+
let fallbackBundleId: String
|
|
2026
|
+
let nextBundleId: String?
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
func captureResetState() -> ResetState {
|
|
2030
|
+
ResetState(
|
|
2031
|
+
currentBundlePath: UserDefaults.standard.string(forKey: self.CAP_SERVER_PATH) ?? self.DEFAULT_FOLDER,
|
|
2032
|
+
fallbackBundleId: UserDefaults.standard.string(forKey: self.FALLBACK_VERSION) ?? BundleInfo.ID_BUILTIN,
|
|
2033
|
+
nextBundleId: UserDefaults.standard.string(forKey: self.NEXT_VERSION)
|
|
2034
|
+
)
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
func restoreResetState(_ state: ResetState) {
|
|
2038
|
+
let currentBundlePath = state.currentBundlePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
2039
|
+
? self.DEFAULT_FOLDER
|
|
2040
|
+
: state.currentBundlePath
|
|
2041
|
+
let fallbackBundleId = state.fallbackBundleId.isEmpty ? BundleInfo.ID_BUILTIN : state.fallbackBundleId
|
|
2042
|
+
|
|
2043
|
+
self.setCurrentBundle(bundle: currentBundlePath)
|
|
2044
|
+
UserDefaults.standard.set(fallbackBundleId, forKey: self.FALLBACK_VERSION)
|
|
2045
|
+
if let nextBundleId = state.nextBundleId, !nextBundleId.isEmpty {
|
|
2046
|
+
UserDefaults.standard.set(nextBundleId, forKey: self.NEXT_VERSION)
|
|
2047
|
+
} else {
|
|
2048
|
+
UserDefaults.standard.removeObject(forKey: self.NEXT_VERSION)
|
|
2049
|
+
}
|
|
2050
|
+
UserDefaults.standard.synchronize()
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
func prepareResetStateForTransition() {
|
|
2054
|
+
self.setCurrentBundle(bundle: "")
|
|
2055
|
+
self.setFallbackBundle(fallback: Optional<BundleInfo>.none)
|
|
2056
|
+
_ = self.setNextBundle(next: Optional<String>.none)
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
func finalizeResetTransition(previousBundleName: String, isInternal: Bool) {
|
|
2060
|
+
if !isInternal {
|
|
2061
|
+
self.sendStats(action: "reset", versionName: self.getCurrentBundle().getVersionName(), oldVersionName: previousBundleName)
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
func canSet(bundle: BundleInfo) -> Bool {
|
|
2066
|
+
bundle.isBuiltin() || self.bundleExists(id: bundle.getId())
|
|
2067
|
+
}
|
|
2068
|
+
|
|
1414
2069
|
public func set(bundle: BundleInfo) -> Bool {
|
|
1415
2070
|
return self.set(id: bundle.getId())
|
|
1416
2071
|
}
|
|
@@ -1448,11 +2103,51 @@ import UIKit
|
|
|
1448
2103
|
return false
|
|
1449
2104
|
}
|
|
1450
2105
|
|
|
2106
|
+
func stagePendingReload(bundle: BundleInfo) -> Bool {
|
|
2107
|
+
guard !bundle.isBuiltin(), bundleExists(id: bundle.getId()) else {
|
|
2108
|
+
return false
|
|
2109
|
+
}
|
|
2110
|
+
self.setCurrentBundle(bundle: self.getBundleDirectory(id: bundle.getId()).path)
|
|
2111
|
+
return true
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
func stagePreviewFallbackReload(bundle: BundleInfo) -> Bool {
|
|
2115
|
+
guard !bundle.isErrorStatus() else {
|
|
2116
|
+
return false
|
|
2117
|
+
}
|
|
2118
|
+
if bundle.isBuiltin() {
|
|
2119
|
+
self.setCurrentBundle(bundle: self.DEFAULT_FOLDER)
|
|
2120
|
+
return true
|
|
2121
|
+
}
|
|
2122
|
+
guard bundleExists(id: bundle.getId()) else {
|
|
2123
|
+
return false
|
|
2124
|
+
}
|
|
2125
|
+
self.setCurrentBundle(bundle: self.getBundleDirectory(id: bundle.getId()).path)
|
|
2126
|
+
return true
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
func finalizePendingReload(bundle: BundleInfo, previousBundleName: String) {
|
|
2130
|
+
guard !bundle.isBuiltin() else {
|
|
2131
|
+
return
|
|
2132
|
+
}
|
|
2133
|
+
self.sendStats(action: "set", versionName: bundle.getVersionName(), oldVersionName: previousBundleName)
|
|
2134
|
+
}
|
|
2135
|
+
|
|
1451
2136
|
public func autoReset() {
|
|
1452
2137
|
let currentBundle: BundleInfo = self.getCurrentBundle()
|
|
1453
2138
|
if !currentBundle.isBuiltin() && !self.bundleExists(id: currentBundle.getId()) {
|
|
1454
2139
|
logger.info("Folder at bundle path does not exist. Triggering reset.")
|
|
1455
2140
|
self.reset()
|
|
2141
|
+
return
|
|
2142
|
+
}
|
|
2143
|
+
let bundlePath = UserDefaults.standard.string(forKey: self.CAP_SERVER_PATH)
|
|
2144
|
+
if Self.shouldResetForForeignBundle(
|
|
2145
|
+
bundlePath: bundlePath,
|
|
2146
|
+
isBuiltin: currentBundle.isBuiltin(),
|
|
2147
|
+
hasStoredBundleInfo: self.hasStoredBundleInfo(id: currentBundle.getId())
|
|
2148
|
+
) {
|
|
2149
|
+
logger.info("Current bundle id is not one of the bundle ids stored by this plugin. Triggering reset.")
|
|
2150
|
+
self.reset()
|
|
1456
2151
|
}
|
|
1457
2152
|
}
|
|
1458
2153
|
|
|
@@ -1463,20 +2158,18 @@ import UIKit
|
|
|
1463
2158
|
public func reset(isInternal: Bool) {
|
|
1464
2159
|
logger.info("reset: \(isInternal)")
|
|
1465
2160
|
let currentBundleName = self.getCurrentBundle().getVersionName()
|
|
1466
|
-
self.
|
|
1467
|
-
self.
|
|
1468
|
-
_ = self.setNextBundle(next: Optional<String>.none)
|
|
1469
|
-
if !isInternal {
|
|
1470
|
-
self.sendStats(action: "reset", versionName: self.getCurrentBundle().getVersionName(), oldVersionName: currentBundleName)
|
|
1471
|
-
}
|
|
2161
|
+
self.prepareResetStateForTransition()
|
|
2162
|
+
self.finalizeResetTransition(previousBundleName: currentBundleName, isInternal: isInternal)
|
|
1472
2163
|
}
|
|
1473
2164
|
|
|
1474
2165
|
public func setSuccess(bundle: BundleInfo, autoDeletePrevious: Bool) {
|
|
1475
2166
|
self.setBundleStatus(id: bundle.getId(), status: BundleStatus.SUCCESS)
|
|
1476
2167
|
let fallback: BundleInfo = self.getFallbackBundle()
|
|
2168
|
+
let previewFallback = self.getPreviewFallbackBundle()
|
|
2169
|
+
let fallbackIsPreviewFallback = previewFallback?.getId() == fallback.getId()
|
|
1477
2170
|
logger.info("Fallback bundle is: \(fallback.toString())")
|
|
1478
2171
|
logger.info("Version successfully loaded: \(bundle.toString())")
|
|
1479
|
-
if autoDeletePrevious && !fallback.isBuiltin() && fallback.getId() != bundle.getId() {
|
|
2172
|
+
if autoDeletePrevious && !fallback.isBuiltin() && fallback.getId() != bundle.getId() && !fallbackIsPreviewFallback {
|
|
1480
2173
|
let res = self.delete(id: fallback.getId())
|
|
1481
2174
|
if res {
|
|
1482
2175
|
logger.info("Deleted previous bundle")
|
|
@@ -1507,7 +2200,12 @@ import UIKit
|
|
|
1507
2200
|
return setChannel
|
|
1508
2201
|
}
|
|
1509
2202
|
|
|
1510
|
-
func setChannel(
|
|
2203
|
+
func setChannel(
|
|
2204
|
+
channel: String,
|
|
2205
|
+
defaultChannelKey: String,
|
|
2206
|
+
allowSetDefaultChannel: Bool,
|
|
2207
|
+
configDefaultChannel: String = ""
|
|
2208
|
+
) -> SetChannel {
|
|
1511
2209
|
let setChannel: SetChannel = SetChannel()
|
|
1512
2210
|
|
|
1513
2211
|
// Check if setting defaultChannel is allowed
|
|
@@ -1532,58 +2230,79 @@ import UIKit
|
|
|
1532
2230
|
setChannel.error = "missing_config"
|
|
1533
2231
|
return setChannel
|
|
1534
2232
|
}
|
|
1535
|
-
let
|
|
2233
|
+
guard let channelURL = URL(string: self.channelUrl) else {
|
|
2234
|
+
logger.error("Invalid channel URL")
|
|
2235
|
+
setChannel.message = "Channel URL is invalid"
|
|
2236
|
+
setChannel.error = "invalid_config"
|
|
2237
|
+
return setChannel
|
|
2238
|
+
}
|
|
1536
2239
|
var parameters: InfoObject = self.createInfoObject()
|
|
1537
2240
|
parameters.channel = channel
|
|
2241
|
+
guard let request = createRequest(url: channelURL, method: "POST", parameters: parameters.toParameters()) else {
|
|
2242
|
+
setChannel.error = "Request failed: invalid request"
|
|
2243
|
+
return setChannel
|
|
2244
|
+
}
|
|
1538
2245
|
|
|
1539
|
-
let
|
|
2246
|
+
let result = performRequest(request, label: "setChannel")
|
|
1540
2247
|
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
semaphore.signal()
|
|
1547
|
-
return
|
|
1548
|
-
}
|
|
2248
|
+
if self.checkAndHandleRateLimitResponse(statusCode: result.response?.statusCode) {
|
|
2249
|
+
setChannel.message = "Rate limit exceeded"
|
|
2250
|
+
setChannel.error = "rate_limit_exceeded"
|
|
2251
|
+
return setChannel
|
|
2252
|
+
}
|
|
1549
2253
|
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
2254
|
+
if result.timedOut {
|
|
2255
|
+
setChannel.error = "Request timed out"
|
|
2256
|
+
return setChannel
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
if let error = result.error {
|
|
2260
|
+
self.logger.error("Error setting channel")
|
|
2261
|
+
self.logger.debug("Error: \(error.localizedDescription)")
|
|
2262
|
+
setChannel.error = "Request failed: \(error.localizedDescription)"
|
|
2263
|
+
return setChannel
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
guard let data = result.data else {
|
|
2267
|
+
setChannel.error = "Request failed: empty response"
|
|
2268
|
+
return setChannel
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
guard let responseValue = try? JSONDecoder().decode(SetChannelDec.self, from: data) else {
|
|
2272
|
+
setChannel.error = "decode_error"
|
|
2273
|
+
return setChannel
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
let statusCode = result.response?.statusCode ?? 0
|
|
2277
|
+
if statusCode < 200 || statusCode >= 300 {
|
|
2278
|
+
setChannel.message = responseValue.message ?? "Server error: \(statusCode)"
|
|
2279
|
+
setChannel.error = responseValue.error ?? "response_error"
|
|
2280
|
+
return setChannel
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
if let error = responseValue.error {
|
|
2284
|
+
setChannel.error = error
|
|
2285
|
+
} else if responseValue.unset == true {
|
|
2286
|
+
UserDefaults.standard.removeObject(forKey: defaultChannelKey)
|
|
2287
|
+
UserDefaults.standard.synchronize()
|
|
2288
|
+
self.defaultChannel = configDefaultChannel
|
|
2289
|
+
self.logger.info("Public channel requested, channel override removed")
|
|
2290
|
+
|
|
2291
|
+
setChannel.status = responseValue.status ?? "ok"
|
|
2292
|
+
setChannel.message = responseValue.message ?? "Public channel requested, channel override removed. Device will use public channel automatically."
|
|
2293
|
+
} else {
|
|
2294
|
+
self.defaultChannel = channel
|
|
2295
|
+
UserDefaults.standard.set(channel, forKey: defaultChannelKey)
|
|
2296
|
+
UserDefaults.standard.synchronize()
|
|
2297
|
+
self.logger.info("defaultChannel persisted locally: \(channel)")
|
|
2298
|
+
|
|
2299
|
+
setChannel.status = responseValue.status ?? ""
|
|
2300
|
+
setChannel.message = responseValue.message ?? ""
|
|
1581
2301
|
}
|
|
1582
|
-
semaphore.wait()
|
|
1583
2302
|
return setChannel
|
|
1584
2303
|
}
|
|
1585
2304
|
|
|
1586
|
-
func getChannel() -> GetChannel {
|
|
2305
|
+
func getChannel(defaultChannelKey: String? = nil) -> GetChannel {
|
|
1587
2306
|
let getChannel: GetChannel = GetChannel()
|
|
1588
2307
|
|
|
1589
2308
|
// Check if rate limit was exceeded
|
|
@@ -1600,52 +2319,96 @@ import UIKit
|
|
|
1600
2319
|
getChannel.error = "missing_config"
|
|
1601
2320
|
return getChannel
|
|
1602
2321
|
}
|
|
1603
|
-
let
|
|
2322
|
+
guard let channelURL = URL(string: self.channelUrl) else {
|
|
2323
|
+
logger.error("Invalid channel URL")
|
|
2324
|
+
getChannel.message = "Channel URL is invalid"
|
|
2325
|
+
getChannel.error = "invalid_config"
|
|
2326
|
+
return getChannel
|
|
2327
|
+
}
|
|
1604
2328
|
let parameters: InfoObject = self.createInfoObject()
|
|
1605
|
-
let request =
|
|
2329
|
+
guard let request = createRequest(url: channelURL, method: "PUT", parameters: parameters.toParameters()) else {
|
|
2330
|
+
getChannel.error = "Request failed: invalid request"
|
|
2331
|
+
return getChannel
|
|
2332
|
+
}
|
|
1606
2333
|
|
|
1607
|
-
request
|
|
1608
|
-
defer {
|
|
1609
|
-
semaphore.signal()
|
|
1610
|
-
}
|
|
2334
|
+
let result = performRequest(request, label: "getChannel")
|
|
1611
2335
|
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
}
|
|
2336
|
+
if self.checkAndHandleRateLimitResponse(statusCode: result.response?.statusCode) {
|
|
2337
|
+
getChannel.message = "Rate limit exceeded"
|
|
2338
|
+
getChannel.error = "rate_limit_exceeded"
|
|
2339
|
+
return getChannel
|
|
2340
|
+
}
|
|
1618
2341
|
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
}
|
|
1631
|
-
case let .failure(error):
|
|
1632
|
-
if let data = response.data, let bodyString = String(data: data, encoding: .utf8) {
|
|
1633
|
-
if bodyString.contains("channel_not_found") && response.response?.statusCode == 400 && !self.defaultChannel.isEmpty {
|
|
1634
|
-
getChannel.channel = self.defaultChannel
|
|
1635
|
-
getChannel.status = "default"
|
|
1636
|
-
return
|
|
1637
|
-
}
|
|
2342
|
+
if result.timedOut {
|
|
2343
|
+
getChannel.error = "Request timed out"
|
|
2344
|
+
return getChannel
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
if let error = result.error {
|
|
2348
|
+
if let data = result.data, let bodyString = String(data: data, encoding: .utf8) {
|
|
2349
|
+
if bodyString.contains("channel_not_found") && result.response?.statusCode == 400 && !self.defaultChannel.isEmpty {
|
|
2350
|
+
getChannel.channel = self.defaultChannel
|
|
2351
|
+
getChannel.status = "default"
|
|
2352
|
+
return getChannel
|
|
1638
2353
|
}
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
self.logger.error("Error getting channel")
|
|
2357
|
+
self.logger.debug("Error: \(error.localizedDescription)")
|
|
2358
|
+
getChannel.error = "Request failed: \(error.localizedDescription)"
|
|
2359
|
+
return getChannel
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
guard let data = result.data else {
|
|
2363
|
+
getChannel.error = "Request failed: empty response"
|
|
2364
|
+
return getChannel
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
guard let responseValue = try? JSONDecoder().decode(GetChannelDec.self, from: data) else {
|
|
2368
|
+
getChannel.error = "decode_error"
|
|
2369
|
+
return getChannel
|
|
2370
|
+
}
|
|
1639
2371
|
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
2372
|
+
let statusCode = result.response?.statusCode ?? 0
|
|
2373
|
+
if let error = responseValue.error {
|
|
2374
|
+
if error == "channel_not_found", statusCode == 400, !self.defaultChannel.isEmpty {
|
|
2375
|
+
getChannel.channel = self.defaultChannel
|
|
2376
|
+
getChannel.status = "default"
|
|
2377
|
+
return getChannel
|
|
1643
2378
|
}
|
|
2379
|
+
getChannel.error = error
|
|
2380
|
+
getChannel.message = responseValue.message ?? ""
|
|
2381
|
+
return getChannel
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
if statusCode < 200 || statusCode >= 300 {
|
|
2385
|
+
getChannel.message = responseValue.message ?? "Server error: \(statusCode)"
|
|
2386
|
+
getChannel.error = "response_error"
|
|
2387
|
+
} else {
|
|
2388
|
+
getChannel.status = responseValue.status ?? ""
|
|
2389
|
+
getChannel.message = responseValue.message ?? ""
|
|
2390
|
+
getChannel.channel = responseValue.channel ?? ""
|
|
2391
|
+
getChannel.allowSet = responseValue.allowSet ?? true
|
|
2392
|
+
persistDefaultChannelFromResponse(channel: responseValue.channel, defaultChannelKey: defaultChannelKey)
|
|
1644
2393
|
}
|
|
1645
|
-
semaphore.wait()
|
|
1646
2394
|
return getChannel
|
|
1647
2395
|
}
|
|
1648
2396
|
|
|
2397
|
+
func persistDefaultChannelFromResponse(channel: String?, defaultChannelKey: String?) {
|
|
2398
|
+
guard let channelName = channel?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
2399
|
+
!channelName.isEmpty,
|
|
2400
|
+
channelName != BundleInfo.ID_BUILTIN else {
|
|
2401
|
+
return
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
self.defaultChannel = channelName
|
|
2405
|
+
if let defaultChannelKey, !defaultChannelKey.isEmpty {
|
|
2406
|
+
UserDefaults.standard.set(channelName, forKey: defaultChannelKey)
|
|
2407
|
+
UserDefaults.standard.synchronize()
|
|
2408
|
+
}
|
|
2409
|
+
logger.info("defaultChannel synchronized from getChannel(): \(channelName)")
|
|
2410
|
+
}
|
|
2411
|
+
|
|
1649
2412
|
func listChannels() -> ListChannels {
|
|
1650
2413
|
let listChannels: ListChannels = ListChannels()
|
|
1651
2414
|
|
|
@@ -1662,27 +2425,13 @@ import UIKit
|
|
|
1662
2425
|
return listChannels
|
|
1663
2426
|
}
|
|
1664
2427
|
|
|
1665
|
-
let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
|
|
1666
|
-
|
|
1667
2428
|
// Create info object and convert to query parameters
|
|
1668
2429
|
let infoObject = self.createInfoObject()
|
|
1669
|
-
|
|
1670
|
-
// Create query parameters from InfoObject
|
|
1671
2430
|
var urlComponents = URLComponents(string: self.channelUrl)
|
|
1672
|
-
var queryItems: [URLQueryItem] = []
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
for child in mirror.children {
|
|
1677
|
-
if let key = child.label, let value = child.value as? CustomStringConvertible {
|
|
1678
|
-
queryItems.append(URLQueryItem(name: key, value: String(describing: value)))
|
|
1679
|
-
} else if let key = child.label {
|
|
1680
|
-
// Handle optional values
|
|
1681
|
-
let mirror = Mirror(reflecting: child.value)
|
|
1682
|
-
if let value = mirror.children.first?.value {
|
|
1683
|
-
queryItems.append(URLQueryItem(name: key, value: String(describing: value)))
|
|
1684
|
-
}
|
|
1685
|
-
}
|
|
2431
|
+
var queryItems: [URLQueryItem] = urlComponents?.queryItems ?? []
|
|
2432
|
+
|
|
2433
|
+
for (key, value) in infoObject.toParameters() {
|
|
2434
|
+
queryItems.append(URLQueryItem(name: key, value: String(describing: value)))
|
|
1686
2435
|
}
|
|
1687
2436
|
|
|
1688
2437
|
urlComponents?.queryItems = queryItems
|
|
@@ -1693,47 +2442,62 @@ import UIKit
|
|
|
1693
2442
|
return listChannels
|
|
1694
2443
|
}
|
|
1695
2444
|
|
|
1696
|
-
let request =
|
|
2445
|
+
guard let request = createRequest(url: url, method: "GET", expectsJSONResponse: true) else {
|
|
2446
|
+
listChannels.error = "Invalid channel URL"
|
|
2447
|
+
return listChannels
|
|
2448
|
+
}
|
|
1697
2449
|
|
|
1698
|
-
request
|
|
1699
|
-
defer {
|
|
1700
|
-
semaphore.signal()
|
|
1701
|
-
}
|
|
2450
|
+
let result = performRequest(request, label: "listChannels")
|
|
1702
2451
|
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
}
|
|
2452
|
+
if self.checkAndHandleRateLimitResponse(statusCode: result.response?.statusCode) {
|
|
2453
|
+
listChannels.error = "rate_limit_exceeded"
|
|
2454
|
+
return listChannels
|
|
2455
|
+
}
|
|
1708
2456
|
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
if let error = responseValue.error {
|
|
1714
|
-
listChannels.error = error
|
|
1715
|
-
return
|
|
1716
|
-
}
|
|
2457
|
+
if result.timedOut {
|
|
2458
|
+
listChannels.error = "Request timed out"
|
|
2459
|
+
return listChannels
|
|
2460
|
+
}
|
|
1717
2461
|
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
2462
|
+
if let error = result.error {
|
|
2463
|
+
self.logger.error("Error listing channels")
|
|
2464
|
+
self.logger.debug("Error: \(error.localizedDescription)")
|
|
2465
|
+
listChannels.error = "Request failed: \(error.localizedDescription)"
|
|
2466
|
+
return listChannels
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
guard let data = result.data else {
|
|
2470
|
+
listChannels.error = "Request failed: empty response"
|
|
2471
|
+
return listChannels
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
guard let responseValue = try? JSONDecoder().decode(ListChannelsDec.self, from: data) else {
|
|
2475
|
+
listChannels.error = "decode_error"
|
|
2476
|
+
return listChannels
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
let statusCode = result.response?.statusCode ?? 0
|
|
2480
|
+
if let error = responseValue.error {
|
|
2481
|
+
listChannels.error = error
|
|
2482
|
+
return listChannels
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
if statusCode < 200 || statusCode >= 300 {
|
|
2486
|
+
listChannels.error = "response_error"
|
|
2487
|
+
return listChannels
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
if let channels = responseValue.channels {
|
|
2491
|
+
listChannels.channels = channels.map { channel in
|
|
2492
|
+
var channelDict: [String: Any] = [:]
|
|
2493
|
+
channelDict["id"] = channel.id
|
|
2494
|
+
channelDict["name"] = channel.name ?? ""
|
|
2495
|
+
channelDict["public"] = channel.public ?? false
|
|
2496
|
+
channelDict["allow_self_set"] = channel.allow_self_set ?? false
|
|
2497
|
+
return channelDict
|
|
1734
2498
|
}
|
|
1735
2499
|
}
|
|
1736
|
-
|
|
2500
|
+
|
|
1737
2501
|
return listChannels
|
|
1738
2502
|
}
|
|
1739
2503
|
|
|
@@ -1747,6 +2511,29 @@ import UIKit
|
|
|
1747
2511
|
}()
|
|
1748
2512
|
|
|
1749
2513
|
func sendStats(action: String, versionName: String? = nil, oldVersionName: String? = "") {
|
|
2514
|
+
sendStatsWithMetadata(action: action, versionName: versionName, oldVersionName: oldVersionName, metadata: nil, onSent: nil)
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
func sendStats(action: String, versionName: String?, oldVersionName: String?, metadata: [String: String]) {
|
|
2518
|
+
sendStatsWithMetadata(action: action, versionName: versionName, oldVersionName: oldVersionName, metadata: metadata, onSent: nil)
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
func sendStats(action: String, versionName: String?, oldVersionName: String?, metadata: [String: String], onSent: @escaping () -> Void) {
|
|
2522
|
+
sendStatsWithMetadata(action: action, versionName: versionName, oldVersionName: oldVersionName, metadata: metadata, onSent: onSent)
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
private func sendStatsWithMetadata(
|
|
2526
|
+
action: String,
|
|
2527
|
+
versionName: String?,
|
|
2528
|
+
oldVersionName: String?,
|
|
2529
|
+
metadata: [String: String]?,
|
|
2530
|
+
onSent: (() -> Void)?
|
|
2531
|
+
) {
|
|
2532
|
+
if previewSession {
|
|
2533
|
+
logger.debug("Skipping sendStats during preview session.")
|
|
2534
|
+
return
|
|
2535
|
+
}
|
|
2536
|
+
|
|
1750
2537
|
// Check if rate limit was exceeded
|
|
1751
2538
|
if CapgoUpdater.rateLimitExceeded {
|
|
1752
2539
|
logger.debug("Skipping sendStats due to rate limit (429). Stats will resume after app restart.")
|
|
@@ -1773,15 +2560,17 @@ import UIKit
|
|
|
1773
2560
|
plugin_version: info.plugin_version,
|
|
1774
2561
|
is_emulator: info.is_emulator,
|
|
1775
2562
|
is_prod: info.is_prod,
|
|
2563
|
+
installSource: info.installSource,
|
|
1776
2564
|
action: action,
|
|
1777
2565
|
channel: info.channel,
|
|
1778
2566
|
defaultChannel: info.defaultChannel,
|
|
1779
2567
|
key_id: info.key_id,
|
|
2568
|
+
metadata: metadata,
|
|
1780
2569
|
timestamp: Int64(Date().timeIntervalSince1970 * 1000)
|
|
1781
2570
|
)
|
|
1782
2571
|
|
|
1783
2572
|
statsQueueLock.lock()
|
|
1784
|
-
statsQueue.append(event)
|
|
2573
|
+
statsQueue.append(QueuedStatsEvent(event: event, onSent: onSent))
|
|
1785
2574
|
statsQueueLock.unlock()
|
|
1786
2575
|
|
|
1787
2576
|
ensureStatsTimerStarted()
|
|
@@ -1808,10 +2597,13 @@ import UIKit
|
|
|
1808
2597
|
statsQueueLock.unlock()
|
|
1809
2598
|
return
|
|
1810
2599
|
}
|
|
1811
|
-
let
|
|
2600
|
+
let queuedEvents = statsQueue
|
|
1812
2601
|
statsQueue.removeAll()
|
|
1813
2602
|
statsQueueLock.unlock()
|
|
1814
2603
|
|
|
2604
|
+
let eventsToSend = queuedEvents.map(\.event)
|
|
2605
|
+
let onSentCallbacks = queuedEvents.compactMap(\.onSent)
|
|
2606
|
+
|
|
1815
2607
|
operationQueue.maxConcurrentOperationCount = 1
|
|
1816
2608
|
|
|
1817
2609
|
let operation = BlockOperation {
|
|
@@ -1829,10 +2621,18 @@ import UIKit
|
|
|
1829
2621
|
return
|
|
1830
2622
|
}
|
|
1831
2623
|
|
|
2624
|
+
if let statusCode = response.response?.statusCode, !(200...299).contains(statusCode) {
|
|
2625
|
+
self.logger.error("Error sending stats batch")
|
|
2626
|
+
self.logger.debug("Response code: \(statusCode)")
|
|
2627
|
+
semaphore.signal()
|
|
2628
|
+
return
|
|
2629
|
+
}
|
|
2630
|
+
|
|
1832
2631
|
switch response.result {
|
|
1833
2632
|
case .success:
|
|
1834
2633
|
self.logger.info("Stats batch sent successfully")
|
|
1835
2634
|
self.logger.debug("Sent \(eventsToSend.count) events")
|
|
2635
|
+
onSentCallbacks.forEach { $0() }
|
|
1836
2636
|
case let .failure(error):
|
|
1837
2637
|
self.logger.error("Error sending stats batch")
|
|
1838
2638
|
self.logger.debug("Response: \(response.value?.debugDescription ?? "nil"), Error: \(error.localizedDescription)")
|
|
@@ -1851,7 +2651,7 @@ import UIKit
|
|
|
1851
2651
|
}
|
|
1852
2652
|
let result: BundleInfo
|
|
1853
2653
|
if BundleInfo.ID_BUILTIN == trueId {
|
|
1854
|
-
result = BundleInfo(id: trueId, version:
|
|
2654
|
+
result = BundleInfo(id: trueId, version: self.versionBuild, status: BundleStatus.SUCCESS, checksum: "")
|
|
1855
2655
|
} else if BundleInfo.VERSION_UNKNOWN == trueId {
|
|
1856
2656
|
result = BundleInfo(id: trueId, version: "", status: BundleStatus.ERROR, checksum: "")
|
|
1857
2657
|
} else {
|
|
@@ -1898,13 +2698,12 @@ import UIKit
|
|
|
1898
2698
|
logger.debug("Bundle ID: \(id), Error: \(error.localizedDescription)")
|
|
1899
2699
|
}
|
|
1900
2700
|
}
|
|
1901
|
-
UserDefaults.standard.synchronize()
|
|
1902
2701
|
}
|
|
1903
2702
|
|
|
1904
2703
|
private func setBundleStatus(id: String, status: BundleStatus) {
|
|
1905
2704
|
logger.info("Setting status for bundle [\(id)] to \(status)")
|
|
1906
2705
|
let info = self.getBundleInfo(id: id)
|
|
1907
|
-
self.saveBundleInfo(id: id, bundle: info.setStatus(status: status.
|
|
2706
|
+
self.saveBundleInfo(id: id, bundle: info.setStatus(status: status.storedValue))
|
|
1908
2707
|
}
|
|
1909
2708
|
|
|
1910
2709
|
public func getCurrentBundle() -> BundleInfo {
|
|
@@ -1941,6 +2740,33 @@ import UIKit
|
|
|
1941
2740
|
return self.getBundleInfo(id: id)
|
|
1942
2741
|
}
|
|
1943
2742
|
|
|
2743
|
+
public func getPreviewFallbackBundle() -> BundleInfo? {
|
|
2744
|
+
guard let id = UserDefaults.standard.string(forKey: self.PREVIEW_FALLBACK_VERSION) else {
|
|
2745
|
+
return nil
|
|
2746
|
+
}
|
|
2747
|
+
let bundle = self.getBundleInfo(id: id)
|
|
2748
|
+
if !bundle.isBuiltin() && !self.bundleExists(id: id) {
|
|
2749
|
+
_ = self.setPreviewFallbackBundle(fallback: nil)
|
|
2750
|
+
return nil
|
|
2751
|
+
}
|
|
2752
|
+
return bundle
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
public func setPreviewFallbackBundle(fallback: String?) -> Bool {
|
|
2756
|
+
guard let fallbackId = fallback else {
|
|
2757
|
+
UserDefaults.standard.removeObject(forKey: self.PREVIEW_FALLBACK_VERSION)
|
|
2758
|
+
UserDefaults.standard.synchronize()
|
|
2759
|
+
return true
|
|
2760
|
+
}
|
|
2761
|
+
let newBundle: BundleInfo = self.getBundleInfo(id: fallbackId)
|
|
2762
|
+
if !newBundle.isBuiltin() && !self.bundleExists(id: fallbackId) {
|
|
2763
|
+
return false
|
|
2764
|
+
}
|
|
2765
|
+
UserDefaults.standard.set(fallbackId, forKey: self.PREVIEW_FALLBACK_VERSION)
|
|
2766
|
+
UserDefaults.standard.synchronize()
|
|
2767
|
+
return true
|
|
2768
|
+
}
|
|
2769
|
+
|
|
1944
2770
|
public func setNextBundle(next: String?) -> Bool {
|
|
1945
2771
|
guard let nextId: String = next else {
|
|
1946
2772
|
UserDefaults.standard.removeObject(forKey: self.NEXT_VERSION)
|
|
@@ -1954,6 +2780,8 @@ import UIKit
|
|
|
1954
2780
|
UserDefaults.standard.set(nextId, forKey: self.NEXT_VERSION)
|
|
1955
2781
|
UserDefaults.standard.synchronize()
|
|
1956
2782
|
self.setBundleStatus(id: nextId, status: BundleStatus.PENDING)
|
|
2783
|
+
self.sendStats(action: "set_next", versionName: newBundle.getVersionName(), oldVersionName: self.getCurrentBundle().getVersionName())
|
|
2784
|
+
self.notifyListeners("setNext", ["bundle": newBundle.toJSON()])
|
|
1957
2785
|
return true
|
|
1958
2786
|
}
|
|
1959
2787
|
}
|