@capgo/capacitor-updater 8.51.14 → 8.51.16

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.
@@ -96,7 +96,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
96
96
  deinit {
97
97
  implementation.shutdown()
98
98
  }
99
- private let pluginVersion: String = "8.51.14"
99
+ private let pluginVersion: String = "8.51.16"
100
100
  private let launchStartedAtMs = Int64(Date().timeIntervalSince1970 * 1000)
101
101
  static let updateUrlDefault = "https://plugin.capgo.app/updates"
102
102
  static let statsUrlDefault = "https://plugin.capgo.app/stats"
@@ -191,9 +191,12 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
191
191
 
192
192
  // Lock to ensure cleanup completes before downloads start
193
193
  private let cleanupLock = NSLock()
194
+ // Short lock for cleanupComplete/cleanupTimedOut only. Never hold across cleanupGroup.wait.
195
+ private let cleanupStateLock = NSLock()
194
196
  private let defaultChannelStateLock = NSLock()
195
197
  private let cleanupGroup = DispatchGroup()
196
198
  private var cleanupComplete = false
199
+ private var cleanupTimedOut = false
197
200
  private var cleanupThread: Thread?
198
201
  private var defaultChannelCleanupMustRetry = false
199
202
  private var persistCustomId = false
@@ -216,6 +219,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
216
219
  // Best-effort flag: set before we expect notifyAppReady (load/reload).
217
220
  // No lock — never held across waits; a rare race only mis-times one wait.
218
221
  private var pendingNotifyAppReady = false
222
+ private let semaphoreWaitTestingLock = NSLock()
223
+ private var didEnterSemaphoreWaitForTesting = false
219
224
 
220
225
  private var delayUpdateUtils: DelayUpdateUtils!
221
226
 
@@ -427,7 +432,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
427
432
 
428
433
  // Downloads (including shake-menu / CapgoUpdater entry points) wait on this gate.
429
434
  self.implementation.beforeDownload = { [weak self] in
430
- self?.waitForCleanupIfNeeded()
435
+ try self?.waitForCleanupIfNeeded()
431
436
  }
432
437
  // Always run async cleanup: delete obsolete bundles on native update (when enabled)
433
438
  // and sweep orphan directories every launch. Must not block app startup.
@@ -594,6 +599,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
594
599
 
