@capgo/capacitor-updater 8.47.2 → 8.47.4

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.
@@ -79,7 +79,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
79
79
  CAPPluginMethod(name: "completeFlexibleUpdate", returnType: CAPPluginReturnPromise)
80
80
  ]
81
81
  public var implementation = CapgoUpdater()
82
- private let pluginVersion: String = "8.47.2"
82
+ private let pluginVersion: String = "8.47.4"
83
83
  static let updateUrlDefault = "https://plugin.capgo.app/updates"
84
84
  static let statsUrlDefault = "https://plugin.capgo.app/stats"
85
85
  static let channelUrlDefault = "https://plugin.capgo.app/channel_self"
@@ -101,7 +101,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
101
101
  private let previewPreviousShakeChannelSelectorDefaultsKey = "CapacitorUpdater.previewPreviousShakeChannelSelector"
102
102
  private let previewPreviousNextBundleDefaultsKey = "CapacitorUpdater.previewPreviousNextBundle"
103
103
  private let previewPreviousAppIdDefaultsKey = "CapacitorUpdater.previewPreviousAppId"
104
+ private let previewPreviousDefaultChannelDefaultsKey = "CapacitorUpdater.previewPreviousDefaultChannel"
105
+ private let previewPreviousDefaultChannelWasSetDefaultsKey = "CapacitorUpdater.previewPreviousDefaultChannelWasSet"
104
106
  private let previewAppIdDefaultsKey = "CapacitorUpdater.previewAppId"
107
+ private let previewPayloadUrlDefaultsKey = "CapacitorUpdater.previewPayloadUrl"
105
108
  // Note: DELAY_CONDITION_PREFERENCES is now defined in DelayUpdateUtils.DELAY_CONDITION_PREFERENCES
106
109
  private var updateUrl = ""
107
110
  private var backgroundTaskID: UIBackgroundTaskIdentifier = UIBackgroundTaskIdentifier.invalid
@@ -749,6 +752,62 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
749
752
  return manifestEntries
750
753
  }
751
754
 
