@capgo/capacitor-updater 8.51.24 → 8.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CapgoCapacitorUpdater.podspec +0 -3
- package/Package.swift +3 -11
- package/README.md +4 -1
- package/android/build.gradle +0 -2
- package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +183 -31
- package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +70 -1
- package/android/src/main/java/ee/forgr/capacitor_updater/DelayUpdateUtils.java +0 -1
- package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +80 -21
- package/android/src/main/java/ee/forgr/capacitor_updater/Version.java +306 -0
- package/dist/docs.json +16 -0
- package/dist/esm/definitions.d.ts +14 -0
- package/dist/esm/definitions.js.map +1 -1
- package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +113 -41
- package/ios/Sources/CapacitorUpdaterPlugin/CapgoSemanticVersion.swift +162 -0
- package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +226 -72
- package/ios/Sources/CapacitorUpdaterPlugin/DelayUpdateUtils.swift +3 -4
- package/ios/Sources/CapacitorUpdaterPlugin/RedirectPolicyDelegate.swift +53 -0
- package/ios/Sources/CapacitorUpdaterPlugin/ZipArchiveReader.swift +247 -0
- package/ios/Sources/CapacitorUpdaterPlugin/ZipCentralDirectory.swift +280 -0
- package/package.json +1 -1
|
@@ -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
|
|
|
@@ -120,7 +118,7 @@ import UIKit
|
|
|
120
118
|
let timedOut: Bool
|
|
121
119
|
}
|
|
122
120
|
|
|
123
|
-
|
|
121
|
+
struct DownloadRequestResult {
|
|
124
122
|
let fileURL: URL?
|
|
125
123
|
let response: HTTPURLResponse?
|
|
126
124
|
let error: Error?
|
|
@@ -194,6 +192,9 @@ import UIKit
|
|
|
194
192
|
}
|
|
195
193
|
|
|
196
194
|
private func isTimedOutError(_ error: Error?) -> Bool {
|
|
195
|
+
if case let .sessionTaskFailed(underlying)? = error as? NetworkError {
|
|
196
|
+
return isTimedOutError(underlying)
|
|
197
|
+
}
|
|
197
198
|
guard let nsError = error as NSError? else {
|
|
198
199
|
return false
|
|
199
200
|
}
|
|
@@ -201,13 +202,49 @@ import UIKit
|
|
|
201
202
|
return nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorTimedOut
|
|
202
203
|
}
|
|
203
204
|
|
|
205
|
+
/// Errors produced by the URLSession transport. Descriptions intentionally match the messages the
|
|
206
|
+
/// previous Alamofire transport surfaced so logs and JS-facing error strings stay the same.
|
|
207
|
+
enum NetworkError: LocalizedError {
|
|
208
|
+
case sessionTaskFailed(Error)
|
|
209
|
+
case emptyResponse
|
|
210
|
+
case invalidURL(String)
|
|
211
|
+
case bodyEncodingFailed(Error?)
|
|
212
|
+
case downloadedFileMoveFailed(Error, source: URL, destination: URL)
|
|
213
|
+
|
|
214
|
+
var errorDescription: String? {
|
|
215
|
+
switch self {
|
|
216
|
+
case let .sessionTaskFailed(error):
|
|
217
|
+
return "URLSessionTask failed with error: \(error.localizedDescription)"
|
|
218
|
+
case .emptyResponse:
|
|
219
|
+
return "Response could not be serialized, input data was nil or zero length."
|
|
220
|
+
case let .invalidURL(url):
|
|
221
|
+
return "URL is not valid: \(url)"
|
|
222
|
+
case let .bodyEncodingFailed(error):
|
|
223
|
+
return "JSON could not be encoded because of error:\n\(error?.localizedDescription ?? "Invalid JSON object provided for parameter or object encoding.")"
|
|
224
|
+
case let .downloadedFileMoveFailed(error, source, destination):
|
|
225
|
+
return "Moving downloaded file from: \(source) to: \(destination) failed with error: \(error.localizedDescription)"
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/// Mirrors Alamofire's `responseData` serializer: an empty body is an error unless the status allows it.
|
|
231
|
+
static func emptyResponseError(data: Data?, response: HTTPURLResponse?, method: String?) -> Error? {
|
|
232
|
+
if let data, !data.isEmpty {
|
|
233
|
+
return nil
|
|
234
|
+
}
|
|
235
|
+
if method?.uppercased() == "HEAD" || [204, 205].contains(response?.statusCode ?? 0) {
|
|
236
|
+
return nil
|
|
237
|
+
}
|
|
238
|
+
return NetworkError.emptyResponse
|
|
239
|
+
}
|
|
240
|
+
|
|
204
241
|
// lazy var is not thread-safe; concurrent manifest downloads can race first access when statsUrl is empty.
|
|
205
|
-
private var
|
|
206
|
-
private let
|
|
207
|
-
private var
|
|
208
|
-
|
|
209
|
-
defer {
|
|
210
|
-
if let session =
|
|
242
|
+
private var cachedUrlSession: URLSession?
|
|
243
|
+
private let urlSessionLock = NSLock()
|
|
244
|
+
private var urlSession: URLSession {
|
|
245
|
+
urlSessionLock.lock()
|
|
246
|
+
defer { urlSessionLock.unlock() }
|
|
247
|
+
if let session = cachedUrlSession {
|
|
211
248
|
return session
|
|
212
249
|
}
|
|
213
250
|
let configuration = URLSessionConfiguration.ephemeral
|
|
@@ -217,10 +254,92 @@ import UIKit
|
|
|
217
254
|
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
|
218
255
|
configuration.urlCache = nil
|
|
219
256
|
configuration.httpMaximumConnectionsPerHost = Self.manifestMaxConcurrentFiles
|
|
220
|
-
|
|
221
|
-
|
|
257
|
+
redirectPolicy.onBlockedRedirect = { [weak self] source, target in
|
|
258
|
+
self?.logger?.error("Blocked HTTPS to HTTP redirect; set allowHttpsToHttpRedirect to true to allow it")
|
|
259
|
+
self?.logger?.debug("Redirect from \(source?.absoluteString ?? "") to \(target?.absoluteString ?? "")")
|
|
260
|
+
}
|
|
261
|
+
let session = URLSession(configuration: configuration, delegate: redirectPolicy, delegateQueue: nil)
|
|
262
|
+
cachedUrlSession = session
|
|
222
263
|
return session
|
|
223
264
|
}
|
|
265
|
+
|
|
266
|
+
/// Owned separately from `CapgoUpdater` because URLSession retains its delegate strongly.
|
|
267
|
+
private let redirectPolicy = RedirectPolicyDelegate()
|
|
268
|
+
|
|
269
|
+
/// Follow redirects from HTTPS to plain HTTP. Off by default so a redirect can never downgrade updater traffic.
|
|
270
|
+
public var allowHttpsToHttpRedirect: Bool {
|
|
271
|
+
get { redirectPolicy.allowHttpsToHttpRedirect }
|
|
272
|
+
set { redirectPolicy.allowHttpsToHttpRedirect = newValue }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/// Runs a raw data task on the updater session (no cookies, no cache, redirect policy applied).
|
|
276
|
+
@discardableResult
|
|
277
|
+
func startRawDataTask(_ request: URLRequest, completion: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask {
|
|
278
|
+
let task = self.urlSession.dataTask(with: request, completionHandler: completion)
|
|
279
|
+
task.resume()
|
|
280
|
+
return task
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/// Runs a data task and reports `(data, response, error)` like Alamofire's `responseData` did:
|
|
284
|
+
/// `data` is nil when no bytes were received, transport errors are wrapped, and an empty body is an error
|
|
285
|
+
/// unless the status code is 204/205 (or the request is HEAD).
|
|
286
|
+
@discardableResult
|
|
287
|
+
private func startDataTask(
|
|
288
|
+
_ request: URLRequest,
|
|
289
|
+
completionQueue: DispatchQueue,
|
|
290
|
+
completion: @escaping (Data?, HTTPURLResponse?, Error?) -> Void
|
|
291
|
+
) -> URLSessionDataTask {
|
|
292
|
+
let task = self.urlSession.dataTask(with: request) { data, response, error in
|
|
293
|
+
let httpResponse = response as? HTTPURLResponse
|
|
294
|
+
let body = (data?.isEmpty ?? true) ? nil : data
|
|
295
|
+
let resultError: Error?
|
|
296
|
+
if let error {
|
|
297
|
+
resultError = NetworkError.sessionTaskFailed(error)
|
|
298
|
+
} else {
|
|
299
|
+
resultError = Self.emptyResponseError(data: body, response: httpResponse, method: request.httpMethod)
|
|
300
|
+
}
|
|
301
|
+
completionQueue.async {
|
|
302
|
+
completion(body, httpResponse, resultError)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
task.resume()
|
|
306
|
+
return task
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/// Builds a POST request with a JSON body (same headers as Alamofire's JSON encoders).
|
|
310
|
+
func makeJSONPostRequest(urlString: String, body: () throws -> Data) -> Result<URLRequest, Error> {
|
|
311
|
+
guard let url = URL(string: urlString) else {
|
|
312
|
+
return .failure(NetworkError.invalidURL(urlString))
|
|
313
|
+
}
|
|
314
|
+
var request = URLRequest(url: url)
|
|
315
|
+
request.httpMethod = "POST"
|
|
316
|
+
request.timeoutInterval = self.timeout
|
|
317
|
+
do {
|
|
318
|
+
request.httpBody = try body()
|
|
319
|
+
} catch let error as NetworkError {
|
|
320
|
+
return .failure(error)
|
|
321
|
+
} catch {
|
|
322
|
+
return .failure(NetworkError.bodyEncodingFailed(error))
|
|
323
|
+
}
|
|
324
|
+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
325
|
+
return .success(request)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/// Fire-and-forget JSON POST whose completion runs on the main queue, like Alamofire's default `responseData`.
|
|
329
|
+
private func sendJSONPost(
|
|
330
|
+
urlString: String,
|
|
331
|
+
body: () throws -> Data,
|
|
332
|
+
completion: @escaping (Data?, HTTPURLResponse?, Error?) -> Void
|
|
333
|
+
) {
|
|
334
|
+
switch makeJSONPostRequest(urlString: urlString, body: body) {
|
|
335
|
+
case let .success(request):
|
|
336
|
+
self.startDataTask(request, completionQueue: .main, completion: completion)
|
|
337
|
+
case let .failure(error):
|
|
338
|
+
DispatchQueue.main.async {
|
|
339
|
+
completion(nil, nil, error)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
224
343
|
private let networkResponseQueue = DispatchQueue(label: "ee.forgr.capacitor-updater.network-response", qos: .utility)
|
|
225
344
|
|
|
226
345
|
public var notifyDownloadRaw: (String, Int, Bool, BundleInfo?) -> Void = { _, _, _, _ in }
|
|
@@ -278,43 +397,69 @@ import UIKit
|
|
|
278
397
|
var responseData: Data?
|
|
279
398
|
var httpResponse: HTTPURLResponse?
|
|
280
399
|
var requestError: Error?
|
|
281
|
-
let
|
|
282
|
-
responseData =
|
|
283
|
-
httpResponse = response
|
|
284
|
-
requestError =
|
|
400
|
+
let dataTask = self.startDataTask(request, completionQueue: self.networkResponseQueue) { data, response, error in
|
|
401
|
+
responseData = data
|
|
402
|
+
httpResponse = response
|
|
403
|
+
requestError = error
|
|
285
404
|
semaphore.signal()
|
|
286
405
|
}
|
|
287
|
-
dataRequest.resume()
|
|
288
406
|
|
|
289
407
|
if semaphore.wait(timeout: .now() + waitTimeout) == .timedOut {
|
|
290
|
-
|
|
408
|
+
dataTask.cancel()
|
|
291
409
|
logger.error("\(label) timed out after \(Int(waitTimeout))s")
|
|
292
410
|
return RequestResult(data: responseData, response: httpResponse, error: requestError, timedOut: true)
|
|
293
411
|
}
|
|
294
412
|
|
|
295
|
-
|
|
413
|
+
// URLSession can report its own timeout (NSURLErrorTimedOut) before the semaphore deadline.
|
|
414
|
+
let timedOut = isTimedOutError(requestError)
|
|
415
|
+
if timedOut {
|
|
416
|
+
logger.error("\(label) timed out after \(Int(request.timeoutInterval))s")
|
|
417
|
+
}
|
|
418
|
+
return RequestResult(data: responseData, response: httpResponse, error: requestError, timedOut: timedOut)
|
|
296
419
|
}
|
|
297
420
|
|
|
298
|
-
|
|
421
|
+
func performDownloadRequest(_ request: URLRequest, label: String) -> DownloadRequestResult {
|
|
299
422
|
let waitTimeout = max(self.timeout + 5, 10)
|
|
300
423
|
let semaphore = DispatchSemaphore(value: 0)
|
|
301
424
|
var tempFileURL: URL?
|
|
302
425
|
var httpResponse: HTTPURLResponse?
|
|
303
426
|
var requestError: Error?
|
|
304
427
|
let temporaryDownloadURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
305
|
-
let
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
428
|
+
let downloadTask = self.urlSession.downloadTask(with: request) { location, response, error in
|
|
429
|
+
// URLSession deletes `location` once this handler returns, so move it synchronously here.
|
|
430
|
+
var movedFileURL: URL?
|
|
431
|
+
var resultError: Error?
|
|
432
|
+
if let error {
|
|
433
|
+
resultError = NetworkError.sessionTaskFailed(error)
|
|
434
|
+
} else if let location {
|
|
435
|
+
do {
|
|
436
|
+
let fileManager = FileManager.default
|
|
437
|
+
try fileManager.createDirectory(
|
|
438
|
+
at: temporaryDownloadURL.deletingLastPathComponent(),
|
|
439
|
+
withIntermediateDirectories: true,
|
|
440
|
+
attributes: nil
|
|
441
|
+
)
|
|
442
|
+
if fileManager.fileExists(atPath: temporaryDownloadURL.path) {
|
|
443
|
+
try fileManager.removeItem(at: temporaryDownloadURL)
|
|
444
|
+
}
|
|
445
|
+
try fileManager.moveItem(at: location, to: temporaryDownloadURL)
|
|
446
|
+
movedFileURL = temporaryDownloadURL
|
|
447
|
+
} catch {
|
|
448
|
+
resultError = NetworkError.downloadedFileMoveFailed(error, source: location, destination: temporaryDownloadURL)
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
let taskResponse = response as? HTTPURLResponse
|
|
452
|
+
self.networkResponseQueue.async {
|
|
453
|
+
tempFileURL = movedFileURL
|
|
454
|
+
httpResponse = taskResponse
|
|
455
|
+
requestError = resultError
|
|
456
|
+
semaphore.signal()
|
|
457
|
+
}
|
|
313
458
|
}
|
|
314
|
-
|
|
459
|
+
downloadTask.resume()
|
|
315
460
|
|
|
316
461
|
if semaphore.wait(timeout: .now() + waitTimeout) == .timedOut {
|
|
317
|
-
|
|
462
|
+
downloadTask.cancel()
|
|
318
463
|
logger.error("\(label) timed out after \(Int(waitTimeout))s")
|
|
319
464
|
return DownloadRequestResult(
|
|
320
465
|
fileURL: existingDownloadFileURL(tempFileURL, fallback: temporaryDownloadURL),
|
|
@@ -441,6 +586,8 @@ import UIKit
|
|
|
441
586
|
|
|
442
587
|
deinit {
|
|
443
588
|
shutdown()
|
|
589
|
+
// Alamofire's Session invalidated its URLSession on deinit; keep releasing the session the same way.
|
|
590
|
+
cachedUrlSession?.invalidateAndCancel()
|
|
444
591
|
}
|
|
445
592
|
|
|
446
593
|
public func shutdown() {
|
|
@@ -719,28 +866,27 @@ import UIKit
|
|
|
719
866
|
|
|
720
867
|
// Send synchronously using semaphore (safe because we're on a background queue)
|
|
721
868
|
let semaphore = DispatchSemaphore(value: 0)
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
let statusCode = response
|
|
730
|
-
|
|
731
|
-
case .success where (200...299).contains(statusCode ?? 0):
|
|
732
|
-
self.logger.info("Rate limit statistic sent")
|
|
733
|
-
case .success:
|
|
869
|
+
let parameterValues = parameters.toParameters()
|
|
870
|
+
self.sendJSONPost(urlString: self.statsUrl, body: {
|
|
871
|
+
guard JSONSerialization.isValidJSONObject(parameterValues) else {
|
|
872
|
+
throw NetworkError.bodyEncodingFailed(nil)
|
|
873
|
+
}
|
|
874
|
+
return try JSONSerialization.data(withJSONObject: parameterValues)
|
|
875
|
+
}, completion: { _, response, error in
|
|
876
|
+
let statusCode = response?.statusCode
|
|
877
|
+
if let error {
|
|
734
878
|
CapgoUpdater.releaseRateLimitStatisticClaim()
|
|
735
879
|
self.logger.error("Error sending rate limit statistic")
|
|
736
|
-
self.logger.debug("
|
|
737
|
-
|
|
880
|
+
self.logger.debug("Error: \(error.localizedDescription)")
|
|
881
|
+
} else if (200...299).contains(statusCode ?? 0) {
|
|
882
|
+
self.logger.info("Rate limit statistic sent")
|
|
883
|
+
} else {
|
|
738
884
|
CapgoUpdater.releaseRateLimitStatisticClaim()
|
|
739
885
|
self.logger.error("Error sending rate limit statistic")
|
|
740
|
-
self.logger.debug("
|
|
886
|
+
self.logger.debug("Response code: \(statusCode.map(String.init) ?? "nil")")
|
|
741
887
|
}
|
|
742
888
|
semaphore.signal()
|
|
743
|
-
}
|
|
889
|
+
})
|
|
744
890
|
semaphore.wait()
|
|
745
891
|
}
|
|
746
892
|
|
|
@@ -832,7 +978,10 @@ import UIKit
|
|
|
832
978
|
}
|
|
833
979
|
}
|
|
834
980
|
|
|
835
|
-
|
|
981
|
+
// Symlink targets are paths; anything bigger than this is not a legitimate link.
|
|
982
|
+
private static let maxZipSymlinkTargetBytes = 64 * 1024
|
|
983
|
+
|
|
984
|
+
private func extractZipEntry(_ archive: ZipArchiveReader, entry: ZipEntry, to destPath: URL, bufferSize: Int = CryptoCipher.ioBufferBytes()) throws {
|
|
836
985
|
let fileManager = FileManager.default
|
|
837
986
|
|
|
838
987
|
switch entry.type {
|
|
@@ -855,16 +1004,13 @@ import UIKit
|
|
|
855
1004
|
fileHandle.closeFile()
|
|
856
1005
|
}
|
|
857
1006
|
|
|
858
|
-
|
|
1007
|
+
try archive.extract(entry, bufferSize: bufferSize) { data in
|
|
859
1008
|
if !data.isEmpty {
|
|
860
1009
|
fileHandle.write(data)
|
|
861
1010
|
}
|
|
862
1011
|
}
|
|
863
1012
|
case .symlink:
|
|
864
|
-
|
|
865
|
-
_ = try archive.extract(entry, bufferSize: bufferSize, skipCRC32: true) { data in
|
|
866
|
-
linkData.append(data)
|
|
867
|
-
}
|
|
1013
|
+
let linkData = try archive.readSmallEntry(entry, maxBytes: Self.maxZipSymlinkTargetBytes, bufferSize: bufferSize)
|
|
868
1014
|
|
|
869
1015
|
guard let linkPath = String(data: linkData, encoding: .utf8) else {
|
|
870
1016
|
throw CustomError.cannotUnzip
|
|
@@ -900,9 +1046,9 @@ import UIKit
|
|
|
900
1046
|
self.notifyDownload(id: id, percent: 75)
|
|
901
1047
|
|
|
902
1048
|
// Open the archive
|
|
903
|
-
let archive:
|
|
1049
|
+
let archive: ZipArchiveReader
|
|
904
1050
|
do {
|
|
905
|
-
archive = try
|
|
1051
|
+
archive = try ZipArchiveReader(url: sourceZip)
|
|
906
1052
|
} catch {
|
|
907
1053
|
self.sendStats(action: "unzip_fail")
|
|
908
1054
|
throw CustomError.cannotUnzip
|
|
@@ -912,11 +1058,11 @@ import UIKit
|
|
|
912
1058
|
try FileManager.default.createDirectory(at: destUnZip, withIntermediateDirectories: true, attributes: nil)
|
|
913
1059
|
|
|
914
1060
|
// Count total entries for progress
|
|
915
|
-
let totalEntries = archive.
|
|
1061
|
+
let totalEntries = archive.entries.count
|
|
916
1062
|
var processedEntries = 0
|
|
917
1063
|
|
|
918
1064
|
do {
|
|
919
|
-
for entry in archive {
|
|
1065
|
+
for entry in archive.entries {
|
|
920
1066
|
let destPath = try resolveZipEntry(path: entry.path, destUnZip: destUnZip)
|
|
921
1067
|
|
|
922
1068
|
if entry.type == .directory {
|
|
@@ -2150,6 +2296,12 @@ import UIKit
|
|
|
2150
2296
|
}
|
|
2151
2297
|
|
|
2152
2298
|
let downloadResult = performDownloadRequest(request, label: "download \(version)")
|
|
2299
|
+
// Error responses (e.g. an HTTP 404 body) are also saved to a temp file; never leave them behind.
|
|
2300
|
+
defer {
|
|
2301
|
+
if let fileURL = downloadResult.fileURL {
|
|
2302
|
+
try? FileManager.default.removeItem(at: fileURL)
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2153
2305
|
|
|
2154
2306
|
if downloadResult.timedOut {
|
|
2155
2307
|
persistPartialDownload(downloadResult, id: id, tempPath: tempPath, existingBytes: totalReceivedBytes)
|
|
@@ -2937,9 +3089,16 @@ import UIKit
|
|
|
2937
3089
|
self.setBundleStatus(id: bundle.getId(), status: BundleStatus.ERROR)
|
|
2938
3090
|
}
|
|
2939
3091
|
|
|
2940
|
-
func unsetChannel(defaultChannelKey: String, configDefaultChannel: String) -> SetChannel {
|
|
3092
|
+
func unsetChannel(defaultChannelKey: String, configDefaultChannel: String, allowSetDefaultChannel: Bool) -> SetChannel {
|
|
2941
3093
|
let setChannel: SetChannel = SetChannel()
|
|
2942
3094
|
|
|
3095
|
+
if !allowSetDefaultChannel {
|
|
3096
|
+
logger.error("unsetChannel is disabled by allowSetDefaultChannel config")
|
|
3097
|
+
setChannel.message = "unsetChannel is disabled by configuration"
|
|
3098
|
+
setChannel.error = "disabled_by_config"
|
|
3099
|
+
return setChannel
|
|
3100
|
+
}
|
|
3101
|
+
|
|
2943
3102
|
// Clear persisted defaultChannel and revert to config value
|
|
2944
3103
|
UserDefaults.standard.removeObject(forKey: defaultChannelKey)
|
|
2945
3104
|
UserDefaults.standard.synchronize()
|
|
@@ -3460,24 +3619,20 @@ import UIKit
|
|
|
3460
3619
|
|
|
3461
3620
|
let operation = BlockOperation {
|
|
3462
3621
|
let semaphore = DispatchSemaphore(value: 0)
|
|
3463
|
-
self.
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
parameters: eventsToSend,
|
|
3467
|
-
encoder: JSONParameterEncoder.default,
|
|
3468
|
-
requestModifier: { $0.timeoutInterval = self.timeout }
|
|
3469
|
-
).responseData { response in
|
|
3622
|
+
self.sendJSONPost(urlString: self.statsUrl, body: {
|
|
3623
|
+
try JSONEncoder().encode(eventsToSend)
|
|
3624
|
+
}, completion: { responseData, httpResponse, responseError in
|
|
3470
3625
|
if self.abandonStoppedStatsFlush() {
|
|
3471
3626
|
semaphore.signal()
|
|
3472
3627
|
return
|
|
3473
3628
|
}
|
|
3474
|
-
if self.checkAndHandleRateLimitResponse(statusCode:
|
|
3629
|
+
if self.checkAndHandleRateLimitResponse(statusCode: httpResponse?.statusCode, data: responseData, response: httpResponse).blocked {
|
|
3475
3630
|
self.requeueStatsEvents(queuedEvents)
|
|
3476
3631
|
semaphore.signal()
|
|
3477
3632
|
return
|
|
3478
3633
|
}
|
|
3479
3634
|
|
|
3480
|
-
if let statusCode =
|
|
3635
|
+
if let statusCode = httpResponse?.statusCode, !(200...299).contains(statusCode) {
|
|
3481
3636
|
if CapgoUpdater.isTransientStatsFailure(statusCode) {
|
|
3482
3637
|
self.requeueStatsEvents(queuedEvents)
|
|
3483
3638
|
self.logger.error("Error sending stats batch")
|
|
@@ -3491,19 +3646,18 @@ import UIKit
|
|
|
3491
3646
|
return
|
|
3492
3647
|
}
|
|
3493
3648
|
|
|
3494
|
-
|
|
3495
|
-
|
|
3649
|
+
if let error = responseError {
|
|
3650
|
+
self.requeueStatsEvents(queuedEvents)
|
|
3651
|
+
self.logger.error("Error sending stats batch")
|
|
3652
|
+
self.logger.debug("Response: nil, Error: \(error.localizedDescription)")
|
|
3653
|
+
} else {
|
|
3496
3654
|
self.clearStatsInFlight()
|
|
3497
3655
|
self.logger.info("Stats batch sent successfully")
|
|
3498
3656
|
self.logger.debug("Sent \(eventsToSend.count) events")
|
|
3499
3657
|
self.runStatsCallbacks(queuedEvents)
|
|
3500
|
-
case let .failure(error):
|
|
3501
|
-
self.requeueStatsEvents(queuedEvents)
|
|
3502
|
-
self.logger.error("Error sending stats batch")
|
|
3503
|
-
self.logger.debug("Response: \(response.value?.debugDescription ?? "nil"), Error: \(error.localizedDescription)")
|
|
3504
3658
|
}
|
|
3505
3659
|
semaphore.signal()
|
|
3506
|
-
}
|
|
3660
|
+
})
|
|
3507
3661
|
semaphore.wait()
|
|
3508
3662
|
if !self.statsStopped {
|
|
3509
3663
|
self.persistStatsQueue()
|
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
//
|
|
14
14
|
|
|
15
15
|
import Foundation
|
|
16
|
-
import Version
|
|
17
16
|
|
|
18
17
|
public class DelayUpdateUtils {
|
|
19
18
|
|
|
@@ -23,7 +22,7 @@ public class DelayUpdateUtils {
|
|
|
23
22
|
// swiftlint:enable identifier_name
|
|
24
23
|
private let logger: Logger
|
|
25
24
|
|
|
26
|
-
private let currentVersionNative:
|
|
25
|
+
private let currentVersionNative: CapgoSemanticVersion
|
|
27
26
|
|
|
28
27
|
public enum CancelDelaySource {
|
|
29
28
|
case killed
|
|
@@ -39,7 +38,7 @@ public class DelayUpdateUtils {
|
|
|
39
38
|
}
|
|
40
39
|
}
|
|
41
40
|
|
|
42
|
-
public init(currentVersionNative:
|
|
41
|
+
public init(currentVersionNative: CapgoSemanticVersion, logger: Logger) {
|
|
43
42
|
self.currentVersionNative = currentVersionNative
|
|
44
43
|
self.logger = logger
|
|
45
44
|
}
|
|
@@ -117,7 +116,7 @@ public class DelayUpdateUtils {
|
|
|
117
116
|
case "nativeVersion":
|
|
118
117
|
if let value = value, !value.isEmpty {
|
|
119
118
|
do {
|
|
120
|
-
let versionLimit = try
|
|
119
|
+
let versionLimit = try CapgoSemanticVersion(value)
|
|
121
120
|
if currentVersionNative >= versionLimit {
|
|
122
121
|
// swiftlint:disable:next line_length
|
|
123
122
|
logger.info("Native version delay (value: \(value)) condition removed due to above limit at index \(index)")
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import Foundation
|
|
8
|
+
|
|
9
|
+
/// URLSession delegate that refuses redirects from HTTPS to plain HTTP unless explicitly allowed.
|
|
10
|
+
/// A refused redirect cancels the task, so callers see a transport error instead of the 3xx body.
|
|
11
|
+
final class RedirectPolicyDelegate: NSObject, URLSessionTaskDelegate {
|
|
12
|
+
private let lock = NSLock()
|
|
13
|
+
private var allowDowngrade = false
|
|
14
|
+
/// Called when a downgrade redirect is refused. Must not retain the updater strongly.
|
|
15
|
+
var onBlockedRedirect: ((URL?, URL?) -> Void)?
|
|
16
|
+
|
|
17
|
+
var allowHttpsToHttpRedirect: Bool {
|
|
18
|
+
get {
|
|
19
|
+
lock.lock()
|
|
20
|
+
defer { lock.unlock() }
|
|
21
|
+
return allowDowngrade
|
|
22
|
+
}
|
|
23
|
+
set {
|
|
24
|
+
lock.lock()
|
|
25
|
+
allowDowngrade = newValue
|
|
26
|
+
lock.unlock()
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
static func isHttpsToHttpDowngrade(from source: URL?, to target: URL?) -> Bool {
|
|
31
|
+
guard let sourceScheme = source?.scheme?.lowercased(), let targetScheme = target?.scheme?.lowercased() else {
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
return sourceScheme == "https" && targetScheme == "http"
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
func urlSession(
|
|
38
|
+
_: URLSession,
|
|
39
|
+
task: URLSessionTask,
|
|
40
|
+
willPerformHTTPRedirection response: HTTPURLResponse,
|
|
41
|
+
newRequest request: URLRequest,
|
|
42
|
+
completionHandler: @escaping (URLRequest?) -> Void
|
|
43
|
+
) {
|
|
44
|
+
let source = response.url ?? task.currentRequest?.url
|
|
45
|
+
if !allowHttpsToHttpRedirect && Self.isHttpsToHttpDowngrade(from: source, to: request.url) {
|
|
46
|
+
onBlockedRedirect?(source, request.url)
|
|
47
|
+
completionHandler(nil)
|
|
48
|
+
task.cancel()
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
completionHandler(request)
|
|
52
|
+
}
|
|
53
|
+
}
|