@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.
@@ -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
- private struct DownloadRequestResult {
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 _alamofireSession: Session?
206
- private let alamofireSessionLock = NSLock()
207
- private var alamofireSession: Session {
208
- alamofireSessionLock.lock()
209
- defer { alamofireSessionLock.unlock() }
210
- if let session = _alamofireSession {
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
- let session = Session(configuration: configuration)
221
- _alamofireSession = session
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 dataRequest = self.alamofireSession.request(request).responseData(queue: self.networkResponseQueue) { response in
282
- responseData = response.data
283
- httpResponse = response.response
284
- requestError = response.error
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
- dataRequest.cancel()
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
- return RequestResult(data: responseData, response: httpResponse, error: requestError, timedOut: false)
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
- private func performDownloadRequest(_ request: URLRequest, label: String) -> DownloadRequestResult {
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 destination: DownloadRequest.Destination = { _, _ in
306
- (temporaryDownloadURL, [.removePreviousFile, .createIntermediateDirectories])
307
- }
308
- let downloadRequest = self.alamofireSession.download(request, to: destination).response(queue: self.networkResponseQueue) { response in
309
- tempFileURL = response.fileURL
310
- httpResponse = response.response
311
- requestError = response.error
312
- semaphore.signal()
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
- downloadRequest.resume()
459
+ downloadTask.resume()
315
460
 
316
461
  if semaphore.wait(timeout: .now() + waitTimeout) == .timedOut {
317
- downloadRequest.cancel()
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
- self.alamofireSession.request(
723
- self.statsUrl,
724
- method: .post,
725
- parameters: parameters.toParameters(),
726
- encoding: JSONEncoding.default,
727
- requestModifier: { $0.timeoutInterval = self.timeout }
728
- ).responseData { response in
729
- let statusCode = response.response?.statusCode
730
- switch response.result {
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("Response code: \(statusCode.map(String.init) ?? "nil")")
737
- case let .failure(error):
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("Error: \(error.localizedDescription)")
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
- private func extractZipEntry(_ archive: Archive, entry: Entry, to destPath: URL, bufferSize: Int = CryptoCipher.ioBufferBytes()) throws {
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
- _ = try archive.extract(entry, bufferSize: bufferSize, skipCRC32: true) { data in
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
- var linkData = Data()
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: Archive
1049
+ let archive: ZipArchiveReader
904
1050
  do {
905
- archive = try Archive(url: sourceZip, accessMode: .read)
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.reduce(0) { count, _ in count + 1 }
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.alamofireSession.request(
3464
- self.statsUrl,
3465
- method: .post,
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: response.response?.statusCode, data: response.data, response: response.response).blocked {
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 = response.response?.statusCode, !(200...299).contains(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
- switch response.result {
3495
- case .success:
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: Version
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: Version, logger: Logger) {
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 Version(value)
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
+ }