755
+ private struct PreviewPayload: Decodable {
756
+ let version: String?
757
+ let url: String?
758
+ let checksum: String?
759
+ let sessionKey: String?
760
+ let manifest: [ManifestEntry]?
761
+ let message: String?
762
+ let error: String?
763
+ }
764
+
765
+ private func makePreviewError(_ message: String) -> NSError {
766
+ NSError(domain: "CapacitorUpdaterPreview", code: 0, userInfo: [NSLocalizedDescriptionKey: message])
767
+ }
768
+
769
+ private func downloadBundle(urlString: String, version: String, sessionKey: String, checksum rawChecksum: String, manifestEntries: [ManifestEntry]?) throws -> BundleInfo {
770
+ guard let url = URL(string: urlString) else {
771
+ throw makePreviewError("Invalid download URL")
772
+ }
773
+
774
+ var checksum = rawChecksum
775
+ let next: BundleInfo
776
+ if let manifestEntries = manifestEntries {
777
+ next = try self.implementation.downloadManifest(manifest: manifestEntries, version: version, sessionKey: sessionKey)
778
+ } else {
779
+ next = try self.implementation.download(url: url, version: version, sessionKey: sessionKey)
780
+ }
781
+
782
+ if self.implementation.publicKey != "" && checksum == "" {
783
+ self.logger.error("Public key present but no checksum provided")
784
+ self.implementation.sendStats(action: "checksum_required", versionName: next.getVersionName())
785
+ let id = next.getId()
786
+ let resDel = self.implementation.delete(id: id)
787
+ if !resDel {
788
+ self.logger.error("Delete failed, id \(id) doesn't exist")
789
+ }
790
+ throw ObjectSavableError.checksum
791
+ }
792
+
793
+ checksum = try CryptoCipher.decryptChecksum(checksum: checksum, publicKey: self.implementation.publicKey)
794
+ CryptoCipher.logChecksumInfo(label: "Bundle checksum", hexChecksum: next.getChecksum())
795
+ CryptoCipher.logChecksumInfo(label: "Expected checksum", hexChecksum: checksum)
796
+ if (checksum != "" || self.implementation.publicKey != "") && next.getChecksum() != checksum {
797
+ self.logger.error("Error checksum \(next.getChecksum()) \(checksum)")
798
+ self.implementation.sendStats(action: "checksum_fail", versionName: next.getVersionName())
799
+ let id = next.getId()
800
+ let resDel = self.implementation.delete(id: id)
801
+ if !resDel {
802
+ self.logger.error("Delete failed, id \(id) doesn't exist")
803
+ }
804
+ throw ObjectSavableError.checksum
805
+ }
806
+
807
+ self.logger.info("Good checksum \(next.getChecksum()) \(checksum)")
808
+ return next
809
+ }
810
+
752
811
  @objc func download(_ call: CAPPluginCall) {
753
812
  guard let urlString = call.getString("url") else {
754
813
  logger.error("Download called without url")
@@ -762,57 +821,30 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
762
821
  }
763
822
 
764
823
  let sessionKey = call.getString("sessionKey", "")
765
- var checksum = call.getString("checksum", "")
824
+ let checksum = call.getString("checksum", "")
766
825
  let manifestArray = call.getArray("manifest")
767
- let url = URL(string: urlString)
768
- logger.info("Downloading \(String(describing: url))")
826
+ logger.info("Downloading \(urlString)")
769
827
  self.saveCallForAsyncHandling(call)
770
828
  self.runBackgroundDownloadWork {
771
829
  do {
772
- let next: BundleInfo
773
- if let manifestEntries = self.manifestEntries(from: manifestArray) {
774
- next = try self.implementation.downloadManifest(manifest: manifestEntries, version: version, sessionKey: sessionKey)
775
- } else {
776
- next = try self.implementation.download(url: url!, version: version, sessionKey: sessionKey)
777
- }
778
- // If public key is present but no checksum provided, refuse installation
779
- if self.implementation.publicKey != "" && checksum == "" {
780
- self.logger.error("Public key present but no checksum provided")
781
- self.implementation.sendStats(action: "checksum_required", versionName: next.getVersionName())
782
- let id = next.getId()
783
- let resDel = self.implementation.delete(id: id)
784
- if !resDel {
785
- self.logger.error("Delete failed, id \(id) doesn't exist")
786
- }
787
- throw ObjectSavableError.checksum
788
- }
789
-
790
- checksum = try CryptoCipher.decryptChecksum(checksum: checksum, publicKey: self.implementation.publicKey)
791
- CryptoCipher.logChecksumInfo(label: "Bundle checksum", hexChecksum: next.getChecksum())
792
- CryptoCipher.logChecksumInfo(label: "Expected checksum", hexChecksum: checksum)
793
- if (checksum != "" || self.implementation.publicKey != "") && next.getChecksum() != checksum {
794
- self.logger.error("Error checksum \(next.getChecksum()) \(checksum)")
795
- self.implementation.sendStats(action: "checksum_fail", versionName: next.getVersionName())
796
- let id = next.getId()
797
- let resDel = self.implementation.delete(id: id)
798
- if !resDel {
799
- self.logger.error("Delete failed, id \(id) doesn't exist")
800
- }
801
- throw ObjectSavableError.checksum
802
- } else {
803
- self.logger.info("Good checksum \(next.getChecksum()) \(checksum)")
804
- }
830
+ let next = try self.downloadBundle(
831
+ urlString: urlString,
832
+ version: version,
833
+ sessionKey: sessionKey,
834
+ checksum: checksum,
835
+ manifestEntries: self.manifestEntries(from: manifestArray)
836
+ )
805
837
  var updateAvailablePayload: JSObject = [:]
806
838
  updateAvailablePayload["bundle"] = self.bundlePayload(next)
807
839
  self.notifyListenersOnMain("updateAvailable", data: updateAvailablePayload)
808
840
  self.resolveCall(call, data: next.toJSON())
809
841
  } catch {
810
- self.logger.error("Failed to download from: \(String(describing: url)) \(error.localizedDescription)")
842
+ self.logger.error("Failed to download from: \(urlString) \(error.localizedDescription)")
811
843
  var downloadFailedPayload: JSObject = [:]
812
844
  downloadFailedPayload["version"] = version
813
845
  self.notifyListenersOnMain("downloadFailed", data: downloadFailedPayload)
814
846
  self.implementation.sendStats(action: "download_fail")
815
- self.rejectCall(call, message: "Failed to download from: \(url!) - \(error.localizedDescription)")
847
+ self.rejectCall(call, message: "Failed to download from: \(urlString) - \(error.localizedDescription)")
816
848
  }
817
849
  }
818
850
  }
