@capgo/capacitor-updater 8.51.20 → 8.51.22

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.20"
99
+ private let pluginVersion: String = "8.51.22"
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"
@@ -3496,13 +3496,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3496
3496
  }
3497
3497
 
3498
3498
  private func performHideSplashscreen() {
3499
- self.cancelSplashscreenTimeout()
3500
3499
  self.removeSplashscreenLoader()
3501
3500
  self.splashscreenInvocationToken += 1
3502
- self.invokeSplashscreenMethod(
3503
- methodName: "hide",
3504
- callbackId: "autoHideSplashscreen",
3505
- options: self.splashscreenOptions(methodName: "hide"),
3501
+ self.invokeSplashscreenAction(
3502
+ .hide,
3506
3503
  retriesRemaining: self.splashscreenMaxRetries,
3507
3504
  requestToken: self.splashscreenInvocationToken
3508
3505
  )
@@ -3522,10 +3519,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3522
3519
  self.cancelSplashscreenTimeout()
3523
3520
  self.autoSplashscreenTimedOut = false
3524
3521
  self.splashscreenInvocationToken += 1
3525
- self.invokeSplashscreenMethod(
3526
- methodName: "show",
3527
- callbackId: "autoShowSplashscreen",
3528
- options: self.splashscreenOptions(methodName: "show"),
3522
+ self.invokeSplashscreenAction(
3523
+ .show,
3529
3524
  retriesRemaining: self.splashscreenMaxRetries,
3530
3525
  requestToken: self.splashscreenInvocationToken
3531
3526
  )
@@ -3535,7 +3530,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3535
3530
  }
3536
3531
 
3537
3532
  private func splashscreenOptions(methodName: String) -> [String: Any] {
3538
- methodName == "show" ? ["autoHide": false] : [:]
3533
+ methodName == "show" ? ["autoHide": false, "fadeInDuration": 0] : [:]
3539
3534
  }
3540
3535
 
3541
3536
  private func splashscreenCompletedMessage(methodName: String) -> String {
@@ -3554,20 +3549,59 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3554
3549
  self.splashscreenInvocationToken += 1
3555
3550
  }
3556
3551
 
3557
- private func makeSplashscreenCall(callbackId: String, options: [String: Any], methodName: String) -> CAPPluginCall {
3558
- CAPPluginCall(callbackId: callbackId, methodName: methodName, options: options, success: { [weak self] (_, _) in
3559
- guard let self = self else { return }
3560
- self.logger.info(self.splashscreenCompletedMessage(methodName: methodName))
3561
- }, error: { [weak self] (_) in
3562
- guard let self = self else { return }
3563
- self.logger.error("Failed to auto-\(methodName) splashscreen")
3564
- })
3552
+ private enum SplashscreenAction {
3553
+ case show
3554
+ case hide
3555
+
3556
+ var methodName: String {
3557
+ switch self {
3558
+ case .show:
3559
+ return "show"
3560
+ case .hide:
3561
+ return "hide"
3562
+ }
3563
+ }
3564
+
3565
+ var unavailableMessageVerb: String {
3566
+ switch self {
3567
+ case .show:
3568
+ return "showing"
3569
+ case .hide:
3570
+ return "hiding"
3571
+ }
3572
+ }
3573
+ }
3574
+
3575
+ private func splashscreenOptionsJSON(methodName: String) -> String {
3576
+ let options = self.splashscreenOptions(methodName: methodName)
3577
+ guard !options.isEmpty,
3578
+ let data = try? JSONSerialization.data(withJSONObject: options),
3579
+ let json = String(data: data, encoding: .utf8) else {
3580
+ return "{}"
3581
+ }
3582
+ return json
3583
+ }
3584
+
3585
+ private func splashscreenPluginExposesMethod(_ methodName: String, on plugin: CAPPlugin) -> Bool {
3586
+ guard let bridgedPlugin = plugin as? CAPBridgedPlugin else {
3587
+ return false
3588
+ }
3589
+ return bridgedPlugin.pluginMethods.contains { $0.name == methodName }
3590
+ }
3591
+
3592
+ private func splashscreenBridgeScript(for action: SplashscreenAction) -> String {
3593
+ let optionsJSON = self.splashscreenOptionsJSON(methodName: action.methodName)
3594
+ return """
3595
+ var cap = window.Capacitor;
3596
+ if (!cap || typeof cap.nativePromise !== 'function') {
3597
+ throw new Error('Capacitor bridge not ready');
3598
+ }
3599
+ return await cap.nativePromise('\(self.splashscreenPluginName)', '\(action.methodName)', \(optionsJSON));
3600
+ """
3565
3601
  }
3566
3602
 
3567
- private func invokeSplashscreenMethod(
3568
- methodName: String,
3569
- callbackId: String,
3570
- options: [String: Any],
3603
+ private func invokeSplashscreenAction(
3604
+ _ action: SplashscreenAction,
3571
3605
  retriesRemaining: Int,
3572
3606
  requestToken: Int
3573
3607
  ) {
@@ -3576,22 +3610,18 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3576
3610
  }
3577
3611
 
3578
3612
  guard let bridge = self.bridge else {
3579
- self.retrySplashscreenMethod(
3580
- methodName: methodName,
3581
- callbackId: callbackId,
3582
- options: options,
3613
+ self.retrySplashscreenAction(
3614
+ action,
3583
3615
  retriesRemaining: retriesRemaining,
3584
3616
  requestToken: requestToken,
3585
- message: "Bridge not available for \(methodName == "show" ? "showing" : "hiding") splashscreen with autoSplashscreen"
3617
+ message: "Bridge not available for \(action.unavailableMessageVerb) splashscreen with autoSplashscreen"
3586
3618
  )
3587
3619
  return
3588
3620
  }
3589
3621
 
3590
3622
  guard let splashScreenPlugin = bridge.plugin(withName: self.splashscreenPluginName) else {
3591
- self.retrySplashscreenMethod(
3592
- methodName: methodName,
3593
- callbackId: callbackId,
3594
- options: options,
3623
+ self.retrySplashscreenAction(
3624
+ action,
3595
3625
  retriesRemaining: retriesRemaining,
3596
3626
  requestToken: requestToken,
3597
3627
  message: "autoSplashscreen: SplashScreen plugin not found. Install @capacitor/splash-screen plugin."
@@ -3599,37 +3629,86 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3599
3629
  return
3600
3630
  }
3601
3631
 
3602
- let selector = NSSelectorFromString("\(methodName):")
3603
- guard splashScreenPlugin.responds(to: selector) else {
3604
- self.retrySplashscreenMethod(
3605
- methodName: methodName,
3606
- callbackId: callbackId,
3607
- options: options,
3632
+ guard self.splashscreenPluginExposesMethod(action.methodName, on: splashScreenPlugin) else {
3633
+ self.retrySplashscreenAction(
3634
+ action,
3635
+ retriesRemaining: retriesRemaining,
3636
+ requestToken: requestToken,
3637
+ message: "autoSplashscreen: SplashScreen plugin does not expose \(action.methodName)(). Make sure @capacitor/splash-screen plugin is properly installed."
3638
+ )
3639
+ return
3640
+ }
3641
+
3642
+ guard let webView = bridge.webView else {
3643
+ self.retrySplashscreenAction(
3644
+ action,
3608
3645
  retriesRemaining: retriesRemaining,
3609
3646
  requestToken: requestToken,
3610
- message: "autoSplashscreen: SplashScreen plugin does not respond to \(methodName): method. Make sure @capacitor/splash-screen plugin is properly installed."
3647
+ message: "WebView not available for \(action.unavailableMessageVerb) splashscreen with autoSplashscreen"
3611
3648
  )
3612
3649
  return
3613
3650
  }
3614
3651
 
3615
- let call = self.makeSplashscreenCall(callbackId: callbackId, options: options, methodName: methodName)
3616
- _ = splashScreenPlugin.perform(selector, with: call)
3617
- self.logger.info("Called SplashScreen \(methodName) method")
3652
+ let script = self.splashscreenBridgeScript(for: action)
3653
+ let runInvocation = { [weak self] in
3654
+ guard let self = self, requestToken == self.splashscreenInvocationToken else {
3655
+ return
3656
+ }
3657
+ webView.callAsyncJavaScript(
3658
+ script,
3659
+ arguments: [:],
3660
+ in: nil,
3661
+ in: .page
3662
+ ) { [weak self] (result: Result<Any, Error>) in
3663
+ guard let self = self, requestToken == self.splashscreenInvocationToken else {
3664
+ return
3665
+ }
3666
+ if case .failure(let error) = result {
3667
+ self.retrySplashscreenAction(
3668
+ action,
3669
+ retriesRemaining: retriesRemaining,
3670
+ requestToken: requestToken,
3671
+ message: "Failed to invoke SplashScreen \(action.methodName) via Capacitor bridge: \(error.localizedDescription)"
3672
+ )
3673
+ return
3674
+ }
3675
+ if action == .hide {
3676
+ self.cancelSplashscreenTimeout()
3677
+ }
3678
+ self.logger.info("Called SplashScreen \(action.methodName) method")
3679
+ self.logger.info(self.splashscreenCompletedMessage(methodName: action.methodName))
3680
+ }
3681
+ }
3682
+
3683
+ if Thread.isMainThread {
3684
+ runInvocation()
3685
+ } else {
3686
+ DispatchQueue.main.async(execute: runInvocation)
3687
+ }
3618
3688
  }
3619
3689
 
3620
- private func retrySplashscreenMethod(
3621
- methodName: String,
3622
- callbackId: String,
3623
- options: [String: Any],
3690
+ private func retrySplashscreenAction(
3691
+ _ action: SplashscreenAction,
3624
3692
  retriesRemaining: Int,
3625
3693
  requestToken: Int,
3626
3694
  message: String
3627
3695
  ) {
3628
3696
  guard retriesRemaining > 0 else {
3629
- if methodName == "show" {
3697
+ if action == .show {
3630
3698
  self.logger.warn(message)
3631
- } else {
3632
- self.logger.error(message)
3699
+ return
3700
+ }
3701
+
3702
+ self.logger.error("\(message). Scheduling another hide retry.")
3703
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(self.splashscreenRetryDelayMilliseconds)) { [weak self] in
3704
+ guard let self = self, requestToken == self.splashscreenInvocationToken else {
3705
+ return
3706
+ }
3707
+ self.invokeSplashscreenAction(
3708
+ action,
3709
+ retriesRemaining: self.splashscreenMaxRetries,
3710
+ requestToken: requestToken
3711
+ )
3633
3712
  }
3634
3713
  return
3635
3714
  }
@@ -3639,10 +3718,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3639
3718
  guard let self = self, requestToken == self.splashscreenInvocationToken else {
3640
3719
  return
3641
3720
  }
3642
- self.invokeSplashscreenMethod(
3643
- methodName: methodName,
3644
- callbackId: callbackId,
3645
- options: options,
3721
+ self.invokeSplashscreenAction(
3722
+ action,
3646
3723
  retriesRemaining: retriesRemaining - 1,
3647
3724
  requestToken: requestToken
3648
3725
  )
@@ -134,6 +134,10 @@ import UIKit
134
134
  case pathTraversal
135
135
  }
136
136
 
137
+ static func containsPathTraversalSegment(_ relativePath: String) -> Bool {
138
+ return relativePath.split(separator: "/").contains(where: { $0 == ".." })
139
+ }
140
+
137
141
  static func resolvePathInsideDirectory(baseDirectory: URL, relativePath: String) throws -> URL {
138
142
  if relativePath.isEmpty {
139
143
  throw SecurePathError.emptyPath
@@ -141,6 +145,9 @@ import UIKit
141
145
  if relativePath.contains("\\") || relativePath.contains("\0") {
142
146
  throw SecurePathError.windowsPath
143
147
  }
148
+ if containsPathTraversalSegment(relativePath) {
149
+ throw SecurePathError.pathTraversal
150
+ }
144
151
  if (relativePath as NSString).isAbsolutePath {
145
152
  throw SecurePathError.absolutePath
146
153
  }
@@ -151,7 +158,8 @@ import UIKit
151
158
  let canonicalTarget = canonicalBase.appendingPathComponent(relativePath).standardizedFileURL
152
159
  let canonicalTargetPath = canonicalTarget.path
153
160
 
154
- if canonicalTargetPath != canonicalBasePath && !canonicalTargetPath.hasPrefix(normalizedBasePath) {
161
+ // Require a strict child of the base. Equality would accept "." and wipe/write the root.
162
+ if !canonicalTargetPath.hasPrefix(normalizedBasePath) {
155
163
  throw SecurePathError.pathTraversal
156
164
  }
157
165
 
@@ -164,6 +172,22 @@ import UIKit
164
172
  return try resolvePathInsideDirectory(baseDirectory: baseDirectory, relativePath: targetFileName)
165
173
  }
166
174
 
175
+ static func rememberManifestTarget(_ seenTargets: inout Set<String>, targetFile: URL) -> Bool {
176
+ return seenTargets.insert(targetFile.standardizedFileURL.path).inserted
177
+ }
178
+
179
+ private struct ManifestDownloadTask {
180
+ let fileName: String
181
+ let downloadUrl: String
182
+ let finalFileHash: String
183
+ let isBrotli: Bool
184
+ let destFileName: String
185
+ let destFilePath: URL
186
+ let builtinFilePath: URL
187
+ let cacheFilePath: URL?
188
+ let legacyCacheFilePath: URL?
189
+ }
190
+
167
191
  private func isTimedOutError(_ error: Error?) -> Bool {
168
192
  guard let nsError = error as NSError? else {
169
193
  return false
@@ -1416,8 +1440,8 @@ import UIKit
1416
1440
  var downloadError: Error?
1417
1441
  let errorLock = NSLock()
1418
1442
 
1419
- // Create operations for each file
1420
- var operations: [Operation] = []
1443
+ var tasks: [ManifestDownloadTask] = []
1444
+ var seenTargets = Set<String>()
1421
1445
 
1422
1446
  for entry in manifest {
1423
1447
  guard let fileName = entry.file_name,
@@ -1489,6 +1513,24 @@ import UIKit
1489
1513
  do {
1490
1514
  destFilePath = try Self.resolveManifestTargetPath(baseDirectory: destFolder, fileName: fileName)
1491
1515
  builtinFilePath = try Self.resolveManifestTargetPath(baseDirectory: builtinFolder, fileName: fileName)
1516
+ if !Self.rememberManifestTarget(&seenTargets, targetFile: destFilePath) {
1517
+ logger.error("Duplicate manifest target path: \(fileName)")
1518
+ self.sendStats(action: "manifest_path_fail", versionName: "\(version):\(fileName)")
1519
+ let error = NSError(
1520
+ domain: "ManifestEntryError",
1521
+ code: 3,
1522
+ userInfo: [
1523
+ NSLocalizedDescriptionKey: "Duplicate manifest target path for \(fileName)"
1524
+ ]
1525
+ )
1526
+ errorLock.lock()
1527
+ if downloadError == nil {
1528
+ downloadError = error
1529
+ }
1530
+ errorLock.unlock()
1531
+ hasError.value = true
1532
+ continue
1533
+ }
1492
1534
  } catch {
1493
1535
  logger.error("Invalid manifest file path: \(fileName)")
1494
1536
  self.sendStats(action: "manifest_path_fail", versionName: "\(version):\(fileName)")
@@ -1501,8 +1543,40 @@ import UIKit
1501
1543
  continue
1502
1544
  }
1503
1545
 
1504
- // Create parent directories synchronously (before operations start)
1505
- try? FileManager.default.createDirectory(at: destFilePath.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
1546
+ tasks.append(
1547
+ ManifestDownloadTask(
1548
+ fileName: fileName,
1549
+ downloadUrl: downloadUrl,
1550
+ finalFileHash: finalFileHash,
1551
+ isBrotli: isBrotli,
1552
+ destFileName: destFileName,
1553
+ destFilePath: destFilePath,
1554
+ builtinFilePath: builtinFilePath,
1555
+ cacheFilePath: cacheFilePath,
1556
+ legacyCacheFilePath: legacyCacheFilePath
1557
+ )
1558
+ )
1559
+ }
1560
+
1561
+ if hasError.value {
1562
+ let resolvedError = downloadError ?? NSError(
1563
+ domain: "ManifestDownloadError",
1564
+ code: 1,
1565
+ userInfo: [NSLocalizedDescriptionKey: "Manifest download failed due to invalid or missing entries"]
1566
+ )
1567
+ let errorBundle = bundleInfo.setStatus(status: BundleStatus.ERROR.storedValue)
1568
+ self.saveBundleInfo(id: id, bundle: errorBundle)
1569
+ throw resolvedError
1570
+ }
1571
+
1572
+ var operations: [Operation] = []
1573
+
1574
+ for task in tasks {
1575
+ try FileManager.default.createDirectory(
1576
+ at: task.destFilePath.deletingLastPathComponent(),
1577
+ withIntermediateDirectories: true,
1578
+ attributes: nil
1579
+ )
1506
1580
 
1507
1581
  let operation = BlockOperation { [weak self] in
1508
1582
  guard let self = self else { return }
@@ -1510,26 +1584,29 @@ import UIKit
1510
1584
 
1511
1585
  do {
1512
1586
  // Try builtin first
1513
- if FileManager.default.fileExists(atPath: builtinFilePath.path) && self.verifyChecksum(file: builtinFilePath, expectedHash: finalFileHash) {
1514
- try self.copyItemReplacing(from: builtinFilePath, to: destFilePath)
1515
- self.logger.info("downloadManifest \(fileName) using builtin file \(id)")
1587
+ if FileManager.default.fileExists(atPath: task.builtinFilePath.path) &&
1588
+ self.verifyChecksum(file: task.builtinFilePath, expectedHash: task.finalFileHash) {
1589
+ try self.copyItemReplacing(from: task.builtinFilePath, to: task.destFilePath)
1590
+ self.logger.info("downloadManifest \(task.fileName) using builtin file \(id)")
1516
1591
  }
1517
1592
  // Try cache
1518
1593
  else if
1519
- (cacheFilePath != nil && self.tryCopyFromCache(from: cacheFilePath!, to: destFilePath, expectedHash: finalFileHash)) ||
1520
- (legacyCacheFilePath != nil && self.tryCopyFromCache(from: legacyCacheFilePath!, to: destFilePath, expectedHash: finalFileHash)) {
1521
- self.logger.info("downloadManifest \(fileName) copy from cache \(id)")
1594
+ (task.cacheFilePath != nil &&
1595
+ self.tryCopyFromCache(from: task.cacheFilePath!, to: task.destFilePath, expectedHash: task.finalFileHash)) ||
1596
+ (task.legacyCacheFilePath != nil &&
1597
+ self.tryCopyFromCache(from: task.legacyCacheFilePath!, to: task.destFilePath, expectedHash: task.finalFileHash)) {
1598
+ self.logger.info("downloadManifest \(task.fileName) copy from cache \(id)")
1522
1599
  }
1523
1600
  // Download
1524
1601
  else {
1525
1602
  try self.downloadManifestFile(
1526
- downloadUrl: downloadUrl,
1527
- destFilePath: destFilePath,
1528
- cacheFilePath: cacheFilePath,
1529
- fileHash: finalFileHash,
1530
- fileName: fileName,
1531
- destFileName: destFileName,
1532
- isBrotli: isBrotli,
1603
+ downloadUrl: task.downloadUrl,
1604
+ destFilePath: task.destFilePath,
1605
+ cacheFilePath: task.cacheFilePath,
1606
+ fileHash: task.finalFileHash,
1607
+ fileName: task.fileName,
1608
+ destFileName: task.destFileName,
1609
+ isBrotli: task.isBrotli,
1533
1610
  sessionKey: sessionKey,
1534
1611
  version: version,
1535
1612
  bundleId: id
@@ -1547,8 +1624,8 @@ import UIKit
1547
1624
  }
1548
1625
  errorLock.unlock()
1549
1626
  hasError.value = true
1550
- self.logger.error("Manifest file download failed: \(fileName)")
1551
- self.logger.debug("Bundle: \(id), File: \(fileName), Error: \(error.localizedDescription)")
1627
+ self.logger.error("Manifest file download failed: \(task.fileName)")
1628
+ self.logger.debug("Bundle: \(id), File: \(task.fileName), Error: \(error.localizedDescription)")
1552
1629
  }
1553
1630
  }
1554
1631
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-updater",
3
- "version": "8.51.20",
3
+ "version": "8.51.22",
4
4
  "license": "MPL-2.0",
5
5
  "description": "Live update for capacitor apps",
6
6
  "main": "dist/plugin.cjs.js",
@@ -63,7 +63,7 @@
63
63
  "test:maestro:ios:smoke": "./scripts/test-maestro-ios.sh",
64
64
  "lint": "bun run eslint && bun run prettier -- --check && bun run swiftlint -- lint",
65
65
  "fmt": "bun run eslint -- --fix && bun run prettier -- --write && bun run swiftlint -- --fix --format",
66
- "eslint": "eslint . --ext .ts",
66
+ "eslint": "eslint .",
67
67
  "prettier": "prettier-pretty-check \"**/*.{css,html,ts,js,java}\" --plugin=prettier-plugin-java",
68
68
  "swiftlint": "node-swiftlint",
69
69
  "docgen": "node scripts/generate-docs.js",
@@ -87,12 +87,13 @@
87
87
  "@capacitor/core": "^8.5.0",
88
88
  "@capacitor/docgen": "^0.3.0",
89
89
  "@capacitor/ios": "^8.5.0",
90
- "@ionic/eslint-config": "^0.4.0",
90
+ "@ionic/eslint-config": "^0.5.0",
91
+ "@typescript-eslint/eslint-plugin": "^8.70.0",
92
+ "@typescript-eslint/parser": "^8.70.0",
91
93
  "@ionic/prettier-config": "^4.0.0",
92
94
  "@ionic/swiftlint-config": "^2.0.0",
93
95
  "@types/node": "^26.0.0",
94
- "eslint": "^8.57.0",
95
- "eslint-plugin-import": "^2.32.0",
96
+ "eslint": "^10.0.0",
96
97
  "husky": "^9.1.7",
97
98
  "prettier": "^3.6.2",
98
99
  "prettier-plugin-java": "^2.7.4",
@@ -107,9 +108,6 @@
107
108
  },
108
109
  "prettier": "@ionic/prettier-config",
109
110
  "swiftlint": "@ionic/swiftlint-config",
110
- "eslintConfig": {
111
- "extends": "@ionic/eslint-config/recommended"
112
- },
113
111
  "capacitor": {
114
112
  "ios": {
115
113
  "src": "ios"