@capgo/cordova-updater 8.1.4 → 8.2.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/dist/esm/definitions.d.ts +116 -86
- package/dist/esm/definitions.js +2 -2
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/exec.js +11 -1
- package/dist/esm/exec.js.map +1 -1
- package/dist/esm/history.js +7 -7
- package/dist/esm/history.js.map +1 -1
- package/dist/plugin.cjs.js +13 -3
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +13 -3
- package/dist/plugin.js.map +1 -1
- package/package.json +5 -3
- package/plugin.xml +9 -9
- package/src/android/CordovaUpdaterPlugin.java +173 -5
- package/src/android/app/capgo/cordova/updater/CapgoUpdater.java +313 -55
- package/src/android/app/capgo/cordova/updater/CryptoCipher.java +51 -18
- package/src/android/app/capgo/cordova/updater/DelayUpdateUtils.java +0 -1
- package/src/android/app/capgo/cordova/updater/DownloadService.java +402 -89
- package/src/android/app/capgo/cordova/updater/Version.java +312 -0
- package/src/android/capgo-cordova-updater.gradle +3 -5
- package/src/android/test/app/capgo/cordova/updater/BackgroundDownloadSettlementTest.java +29 -0
- package/src/android/test/app/capgo/cordova/updater/CapacitorUpdaterUnitTest.java.skip +2 -12
- package/src/android/test/app/capgo/cordova/updater/DelayUpdateUtilsTest.java +0 -1
- package/src/android/test/app/capgo/cordova/updater/RsaContractTest.java +137 -0
- package/src/android/test/app/capgo/cordova/updater/SecurityHardeningTest.java +197 -0
- package/src/android/test/app/capgo/cordova/updater/SessionKeyRequiredTest.java +344 -0
- package/src/ios/CapgoSemanticVersion.swift +181 -0
- package/src/ios/CapgoUpdater.swift +427 -114
- package/src/ios/CordovaUpdaterPlugin.swift +171 -40
- package/src/ios/CryptoCipher.swift +41 -38
- package/src/ios/DelayUpdateUtils.swift +19 -45
- package/src/ios/Logger.swift +81 -5
- package/src/ios/RedirectPolicyDelegate.swift +53 -0
- package/src/ios/WebViewStatsReporter.swift +5 -0
- package/src/ios/ZipArchiveReader.swift +263 -0
- package/src/ios/ZipCentralDirectory.swift +283 -0
|
@@ -5,8 +5,6 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import Foundation
|
|
8
|
-
import ZIPFoundation
|
|
9
|
-
import Alamofire
|
|
10
8
|
import Compression
|
|
11
9
|
import UIKit
|
|
12
10
|
|
|
@@ -74,10 +72,11 @@ import UIKit
|
|
|
74
72
|
private var statsQueue: [QueuedStatsEvent] = []
|
|
75
73
|
private var statsInFlight: [QueuedStatsEvent] = []
|
|
76
74
|
private let statsQueueLock = NSLock()
|
|
77
|
-
private let statsPersistLock = NSLock()
|
|
78
75
|
private let statsStateLock = NSLock()
|
|
76
|
+
private let statsPersistLock = NSLock()
|
|
79
77
|
private var statsFlushTimer: Timer?
|
|
80
78
|
private var _statsStopped = false
|
|
79
|
+
|
|
81
80
|
private var statsStopped: Bool {
|
|
82
81
|
get {
|
|
83
82
|
statsStateLock.lock()
|
|
@@ -133,7 +132,7 @@ import UIKit
|
|
|
133
132
|
let timedOut: Bool
|
|
134
133
|
}
|
|
135
134
|
|
|
136
|
-
|
|
135
|
+
struct DownloadRequestResult {
|
|
137
136
|
let fileURL: URL?
|
|
138
137
|
let response: HTTPURLResponse?
|
|
139
138
|
let error: Error?
|
|
@@ -147,6 +146,10 @@ import UIKit
|
|
|
147
146
|
case pathTraversal
|
|
148
147
|
}
|
|
149
148
|
|
|
149
|
+
static func containsPathTraversalSegment(_ relativePath: String) -> Bool {
|
|
150
|
+
return relativePath.split(separator: "/").contains(where: { $0 == ".." })
|
|
151
|
+
}
|
|
152
|
+
|
|
150
153
|
static func resolvePathInsideDirectory(baseDirectory: URL, relativePath: String) throws -> URL {
|
|
151
154
|
if relativePath.isEmpty {
|
|
152
155
|
throw SecurePathError.emptyPath
|
|
@@ -154,21 +157,35 @@ import UIKit
|
|
|
154
157
|
if relativePath.contains("\\") || relativePath.contains("\0") {
|
|
155
158
|
throw SecurePathError.windowsPath
|
|
156
159
|
}
|
|
160
|
+
if containsPathTraversalSegment(relativePath) {
|
|
161
|
+
throw SecurePathError.pathTraversal
|
|
162
|
+
}
|
|
157
163
|
if (relativePath as NSString).isAbsolutePath {
|
|
158
164
|
throw SecurePathError.absolutePath
|
|
159
165
|
}
|
|
160
166
|
|
|
161
|
-
let canonicalBase = baseDirectory.standardizedFileURL
|
|
167
|
+
let canonicalBase = baseDirectory.standardizedFileURL.resolvingSymlinksInPath()
|
|
162
168
|
let canonicalBasePath = canonicalBase.path
|
|
163
169
|
let normalizedBasePath = canonicalBasePath.hasSuffix("/") ? canonicalBasePath : "\(canonicalBasePath)/"
|
|
164
|
-
let
|
|
165
|
-
let canonicalTargetPath
|
|
170
|
+
let targetURL = canonicalBase.appendingPathComponent(relativePath, isDirectory: false).standardizedFileURL
|
|
171
|
+
let canonicalTargetPath: String
|
|
172
|
+
if FileManager.default.fileExists(atPath: targetURL.path) {
|
|
173
|
+
canonicalTargetPath = targetURL.resolvingSymlinksInPath().path
|
|
174
|
+
} else {
|
|
175
|
+
canonicalTargetPath = targetURL.path
|
|
176
|
+
}
|
|
166
177
|
|
|
167
|
-
|
|
178
|
+
// Require a strict child of the base. Equality would accept "." and wipe/write the root.
|
|
179
|
+
if !canonicalTargetPath.hasPrefix(normalizedBasePath) {
|
|
168
180
|
throw SecurePathError.pathTraversal
|
|
169
181
|
}
|
|
170
182
|
|
|
171
|
-
return
|
|
183
|
+
return URL(fileURLWithPath: canonicalTargetPath, isDirectory: false)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
static func resolveBundleDirectory(libraryDir: URL, bundleId: String) throws -> URL {
|
|
187
|
+
let bundleRoot = libraryDir.appendingPathComponent("NoCloud/ionic_built_snapshots")
|
|
188
|
+
return try resolvePathInsideDirectory(baseDirectory: bundleRoot, relativePath: bundleId)
|
|
172
189
|
}
|
|
173
190
|
|
|
174
191
|
static func resolveManifestTargetPath(baseDirectory: URL, fileName: String) throws -> URL {
|
|
@@ -177,7 +194,26 @@ import UIKit
|
|
|
177
194
|
return try resolvePathInsideDirectory(baseDirectory: baseDirectory, relativePath: targetFileName)
|
|
178
195
|
}
|
|
179
196
|
|
|
197
|
+
static func rememberManifestTarget(_ seenTargets: inout Set<String>, targetFile: URL) -> Bool {
|
|
198
|
+
return seenTargets.insert(targetFile.standardizedFileURL.path).inserted
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private struct ManifestDownloadTask {
|
|
202
|
+
let fileName: String
|
|
203
|
+
let downloadUrl: String
|
|
204
|
+
let finalFileHash: String
|
|
205
|
+
let isBrotli: Bool
|
|
206
|
+
let destFileName: String
|
|
207
|
+
let destFilePath: URL
|
|
208
|
+
let builtinFilePath: URL
|
|
209
|
+
let cacheFilePath: URL?
|
|
210
|
+
let legacyCacheFilePath: URL?
|
|
211
|
+
}
|
|
212
|
+
|
|
180
213
|
private func isTimedOutError(_ error: Error?) -> Bool {
|
|
214
|
+
if case let .sessionTaskFailed(underlying)? = error as? NetworkError {
|
|
215
|
+
return isTimedOutError(underlying)
|
|
216
|
+
}
|
|
181
217
|
guard let nsError = error as NSError? else {
|
|
182
218
|
return false
|
|
183
219
|
}
|
|
@@ -185,7 +221,51 @@ import UIKit
|
|
|
185
221
|
return nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorTimedOut
|
|
186
222
|
}
|
|
187
223
|
|
|
188
|
-
|
|
224
|
+
/// Errors produced by the URLSession transport. Descriptions intentionally match the messages the
|
|
225
|
+
/// previous Alamofire transport surfaced so logs and JS-facing error strings stay the same.
|
|
226
|
+
enum NetworkError: LocalizedError {
|
|
227
|
+
case sessionTaskFailed(Error)
|
|
228
|
+
case emptyResponse
|
|
229
|
+
case invalidURL(String)
|
|
230
|
+
case bodyEncodingFailed(Error?)
|
|
231
|
+
case downloadedFileMoveFailed(Error, source: URL, destination: URL)
|
|
232
|
+
|
|
233
|
+
var errorDescription: String? {
|
|
234
|
+
switch self {
|
|
235
|
+
case let .sessionTaskFailed(error):
|
|
236
|
+
return "URLSessionTask failed with error: \(error.localizedDescription)"
|
|
237
|
+
case .emptyResponse:
|
|
238
|
+
return "Response could not be serialized, input data was nil or zero length."
|
|
239
|
+
case let .invalidURL(url):
|
|
240
|
+
return "URL is not valid: \(url)"
|
|
241
|
+
case let .bodyEncodingFailed(error):
|
|
242
|
+
return "JSON could not be encoded because of error:\n\(error?.localizedDescription ?? "Invalid JSON object provided for parameter or object encoding.")"
|
|
243
|
+
case let .downloadedFileMoveFailed(error, source, destination):
|
|
244
|
+
return "Moving downloaded file from: \(source) to: \(destination) failed with error: \(error.localizedDescription)"
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/// Mirrors Alamofire's `responseData` serializer: an empty body is an error unless the status allows it.
|
|
250
|
+
static func emptyResponseError(data: Data?, response: HTTPURLResponse?, method: String?) -> Error? {
|
|
251
|
+
if let data, !data.isEmpty {
|
|
252
|
+
return nil
|
|
253
|
+
}
|
|
254
|
+
if method?.uppercased() == "HEAD" || [204, 205].contains(response?.statusCode ?? 0) {
|
|
255
|
+
return nil
|
|
256
|
+
}
|
|
257
|
+
return NetworkError.emptyResponse
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// lazy var is not thread-safe; concurrent manifest downloads can race first access when statsUrl is empty.
|
|
261
|
+
private var cachedUrlSession: URLSession?
|
|
262
|
+
private let urlSessionLock = NSLock()
|
|
263
|
+
private var urlSession: URLSession {
|
|
264
|
+
urlSessionLock.lock()
|
|
265
|
+
defer { urlSessionLock.unlock() }
|
|
266
|
+
if let session = cachedUrlSession {
|
|
267
|
+
return session
|
|
268
|
+
}
|
|
189
269
|
let configuration = URLSessionConfiguration.ephemeral
|
|
190
270
|
configuration.httpAdditionalHeaders = ["User-Agent": self.userAgent]
|
|
191
271
|
configuration.httpCookieStorage = nil
|
|
@@ -193,8 +273,84 @@ import UIKit
|
|
|
193
273
|
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
|
194
274
|
configuration.urlCache = nil
|
|
195
275
|
configuration.httpMaximumConnectionsPerHost = Self.manifestMaxConcurrentFiles
|
|
196
|
-
|
|
197
|
-
|
|
276
|
+
redirectPolicy.onBlockedRedirect = { [weak self] source, target in
|
|
277
|
+
self?.logger?.error("Blocked HTTPS to HTTP redirect; set allowHttpsToHttpRedirect to true to allow it")
|
|
278
|
+
self?.logger?.debug("Redirect from \(source?.absoluteString ?? "") to \(target?.absoluteString ?? "")")
|
|
279
|
+
}
|
|
280
|
+
let session = URLSession(configuration: configuration, delegate: redirectPolicy, delegateQueue: nil)
|
|
281
|
+
cachedUrlSession = session
|
|
282
|
+
return session
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/// Owned separately from `CapgoUpdater` because URLSession retains its delegate strongly.
|
|
286
|
+
private let redirectPolicy = RedirectPolicyDelegate()
|
|
287
|
+
|
|
288
|
+
/// Follow redirects from HTTPS to plain HTTP. Off by default so a redirect can never downgrade updater traffic.
|
|
289
|
+
public var allowHttpsToHttpRedirect: Bool {
|
|
290
|
+
get { redirectPolicy.allowHttpsToHttpRedirect }
|
|
291
|
+
set { redirectPolicy.allowHttpsToHttpRedirect = newValue }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/// Runs a data task and reports `(data, response, error)` like Alamofire's `responseData` did:
|
|
295
|
+
/// `data` is nil when no bytes were received, transport errors are wrapped, and an empty body is an error
|
|
296
|
+
/// unless the status code is 204/205 (or the request is HEAD).
|
|
297
|
+
@discardableResult
|
|
298
|
+
private func startDataTask(
|
|
299
|
+
_ request: URLRequest,
|
|
300
|
+
completionQueue: DispatchQueue,
|
|
301
|
+
completion: @escaping (Data?, HTTPURLResponse?, Error?) -> Void
|
|
302
|
+
) -> URLSessionDataTask {
|
|
303
|
+
let task = self.urlSession.dataTask(with: request) { data, response, error in
|
|
304
|
+
let httpResponse = response as? HTTPURLResponse
|
|
305
|
+
let body = (data?.isEmpty ?? true) ? nil : data
|
|
306
|
+
let resultError: Error?
|
|
307
|
+
if let error {
|
|
308
|
+
resultError = NetworkError.sessionTaskFailed(error)
|
|
309
|
+
} else {
|
|
310
|
+
resultError = Self.emptyResponseError(data: body, response: httpResponse, method: request.httpMethod)
|
|
311
|
+
}
|
|
312
|
+
completionQueue.async {
|
|
313
|
+
completion(body, httpResponse, resultError)
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
task.resume()
|
|
317
|
+
return task
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/// Builds a POST request with a JSON body (same headers as Alamofire's JSON encoders).
|
|
321
|
+
func makeJSONPostRequest(urlString: String, body: () throws -> Data) -> Result<URLRequest, Error> {
|
|
322
|
+
guard let url = URL(string: urlString) else {
|
|
323
|
+
return .failure(NetworkError.invalidURL(urlString))
|
|
324
|
+
}
|
|
325
|
+
var request = URLRequest(url: url)
|
|
326
|
+
request.httpMethod = "POST"
|
|
327
|
+
request.timeoutInterval = self.timeout
|
|
328
|
+
do {
|
|
329
|
+
request.httpBody = try body()
|
|
330
|
+
} catch let error as NetworkError {
|
|
331
|
+
return .failure(error)
|
|
332
|
+
} catch {
|
|
333
|
+
return .failure(NetworkError.bodyEncodingFailed(error))
|
|
334
|
+
}
|
|
335
|
+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
336
|
+
return .success(request)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/// Fire-and-forget JSON POST whose completion runs on the main queue, like Alamofire's default `responseData`.
|
|
340
|
+
private func sendJSONPost(
|
|
341
|
+
urlString: String,
|
|
342
|
+
body: () throws -> Data,
|
|
343
|
+
completion: @escaping (Data?, HTTPURLResponse?, Error?) -> Void
|
|
344
|
+
) {
|
|
345
|
+
switch makeJSONPostRequest(urlString: urlString, body: body) {
|
|
346
|
+
case let .success(request):
|
|
347
|
+
self.startDataTask(request, completionQueue: .main, completion: completion)
|
|
348
|
+
case let .failure(error):
|
|
349
|
+
DispatchQueue.main.async {
|
|
350
|
+
completion(nil, nil, error)
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
198
354
|
private let networkResponseQueue = DispatchQueue(label: "ee.forgr.capacitor-updater.network-response", qos: .utility)
|
|
199
355
|
|
|
200
356
|
public var notifyDownloadRaw: (String, Int, Bool, BundleInfo?) -> Void = { _, _, _, _ in }
|
|
@@ -252,43 +408,69 @@ import UIKit
|
|
|
252
408
|
var responseData: Data?
|
|
253
409
|
var httpResponse: HTTPURLResponse?
|
|
254
410
|
var requestError: Error?
|
|
255
|
-
let
|
|
256
|
-
responseData =
|
|
257
|
-
httpResponse = response
|
|
258
|
-
requestError =
|
|
411
|
+
let dataTask = self.startDataTask(request, completionQueue: self.networkResponseQueue) { data, response, error in
|
|
412
|
+
responseData = data
|
|
413
|
+
httpResponse = response
|
|
414
|
+
requestError = error
|
|
259
415
|
semaphore.signal()
|
|
260
416
|
}
|
|
261
|
-
dataRequest.resume()
|
|
262
417
|
|
|
263
418
|
if semaphore.wait(timeout: .now() + waitTimeout) == .timedOut {
|
|
264
|
-
|
|
419
|
+
dataTask.cancel()
|
|
265
420
|
logger.error("\(label) timed out after \(Int(waitTimeout))s")
|
|
266
421
|
return RequestResult(data: responseData, response: httpResponse, error: requestError, timedOut: true)
|
|
267
422
|
}
|
|
268
423
|
|
|
269
|
-
|
|
424
|
+
// URLSession can report its own timeout (NSURLErrorTimedOut) before the semaphore deadline.
|
|
425
|
+
let timedOut = isTimedOutError(requestError)
|
|
426
|
+
if timedOut {
|
|
427
|
+
logger.error("\(label) timed out after \(Int(request.timeoutInterval))s")
|
|
428
|
+
}
|
|
429
|
+
return RequestResult(data: responseData, response: httpResponse, error: requestError, timedOut: timedOut)
|
|
270
430
|
}
|
|
271
431
|
|
|
272
|
-
|
|
432
|
+
func performDownloadRequest(_ request: URLRequest, label: String) -> DownloadRequestResult {
|
|
273
433
|
let waitTimeout = max(self.timeout + 5, 10)
|
|
274
434
|
let semaphore = DispatchSemaphore(value: 0)
|
|
275
435
|
var tempFileURL: URL?
|
|
276
436
|
var httpResponse: HTTPURLResponse?
|
|
277
437
|
var requestError: Error?
|
|
278
438
|
let temporaryDownloadURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
279
|
-
let
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
439
|
+
let downloadTask = self.urlSession.downloadTask(with: request) { location, response, error in
|
|
440
|
+
// URLSession deletes `location` once this handler returns, so move it synchronously here.
|
|
441
|
+
var movedFileURL: URL?
|
|
442
|
+
var resultError: Error?
|
|
443
|
+
if let error {
|
|
444
|
+
resultError = NetworkError.sessionTaskFailed(error)
|
|
445
|
+
} else if let location {
|
|
446
|
+
do {
|
|
447
|
+
let fileManager = FileManager.default
|
|
448
|
+
try fileManager.createDirectory(
|
|
449
|
+
at: temporaryDownloadURL.deletingLastPathComponent(),
|
|
450
|
+
withIntermediateDirectories: true,
|
|
451
|
+
attributes: nil
|
|
452
|
+
)
|
|
453
|
+
if fileManager.fileExists(atPath: temporaryDownloadURL.path) {
|
|
454
|
+
try fileManager.removeItem(at: temporaryDownloadURL)
|
|
455
|
+
}
|
|
456
|
+
try fileManager.moveItem(at: location, to: temporaryDownloadURL)
|
|
457
|
+
movedFileURL = temporaryDownloadURL
|
|
458
|
+
} catch {
|
|
459
|
+
resultError = NetworkError.downloadedFileMoveFailed(error, source: location, destination: temporaryDownloadURL)
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
let taskResponse = response as? HTTPURLResponse
|
|
463
|
+
self.networkResponseQueue.async {
|
|
464
|
+
tempFileURL = movedFileURL
|
|
465
|
+
httpResponse = taskResponse
|
|
466
|
+
requestError = resultError
|
|
467
|
+
semaphore.signal()
|
|
468
|
+
}
|
|
287
469
|
}
|
|
288
|
-
|
|
470
|
+
downloadTask.resume()
|
|
289
471
|
|
|
290
472
|
if semaphore.wait(timeout: .now() + waitTimeout) == .timedOut {
|
|
291
|
-
|
|
473
|
+
downloadTask.cancel()
|
|
292
474
|
logger.error("\(label) timed out after \(Int(waitTimeout))s")
|
|
293
475
|
return DownloadRequestResult(
|
|
294
476
|
fileURL: existingDownloadFileURL(tempFileURL, fallback: temporaryDownloadURL),
|
|
@@ -415,10 +597,14 @@ import UIKit
|
|
|
415
597
|
|
|
416
598
|
deinit {
|
|
417
599
|
shutdown()
|
|
600
|
+
// Alamofire's Session invalidated its URLSession on deinit; keep releasing the session the same way.
|
|
601
|
+
cachedUrlSession?.invalidateAndCancel()
|
|
418
602
|
}
|
|
419
603
|
|
|
420
604
|
public func shutdown() {
|
|
605
|
+
statsPersistLock.lock()
|
|
421
606
|
statsStopped = true
|
|
607
|
+
statsPersistLock.unlock()
|
|
422
608
|
statsFlushTimer?.invalidate()
|
|
423
609
|
statsFlushTimer = nil
|
|
424
610
|
persistStatsQueue(force: true)
|
|
@@ -433,6 +619,18 @@ import UIKit
|
|
|
433
619
|
return String((0..<length).map { _ in letters.randomElement()! })
|
|
434
620
|
}
|
|
435
621
|
|
|
622
|
+
private func requireSessionKeyForEncryptedUpdate(sessionKey: String, versionName: String? = nil) throws {
|
|
623
|
+
if !self.publicKey.isEmpty && !CryptoCipher.isValidSessionKey(sessionKey) {
|
|
624
|
+
logger.error("Public key present but no valid session key provided")
|
|
625
|
+
self.sendStats(action: "session_key_required", versionName: versionName)
|
|
626
|
+
throw NSError(
|
|
627
|
+
domain: "CapgoUpdater",
|
|
628
|
+
code: 1,
|
|
629
|
+
userInfo: [NSLocalizedDescriptionKey: "Session key required when public key is present"]
|
|
630
|
+
)
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
436
634
|
public func setPublicKey(_ publicKey: String) {
|
|
437
635
|
// Empty string means no encryption - proceed normally
|
|
438
636
|
if publicKey.isEmpty {
|
|
@@ -679,28 +877,27 @@ import UIKit
|
|
|
679
877
|
|
|
680
878
|
// Send synchronously using semaphore (safe because we're on a background queue)
|
|
681
879
|
let semaphore = DispatchSemaphore(value: 0)
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
let statusCode = response
|
|
690
|
-
|
|
691
|
-
case .success where (200...299).contains(statusCode ?? 0):
|
|
692
|
-
self.logger.info("Rate limit statistic sent")
|
|
693
|
-
case .success:
|
|
880
|
+
let parameterValues = parameters.toParameters()
|
|
881
|
+
self.sendJSONPost(urlString: self.statsUrl, body: {
|
|
882
|
+
guard JSONSerialization.isValidJSONObject(parameterValues) else {
|
|
883
|
+
throw NetworkError.bodyEncodingFailed(nil)
|
|
884
|
+
}
|
|
885
|
+
return try JSONSerialization.data(withJSONObject: parameterValues)
|
|
886
|
+
}, completion: { _, response, error in
|
|
887
|
+
let statusCode = response?.statusCode
|
|
888
|
+
if let error {
|
|
694
889
|
CapgoUpdater.releaseRateLimitStatisticClaim()
|
|
695
890
|
self.logger.error("Error sending rate limit statistic")
|
|
696
|
-
self.logger.debug("
|
|
697
|
-
|
|
891
|
+
self.logger.debug("Error: \(error.localizedDescription)")
|
|
892
|
+
} else if (200...299).contains(statusCode ?? 0) {
|
|
893
|
+
self.logger.info("Rate limit statistic sent")
|
|
894
|
+
} else {
|
|
698
895
|
CapgoUpdater.releaseRateLimitStatisticClaim()
|
|
699
896
|
self.logger.error("Error sending rate limit statistic")
|
|
700
|
-
self.logger.debug("
|
|
897
|
+
self.logger.debug("Response code: \(statusCode.map(String.init) ?? "nil")")
|
|
701
898
|
}
|
|
702
899
|
semaphore.signal()
|
|
703
|
-
}
|
|
900
|
+
})
|
|
704
901
|
semaphore.wait()
|
|
705
902
|
}
|
|
706
903
|
|
|
@@ -792,7 +989,10 @@ import UIKit
|
|
|
792
989
|
}
|
|
793
990
|
}
|
|
794
991
|
|
|
795
|
-
|
|
992
|
+
// Symlink targets are paths; anything bigger than this is not a legitimate link.
|
|
993
|
+
private static let maxZipSymlinkTargetBytes = 64 * 1024
|
|
994
|
+
|
|
995
|
+
private func extractZipEntry(_ archive: ZipArchiveReader, entry: ZipEntry, to destPath: URL, bufferSize: Int = CryptoCipher.ioBufferBytes()) throws {
|
|
796
996
|
let fileManager = FileManager.default
|
|
797
997
|
|
|
798
998
|
switch entry.type {
|
|
@@ -815,16 +1015,13 @@ import UIKit
|
|
|
815
1015
|
fileHandle.closeFile()
|
|
816
1016
|
}
|
|
817
1017
|
|
|
818
|
-
|
|
1018
|
+
try archive.extract(entry, bufferSize: bufferSize) { data in
|
|
819
1019
|
if !data.isEmpty {
|
|
820
1020
|
fileHandle.write(data)
|
|
821
1021
|
}
|
|
822
1022
|
}
|
|
823
1023
|
case .symlink:
|
|
824
|
-
|
|
825
|
-
_ = try archive.extract(entry, bufferSize: bufferSize, skipCRC32: true) { data in
|
|
826
|
-
linkData.append(data)
|
|
827
|
-
}
|
|
1024
|
+
let linkData = try archive.readSmallEntry(entry, maxBytes: Self.maxZipSymlinkTargetBytes, bufferSize: bufferSize)
|
|
828
1025
|
|
|
829
1026
|
guard let linkPath = String(data: linkData, encoding: .utf8) else {
|
|
830
1027
|
throw CustomError.cannotUnzip
|
|
@@ -860,9 +1057,9 @@ import UIKit
|
|
|
860
1057
|
self.notifyDownload(id: id, percent: 75)
|
|
861
1058
|
|
|
862
1059
|
// Open the archive
|
|
863
|
-
let archive:
|
|
1060
|
+
let archive: ZipArchiveReader
|
|
864
1061
|
do {
|
|
865
|
-
archive = try
|
|
1062
|
+
archive = try ZipArchiveReader(url: sourceZip)
|
|
866
1063
|
} catch {
|
|
867
1064
|
self.sendStats(action: "unzip_fail")
|
|
868
1065
|
throw CustomError.cannotUnzip
|
|
@@ -872,11 +1069,11 @@ import UIKit
|
|
|
872
1069
|
try FileManager.default.createDirectory(at: destUnZip, withIntermediateDirectories: true, attributes: nil)
|
|
873
1070
|
|
|
874
1071
|
// Count total entries for progress
|
|
875
|
-
let totalEntries = archive.
|
|
1072
|
+
let totalEntries = archive.entries.count
|
|
876
1073
|
var processedEntries = 0
|
|
877
1074
|
|
|
878
1075
|
do {
|
|
879
|
-
for entry in archive {
|
|
1076
|
+
for entry in archive.entries {
|
|
880
1077
|
let destPath = try resolveZipEntry(path: entry.path, destUnZip: destUnZip)
|
|
881
1078
|
|
|
882
1079
|
if entry.type == .directory {
|
|
@@ -939,10 +1136,7 @@ import UIKit
|
|
|
939
1136
|
|
|
940
1137
|
struct ManifestLookupEntry {
|
|
941
1138
|
let hash: String
|
|
942
|
-
/// The manifest's own file name, `.br` suffix included when present.
|
|
943
|
-
/// built-in bundle stores files under this exact name (see
|
|
944
|
-
/// isManifestEntryAvailableLocally), unlike the extracted/cached copy which
|
|
945
|
-
/// is always named without the suffix.
|
|
1139
|
+
/// The manifest's own file name, `.br` suffix included when present.
|
|
946
1140
|
let originalFileName: String
|
|
947
1141
|
}
|
|
948
1142
|
|
|
@@ -967,7 +1161,13 @@ import UIKit
|
|
|
967
1161
|
/// `manifest` must only contain entries the caller already checksum-verified
|
|
968
1162
|
/// (as `downloadManifest` does) — the hashes are trusted as-is, not re-checked.
|
|
969
1163
|
func populateDeltaCache(for id: String, manifest: [ManifestEntry]? = nil, sessionKey: String = "") {
|
|
970
|
-
let bundleDir
|
|
1164
|
+
let bundleDir: URL
|
|
1165
|
+
do {
|
|
1166
|
+
bundleDir = try self.getBundleDirectory(id: id)
|
|
1167
|
+
} catch {
|
|
1168
|
+
logger.debug("Skip delta cache population: invalid bundle id")
|
|
1169
|
+
return
|
|
1170
|
+
}
|
|
971
1171
|
let fileManager = FileManager.default
|
|
972
1172
|
|
|
973
1173
|
guard fileManager.fileExists(atPath: bundleDir.path) else {
|
|
@@ -1005,10 +1205,14 @@ import UIKit
|
|
|
1005
1205
|
|
|
1006
1206
|
// Builtin is already a permanent reuse source (see isManifestEntryAvailableLocally),
|
|
1007
1207
|
// so there's no need to also duplicate this file into the delta cache
|
|
1008
|
-
let
|
|
1009
|
-
let
|
|
1010
|
-
let
|
|
1011
|
-
|
|
1208
|
+
let builtinLookupName = knownEntry?.originalFileName ?? relativePath
|
|
1209
|
+
let isBuiltinOrigin: Bool
|
|
1210
|
+
if let builtinFilePath = try? Self.resolveManifestTargetPath(baseDirectory: builtinFolder, fileName: builtinLookupName) {
|
|
1211
|
+
isBuiltinOrigin = fileManager.fileExists(atPath: builtinFilePath.path) &&
|
|
1212
|
+
verifyChecksum(file: builtinFilePath, expectedHash: checksum)
|
|
1213
|
+
} else {
|
|
1214
|
+
isBuiltinOrigin = false
|
|
1215
|
+
}
|
|
1012
1216
|
if isBuiltinOrigin {
|
|
1013
1217
|
continue
|
|
1014
1218
|
}
|
|
@@ -1223,7 +1427,10 @@ import UIKit
|
|
|
1223
1427
|
guard var fileHash = entry.file_hash, !fileHash.isEmpty else {
|
|
1224
1428
|
return nil
|
|
1225
1429
|
}
|
|
1226
|
-
if !self.publicKey.isEmpty
|
|
1430
|
+
if !self.publicKey.isEmpty {
|
|
1431
|
+
if !CryptoCipher.isValidSessionKey(sessionKey) {
|
|
1432
|
+
return nil
|
|
1433
|
+
}
|
|
1227
1434
|
do {
|
|
1228
1435
|
fileHash = try CryptoCipher.decryptChecksum(checksum: fileHash, publicKey: self.publicKey)
|
|
1229
1436
|
} catch {
|
|
@@ -1242,7 +1449,10 @@ import UIKit
|
|
|
1242
1449
|
}
|
|
1243
1450
|
|
|
1244
1451
|
let builtinFolder = self.builtinFolderURL()
|
|
1245
|
-
|
|
1452
|
+
// The .br suffix describes transport; builtin assets are uncompressed.
|
|
1453
|
+
guard let builtinFilePath = try? Self.resolveManifestTargetPath(baseDirectory: builtinFolder, fileName: fileName) else {
|
|
1454
|
+
return false
|
|
1455
|
+
}
|
|
1246
1456
|
if FileManager.default.fileExists(atPath: builtinFilePath.path) && verifyChecksum(file: builtinFilePath, expectedHash: fileHash) {
|
|
1247
1457
|
return true
|
|
1248
1458
|
}
|
|
@@ -1381,10 +1591,11 @@ import UIKit
|
|
|
1381
1591
|
}
|
|
1382
1592
|
|
|
1383
1593
|
public func downloadManifest(manifest: [ManifestEntry], version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
|
|
1594
|
+
try self.requireSessionKeyForEncryptedUpdate(sessionKey: sessionKey, versionName: version)
|
|
1384
1595
|
try self.runBeforeDownload()
|
|
1385
1596
|
let id = self.randomString(length: 10)
|
|
1386
1597
|
logger.info("downloadManifest start \(id)")
|
|
1387
|
-
let destFolder = self.getBundleDirectory(id: id)
|
|
1598
|
+
let destFolder = try self.getBundleDirectory(id: id)
|
|
1388
1599
|
let builtinFolder = self.builtinFolderURL()
|
|
1389
1600
|
|
|
1390
1601
|
// Check disk space before starting manifest download (estimate 100KB per file, minimum 50MB)
|
|
@@ -1413,8 +1624,8 @@ import UIKit
|
|
|
1413
1624
|
var downloadError: Error?
|
|
1414
1625
|
let errorLock = NSLock()
|
|
1415
1626
|
|
|
1416
|
-
|
|
1417
|
-
var
|
|
1627
|
+
var tasks: [ManifestDownloadTask] = []
|
|
1628
|
+
var seenTargets = Set<String>()
|
|
1418
1629
|
|
|
1419
1630
|
for entry in manifest {
|
|
1420
1631
|
guard let fileName = entry.file_name,
|
|
@@ -1455,7 +1666,23 @@ import UIKit
|
|
|
1455
1666
|
var fileHash = entryFileHash
|
|
1456
1667
|
|
|
1457
1668
|
// Decrypt checksum if needed (done before creating operation)
|
|
1458
|
-
if !self.publicKey.isEmpty
|
|
1669
|
+
if !self.publicKey.isEmpty {
|
|
1670
|
+
if !CryptoCipher.isValidSessionKey(sessionKey) {
|
|
1671
|
+
let error = NSError(
|
|
1672
|
+
domain: "CapgoUpdater",
|
|
1673
|
+
code: 1,
|
|
1674
|
+
userInfo: [NSLocalizedDescriptionKey: "Session key required when public key is present"]
|
|
1675
|
+
)
|
|
1676
|
+
self.sendStats(action: "session_key_required", versionName: version)
|
|
1677
|
+
errorLock.lock()
|
|
1678
|
+
if downloadError == nil {
|
|
1679
|
+
downloadError = error
|
|
1680
|
+
}
|
|
1681
|
+
errorLock.unlock()
|
|
1682
|
+
hasError.value = true
|
|
1683
|
+
logger.error("Public key present but no valid session key provided")
|
|
1684
|
+
continue
|
|
1685
|
+
}
|
|
1459
1686
|
do {
|
|
1460
1687
|
fileHash = try CryptoCipher.decryptChecksum(checksum: fileHash, publicKey: self.publicKey)
|
|
1461
1688
|
} catch {
|
|
@@ -1485,7 +1712,25 @@ import UIKit
|
|
|
1485
1712
|
let builtinFilePath: URL
|
|
1486
1713
|
do {
|
|
1487
1714
|
destFilePath = try Self.resolveManifestTargetPath(baseDirectory: destFolder, fileName: fileName)
|
|
1488
|
-
builtinFilePath = try Self.
|
|
1715
|
+
builtinFilePath = try Self.resolveManifestTargetPath(baseDirectory: builtinFolder, fileName: fileName)
|
|
1716
|
+
if !Self.rememberManifestTarget(&seenTargets, targetFile: destFilePath) {
|
|
1717
|
+
logger.error("Duplicate manifest target path: \(fileName)")
|
|
1718
|
+
self.sendStats(action: "manifest_path_fail", versionName: "\(version):\(fileName)")
|
|
1719
|
+
let error = NSError(
|
|
1720
|
+
domain: "ManifestEntryError",
|
|
1721
|
+
code: 3,
|
|
1722
|
+
userInfo: [
|
|
1723
|
+
NSLocalizedDescriptionKey: "Duplicate manifest target path for \(fileName)"
|
|
1724
|
+
]
|
|
1725
|
+
)
|
|
1726
|
+
errorLock.lock()
|
|
1727
|
+
if downloadError == nil {
|
|
1728
|
+
downloadError = error
|
|
1729
|
+
}
|
|
1730
|
+
errorLock.unlock()
|
|
1731
|
+
hasError.value = true
|
|
1732
|
+
continue
|
|
1733
|
+
}
|
|
1489
1734
|
} catch {
|
|
1490
1735
|
logger.error("Invalid manifest file path: \(fileName)")
|
|
1491
1736
|
self.sendStats(action: "manifest_path_fail", versionName: "\(version):\(fileName)")
|
|
@@ -1498,8 +1743,40 @@ import UIKit
|
|
|
1498
1743
|
continue
|
|
1499
1744
|
}
|
|
1500
1745
|
|
|
1501
|
-
|
|
1502
|
-
|
|
1746
|
+
tasks.append(
|
|
1747
|
+
ManifestDownloadTask(
|
|
1748
|
+
fileName: fileName,
|
|
1749
|
+
downloadUrl: downloadUrl,
|
|
1750
|
+
finalFileHash: finalFileHash,
|
|
1751
|
+
isBrotli: isBrotli,
|
|
1752
|
+
destFileName: destFileName,
|
|
1753
|
+
destFilePath: destFilePath,
|
|
1754
|
+
builtinFilePath: builtinFilePath,
|
|
1755
|
+
cacheFilePath: cacheFilePath,
|
|
1756
|
+
legacyCacheFilePath: legacyCacheFilePath
|
|
1757
|
+
)
|
|
1758
|
+
)
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
if hasError.value {
|
|
1762
|
+
let resolvedError = downloadError ?? NSError(
|
|
1763
|
+
domain: "ManifestDownloadError",
|
|
1764
|
+
code: 1,
|
|
1765
|
+
userInfo: [NSLocalizedDescriptionKey: "Manifest download failed due to invalid or missing entries"]
|
|
1766
|
+
)
|
|
1767
|
+
let errorBundle = bundleInfo.setStatus(status: BundleStatus.ERROR.storedValue)
|
|
1768
|
+
self.saveBundleInfo(id: id, bundle: errorBundle)
|
|
1769
|
+
throw resolvedError
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
var operations: [Operation] = []
|
|
1773
|
+
|
|
1774
|
+
for task in tasks {
|
|
1775
|
+
try FileManager.default.createDirectory(
|
|
1776
|
+
at: task.destFilePath.deletingLastPathComponent(),
|
|
1777
|
+
withIntermediateDirectories: true,
|
|
1778
|
+
attributes: nil
|
|
1779
|
+
)
|
|
1503
1780
|
|
|
1504
1781
|
let operation = BlockOperation { [weak self] in
|
|
1505
1782
|
guard let self = self else { return }
|
|
@@ -1507,26 +1784,29 @@ import UIKit
|
|
|
1507
1784
|
|
|
1508
1785
|
do {
|
|
1509
1786
|
// Try builtin first
|
|
1510
|
-
if FileManager.default.fileExists(atPath: builtinFilePath.path) &&
|
|
1511
|
-
|
|
1512
|
-
self.
|
|
1787
|
+
if FileManager.default.fileExists(atPath: task.builtinFilePath.path) &&
|
|
1788
|
+
self.verifyChecksum(file: task.builtinFilePath, expectedHash: task.finalFileHash) {
|
|
1789
|
+
try self.copyItemReplacing(from: task.builtinFilePath, to: task.destFilePath)
|
|
1790
|
+
self.logger.info("downloadManifest \(task.fileName) using builtin file \(id)")
|
|
1513
1791
|
}
|
|
1514
1792
|
// Try cache
|
|
1515
1793
|
else if
|
|
1516
|
-
(cacheFilePath != nil &&
|
|
1517
|
-
|
|
1518
|
-
|
|
1794
|
+
(task.cacheFilePath != nil &&
|
|
1795
|
+
self.tryCopyFromCache(from: task.cacheFilePath!, to: task.destFilePath, expectedHash: task.finalFileHash)) ||
|
|
1796
|
+
(task.legacyCacheFilePath != nil &&
|
|
1797
|
+
self.tryCopyFromCache(from: task.legacyCacheFilePath!, to: task.destFilePath, expectedHash: task.finalFileHash)) {
|
|
1798
|
+
self.logger.info("downloadManifest \(task.fileName) copy from cache \(id)")
|
|
1519
1799
|
}
|
|
1520
1800
|
// Download
|
|
1521
1801
|
else {
|
|
1522
1802
|
try self.downloadManifestFile(
|
|
1523
|
-
downloadUrl: downloadUrl,
|
|
1524
|
-
destFilePath: destFilePath,
|
|
1525
|
-
cacheFilePath: cacheFilePath,
|
|
1526
|
-
fileHash: finalFileHash,
|
|
1527
|
-
fileName: fileName,
|
|
1528
|
-
destFileName: destFileName,
|
|
1529
|
-
isBrotli: isBrotli,
|
|
1803
|
+
downloadUrl: task.downloadUrl,
|
|
1804
|
+
destFilePath: task.destFilePath,
|
|
1805
|
+
cacheFilePath: task.cacheFilePath,
|
|
1806
|
+
fileHash: task.finalFileHash,
|
|
1807
|
+
fileName: task.fileName,
|
|
1808
|
+
destFileName: task.destFileName,
|
|
1809
|
+
isBrotli: task.isBrotli,
|
|
1530
1810
|
sessionKey: sessionKey,
|
|
1531
1811
|
version: version,
|
|
1532
1812
|
bundleId: id
|
|
@@ -1544,8 +1824,8 @@ import UIKit
|
|
|
1544
1824
|
}
|
|
1545
1825
|
errorLock.unlock()
|
|
1546
1826
|
hasError.value = true
|
|
1547
|
-
self.logger.error("Manifest file download failed: \(fileName)")
|
|
1548
|
-
self.logger.debug("Bundle: \(id), File: \(fileName), Error: \(error.localizedDescription)")
|
|
1827
|
+
self.logger.error("Manifest file download failed: \(task.fileName)")
|
|
1828
|
+
self.logger.debug("Bundle: \(id), File: \(task.fileName), Error: \(error.localizedDescription)")
|
|
1549
1829
|
}
|
|
1550
1830
|
}
|
|
1551
1831
|
|
|
@@ -1682,7 +1962,7 @@ import UIKit
|
|
|
1682
1962
|
|
|
1683
1963
|
do {
|
|
1684
1964
|
var source = partialURL
|
|
1685
|
-
if !self.publicKey.isEmpty &&
|
|
1965
|
+
if !self.publicKey.isEmpty && CryptoCipher.isValidSessionKey(sessionKey) {
|
|
1686
1966
|
let work = cacheFolder.appendingPathComponent("work_\(UUID().uuidString)_\((fileName as NSString).lastPathComponent)")
|
|
1687
1967
|
try FileManager.default.copyItem(at: partialURL, to: work)
|
|
1688
1968
|
workURL = work
|
|
@@ -1984,6 +2264,7 @@ import UIKit
|
|
|
1984
2264
|
}
|
|
1985
2265
|
|
|
1986
2266
|
public func download(url: URL, version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
|
|
2267
|
+
try self.requireSessionKeyForEncryptedUpdate(sessionKey: sessionKey, versionName: version)
|
|
1987
2268
|
try self.runBeforeDownload()
|
|
1988
2269
|
let id: String = self.randomString(length: 10)
|
|
1989
2270
|
// Each download uses its own temp files keyed by bundle ID to prevent collisions
|
|
@@ -2026,6 +2307,12 @@ import UIKit
|
|
|
2026
2307
|
}
|
|
2027
2308
|
|
|
2028
2309
|
let downloadResult = performDownloadRequest(request, label: "download \(version)")
|
|
2310
|
+
// Error responses (e.g. an HTTP 404 body) are also saved to a temp file; never leave them behind.
|
|
2311
|
+
defer {
|
|
2312
|
+
if let fileURL = downloadResult.fileURL {
|
|
2313
|
+
try? FileManager.default.removeItem(at: fileURL)
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2029
2316
|
|
|
2030
2317
|
if downloadResult.timedOut {
|
|
2031
2318
|
persistPartialDownload(downloadResult, id: id, tempPath: tempPath, existingBytes: totalReceivedBytes)
|
|
@@ -2264,6 +2551,15 @@ import UIKit
|
|
|
2264
2551
|
self.deleteLock.lock()
|
|
2265
2552
|
defer { self.deleteLock.unlock() }
|
|
2266
2553
|
|
|
2554
|
+
let destPersist: URL
|
|
2555
|
+
do {
|
|
2556
|
+
destPersist = try self.getBundleDirectory(id: id)
|
|
2557
|
+
} catch {
|
|
2558
|
+
logger.error("Cannot delete bundle with invalid id")
|
|
2559
|
+
logger.debug("Bundle ID: \(id), Error: \(error.localizedDescription)")
|
|
2560
|
+
return false
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2267
2563
|
let deleted: BundleInfo = self.getBundleInfo(id: id)
|
|
2268
2564
|
if deleted.isBuiltin() || self.getCurrentBundleId() == id {
|
|
2269
2565
|
logger.info("Cannot delete current or builtin bundle")
|
|
@@ -2292,7 +2588,6 @@ import UIKit
|
|
|
2292
2588
|
return false
|
|
2293
2589
|
}
|
|
2294
2590
|
|
|
2295
|
-
let destPersist: URL = libraryDir.appendingPathComponent(bundleDirectory).appendingPathComponent(id)
|
|
2296
2591
|
let hadRegistry = self.hasStoredBundleInfo(id: id)
|
|
2297
2592
|
let hadFolder = FileManager.default.fileExists(atPath: destPersist.path)
|
|
2298
2593
|
if !hadRegistry && !hadFolder {
|
|
@@ -2596,8 +2891,8 @@ import UIKit
|
|
|
2596
2891
|
}
|
|
2597
2892
|
}
|
|
2598
2893
|
|
|
2599
|
-
public func getBundleDirectory(id: String) -> URL {
|
|
2600
|
-
return
|
|
2894
|
+
public func getBundleDirectory(id: String) throws -> URL {
|
|
2895
|
+
return try Self.resolveBundleDirectory(libraryDir: libraryDir, bundleId: id)
|
|
2601
2896
|
}
|
|
2602
2897
|
|
|
2603
2898
|
struct ResetState {
|
|
@@ -2651,7 +2946,12 @@ import UIKit
|
|
|
2651
2946
|
}
|
|
2652
2947
|
|
|
2653
2948
|
private func bundleExists(id: String) -> Bool {
|
|
2654
|
-
let destPersist: URL
|
|
2949
|
+
let destPersist: URL
|
|
2950
|
+
do {
|
|
2951
|
+
destPersist = try self.getBundleDirectory(id: id)
|
|
2952
|
+
} catch {
|
|
2953
|
+
return false
|
|
2954
|
+
}
|
|
2655
2955
|
let indexPersist: URL = destPersist.appendingPathComponent("index.html")
|
|
2656
2956
|
let bundleIndo: BundleInfo = self.getBundleInfo(id: id)
|
|
2657
2957
|
if
|
|
@@ -2674,7 +2974,12 @@ import UIKit
|
|
|
2674
2974
|
}
|
|
2675
2975
|
if bundleExists(id: id) {
|
|
2676
2976
|
let currentBundleName = self.getCurrentBundle().getVersionName()
|
|
2677
|
-
|
|
2977
|
+
guard let bundleDir = try? self.getBundleDirectory(id: id) else {
|
|
2978
|
+
self.setBundleStatus(id: id, status: BundleStatus.ERROR)
|
|
2979
|
+
self.sendStats(action: "set_fail", versionName: newBundle.getVersionName())
|
|
2980
|
+
return false
|
|
2981
|
+
}
|
|
2982
|
+
self.setCurrentBundle(bundle: bundleDir.path)
|
|
2678
2983
|
self.setBundleStatus(id: id, status: BundleStatus.PENDING)
|
|
2679
2984
|
self.sendStats(action: "set", versionName: newBundle.getVersionName(), oldVersionName: currentBundleName)
|
|
2680
2985
|
return true
|
|
@@ -2688,7 +2993,10 @@ import UIKit
|
|
|
2688
2993
|
guard !bundle.isBuiltin(), bundleExists(id: bundle.getId()) else {
|
|
2689
2994
|
return false
|
|
2690
2995
|
}
|
|
2691
|
-
|
|
2996
|
+
guard let bundleDir = try? self.getBundleDirectory(id: bundle.getId()) else {
|
|
2997
|
+
return false
|
|
2998
|
+
}
|
|
2999
|
+
self.setCurrentBundle(bundle: bundleDir.path)
|
|
2692
3000
|
return true
|
|
2693
3001
|
}
|
|
2694
3002
|
|
|
@@ -2703,7 +3011,10 @@ import UIKit
|
|
|
2703
3011
|
guard bundleExists(id: bundle.getId()) else {
|
|
2704
3012
|
return false
|
|
2705
3013
|
}
|
|
2706
|
-
|
|
3014
|
+
guard let bundleDir = try? self.getBundleDirectory(id: bundle.getId()) else {
|
|
3015
|
+
return false
|
|
3016
|
+
}
|
|
3017
|
+
self.setCurrentBundle(bundle: bundleDir.path)
|
|
2707
3018
|
return true
|
|
2708
3019
|
}
|
|
2709
3020
|
|
|
@@ -2789,9 +3100,16 @@ import UIKit
|
|
|
2789
3100
|
self.setBundleStatus(id: bundle.getId(), status: BundleStatus.ERROR)
|
|
2790
3101
|
}
|
|
2791
3102
|
|
|
2792
|
-
func unsetChannel(defaultChannelKey: String, configDefaultChannel: String) -> SetChannel {
|
|
3103
|
+
func unsetChannel(defaultChannelKey: String, configDefaultChannel: String, allowSetDefaultChannel: Bool) -> SetChannel {
|
|
2793
3104
|
let setChannel: SetChannel = SetChannel()
|
|
2794
3105
|
|
|
3106
|
+
if !allowSetDefaultChannel {
|
|
3107
|
+
logger.error("unsetChannel is disabled by allowSetDefaultChannel config")
|
|
3108
|
+
setChannel.message = "unsetChannel is disabled by configuration"
|
|
3109
|
+
setChannel.error = "disabled_by_config"
|
|
3110
|
+
return setChannel
|
|
3111
|
+
}
|
|
3112
|
+
|
|
2795
3113
|
// Clear persisted defaultChannel and revert to config value
|
|
2796
3114
|
UserDefaults.standard.removeObject(forKey: defaultChannelKey)
|
|
2797
3115
|
UserDefaults.standard.synchronize()
|
|
@@ -3312,24 +3630,20 @@ import UIKit
|
|
|
3312
3630
|
|
|
3313
3631
|
let operation = BlockOperation {
|
|
3314
3632
|
let semaphore = DispatchSemaphore(value: 0)
|
|
3315
|
-
self.
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
parameters: eventsToSend,
|
|
3319
|
-
encoder: JSONParameterEncoder.default,
|
|
3320
|
-
requestModifier: { $0.timeoutInterval = self.timeout }
|
|
3321
|
-
).responseData { response in
|
|
3633
|
+
self.sendJSONPost(urlString: self.statsUrl, body: {
|
|
3634
|
+
try JSONEncoder().encode(eventsToSend)
|
|
3635
|
+
}, completion: { responseData, httpResponse, responseError in
|
|
3322
3636
|
if self.abandonStoppedStatsFlush() {
|
|
3323
3637
|
semaphore.signal()
|
|
3324
3638
|
return
|
|
3325
3639
|
}
|
|
3326
|
-
if self.checkAndHandleRateLimitResponse(statusCode:
|
|
3640
|
+
if self.checkAndHandleRateLimitResponse(statusCode: httpResponse?.statusCode, data: responseData, response: httpResponse).blocked {
|
|
3327
3641
|
self.requeueStatsEvents(queuedEvents)
|
|
3328
3642
|
semaphore.signal()
|
|
3329
3643
|
return
|
|
3330
3644
|
}
|
|
3331
3645
|
|
|
3332
|
-
if let statusCode =
|
|
3646
|
+
if let statusCode = httpResponse?.statusCode, !(200...299).contains(statusCode) {
|
|
3333
3647
|
if CapgoUpdater.isTransientStatsFailure(statusCode) {
|
|
3334
3648
|
self.requeueStatsEvents(queuedEvents)
|
|
3335
3649
|
self.logger.error("Error sending stats batch")
|
|
@@ -3343,19 +3657,18 @@ import UIKit
|
|
|
3343
3657
|
return
|
|
3344
3658
|
}
|
|
3345
3659
|
|
|
3346
|
-
|
|
3347
|
-
|
|
3660
|
+
if let error = responseError {
|
|
3661
|
+
self.requeueStatsEvents(queuedEvents)
|
|
3662
|
+
self.logger.error("Error sending stats batch")
|
|
3663
|
+
self.logger.debug("Response: nil, Error: \(error.localizedDescription)")
|
|
3664
|
+
} else {
|
|
3348
3665
|
self.clearStatsInFlight()
|
|
3349
3666
|
self.logger.info("Stats batch sent successfully")
|
|
3350
3667
|
self.logger.debug("Sent \(eventsToSend.count) events")
|
|
3351
3668
|
self.runStatsCallbacks(queuedEvents)
|
|
3352
|
-
case let .failure(error):
|
|
3353
|
-
self.requeueStatsEvents(queuedEvents)
|
|
3354
|
-
self.logger.error("Error sending stats batch")
|
|
3355
|
-
self.logger.debug("Response: \(response.value?.debugDescription ?? "nil"), Error: \(error.localizedDescription)")
|
|
3356
3669
|
}
|
|
3357
3670
|
semaphore.signal()
|
|
3358
|
-
}
|
|
3671
|
+
})
|
|
3359
3672
|
semaphore.wait()
|
|
3360
3673
|
if !self.statsStopped {
|
|
3361
3674
|
self.persistStatsQueue()
|