@@ -989,6 +1021,13 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
989
1021
  return
990
1022
  }
991
1023
  let previewAppId = self.normalizedPreviewAppId(call.getString("appId"))
1024
+ let rawPayloadUrl = call.getString("payloadUrl")
1025
+ let previewPayloadUrl = self.normalizedPreviewPayloadUrl(rawPayloadUrl)
1026
+ if let rawPayloadUrl = rawPayloadUrl, !rawPayloadUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, previewPayloadUrl == nil {
1027
+ logger.error("startPreviewSession called with invalid payloadUrl")
1028
+ call.reject("Invalid preview payloadUrl")
1029
+ return
1030
+ }
992
1031
 
993
1032
  if !self.previewSessionEnabled {
994
1033
  let current = self.implementation.getCurrentBundle()
@@ -1007,6 +1046,13 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1007
1046
  }
1008
1047
 
1009
1048
  UserDefaults.standard.set(self.implementation.appId, forKey: self.previewPreviousAppIdDefaultsKey)
1049
+ if let previousDefaultChannel = UserDefaults.standard.object(forKey: self.defaultChannelDefaultsKey) as? String {
1050
+ UserDefaults.standard.set(previousDefaultChannel, forKey: self.previewPreviousDefaultChannelDefaultsKey)
1051
+ UserDefaults.standard.set(true, forKey: self.previewPreviousDefaultChannelWasSetDefaultsKey)
1052
+ } else {
1053
+ UserDefaults.standard.removeObject(forKey: self.previewPreviousDefaultChannelDefaultsKey)
1054
+ UserDefaults.standard.set(false, forKey: self.previewPreviousDefaultChannelWasSetDefaultsKey)
1055
+ }
1010
1056
  UserDefaults.standard.set(self.shakeMenuEnabled, forKey: self.previewPreviousShakeMenuDefaultsKey)
1011
1057
  UserDefaults.standard.set(self.shakeChannelSelectorEnabled, forKey: self.previewPreviousShakeChannelSelectorDefaultsKey)
1012
1058
  logger.info("Preview session started with fallback bundle: \(current.toString())")
@@ -1018,6 +1064,13 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1018
1064
  logger.info("Preview session using appId: \(previewAppId)")
1019
1065
  }
1020
1066
 
1067
+ if let previewPayloadUrl = previewPayloadUrl {
1068
+ UserDefaults.standard.set(previewPayloadUrl.absoluteString, forKey: self.previewPayloadUrlDefaultsKey)
1069
+ logger.info("Preview session using payload URL")
1070
+ } else {
1071
+ UserDefaults.standard.removeObject(forKey: self.previewPayloadUrlDefaultsKey)
1072
+ }
1073
+
1021
1074
  self.previewSessionEnabled = true
1022
1075
  self.previewSessionAlertPending = true
1023
1076
  self.implementation.previewSession = true
@@ -1030,14 +1083,12 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1030
1083
 