595
600
  private func semaphoreWait(waitTime: Int) {
596
601
  // print("\\(CapgoUpdater.TAG) semaphoreWait \\(waitTime)")
602
+ semaphoreWaitTestingLock.lock()
603
+ didEnterSemaphoreWaitForTesting = true
604
+ semaphoreWaitTestingLock.unlock()
597
605
  let result = semaphoreReady.wait(timeout: .now() + .milliseconds(waitTime))
598
606
  if result == .timedOut {
599
607
  logger.error("Semaphore wait timed out after \(waitTime)ms")
@@ -1035,9 +1043,12 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1035
1043
  }
1036
1044
 
1037
1045
  private func cleanupObsoleteVersions(resetWhenUpdate: Bool = true, didResetCurrentBundle: Bool = false) {
1038
- // Enter before start so waiters never race past an unstarted cleanup thread.
1046
+ // Enter before publishing incomplete state so waiters cannot hit an empty group.
1047
+ self.cleanupStateLock.lock()
1039
1048
  self.cleanupComplete = false
1049
+ self.cleanupTimedOut = false
1040
1050
  self.cleanupGroup.enter()
1051
+ self.cleanupStateLock.unlock()
1041
1052
  cleanupThread = Thread {
1042
1053
  let bgTaskLock = NSLock()
1043
1054
  var cleanupBackgroundTask = UIBackgroundTaskIdentifier.invalid
@@ -1054,7 +1065,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1054
1065
  }
1055
1066
  self.cleanupLock.lock()
1056
1067
  defer {
1068
+ self.cleanupStateLock.lock()
1057
1069
  self.cleanupComplete = true
1070
+ self.cleanupStateLock.unlock()
1058
1071
  self.cleanupLock.unlock()
1059
1072
  self.cleanupGroup.leave()
1060
1073
  endCleanupBackgroundTask()
@@ -1139,17 +1152,39 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1139
1152
  cleanupThread?.start()
1140
1153
  }
1141
1154
 
1142
- private func waitForCleanupIfNeeded() {
1143
- if cleanupComplete {
1155
+ private func cleanupTimeoutError() -> NSError {
1156
+ NSError(
1157
+ domain: "CapacitorUpdater",
1158
+ code: 1,
1159
+ userInfo: [NSLocalizedDescriptionKey: "Cleanup did not finish before download"]
1160
+ )
1161
+ }
1162
+
1163
+ private func waitForCleanupIfNeeded() throws {
1164
+ cleanupStateLock.lock()
1165
+ let alreadyDone = cleanupComplete
1166
+ let alreadyTimedOut = cleanupTimedOut
1167
+ cleanupStateLock.unlock()
1168
+ if alreadyDone {
1144
1169
  return // Already done, no need to wait
1145
1170
  }
1171
+ if alreadyTimedOut {
1172
+ throw cleanupTimeoutError()
1173
+ }
1146
1174
 
1147
1175
  logger.info("Waiting for cleanup to complete before starting download...")
1148
1176
  let result = cleanupGroup.wait(timeout: .now() + .seconds(60))
1149
1177
  if result == .timedOut {
1150
1178
  logger.warn("Cleanup wait timed out after 60s, cancelling leftover cleanup")
1151
1179
  cleanupThread?.cancel()
1152
- _ = cleanupGroup.wait(timeout: .now() + .seconds(60))
1180
+ let cancelled = cleanupGroup.wait(timeout: .now() + .seconds(60))
1181
+ if cancelled == .timedOut {
1182
+ cleanupStateLock.lock()
1183
+ cleanupTimedOut = true
1184
+ cleanupStateLock.unlock()
1185
+ logger.error("Cleanup did not finish after cancel, aborting download")
1186
+ throw cleanupTimeoutError()
1187
+ }
1153
1188
  return
1154
1189
  }
1155
1190
  logger.info("Cleanup finished, proceeding with download")
@@ -1568,8 +1603,6 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1568
1603
  }
1569
1604
 
1570
1605
  private func downloadBundle(urlString: String, version: String, sessionKey: String, checksum rawChecksum: String, manifestEntries: [ManifestEntry]?) throws -> BundleInfo {
1571
- // Manual/preview downloads must wait too - launch orphan sweep can delete their temps.
1572
- self.waitForCleanupIfNeeded()
1573
1606
  guard let url = URL(string: urlString) else {
1574
1607
  throw makePreviewError("Invalid download URL")
1575
1608
  }
@@ -1809,6 +1842,14 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1809
1842
  }
1810
1843
  }
1811
1844
 
1845
+ private func queueBundleForNextBackgroundInstall(_ next: BundleInfo) -> Bool {
1846
+ guard self.implementation.setNextBundle(next: next.getId()) else {
1847
+ self.logger.error("Failed to queue downloaded bundle as next: \(next.toString())")
1848
+ return false
1849
+ }
1850
+ return true
1851
+ }
1852
+
1812
1853
  private func applyDownloadedBundleForDirectUpdate(_ next: BundleInfo) -> Bool {
1813
1854
  let previousState = self.implementation.captureResetState()
1814
1855
  let previousBundleName = self.implementation.getCurrentBundle().getVersionName()
@@ -3993,6 +4034,13 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3993
4034
  isAutoUpdateModeEnabled(autoUpdateMode) && autoUpdateMode != autoUpdateModeOnlyDownload
3994
4035
  }
3995
4036
 
4037
+ /// Incomplete leftovers must be downloaded again. Android already skips
4038
+ /// `DOWNLOADING` here; iOS used to treat them as ready, then `setNextBundle`
4039
+ /// failed quietly and the update never applied.
4040
+ static func shouldRetryDownloadForExistingBundle(_ bundle: BundleInfo) -> Bool {
4041
+ bundle.isDeleted() || bundle.isDeleting() || bundle.isDownloading()
4042
+ }
4043
+
3996
4044
  static func isDirectUpdateMode(_ directUpdateMode: String) -> Bool {
3997
4045
  directUpdateMode == autoUpdateModeInstall || directUpdateMode == autoUpdateModeLaunch || directUpdateMode == autoUpdateModeAlways
3998
4046
  }
@@ -4063,6 +4111,18 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
4063
4111
  pendingNotifyAppReady = true
4064
4112
  }
4065
4113
 
4114
+ func resetSemaphoreWaitTestingStateForTesting() {
4115
+ semaphoreWaitTestingLock.lock()
4116
+ didEnterSemaphoreWaitForTesting = false
4117
+ semaphoreWaitTestingLock.unlock()
4118
+ }
4119
+
4120
+ var didEnterSemaphoreWaitForTestingState: Bool {
4121
+ semaphoreWaitTestingLock.lock()
4122
+ defer { semaphoreWaitTestingLock.unlock() }
4123
+ return didEnterSemaphoreWaitForTesting
4124
+ }
4125
+
4066
4126
  func clearPendingNotifyAppReadyForTesting() {
4067
4127
  clearPendingNotifyAppReady()
4068
4128
  }
@@ -4220,7 +4280,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
4220
4280
  func runBackgroundDownloadWork(_ work: @escaping () -> Void) {
4221
4281
  // Live update checks/downloads are user-visible work. Using `.background`
4222
4282
  // lets the scheduler starve them for minutes while the app is active.
4223
- DispatchQueue.global(qos: .utility).async(execute: work)
4283
+ DispatchQueue.global(qos: .userInitiated).async(execute: work)
4224
4284
  }
4225
4285
 
4226
4286
  private func beginDownloadBackgroundTask() {
@@ -4271,8 +4331,6 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
4271
4331
  }
4272
4332
 
4273
4333
  self.runBackgroundDownloadWork {
4274
- // Wait for cleanup to complete before starting download
4275
- self.waitForCleanupIfNeeded()
4276
4334
  if self.shouldBlockAutoUpdateForPreviewSession() {
4277
4335
  self.clearDownloadInProgressState()
4278
4336
  return
@@ -4280,7 +4338,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
4280
4338
  self.beginDownloadBackgroundTask()
4281
4339
  self.logger.info("Check for update via \(self.updateUrl)")
4282
4340
  let res = self.implementation.getLatest(url: url, channel: nil)
4283
- let current = self.implementation.getCurrentBundle()
4341
+ var current = self.implementation.getCurrentBundle()
4284
4342
  if self.shouldBlockAutoUpdateForPreviewSession() {
4285
4343
  self.clearDownloadInProgressState()
4286
4344
  self.endBackGroundTask()
@@ -4299,6 +4357,16 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
4299
4357
  )
4300
4358
  return
4301
4359
  }
4360
+ // File mutations wait here. getLatest already ran in parallel with cleanup.
4361
+ do {
4362
+ try self.waitForCleanupIfNeeded()
4363
+ } catch {
4364
+ self.logger.error("Cleanup still running, skipping download")
4365
+ self.clearDownloadInProgressState()
4366
+ self.endBackGroundTask()
4367
+ return
4368
+ }
4369
+ current = self.implementation.getCurrentBundle()
4302
4370
  if res.version == "builtin" {
4303
4371
  self.logger.info("Latest version is builtin")
4304
4372
  let directUpdateAllowed = plannedDirectUpdate && !self.autoSplashscreenTimedOut
@@ -4360,14 +4428,16 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
4360
4428
  do {
4361
4429
  self.logger.info("New bundle: \(latestVersionName) found. Current is: \(current.getVersionName()). \(messageUpdate)")
4362
4430
  var nextImpl = self.implementation.getBundleInfoByVersionName(version: latestVersionName)
4363
- if nextImpl == nil || nextImpl?.isDeleted() == true {
4364
- if nextImpl?.isDeleted() == true {
4365
- self.logger.info("Latest bundle already exists and will be deleted, download will overwrite it.")
4366
- let res = self.implementation.delete(id: nextImpl!.getId(), removeInfo: true)
4367
- if res {
4368
- self.logger.info("Failed bundle deleted: \(nextImpl!.toString())")
4431
+ let needsDownload = nextImpl.map(Self.shouldRetryDownloadForExistingBundle) ?? true
4432
+ if needsDownload {
4433
+ if let existing = nextImpl {
4434
+ self.logger.info("Latest bundle already exists in incomplete state (\(existing.getStatus())) and will be deleted, download will overwrite it.")
4435
+ _ = self.implementation.setNextBundle(next: Optional<String>.none)
4436
+ let deleted = self.implementation.delete(id: existing.getId(), removeInfo: true)
4437
+ if deleted {
4438
+ self.logger.info("Incomplete bundle deleted: \(existing.toString())")
4369
4439
  } else {
4370
- self.logger.error("Failed to delete failed bundle: \(nextImpl!.toString())")
4440
+ self.logger.error("Failed to delete incomplete bundle: \(existing.toString())")
4371
4441
  }
4372
4442
  }
4373
4443
  self.consumeOnLaunchDirectUpdateAttempt(plannedDirectUpdate: plannedDirectUpdate)
@@ -4454,8 +4524,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
4454
4524
  error: false,
4455
4525
  plannedDirectUpdate: plannedDirectUpdate
4456
4526
  )
4457
- } else {
4458
- _ = self.implementation.setNextBundle(next: next.getId())
4527
+ } else if self.queueBundleForNextBackgroundInstall(next) {
4459
4528
  self.notifyListeners("updateAvailable", data: ["bundle": next.toJSON()])
4460
4529
  self.endBackGroundTaskWithNotif(
4461
4530
  msg: "Direct update reload failed, update will install next background",
@@ -4464,20 +4533,35 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
4464
4533
  error: false,
4465
4534
  plannedDirectUpdate: plannedDirectUpdate
4466
4535
  )
4536
+ } else {
4537
+ self.endBackGroundTaskWithNotif(
4538
+ msg: "Direct update reload failed, and next bundle could not be queued",
4539
+ latestVersionName: latestVersionName,
4540
+ current: current,
4541
+ plannedDirectUpdate: plannedDirectUpdate
4542
+ )
4467
4543
  }
4468
4544
  } else if self.shouldAutoSetNextBundle() {
4469
4545
  if plannedDirectUpdate && !directUpdateAllowed {
4470
4546
  self.logger.info("Direct update skipped because splashscreen timeout occurred. Update will install on next app background.")
4471
4547
  }
4472
- self.notifyListeners("updateAvailable", data: ["bundle": next.toJSON()])
4473
- _ = self.implementation.setNextBundle(next: next.getId())
4474
- self.endBackGroundTaskWithNotif(
4475
- msg: "update downloaded, will install next background",
4476
- latestVersionName: latestVersionName,
4477
- current: current,
4478
- error: false,
4479
- plannedDirectUpdate: plannedDirectUpdate
4480
- )
4548
+ if self.queueBundleForNextBackgroundInstall(next) {
4549
+ self.notifyListeners("updateAvailable", data: ["bundle": next.toJSON()])
4550
+ self.endBackGroundTaskWithNotif(
4551
+ msg: "update downloaded, will install next background",
4552
+ latestVersionName: latestVersionName,
4553
+ current: current,
4554
+ error: false,
4555
+ plannedDirectUpdate: plannedDirectUpdate
4556
+ )
4557
+ } else {
4558
+ self.endBackGroundTaskWithNotif(
4559
+ msg: "Update downloaded, but next bundle could not be queued",
4560
+ latestVersionName: latestVersionName,
4561
+ current: current,
4562
+ plannedDirectUpdate: plannedDirectUpdate
4563
+ )
4564
+ }
4481
4565
  } else {
4482
4566
  self.logger.info("autoUpdate is set to onlyDownload, downloaded update will not be set as next bundle")
4483
4567
  self.notifyListeners("updateAvailable", data: ["bundle": next.toJSON()], retainUntilConsumed: true)
@@ -0,0 +1,36 @@
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
+ import Security
9
+ import os.log
10
+
11
+ /// Isolated raw RSA primitives for Capgo v2 checksum/session-key recovery.
12
+ /// SonarCloud automatic analysis has no Swift // NOSONAR; swift:S5542 is ignored
13
+ /// via `.sonarcloud.properties` multicriteria because `.rsaEncryptionRaw` is
14
+ /// intentional wire compatibility with Node `privateEncrypt` / `publicDecrypt`.
15
+ enum CapgoRawRsa {
16
+ private static let log = OSLog(subsystem: "ee.forgr.capacitor_updater", category: "RSA")
17
+
18
+ static func supportsCapgoRecovery(key: SecKey) -> Bool {
19
+ SecKeyGetBlockSize(key) == 256
20
+ && SecKeyIsAlgorithmSupported(key, .encrypt, .rsaEncryptionRaw) // NOSONAR intentional raw RSA for Node privateEncrypt / Capgo checksum recovery
21
+ }
22
+
23
+ static func recoverSignatureBlock(key: SecKey, data: Data) -> Data? {
24
+ var error: Unmanaged<CFError>?
25
+ guard let recovered = SecKeyCreateEncryptedData(key, .rsaEncryptionRaw, data as CFData, &error) as Data? else { // NOSONAR intentional raw RSA for Node privateEncrypt / Capgo checksum recovery
26
+ os_log(
27
+ "RSA raw recovery failed: %{public}@",
28
+ log: log,
29
+ type: .error,
30
+ String(describing: error?.takeRetainedValue())
31
+ )
32
+ return nil
33
+ }
34
+ return recovered
35
+ }
36
+ }
@@ -45,7 +45,7 @@ import UIKit
45
45
  public var timeout: Double = 20
46
46
  public var statsUrl: String = ""
47
47
  /// Optional gate run before any download touches disk (e.g. wait for launch cleanup).
48
- public var beforeDownload: (() -> Void)?
48
+ public var beforeDownload: (() throws -> Void)?
49
49
  public var channelUrl: String = ""
50
50
  public var defaultChannel: String = ""
51
51
  public var appId: String = ""
@@ -781,7 +781,7 @@ import UIKit
781
781
  }
782
782
  }
783
783
 
784
- private func extractZipEntry(_ archive: Archive, entry: Entry, to destPath: URL) throws {
784
+ private func extractZipEntry(_ archive: Archive, entry: Entry, to destPath: URL, bufferSize: Int = CryptoCipher.ioBufferBytes()) throws {
785
785
  let fileManager = FileManager.default
786
786
 
787
787
  switch entry.type {
@@ -804,14 +804,14 @@ import UIKit
804
804
  fileHandle.closeFile()
805
805
  }
806
806
 
807
- _ = try archive.extract(entry, bufferSize: 16 * 1024, skipCRC32: true) { data in
807
+ _ = try archive.extract(entry, bufferSize: bufferSize, skipCRC32: true) { data in
808
808
  if !data.isEmpty {
809
809
  fileHandle.write(data)
810
810
  }
811
811
  }
812
812
  case .symlink:
813
813
  var linkData = Data()
814
- _ = try archive.extract(entry, bufferSize: 16 * 1024, skipCRC32: true) { data in
814
+ _ = try archive.extract(entry, bufferSize: bufferSize, skipCRC32: true) { data in
815
815
  linkData.append(data)
816
816
  }
817
817
 
@@ -840,7 +840,7 @@ import UIKit
840
840
  }
841
841
  }
842
842
 
843
- private func saveDownloaded(sourceZip: URL, id: String, base: URL, notify: Bool) throws {
843
+ func saveDownloaded(sourceZip: URL, id: String, base: URL, notify: Bool, bufferSize: Int = CryptoCipher.ioBufferBytes()) throws {
844
844
  try prepareFolder(source: base)
845
845
  let destPersist: URL = base.appendingPathComponent(id)
846
846
  let destUnZip: URL = libraryDir.appendingPathComponent(TEMP_UNZIP_PREFIX + randomString(length: 10))
@@ -887,7 +887,7 @@ import UIKit
887
887
  try FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true, attributes: nil)
888
888
  }
889
889
 
890
- try self.extractZipEntry(archive, entry: entry, to: destPath)
890
+ try self.extractZipEntry(archive, entry: entry, to: destPath, bufferSize: bufferSize)
891
891
 
892
892
  // Update progress
893
893
  processedEntries += 1
@@ -928,10 +928,7 @@ import UIKit
928
928
 
929
929
  struct ManifestLookupEntry {
930
930
  let hash: String
931
- /// The manifest's own file name, `.br` suffix included when present. The
932
- /// built-in bundle stores files under this exact name (see
933
- /// isManifestEntryAvailableLocally), unlike the extracted/cached copy which
934
- /// is always named without the suffix.
931
+ /// The manifest's own file name, `.br` suffix included when present.
935
932
  let originalFileName: String
936
933
  }
937
934
 
@@ -994,10 +991,14 @@ import UIKit
994
991
 
995
992
  // Builtin is already a permanent reuse source (see isManifestEntryAvailableLocally),
996
993
  // so there's no need to also duplicate this file into the delta cache
997
- let builtinRelativePath = knownEntry?.originalFileName ?? relativePath
998
- let builtinFilePath = builtinFolder.appendingPathComponent(builtinRelativePath)
999
- let isBuiltinOrigin = fileManager.fileExists(atPath: builtinFilePath.path) &&
1000
- verifyChecksum(file: builtinFilePath, expectedHash: checksum)
994
+ let builtinLookupName = knownEntry?.originalFileName ?? relativePath
995
+ let isBuiltinOrigin: Bool
996
+ if let builtinFilePath = try? Self.resolveManifestTargetPath(baseDirectory: builtinFolder, fileName: builtinLookupName) {
997
+ isBuiltinOrigin = fileManager.fileExists(atPath: builtinFilePath.path) &&
998
+ verifyChecksum(file: builtinFilePath, expectedHash: checksum)
999
+ } else {
1000
+ isBuiltinOrigin = false
1001
+ }
1001
1002
  if isBuiltinOrigin {
1002
1003
  continue
1003
1004
  }
@@ -1231,7 +1232,10 @@ import UIKit
1231
1232
  }
1232
1233
 
1233
1234
  let builtinFolder = self.builtinFolderURL()
1234
- let builtinFilePath = builtinFolder.appendingPathComponent(fileName)
1235
+ // The .br suffix describes transport; builtin assets are uncompressed.
1236
+ guard let builtinFilePath = try? Self.resolveManifestTargetPath(baseDirectory: builtinFolder, fileName: fileName) else {
1237
+ return false
1238
+ }
1235
1239
  if FileManager.default.fileExists(atPath: builtinFilePath.path) && verifyChecksum(file: builtinFilePath, expectedHash: fileHash) {
1236
1240
  return true
1237
1241
  }
@@ -1365,12 +1369,12 @@ import UIKit
1365
1369
  return json
1366
1370
  }
1367
1371
 
1368
- private func runBeforeDownload() {
1369
- beforeDownload?()
1372
+ private func runBeforeDownload() throws {
1373
+ try beforeDownload?()
1370
1374
  }
1371
1375
 
1372
1376
  public func downloadManifest(manifest: [ManifestEntry], version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
1373
- self.runBeforeDownload()
1377
+ try self.runBeforeDownload()
1374
1378
  let id = self.randomString(length: 10)
1375
1379
  logger.info("downloadManifest start \(id)")
1376
1380
  let destFolder = self.getBundleDirectory(id: id)
@@ -1474,7 +1478,7 @@ import UIKit
1474
1478
  let builtinFilePath: URL
1475
1479
  do {
1476
1480
  destFilePath = try Self.resolveManifestTargetPath(baseDirectory: destFolder, fileName: fileName)
1477
- builtinFilePath = try Self.resolvePathInsideDirectory(baseDirectory: builtinFolder, relativePath: fileName)
1481
+ builtinFilePath = try Self.resolveManifestTargetPath(baseDirectory: builtinFolder, fileName: fileName)
1478
1482
  } catch {
1479
1483
  logger.error("Invalid manifest file path: \(fileName)")
1480
1484
  self.sendStats(action: "manifest_path_fail", versionName: "\(version):\(fileName)")
@@ -1736,6 +1740,38 @@ import UIKit
1736
1740
  try fileManager.copyItem(at: source, to: destination)
1737
1741
  }
1738
1742
 
1743
+ func copyMatchingBuiltinFilesForTests(files: [(source: URL, dest: URL, hash: String)]) throws {
1744
+ let queue = OperationQueue()
1745
+ queue.maxConcurrentOperationCount = CapgoUpdater.manifestMaxConcurrentFiles
1746
+ let lock = NSLock()
1747
+ var firstError: Error?
1748
+ for file in files {
1749
+ queue.addOperation { [weak self] in
1750
+ guard let self else { return }
1751
+ do {
1752
+ guard self.verifyChecksum(file: file.source, expectedHash: file.hash) else {
1753
+ throw NSError(
1754
+ domain: "CapgoInstallPerf",
1755
+ code: 1,
1756
+ userInfo: [NSLocalizedDescriptionKey: "builtin checksum mismatch"]
1757
+ )
1758
+ }
1759
+ try self.copyItemReplacing(from: file.source, to: file.dest)
1760
+ } catch {
1761
+ lock.lock()
1762
+ if firstError == nil {
1763
+ firstError = error
1764
+ }
1765
+ lock.unlock()
1766
+ }
1767
+ }
1768
+ }
1769
+ queue.waitUntilAllOperationsAreFinished()
1770
+ if let firstError {
1771
+ throw firstError
1772
+ }
1773
+ }
1774
+
1739
1775
  /// Copy via a unique temp name then rename, so a crash cannot leave a
1740
1776
  /// non-empty partial file that `isReusableCacheFile` would trust.
1741
1777
  private func copyItemAtomically(from source: URL, to destination: URL) throws {
@@ -1941,7 +1977,7 @@ import UIKit
1941
1977
  }
1942
1978
 
1943
1979
  public func download(url: URL, version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
1944
- self.runBeforeDownload()
1980
+ try self.runBeforeDownload()
1945
1981
  let id: String = self.randomString(length: 10)
1946
1982
  // Each download uses its own temp files keyed by bundle ID to prevent collisions
1947
1983
  if version != getLocalUpdateVersion(for: id) {