@capgo/capacitor-updater 7.45.10 → 7.50.2
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 +1 -1
- package/README.md +510 -92
- package/android/src/main/java/ee/forgr/capacitor_updater/AndroidAppExitReporter.java +92 -0
- package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +3192 -777
- package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +798 -299
- package/android/src/main/java/ee/forgr/capacitor_updater/DeviceIdHelper.java +45 -30
- package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +46 -28
- 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 +359 -25
- package/android/src/main/java/ee/forgr/capacitor_updater/ThreeFingerPinchDetector.java +323 -0
- package/dist/docs.json +1283 -169
- package/dist/esm/definitions.d.ts +621 -44
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/web.d.ts +11 -1
- package/dist/esm/web.js +59 -1
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +59 -1
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +59 -1
- 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 +2116 -223
- package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +997 -332
- package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +0 -1
- package/ios/Sources/CapacitorUpdaterPlugin/InternalUtils.swift +78 -1
- package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +418 -36
- package/ios/Sources/CapacitorUpdaterPlugin/WebViewStatsReporter.swift +276 -0
- package/package.json +15 -3
|
@@ -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,11 +51,16 @@ 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
|
+
|
|
57
64
|
private static func sanitizeHeaderValue(_ value: String) -> String {
|
|
58
65
|
if value.isEmpty {
|
|
59
66
|
return "unknown"
|
|
@@ -81,15 +88,88 @@ import UIKit
|
|
|
81
88
|
CapgoUpdater.buildUserAgent(appId: appId, pluginVersion: pluginVersion, versionOs: versionOs)
|
|
82
89
|
}
|
|
83
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
|
|
148
|
+
}
|
|
149
|
+
|
|
84
150
|
private lazy var alamofireSession: Session = {
|
|
85
|
-
let configuration = URLSessionConfiguration.
|
|
151
|
+
let configuration = URLSessionConfiguration.ephemeral
|
|
86
152
|
configuration.httpAdditionalHeaders = ["User-Agent": self.userAgent]
|
|
153
|
+
configuration.httpCookieStorage = nil
|
|
154
|
+
configuration.httpShouldSetCookies = false
|
|
155
|
+
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
|
156
|
+
configuration.urlCache = nil
|
|
87
157
|
return Session(configuration: configuration)
|
|
88
158
|
}()
|
|
159
|
+
private let networkResponseQueue = DispatchQueue(label: "ee.forgr.capacitor-updater.network-response", qos: .utility)
|
|
89
160
|
|
|
90
161
|
public var notifyDownloadRaw: (String, Int, Bool, BundleInfo?) -> Void = { _, _, _, _ in }
|
|
91
162
|
public func notifyDownload(id: String, percent: Int, ignoreMultipleOfTen: Bool = false, bundle: BundleInfo? = nil) {
|
|
92
|
-
|
|
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
|
+
}
|
|
93
173
|
}
|
|
94
174
|
public var notifyDownload: (String, Int) -> Void = { _, _ in }
|
|
95
175
|
public var notifyListeners: (String, [String: Any]) -> Void = { _, _ in }
|
|
@@ -98,6 +178,145 @@ import UIKit
|
|
|
98
178
|
self.logger = logger
|
|
99
179
|
}
|
|
100
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
|
+
|
|
101
320
|
deinit {
|
|
102
321
|
// Invalidate the stats timer to prevent memory leaks
|
|
103
322
|
statsFlushTimer?.invalidate()
|
|
@@ -149,6 +368,22 @@ import UIKit
|
|
|
149
368
|
return !self.isDevEnvironment && !self.isAppStoreReceiptSandbox() && !self.hasEmbeddedMobileProvision()
|
|
150
369
|
}
|
|
151
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
|
+
|
|
152
387
|
/**
|
|
153
388
|
* Checks if there is sufficient disk space for a download.
|
|
154
389
|
* Matches Android behavior: 2x safety margin, throws "insufficient_disk_space"
|
|
@@ -188,7 +423,7 @@ import UIKit
|
|
|
188
423
|
if statusCode == 429 {
|
|
189
424
|
// Send a statistic about the rate limit BEFORE setting the flag
|
|
190
425
|
// Only send once to prevent infinite loop if the stat request itself gets rate limited
|
|
191
|
-
if !CapgoUpdater.rateLimitExceeded && !CapgoUpdater.rateLimitStatisticSent {
|
|
426
|
+
if !previewSession && !CapgoUpdater.rateLimitExceeded && !CapgoUpdater.rateLimitStatisticSent {
|
|
192
427
|
CapgoUpdater.rateLimitStatisticSent = true
|
|
193
428
|
|
|
194
429
|
// Dispatch to background queue to avoid blocking the main thread
|
|
@@ -224,8 +459,8 @@ import UIKit
|
|
|
224
459
|
self.alamofireSession.request(
|
|
225
460
|
self.statsUrl,
|
|
226
461
|
method: .post,
|
|
227
|
-
parameters: parameters,
|
|
228
|
-
|
|
462
|
+
parameters: parameters.toParameters(),
|
|
463
|
+
encoding: JSONEncoding.default,
|
|
229
464
|
requestModifier: { $0.timeoutInterval = self.timeout }
|
|
230
465
|
).responseData { response in
|
|
231
466
|
switch response.result {
|
|
@@ -314,23 +549,76 @@ import UIKit
|
|
|
314
549
|
}
|
|
315
550
|
}
|
|
316
551
|
|
|
317
|
-
private func
|
|
318
|
-
|
|
319
|
-
|
|
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 {
|
|
320
556
|
logger.error("Unzip failed: Windows path not supported")
|
|
321
557
|
logger.debug("Invalid path: \(path)")
|
|
322
558
|
self.sendStats(action: "windows_path_fail")
|
|
323
559
|
throw CustomError.cannotUnzip
|
|
560
|
+
} catch {
|
|
561
|
+
self.sendStats(action: "canonical_path_fail")
|
|
562
|
+
throw CustomError.cannotUnzip
|
|
324
563
|
}
|
|
564
|
+
}
|
|
325
565
|
|
|
326
|
-
|
|
327
|
-
let
|
|
328
|
-
let canonicalPath = fileURL.standardizedFileURL.path
|
|
329
|
-
let canonicalDir = destUnZip.standardizedFileURL.path
|
|
566
|
+
private func extractZipEntry(_ archive: Archive, entry: Entry, to destPath: URL) throws {
|
|
567
|
+
let fileManager = FileManager.default
|
|
330
568
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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)
|
|
334
622
|
}
|
|
335
623
|
}
|
|
336
624
|
|
|
@@ -360,10 +648,20 @@ import UIKit
|
|
|
360
648
|
|
|
361
649
|
do {
|
|
362
650
|
for entry in archive {
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
+
}
|
|
367
665
|
|
|
368
666
|
// Create parent directories if needed
|
|
369
667
|
let parentDir = destPath.deletingLastPathComponent()
|
|
@@ -371,8 +669,7 @@ import UIKit
|
|
|
371
669
|
try FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true, attributes: nil)
|
|
372
670
|
}
|
|
373
671
|
|
|
374
|
-
|
|
375
|
-
_ = try archive.extract(entry, to: destPath, skipCRC32: true)
|
|
672
|
+
try self.extractZipEntry(archive, entry: entry, to: destPath)
|
|
376
673
|
|
|
377
674
|
// Update progress
|
|
378
675
|
processedEntries += 1
|
|
@@ -455,11 +752,11 @@ import UIKit
|
|
|
455
752
|
}
|
|
456
753
|
}
|
|
457
754
|
|
|
458
|
-
private func createInfoObject() -> InfoObject {
|
|
755
|
+
private func createInfoObject(appIdOverride: String? = nil) -> InfoObject {
|
|
459
756
|
return InfoObject(
|
|
460
757
|
platform: "ios",
|
|
461
758
|
device_id: self.deviceID,
|
|
462
|
-
app_id: self.appId,
|
|
759
|
+
app_id: appIdOverride ?? self.appId,
|
|
463
760
|
custom_id: self.customId,
|
|
464
761
|
version_build: self.versionBuild,
|
|
465
762
|
version_code: self.versionCode,
|
|
@@ -468,6 +765,7 @@ import UIKit
|
|
|
468
765
|
plugin_version: self.pluginVersion,
|
|
469
766
|
is_emulator: self.isEmulator(),
|
|
470
767
|
is_prod: self.isProd(),
|
|
768
|
+
installSource: self.installSource(),
|
|
471
769
|
action: nil,
|
|
472
770
|
channel: nil,
|
|
473
771
|
defaultChannel: self.defaultChannel,
|
|
@@ -475,8 +773,7 @@ import UIKit
|
|
|
475
773
|
)
|
|
476
774
|
}
|
|
477
775
|
|
|
478
|
-
public func getLatest(url: URL, channel: String?) -> AppVersion {
|
|
479
|
-
let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
|
|
776
|
+
public func getLatest(url: URL, channel: String?, appIdOverride: String? = nil) -> AppVersion {
|
|
480
777
|
let latest: AppVersion = AppVersion()
|
|
481
778
|
func applyLatestResponse(_ value: AppVersionDec?) {
|
|
482
779
|
if let url = value?.url {
|
|
@@ -519,47 +816,75 @@ import UIKit
|
|
|
519
816
|
latest.comment = comment
|
|
520
817
|
}
|
|
521
818
|
}
|
|
522
|
-
|
|
819
|
+
|
|
820
|
+
var parameters: InfoObject = self.createInfoObject(appIdOverride: appIdOverride)
|
|
523
821
|
if let channel = channel {
|
|
524
822
|
parameters.defaultChannel = channel
|
|
525
823
|
}
|
|
526
|
-
|
|
527
|
-
|
|
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
|
+
}
|
|
528
830
|
|
|
529
|
-
request
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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)"
|
|
559
878
|
}
|
|
560
|
-
|
|
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
|
|
561
886
|
}
|
|
562
|
-
|
|
887
|
+
|
|
563
888
|
return latest
|
|
564
889
|
}
|
|
565
890
|
|
|
@@ -601,6 +926,132 @@ import UIKit
|
|
|
601
926
|
return actualHash == expectedHash
|
|
602
927
|
}
|
|
603
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
|
+
|
|
604
1055
|
public func downloadManifest(manifest: [ManifestEntry], version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
|
|
605
1056
|
let id = self.randomString(length: 10)
|
|
606
1057
|
logger.info("downloadManifest start \(id)")
|
|
@@ -626,8 +1077,8 @@ import UIKit
|
|
|
626
1077
|
|
|
627
1078
|
let totalFiles = manifest.count
|
|
628
1079
|
|
|
629
|
-
//
|
|
630
|
-
manifestDownloadQueue.maxConcurrentOperationCount = min(
|
|
1080
|
+
// Keep this bounded because each manifest operation waits on a URLSession callback.
|
|
1081
|
+
manifestDownloadQueue.maxConcurrentOperationCount = min(8, max(1, totalFiles))
|
|
631
1082
|
|
|
632
1083
|
// Thread-safe counters for concurrent operations
|
|
633
1084
|
let completedFiles = AtomicCounter()
|
|
@@ -699,8 +1150,22 @@ import UIKit
|
|
|
699
1150
|
let legacyCacheFilePath: URL? = isBrotli ? cacheFolder.appendingPathComponent("\(finalFileHash)_\(fileNameWithoutPath)") : nil
|
|
700
1151
|
|
|
701
1152
|
let destFileName = isBrotli ? String(fileName.dropLast(3)) : fileName
|
|
702
|
-
let destFilePath
|
|
703
|
-
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
|
+
}
|
|
704
1169
|
|
|
705
1170
|
// Create parent directories synchronously (before operations start)
|
|
706
1171
|
try? FileManager.default.createDirectory(at: destFilePath.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
|
|
@@ -712,7 +1177,7 @@ import UIKit
|
|
|
712
1177
|
do {
|
|
713
1178
|
// Try builtin first
|
|
714
1179
|
if FileManager.default.fileExists(atPath: builtinFilePath.path) && self.verifyChecksum(file: builtinFilePath, expectedHash: finalFileHash) {
|
|
715
|
-
try
|
|
1180
|
+
try self.copyItemReplacing(from: builtinFilePath, to: destFilePath)
|
|
716
1181
|
self.logger.info("downloadManifest \(fileName) using builtin file \(id)")
|
|
717
1182
|
}
|
|
718
1183
|
// Try cache
|
|
@@ -766,13 +1231,13 @@ import UIKit
|
|
|
766
1231
|
userInfo: [NSLocalizedDescriptionKey: "Manifest download failed due to invalid or missing entries"]
|
|
767
1232
|
)
|
|
768
1233
|
// Update bundle status to ERROR if download failed
|
|
769
|
-
let errorBundle = bundleInfo.setStatus(status: BundleStatus.ERROR.
|
|
1234
|
+
let errorBundle = bundleInfo.setStatus(status: BundleStatus.ERROR.storedValue)
|
|
770
1235
|
self.saveBundleInfo(id: id, bundle: errorBundle)
|
|
771
1236
|
throw resolvedError
|
|
772
1237
|
}
|
|
773
1238
|
|
|
774
1239
|
// Update bundle status to PENDING after successful download
|
|
775
|
-
let updatedBundle = bundleInfo.setStatus(status: BundleStatus.PENDING.
|
|
1240
|
+
let updatedBundle = bundleInfo.setStatus(status: BundleStatus.PENDING.storedValue)
|
|
776
1241
|
self.saveBundleInfo(id: id, bundle: updatedBundle)
|
|
777
1242
|
|
|
778
1243
|
// Send stats for manifest download complete
|
|
@@ -797,105 +1262,152 @@ import UIKit
|
|
|
797
1262
|
version: String,
|
|
798
1263
|
bundleId: String
|
|
799
1264
|
) throws {
|
|
800
|
-
let
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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
|
+
}
|
|
805
1272
|
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
throw NSError(domain: "StatusCodeError", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid. Data: \(stringData) for file \(fileName) at url \(downloadUrl)"])
|
|
814
|
-
} else {
|
|
815
|
-
throw NSError(domain: "StatusCodeError", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid for file \(fileName) at url \(downloadUrl)"])
|
|
816
|
-
}
|
|
817
|
-
}
|
|
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
|
+
}
|
|
818
1280
|
|
|
819
|
-
|
|
820
|
-
var finalData = data
|
|
821
|
-
if !self.publicKey.isEmpty && !sessionKey.isEmpty {
|
|
822
|
-
let tempFile = self.cacheFolder.appendingPathComponent("temp_\(UUID().uuidString)")
|
|
823
|
-
try finalData.write(to: tempFile)
|
|
824
|
-
do {
|
|
825
|
-
try CryptoCipher.decryptFile(filePath: tempFile, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
|
|
826
|
-
} catch {
|
|
827
|
-
self.sendStats(action: "decrypt_fail", versionName: version)
|
|
828
|
-
throw error
|
|
829
|
-
}
|
|
830
|
-
finalData = try Data(contentsOf: tempFile)
|
|
831
|
-
try FileManager.default.removeItem(at: tempFile)
|
|
832
|
-
}
|
|
1281
|
+
let result = performRequest(request, label: "downloadManifestFile \(fileName)")
|
|
833
1282
|
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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
|
+
}
|
|
842
1291
|
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
CryptoCipher.logChecksumInfo(label: "Expected checksum", hexChecksum: fileHash)
|
|
850
|
-
if calculatedChecksum != fileHash {
|
|
851
|
-
try? FileManager.default.removeItem(at: destFilePath)
|
|
852
|
-
self.sendStats(action: "download_manifest_checksum_fail", versionName: "\(version):\(destFileName)")
|
|
853
|
-
throw NSError(domain: "ChecksumError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Computed checksum is not equal to required checksum (\(calculatedChecksum) != \(fileHash)) for file \(fileName) at url \(downloadUrl)"])
|
|
854
|
-
}
|
|
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
|
+
}
|
|
855
1298
|
|
|
856
|
-
|
|
857
|
-
|
|
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
|
+
}
|
|
858
1307
|
|
|
859
|
-
|
|
860
|
-
|
|
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
|
+
}
|
|
861
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)
|
|
862
1326
|
} catch {
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
self.logger.debug("Bundle: \(bundleId), File: \(fileName), Error: \(error.localizedDescription)")
|
|
1327
|
+
self.sendStats(action: "decrypt_fail", versionName: version)
|
|
1328
|
+
throw error
|
|
866
1329
|
}
|
|
1330
|
+
finalData = try Data(contentsOf: tempFile)
|
|
1331
|
+
try FileManager.default.removeItem(at: tempFile)
|
|
1332
|
+
}
|
|
867
1333
|
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
self.
|
|
871
|
-
|
|
872
|
-
|
|
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
|
|
873
1341
|
}
|
|
874
|
-
}
|
|
875
1342
|
|
|
876
|
-
|
|
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)"])
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
// Save to cache (replace stale cache entries from partial or concurrent downloads)
|
|
1357
|
+
try writeDataAtomically(finalData, to: cacheFilePath)
|
|
877
1358
|
|
|
878
|
-
|
|
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)")
|
|
879
1364
|
throw error
|
|
880
1365
|
}
|
|
881
1366
|
}
|
|
882
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
|
+
|
|
883
1392
|
/// Atomically try to copy a file from cache - returns true if successful, false if file doesn't exist or copy failed
|
|
884
1393
|
/// This handles the race condition where OS can delete cache files between exists() check and copy
|
|
885
1394
|
private func tryCopyFromCache(from source: URL, to destination: URL, expectedHash: String) -> Bool {
|
|
1395
|
+
let fileManager = FileManager.default
|
|
1396
|
+
|
|
886
1397
|
// First quick check - if file doesn't exist, don't bother
|
|
887
|
-
guard
|
|
1398
|
+
guard fileManager.fileExists(atPath: source.path) else {
|
|
888
1399
|
return false
|
|
889
1400
|
}
|
|
890
1401
|
|
|
891
|
-
// Verify checksum before copy
|
|
1402
|
+
// Verify checksum before copy; remove stale cache entries that would block re-download
|
|
892
1403
|
guard verifyChecksum(file: source, expectedHash: expectedHash) else {
|
|
1404
|
+
try? fileManager.removeItem(at: source)
|
|
893
1405
|
return false
|
|
894
1406
|
}
|
|
895
1407
|
|
|
896
1408
|
// Try to copy - if it fails (file deleted by OS between check and copy), return false
|
|
897
1409
|
do {
|
|
898
|
-
try
|
|
1410
|
+
try copyItemReplacing(from: source, to: destination)
|
|
899
1411
|
return true
|
|
900
1412
|
} catch {
|
|
901
1413
|
// File was deleted between check and copy, or other IO error - caller should download instead
|
|
@@ -1024,7 +1536,6 @@ import UIKit
|
|
|
1024
1536
|
|
|
1025
1537
|
public func download(url: URL, version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
|
|
1026
1538
|
let id: String = self.randomString(length: 10)
|
|
1027
|
-
let semaphore = DispatchSemaphore(value: 0)
|
|
1028
1539
|
// Each download uses its own temp files keyed by bundle ID to prevent collisions
|
|
1029
1540
|
if version != getLocalUpdateVersion(for: id) {
|
|
1030
1541
|
cleanDownloadData(for: id)
|
|
@@ -1036,10 +1547,11 @@ import UIKit
|
|
|
1036
1547
|
try checkDiskSpace()
|
|
1037
1548
|
|
|
1038
1549
|
var checksum = ""
|
|
1039
|
-
var targetSize = -1
|
|
1040
1550
|
var lastSentProgress = 0
|
|
1041
|
-
|
|
1042
|
-
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)
|
|
1043
1555
|
|
|
1044
1556
|
// Send stats for zip download start
|
|
1045
1557
|
self.sendStats(action: "download_zip_start", versionName: version)
|
|
@@ -1049,66 +1561,61 @@ import UIKit
|
|
|
1049
1561
|
self.notifyDownload(id: id, percent: 0, ignoreMultipleOfTen: true)
|
|
1050
1562
|
}
|
|
1051
1563
|
var mainError: NSError?
|
|
1052
|
-
let monitor = ClosureEventMonitor()
|
|
1053
|
-
monitor.requestDidCompleteTaskWithError = { (_, _, error) in
|
|
1054
|
-
if error != nil {
|
|
1055
|
-
self.logger.error("Downloading failed - ClosureEventMonitor activated")
|
|
1056
|
-
mainError = error as NSError?
|
|
1057
|
-
}
|
|
1058
|
-
}
|
|
1059
|
-
let configuration = URLSessionConfiguration.default
|
|
1060
|
-
configuration.httpAdditionalHeaders = ["User-Agent": self.userAgent]
|
|
1061
|
-
let session = Session(configuration: configuration, eventMonitors: [monitor])
|
|
1062
1564
|
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
if case .success(let data) = result {
|
|
1072
|
-
self.tempData.append(data)
|
|
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
|
+
}
|
|
1073
1573
|
|
|
1074
|
-
|
|
1075
|
-
|
|
1574
|
+
if totalReceivedBytes > 0 {
|
|
1575
|
+
request.setValue("bytes=\(totalReceivedBytes)-", forHTTPHeaderField: "Range")
|
|
1576
|
+
}
|
|
1076
1577
|
|
|
1077
|
-
|
|
1578
|
+
let downloadResult = performDownloadRequest(request, label: "download \(version)")
|
|
1078
1579
|
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
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)
|
|
1086
1601
|
|
|
1087
|
-
|
|
1088
|
-
self.
|
|
1602
|
+
if lastSentProgress < 70 {
|
|
1603
|
+
self.notifyDownload(id: id, percent: 70, ignoreMultipleOfTen: true)
|
|
1604
|
+
lastSentProgress = 70
|
|
1089
1605
|
}
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
}
|
|
1096
|
-
}
|
|
1097
|
-
self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: checksum, link: link, comment: comment))
|
|
1098
|
-
let reachabilityManager = NetworkReachabilityManager()
|
|
1099
|
-
reachabilityManager?.startListening { status in
|
|
1100
|
-
switch status {
|
|
1101
|
-
case .notReachable:
|
|
1102
|
-
// Stop the download request if the network is not reachable
|
|
1103
|
-
request.cancel()
|
|
1104
|
-
mainError = NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet, userInfo: nil)
|
|
1105
|
-
semaphore.signal()
|
|
1106
|
-
default:
|
|
1107
|
-
break
|
|
1606
|
+
self.logger.info("Download complete")
|
|
1607
|
+
} catch let error as NSError {
|
|
1608
|
+
mainError = error
|
|
1609
|
+
} catch {
|
|
1610
|
+
mainError = error as NSError
|
|
1108
1611
|
}
|
|
1612
|
+
} else {
|
|
1613
|
+
mainError = NSError(
|
|
1614
|
+
domain: "DownloadError",
|
|
1615
|
+
code: 1,
|
|
1616
|
+
userInfo: [NSLocalizedDescriptionKey: "Downloaded file is missing at \(tempPath.path)"]
|
|
1617
|
+
)
|
|
1109
1618
|
}
|
|
1110
|
-
semaphore.wait()
|
|
1111
|
-
reachabilityManager?.stopListening()
|
|
1112
1619
|
|
|
1113
1620
|
if mainError != nil {
|
|
1114
1621
|
logger.error("Failed to download bundle")
|
|
@@ -1117,7 +1624,6 @@ import UIKit
|
|
|
1117
1624
|
throw mainError!
|
|
1118
1625
|
}
|
|
1119
1626
|
|
|
1120
|
-
let tempPath = tempDataPath(for: id)
|
|
1121
1627
|
let finalPath = tempPath.deletingLastPathComponent().appendingPathComponent("\(id)")
|
|
1122
1628
|
do {
|
|
1123
1629
|
try CryptoCipher.decryptFile(filePath: tempPath, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
|
|
@@ -1312,6 +1818,15 @@ import UIKit
|
|
|
1312
1818
|
return false
|
|
1313
1819
|
}
|
|
1314
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
|
+
|
|
1315
1830
|
// Check if this is the next bundle and prevent deletion if it is
|
|
1316
1831
|
if let next = self.getNextBundle(),
|
|
1317
1832
|
!next.isDeleted() &&
|
|
@@ -1338,7 +1853,7 @@ import UIKit
|
|
|
1338
1853
|
if removeInfo {
|
|
1339
1854
|
self.removeBundleInfo(id: id)
|
|
1340
1855
|
} else {
|
|
1341
|
-
self.saveBundleInfo(id: id, bundle: deleted.setStatus(status: BundleStatus.DELETED.
|
|
1856
|
+
self.saveBundleInfo(id: id, bundle: deleted.setStatus(status: BundleStatus.DELETED.storedValue))
|
|
1342
1857
|
}
|
|
1343
1858
|
logger.info("Bundle deleted successfully")
|
|
1344
1859
|
logger.debug("Version: \(deleted.getVersionName())")
|
|
@@ -1596,6 +2111,21 @@ import UIKit
|
|
|
1596
2111
|
return true
|
|
1597
2112
|
}
|
|
1598
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
|
+
|
|
1599
2129
|
func finalizePendingReload(bundle: BundleInfo, previousBundleName: String) {
|
|
1600
2130
|
guard !bundle.isBuiltin() else {
|
|
1601
2131
|
return
|
|
@@ -1635,9 +2165,11 @@ import UIKit
|
|
|
1635
2165
|
public func setSuccess(bundle: BundleInfo, autoDeletePrevious: Bool) {
|
|
1636
2166
|
self.setBundleStatus(id: bundle.getId(), status: BundleStatus.SUCCESS)
|
|
1637
2167
|
let fallback: BundleInfo = self.getFallbackBundle()
|
|
2168
|
+
let previewFallback = self.getPreviewFallbackBundle()
|
|
2169
|
+
let fallbackIsPreviewFallback = previewFallback?.getId() == fallback.getId()
|
|
1638
2170
|
logger.info("Fallback bundle is: \(fallback.toString())")
|
|
1639
2171
|
logger.info("Version successfully loaded: \(bundle.toString())")
|
|
1640
|
-
if autoDeletePrevious && !fallback.isBuiltin() && fallback.getId() != bundle.getId() {
|
|
2172
|
+
if autoDeletePrevious && !fallback.isBuiltin() && fallback.getId() != bundle.getId() && !fallbackIsPreviewFallback {
|
|
1641
2173
|
let res = self.delete(id: fallback.getId())
|
|
1642
2174
|
if res {
|
|
1643
2175
|
logger.info("Deleted previous bundle")
|
|
@@ -1668,7 +2200,12 @@ import UIKit
|
|
|
1668
2200
|
return setChannel
|
|
1669
2201
|
}
|
|
1670
2202
|
|
|
1671
|
-
func setChannel(
|
|
2203
|
+
func setChannel(
|
|
2204
|
+
channel: String,
|
|
2205
|
+
defaultChannelKey: String,
|
|
2206
|
+
allowSetDefaultChannel: Bool,
|
|
2207
|
+
configDefaultChannel: String = ""
|
|
2208
|
+
) -> SetChannel {
|
|
1672
2209
|
let setChannel: SetChannel = SetChannel()
|
|
1673
2210
|
|
|
1674
2211
|
// Check if setting defaultChannel is allowed
|
|
@@ -1693,58 +2230,79 @@ import UIKit
|
|
|
1693
2230
|
setChannel.error = "missing_config"
|
|
1694
2231
|
return setChannel
|
|
1695
2232
|
}
|
|
1696
|
-
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
|
+
}
|
|
1697
2239
|
var parameters: InfoObject = self.createInfoObject()
|
|
1698
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
|
+
}
|
|
1699
2245
|
|
|
1700
|
-
let
|
|
2246
|
+
let result = performRequest(request, label: "setChannel")
|
|
1701
2247
|
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
semaphore.signal()
|
|
1708
|
-
return
|
|
1709
|
-
}
|
|
2248
|
+
if self.checkAndHandleRateLimitResponse(statusCode: result.response?.statusCode) {
|
|
2249
|
+
setChannel.message = "Rate limit exceeded"
|
|
2250
|
+
setChannel.error = "rate_limit_exceeded"
|
|
2251
|
+
return setChannel
|
|
2252
|
+
}
|
|
1710
2253
|
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
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 ?? ""
|
|
1742
2301
|
}
|
|
1743
|
-
semaphore.wait()
|
|
1744
2302
|
return setChannel
|
|
1745
2303
|
}
|
|
1746
2304
|
|
|
1747
|
-
func getChannel() -> GetChannel {
|
|
2305
|
+
func getChannel(defaultChannelKey: String? = nil) -> GetChannel {
|
|
1748
2306
|
let getChannel: GetChannel = GetChannel()
|
|
1749
2307
|
|
|
1750
2308
|
// Check if rate limit was exceeded
|
|
@@ -1761,52 +2319,96 @@ import UIKit
|
|
|
1761
2319
|
getChannel.error = "missing_config"
|
|
1762
2320
|
return getChannel
|
|
1763
2321
|
}
|
|
1764
|
-
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
|
+
}
|
|
1765
2328
|
let parameters: InfoObject = self.createInfoObject()
|
|
1766
|
-
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
|
+
}
|
|
1767
2333
|
|
|
1768
|
-
request
|
|
1769
|
-
defer {
|
|
1770
|
-
semaphore.signal()
|
|
1771
|
-
}
|
|
2334
|
+
let result = performRequest(request, label: "getChannel")
|
|
1772
2335
|
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
}
|
|
2336
|
+
if self.checkAndHandleRateLimitResponse(statusCode: result.response?.statusCode) {
|
|
2337
|
+
getChannel.message = "Rate limit exceeded"
|
|
2338
|
+
getChannel.error = "rate_limit_exceeded"
|
|
2339
|
+
return getChannel
|
|
2340
|
+
}
|
|
1779
2341
|
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
}
|
|
1792
|
-
case let .failure(error):
|
|
1793
|
-
if let data = response.data, let bodyString = String(data: data, encoding: .utf8) {
|
|
1794
|
-
if bodyString.contains("channel_not_found") && response.response?.statusCode == 400 && !self.defaultChannel.isEmpty {
|
|
1795
|
-
getChannel.channel = self.defaultChannel
|
|
1796
|
-
getChannel.status = "default"
|
|
1797
|
-
return
|
|
1798
|
-
}
|
|
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
|
|
1799
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
|
+
}
|
|
1800
2366
|
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
2367
|
+
guard let responseValue = try? JSONDecoder().decode(GetChannelDec.self, from: data) else {
|
|
2368
|
+
getChannel.error = "decode_error"
|
|
2369
|
+
return getChannel
|
|
2370
|
+
}
|
|
2371
|
+
|
|
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
|
|
1804
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)
|
|
1805
2393
|
}
|
|
1806
|
-
semaphore.wait()
|
|
1807
2394
|
return getChannel
|
|
1808
2395
|
}
|
|
1809
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
|
+
|
|
1810
2412
|
func listChannels() -> ListChannels {
|
|
1811
2413
|
let listChannels: ListChannels = ListChannels()
|
|
1812
2414
|
|
|
@@ -1823,27 +2425,13 @@ import UIKit
|
|
|
1823
2425
|
return listChannels
|
|
1824
2426
|
}
|
|
1825
2427
|
|
|
1826
|
-
let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
|
|
1827
|
-
|
|
1828
2428
|
// Create info object and convert to query parameters
|
|
1829
2429
|
let infoObject = self.createInfoObject()
|
|
1830
|
-
|
|
1831
|
-
// Create query parameters from InfoObject
|
|
1832
2430
|
var urlComponents = URLComponents(string: self.channelUrl)
|
|
1833
|
-
var queryItems: [URLQueryItem] = []
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
for child in mirror.children {
|
|
1838
|
-
if let key = child.label, let value = child.value as? CustomStringConvertible {
|
|
1839
|
-
queryItems.append(URLQueryItem(name: key, value: String(describing: value)))
|
|
1840
|
-
} else if let key = child.label {
|
|
1841
|
-
// Handle optional values
|
|
1842
|
-
let mirror = Mirror(reflecting: child.value)
|
|
1843
|
-
if let value = mirror.children.first?.value {
|
|
1844
|
-
queryItems.append(URLQueryItem(name: key, value: String(describing: value)))
|
|
1845
|
-
}
|
|
1846
|
-
}
|
|
2431
|
+
var queryItems: [URLQueryItem] = urlComponents?.queryItems ?? []
|
|
2432
|
+
|
|
2433
|
+
for (key, value) in infoObject.toParameters() {
|
|
2434
|
+
queryItems.append(URLQueryItem(name: key, value: String(describing: value)))
|
|
1847
2435
|
}
|
|
1848
2436
|
|
|
1849
2437
|
urlComponents?.queryItems = queryItems
|
|
@@ -1854,47 +2442,62 @@ import UIKit
|
|
|
1854
2442
|
return listChannels
|
|
1855
2443
|
}
|
|
1856
2444
|
|
|
1857
|
-
let request =
|
|
2445
|
+
guard let request = createRequest(url: url, method: "GET", expectsJSONResponse: true) else {
|
|
2446
|
+
listChannels.error = "Invalid channel URL"
|
|
2447
|
+
return listChannels
|
|
2448
|
+
}
|
|
1858
2449
|
|
|
1859
|
-
request
|
|
1860
|
-
defer {
|
|
1861
|
-
semaphore.signal()
|
|
1862
|
-
}
|
|
2450
|
+
let result = performRequest(request, label: "listChannels")
|
|
1863
2451
|
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
}
|
|
2452
|
+
if self.checkAndHandleRateLimitResponse(statusCode: result.response?.statusCode) {
|
|
2453
|
+
listChannels.error = "rate_limit_exceeded"
|
|
2454
|
+
return listChannels
|
|
2455
|
+
}
|
|
1869
2456
|
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
if let error = responseValue.error {
|
|
1875
|
-
listChannels.error = error
|
|
1876
|
-
return
|
|
1877
|
-
}
|
|
2457
|
+
if result.timedOut {
|
|
2458
|
+
listChannels.error = "Request timed out"
|
|
2459
|
+
return listChannels
|
|
2460
|
+
}
|
|
1878
2461
|
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
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
|
|
1895
2498
|
}
|
|
1896
2499
|
}
|
|
1897
|
-
|
|
2500
|
+
|
|
1898
2501
|
return listChannels
|
|
1899
2502
|
}
|
|
1900
2503
|
|
|
@@ -1908,6 +2511,29 @@ import UIKit
|
|
|
1908
2511
|
}()
|
|
1909
2512
|
|
|
1910
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
|
+
|
|
1911
2537
|
// Check if rate limit was exceeded
|
|
1912
2538
|
if CapgoUpdater.rateLimitExceeded {
|
|
1913
2539
|
logger.debug("Skipping sendStats due to rate limit (429). Stats will resume after app restart.")
|
|
@@ -1934,15 +2560,17 @@ import UIKit
|
|
|
1934
2560
|
plugin_version: info.plugin_version,
|
|
1935
2561
|
is_emulator: info.is_emulator,
|
|
1936
2562
|
is_prod: info.is_prod,
|
|
2563
|
+
installSource: info.installSource,
|
|
1937
2564
|
action: action,
|
|
1938
2565
|
channel: info.channel,
|
|
1939
2566
|
defaultChannel: info.defaultChannel,
|
|
1940
2567
|
key_id: info.key_id,
|
|
2568
|
+
metadata: metadata,
|
|
1941
2569
|
timestamp: Int64(Date().timeIntervalSince1970 * 1000)
|
|
1942
2570
|
)
|
|
1943
2571
|
|
|
1944
2572
|
statsQueueLock.lock()
|
|
1945
|
-
statsQueue.append(event)
|
|
2573
|
+
statsQueue.append(QueuedStatsEvent(event: event, onSent: onSent))
|
|
1946
2574
|
statsQueueLock.unlock()
|
|
1947
2575
|
|
|
1948
2576
|
ensureStatsTimerStarted()
|
|
@@ -1969,10 +2597,13 @@ import UIKit
|
|
|
1969
2597
|
statsQueueLock.unlock()
|
|
1970
2598
|
return
|
|
1971
2599
|
}
|
|
1972
|
-
let
|
|
2600
|
+
let queuedEvents = statsQueue
|
|
1973
2601
|
statsQueue.removeAll()
|
|
1974
2602
|
statsQueueLock.unlock()
|
|
1975
2603
|
|
|
2604
|
+
let eventsToSend = queuedEvents.map(\.event)
|
|
2605
|
+
let onSentCallbacks = queuedEvents.compactMap(\.onSent)
|
|
2606
|
+
|
|
1976
2607
|
operationQueue.maxConcurrentOperationCount = 1
|
|
1977
2608
|
|
|
1978
2609
|
let operation = BlockOperation {
|
|
@@ -1990,10 +2621,18 @@ import UIKit
|
|
|
1990
2621
|
return
|
|
1991
2622
|
}
|
|
1992
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
|
+
|
|
1993
2631
|
switch response.result {
|
|
1994
2632
|
case .success:
|
|
1995
2633
|
self.logger.info("Stats batch sent successfully")
|
|
1996
2634
|
self.logger.debug("Sent \(eventsToSend.count) events")
|
|
2635
|
+
onSentCallbacks.forEach { $0() }
|
|
1997
2636
|
case let .failure(error):
|
|
1998
2637
|
self.logger.error("Error sending stats batch")
|
|
1999
2638
|
self.logger.debug("Response: \(response.value?.debugDescription ?? "nil"), Error: \(error.localizedDescription)")
|
|
@@ -2059,13 +2698,12 @@ import UIKit
|
|
|
2059
2698
|
logger.debug("Bundle ID: \(id), Error: \(error.localizedDescription)")
|
|
2060
2699
|
}
|
|
2061
2700
|
}
|
|
2062
|
-
UserDefaults.standard.synchronize()
|
|
2063
2701
|
}
|
|
2064
2702
|
|
|
2065
2703
|
private func setBundleStatus(id: String, status: BundleStatus) {
|
|
2066
2704
|
logger.info("Setting status for bundle [\(id)] to \(status)")
|
|
2067
2705
|
let info = self.getBundleInfo(id: id)
|
|
2068
|
-
self.saveBundleInfo(id: id, bundle: info.setStatus(status: status.
|
|
2706
|
+
self.saveBundleInfo(id: id, bundle: info.setStatus(status: status.storedValue))
|
|
2069
2707
|
}
|
|
2070
2708
|
|
|
2071
2709
|
public func getCurrentBundle() -> BundleInfo {
|
|
@@ -2102,6 +2740,33 @@ import UIKit
|
|
|
2102
2740
|
return self.getBundleInfo(id: id)
|
|
2103
2741
|
}
|
|
2104
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
|
+
|
|
2105
2770
|
public func setNextBundle(next: String?) -> Bool {
|
|
2106
2771
|
guard let nextId: String = next else {
|
|
2107
2772
|
UserDefaults.standard.removeObject(forKey: self.NEXT_VERSION)
|