1031
1084
  func leavePreviewSessionFromShakeMenu() -> Bool {
1032
1085
  let previewBundle = self.implementation.getCurrentBundle()
1033
- let configDefaultChannel = self.getConfig().getString("defaultChannel", "")!
1034
1086
 
1035
1087
  let didReset = self.resetToPreviewFallbackBundle()
1036
1088
  guard didReset else {
1037
1089
  return false
1038
1090
  }
1039
1091
 
1040
- _ = self.implementation.unsetChannel(defaultChannelKey: self.defaultChannelDefaultsKey, configDefaultChannel: configDefaultChannel)
1041
1092
  let previewFallbackBundle = self.implementation.getPreviewFallbackBundle()
1042
1093
  self.endPreviewSession()
1043
1094
  let restoredNextBundle = self.implementation.getNextBundle()
@@ -1050,7 +1101,11 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1050
1101
  }
1051
1102
 
1052
1103
  func reloadPreviewSessionFromShakeMenu() -> Bool {
1053
- self._reload()
1104
+ if let payloadUrl = self.storedPreviewPayloadUrl() {
1105
+ return self.refreshPreviewSessionFromPayloadUrl(payloadUrl)
1106
+ }
1107
+
1108
+ return self._reload()
1054
1109
  }
1055
1110
 
1056
1111
  func hasActivePreviewSession() -> Bool {
@@ -1088,6 +1143,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1088
1143
  ?? getConfig().getBoolean("allowShakeChannelSelector", false)
1089
1144
  self.restorePreviewPreviousNextBundle()
1090
1145
  self.restorePreviewPreviousAppId()
1146
+ self.restorePreviewPreviousDefaultChannel()
1091
1147
 
1092
1148
  self.previewSessionEnabled = false
1093
1149
  self.previewSessionAlertPending = false
@@ -1117,6 +1173,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1117
1173
 
1118
1174
  self.restorePreviewPreviousNextBundle()
1119
1175
  self.restorePreviewPreviousAppId()
1176
+ self.restorePreviewPreviousDefaultChannel()
1120
1177
  self.previewSessionEnabled = false
1121
1178
  self.previewSessionAlertPending = false
1122
1179
  self.implementation.previewSession = false
@@ -1132,7 +1189,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1132
1189
  UserDefaults.standard.removeObject(forKey: self.previewPreviousShakeChannelSelectorDefaultsKey)
1133
1190
  UserDefaults.standard.removeObject(forKey: self.previewPreviousNextBundleDefaultsKey)
1134
1191
  UserDefaults.standard.removeObject(forKey: self.previewPreviousAppIdDefaultsKey)
1192
+ UserDefaults.standard.removeObject(forKey: self.previewPreviousDefaultChannelDefaultsKey)
1193
+ UserDefaults.standard.removeObject(forKey: self.previewPreviousDefaultChannelWasSetDefaultsKey)
1135
1194
  UserDefaults.standard.removeObject(forKey: self.previewAppIdDefaultsKey)
1195
+ UserDefaults.standard.removeObject(forKey: self.previewPayloadUrlDefaultsKey)
1136
1196
  UserDefaults.standard.synchronize()
1137
1197
  }
1138
1198
 
@@ -1145,6 +1205,23 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1145
1205
  logger.info("Restored appId after preview: \(previousAppId)")
1146
1206
  }
1147
1207
 
1208
+ private func restorePreviewPreviousDefaultChannel() {
1209
+ let configDefaultChannel = self.getConfig().getString("defaultChannel", "")!
1210
+ let hadPreviousDefaultChannel = UserDefaults.standard.object(forKey: self.previewPreviousDefaultChannelWasSetDefaultsKey) as? Bool ?? false
1211
+
1212
+ guard hadPreviousDefaultChannel,
1213
+ let previousDefaultChannel = UserDefaults.standard.string(forKey: self.previewPreviousDefaultChannelDefaultsKey) else {
1214
+ UserDefaults.standard.removeObject(forKey: self.defaultChannelDefaultsKey)
1215
+ self.implementation.defaultChannel = configDefaultChannel
1216
+ logger.info("Restored defaultChannel after preview to config value")
1217
+ return
1218
+ }
1219
+
1220
+ UserDefaults.standard.set(previousDefaultChannel, forKey: self.defaultChannelDefaultsKey)
1221
+ self.implementation.defaultChannel = previousDefaultChannel
1222
+ logger.info("Restored defaultChannel after preview")
1223
+ }
1224
+
1148
1225
  private func normalizedPreviewAppId(_ rawAppId: String?) -> String? {
1149
1226
  guard let rawAppId else {
1150
1227
  return nil
@@ -1163,6 +1240,99 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1163
1240
  return appId
1164
1241
  }
1165
1242
 
1243
+ private func normalizedPreviewPayloadUrl(_ rawPayloadUrl: String?) -> URL? {
1244
+ guard let rawPayloadUrl else {
1245
+ return nil
1246
+ }
1247
+
1248
+ let payloadUrl = rawPayloadUrl.trimmingCharacters(in: .whitespacesAndNewlines)
1249
+ guard !payloadUrl.isEmpty,
1250
+ let url = URL(string: payloadUrl),
1251
+ url.scheme == "https" || url.scheme == "http" else {
1252
+ return nil
1253
+ }
1254
+
1255
+ return url
1256
+ }
1257
+
1258
+ private func storedPreviewPayloadUrl() -> URL? {
1259
+ normalizedPreviewPayloadUrl(UserDefaults.standard.string(forKey: self.previewPayloadUrlDefaultsKey))
1260
+ }
1261
+
1262
+ private func fetchPreviewPayload(_ payloadUrl: URL) throws -> PreviewPayload {
1263
+ var request = URLRequest(url: payloadUrl)
1264
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
1265
+
1266
+ let semaphore = DispatchSemaphore(value: 0)
1267
+ var responseData: Data?
1268
+ var response: URLResponse?
1269
+ var responseError: Error?
1270
+
1271
+ URLSession.shared.dataTask(with: request) { data, urlResponse, error in
1272
+ responseData = data
1273
+ response = urlResponse
1274
+ responseError = error
1275
+ semaphore.signal()
1276
+ }.resume()
1277
+
1278
+ if semaphore.wait(timeout: .now() + 60) == .timedOut {
1279
+ throw makePreviewError("Preview payload request timed out")
1280
+ }
1281
+
1282
+ if let responseError = responseError {
1283
+ throw responseError
1284
+ }
1285
+
1286
+ let data = responseData ?? Data()
1287
+ if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) {
1288
+ if let payload = try? JSONDecoder().decode(PreviewPayload.self, from: data) {
1289
+ throw makePreviewError(payload.message ?? payload.error ?? "Preview payload request failed with HTTP \(httpResponse.statusCode)")
1290
+ }
1291
+ let message = String(data: data, encoding: .utf8) ?? "Preview payload request failed with HTTP \(httpResponse.statusCode)"
1292
+ throw makePreviewError(message)
1293
+ }
1294
+
1295
+ return try JSONDecoder().decode(PreviewPayload.self, from: data)
1296
+ }
1297
+
1298
+ private func refreshPreviewSessionFromPayloadUrl(_ payloadUrl: URL) -> Bool {
1299
+ do {
1300
+ let payload = try self.fetchPreviewPayload(payloadUrl)
1301
+ guard let version = payload.version, !version.isEmpty else {
1302
+ throw makePreviewError("Preview payload is missing a version")
1303
+ }
1304
+ guard payload.url != nil || payload.manifest?.isEmpty == false else {
1305
+ throw makePreviewError("Preview payload is missing download information")
1306
+ }
1307
+
1308
+ let current = self.implementation.getCurrentBundle()
1309
+ if current.getVersionName() == version {
1310
+ self.logger.info("Preview payload unchanged, reloading current bundle")
1311
+ return self._reload()
1312
+ }
1313
+
1314
+ let next = try self.downloadBundle(
1315
+ // Fallback URL is only provided when payload.url is missing; when manifestEntries is present,
1316
+ // downloadBundle routes through downloadManifest and ignores urlString.
1317
+ urlString: payload.url ?? "https://404.capgo.app/no.zip",
1318
+ version: version,
1319
+ sessionKey: payload.sessionKey ?? "",
1320
+ checksum: payload.checksum ?? "",
1321
+ manifestEntries: payload.manifest
1322
+ )
1323
+
1324
+ guard self.implementation.set(id: next.getId()) else {
1325
+ throw makePreviewError("Downloaded preview bundle cannot be applied")
1326
+ }
1327
+
1328
+ self.notifyBundleSet(next)
1329
+ return self._reload()
1330
+ } catch {
1331
+ self.logger.error("Could not refresh preview session: \(error.localizedDescription)")
1332
+ return false
1333
+ }
1334
+ }
1335
+
1166
1336
  private func clearPreviewSessionForNativeBuildChange() {
1167
1337
  guard self.previewSessionEnabled || self.implementation.getPreviewFallbackBundle() != nil else {
1168
1338
  return
@@ -1174,10 +1344,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1174
1344
  self.shakeMenuEnabled = getConfig().getBoolean("shakeMenu", false)
1175
1345
  self.shakeChannelSelectorEnabled = getConfig().getBoolean("allowShakeChannelSelector", false)
1176
1346
  self.restorePreviewPreviousAppId()
1347
+ self.restorePreviewPreviousDefaultChannel()
1177
1348
  _ = self.implementation.setPreviewFallbackBundle(fallback: nil)
1178
1349
  _ = self.implementation.setNextBundle(next: Optional<String>.none)
1179
- let configDefaultChannel = self.getConfig().getString("defaultChannel", "")!
1180
- _ = self.implementation.unsetChannel(defaultChannelKey: self.defaultChannelDefaultsKey, configDefaultChannel: configDefaultChannel)
1181
1350
  self.clearPreviewSessionPreferences()
1182
1351
  }
1183
1352
 
@@ -97,6 +97,43 @@ import UIKit
97
97
  let timedOut: Bool
98
98
  }
99
99
 
100
+ enum SecurePathError: Error {
101
+ case emptyPath
102
+ case windowsPath
103
+ case absolutePath
104
+ case pathTraversal
105
+ }
106
+
107
+ static func resolvePathInsideDirectory(baseDirectory: URL, relativePath: String) throws -> URL {
108
+ if relativePath.isEmpty {
109
+ throw SecurePathError.emptyPath
110
+ }
111
+ if relativePath.contains("\\") || relativePath.contains("\0") {
112
+ throw SecurePathError.windowsPath
113
+ }
114
+ if (relativePath as NSString).isAbsolutePath {
115
+ throw SecurePathError.absolutePath
116
+ }
117
+
118
+ let canonicalBase = baseDirectory.standardizedFileURL
119
+ let canonicalBasePath = canonicalBase.path
120
+ let normalizedBasePath = canonicalBasePath.hasSuffix("/") ? canonicalBasePath : "\(canonicalBasePath)/"
121
+ let canonicalTarget = canonicalBase.appendingPathComponent(relativePath).standardizedFileURL
122
+ let canonicalTargetPath = canonicalTarget.path
123
+
124
+ if canonicalTargetPath != canonicalBasePath && !canonicalTargetPath.hasPrefix(normalizedBasePath) {
125
+ throw SecurePathError.pathTraversal
126
+ }
127
+
128
+ return canonicalTarget
129
+ }
130
+
131
+ static func resolveManifestTargetPath(baseDirectory: URL, fileName: String) throws -> URL {
132
+ let isBrotli = fileName.hasSuffix(".br")
133
+ let targetFileName = isBrotli ? String(fileName.dropLast(3)) : fileName
134
+ return try resolvePathInsideDirectory(baseDirectory: baseDirectory, relativePath: targetFileName)
135
+ }
136
+
100
137
  private func isTimedOutError(_ error: Error?) -> Bool {
101
138
  guard let nsError = error as NSError? else {
102
139
  return false
@@ -491,21 +528,15 @@ import UIKit
491
528
  }
492
529
  }
493
530
 
494
- private func validateZipEntry(path: String, destUnZip: URL) throws {
495
- // Check for Windows paths
496
- if path.contains("\\") {
531
+ private func resolveZipEntry(path: String, destUnZip: URL) throws -> URL {
532
+ do {
533
+ return try Self.resolvePathInsideDirectory(baseDirectory: destUnZip, relativePath: path)
534
+ } catch SecurePathError.windowsPath {
497
535
  logger.error("Unzip failed: Windows path not supported")
498
536
  logger.debug("Invalid path: \(path)")
499
537
  self.sendStats(action: "windows_path_fail")
500
538
  throw CustomError.cannotUnzip
501
- }
502
-
503
- // Check for path traversal
504
- let fileURL = destUnZip.appendingPathComponent(path)
505
- let canonicalPath = fileURL.standardizedFileURL.path
506
- let canonicalDir = destUnZip.standardizedFileURL.path
507
-
508
- if !canonicalPath.hasPrefix(canonicalDir) {
539
+ } catch {
509
540
  self.sendStats(action: "canonical_path_fail")
510
541
  throw CustomError.cannotUnzip
511
542
  }
@@ -596,10 +627,7 @@ import UIKit
596
627
 
597
628
  do {
598
629
  for entry in archive {
599
- // Validate entry path for security
600
- try validateZipEntry(path: entry.path, destUnZip: destUnZip)
601
-
602
- let destPath = destUnZip.appendingPathComponent(entry.path)
630
+ let destPath = try resolveZipEntry(path: entry.path, destUnZip: destUnZip)
603
631
 
604
632
  if entry.type == .directory {
605
633
  try FileManager.default.createDirectory(at: destPath, withIntermediateDirectories: true, attributes: nil)
@@ -1100,8 +1128,22 @@ import UIKit
1100
1128
  let legacyCacheFilePath: URL? = isBrotli ? cacheFolder.appendingPathComponent("\(finalFileHash)_\(fileNameWithoutPath)") : nil
1101
1129
 
1102
1130
  let destFileName = isBrotli ? String(fileName.dropLast(3)) : fileName
1103
- let destFilePath = destFolder.appendingPathComponent(destFileName)
1104
- let builtinFilePath = builtinFolder.appendingPathComponent(fileName)
1131
+ let destFilePath: URL
1132
+ let builtinFilePath: URL
1133
+ do {
1134
+ destFilePath = try Self.resolveManifestTargetPath(baseDirectory: destFolder, fileName: fileName)
1135
+ builtinFilePath = try Self.resolvePathInsideDirectory(baseDirectory: builtinFolder, relativePath: fileName)
1136
+ } catch {
1137
+ logger.error("Invalid manifest file path: \(fileName)")
1138
+ self.sendStats(action: "manifest_path_fail", versionName: "\(version):\(fileName)")
1139
+ errorLock.lock()
1140
+ if downloadError == nil {
1141
+ downloadError = error
1142
+ }
1143
+ errorLock.unlock()
1144
+ hasError.value = true
1145
+ continue
1146
+ }
1105
1147
 
1106
1148
  // Create parent directories synchronously (before operations start)
1107
1149
  try? FileManager.default.createDirectory(at: destFilePath.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
@@ -81,9 +81,11 @@ extension UIWindow {
81
81
  })
82
82
 
83
83
  alertShake.addAction(UIAlertAction(title: reloadButtonTitle, style: .default) { _ in
84
- DispatchQueue.main.async {
84
+ DispatchQueue.global(qos: .userInitiated).async {
85
85
  if !plugin.reloadPreviewSessionFromShakeMenu() {
86
- self.showError(message: "Could not reload the test app.", plugin: plugin)
86
+ DispatchQueue.main.async {
87
+ self.showError(message: "Could not reload the test app.", plugin: plugin)
88
+ }
87
89
  }
88
90
  }
89
91
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-updater",
3
- "version": "8.47.2",
3
+ "version": "8.47.4",
4
4
  "license": "MPL-2.0",
5
5
  "description": "Live update for capacitor apps",
6
6
  "main": "dist/plugin.cjs.js",