@capgo/capacitor-updater 8.47.1 → 8.47.3

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,10 +79,16 @@ 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.1"
82
+ private let pluginVersion: String = "8.47.3"
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"
86
+ static let autoUpdateModeOff = "off"
87
+ static let autoUpdateModeBackground = "atBackground"
88
+ static let autoUpdateModeInstall = "atInstall"
89
+ static let autoUpdateModeLaunch = "onLaunch"
90
+ static let autoUpdateModeAlways = "always"
91
+ static let autoUpdateModeOnlyDownload = "onlyDownload"
86
92
  private let keepUrlPathFlagKey = "__capgo_keep_url_path_after_reload"
87
93
  private let customIdDefaultsKey = "CapacitorUpdater.customId"
88
94
  private let updateUrlDefaultsKey = "CapacitorUpdater.updateUrl"
@@ -102,6 +108,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
102
108
  private var currentVersionNative: Version = "0.0.0"
103
109
  private var currentBuildVersion: String = "0"
104
110
  private var autoUpdate = false
111
+ private var autoUpdateMode = CapacitorUpdaterPlugin.autoUpdateModeOff
105
112
  private var appReadyTimeout = 10000
106
113
  private var appReadyCheck: DispatchWorkItem?
107
114
  private var resetWhenUpdate = true
@@ -203,33 +210,6 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
203
210
  keepUrlPathAfterReload = getConfig().getBoolean("keepUrlPathAfterReload", false)
204
211
  syncKeepUrlPathFlag(enabled: keepUrlPathAfterReload)
205
212
 
206
- // Handle directUpdate configuration - support string values and backward compatibility
207
- if let directUpdateString = getConfig().getString("directUpdate") {
208
- // Handle backward compatibility for boolean true
209
- if directUpdateString == "true" {
210
- directUpdateMode = "always"
211
- directUpdate = true
212
- } else {
213
- directUpdateMode = directUpdateString
214
- directUpdate = directUpdateString == "always" || directUpdateString == "atInstall" || directUpdateString == "onLaunch"
215
- // Validate directUpdate value
216
- if directUpdateString != "false" && directUpdateString != "always" && directUpdateString != "atInstall" && directUpdateString != "onLaunch" {
217
- logger.error("Invalid directUpdate value: \"\(directUpdateString)\". Supported values are: \"false\", \"true\", \"always\", \"atInstall\", \"onLaunch\". Defaulting to \"false\".")
218
- directUpdateMode = "false"
219
- directUpdate = false
220
- }
221
- }
222
- } else {
223
- let directUpdateBool = getConfig().getBoolean("directUpdate", false)
224
- if directUpdateBool {
225
- directUpdateMode = "always" // backward compatibility: true = always
226
- directUpdate = true
227
- } else {
228
- directUpdateMode = "false"
229
- directUpdate = false
230
- }
231
- }
232
-
233
213
  autoSplashscreen = getConfig().getBoolean("autoSplashscreen", false)
234
214
  autoSplashscreenLoader = getConfig().getBoolean("autoSplashscreenLoader", false)
235
215
  let splashscreenTimeoutValue = getConfig().getInt("autoSplashscreenTimeout", 10000)
@@ -239,7 +219,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
239
219
  updateUrl = storedUpdateUrl
240
220
  logger.info("Loaded persisted updateUrl")
241
221
  }
242
- autoUpdate = getConfig().getBoolean("autoUpdate", true)
222
+ configureAutoUpdateModeFromConfig()
243
223
  appReadyTimeout = max(1000, getConfig().getInt("appReadyTimeout", 10000)) // Minimum 1 second
244
224
  implementation.timeout = Double(getConfig().getInt("responseTimeout", 20))
245
225
  resetWhenUpdate = getConfig().getBoolean("resetWhenUpdate", true)
@@ -787,7 +767,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
787
767
  let url = URL(string: urlString)
788
768
  logger.info("Downloading \(String(describing: url))")
789
769
  self.saveCallForAsyncHandling(call)
790
- DispatchQueue.global(qos: .background).async {
770
+ self.runBackgroundDownloadWork {
791
771
  do {
792
772
  let next: BundleInfo
793
773
  if let manifestEntries = self.manifestEntries(from: manifestArray) {
@@ -2076,6 +2056,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2076
2056
  }
2077
2057
 
2078
2058
  private func shouldUseDirectUpdate() -> Bool {
2059
+ if !self.autoUpdate || self.autoUpdateMode == Self.autoUpdateModeOnlyDownload {
2060
+ return false
2061
+ }
2079
2062
  if self.autoSplashscreenTimedOut {
2080
2063
  return false
2081
2064
  }
@@ -2102,6 +2085,99 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2102
2085
  }
2103
2086
  }
2104
2087
 
2088
+ private func configureAutoUpdateModeFromConfig() {
2089
+ if let configuredMode = getConfig().getString("autoUpdate"),
2090
+ configuredMode != "",
2091
+ configuredMode != "true",
2092
+ configuredMode != "false" {
2093
+ autoUpdateMode = Self.normalizedAutoUpdateMode(configuredMode)
2094
+ if autoUpdateMode != configuredMode {
2095
+ logger.error(
2096
+ "Invalid autoUpdate value: \"\(configuredMode)\". Supported values are: true, false, " +
2097
+ "\"off\", \"atBackground\", \"atInstall\", \"onLaunch\", \"always\", \"onlyDownload\". Defaulting to \"atBackground\"."
2098
+ )
2099
+ }
2100
+ } else {
2101
+ let configuredMode = getConfig().getString("autoUpdate")
2102
+ let enabled = configuredMode != nil ? configuredMode == "true" : getConfig().getBoolean("autoUpdate", true)
2103
+ autoUpdateMode = enabled
2104
+ ? Self.autoUpdateModeForLegacyDirectUpdateMode(resolveLegacyDirectUpdateModeFromConfig())
2105
+ : Self.autoUpdateModeOff
2106
+ }
2107
+
2108
+ autoUpdate = Self.isAutoUpdateModeEnabled(autoUpdateMode)
2109
+ directUpdateMode = Self.directUpdateModeForAutoUpdateMode(autoUpdateMode)
2110
+ directUpdate = Self.isDirectUpdateMode(directUpdateMode)
2111
+ }
2112
+
2113
+ private func resolveLegacyDirectUpdateModeFromConfig() -> String {
2114
+ if let directUpdateString = getConfig().getString("directUpdate") {
2115
+ if directUpdateString == "true" {
2116
+ return Self.autoUpdateModeAlways
2117
+ }
2118
+ if directUpdateString == "false" || Self.isDirectUpdateMode(directUpdateString) {
2119
+ return directUpdateString
2120
+ }
2121
+ logger.error(
2122
+ "Invalid directUpdate value: \"\(directUpdateString)\". Supported values are: false, true, " +
2123
+ "\"always\", \"atInstall\", \"onLaunch\". Defaulting to \"false\"."
2124
+ )
2125
+ return "false"
2126
+ }
2127
+
2128
+ return getConfig().getBoolean("directUpdate", false) ? Self.autoUpdateModeAlways : "false"
2129
+ }
2130
+
2131
+ static func normalizedAutoUpdateMode(_ value: String?) -> String {
2132
+ guard let value else {
2133
+ return autoUpdateModeBackground
2134
+ }
2135
+ switch value {
2136
+ case "false", autoUpdateModeOff:
2137
+ return autoUpdateModeOff
2138
+ case "true", autoUpdateModeBackground:
2139
+ return autoUpdateModeBackground
2140
+ case autoUpdateModeInstall, autoUpdateModeLaunch, autoUpdateModeAlways, autoUpdateModeOnlyDownload:
2141
+ return value
2142
+ default:
2143
+ return autoUpdateModeBackground
2144
+ }
2145
+ }
2146
+
2147
+ static func autoUpdateModeForLegacyDirectUpdateMode(_ directUpdateMode: String) -> String {
2148
+ switch directUpdateMode {
2149
+ case autoUpdateModeInstall, autoUpdateModeLaunch, autoUpdateModeAlways:
2150
+ return directUpdateMode
2151
+ default:
2152
+ return autoUpdateModeBackground
2153
+ }
2154
+ }
2155
+
2156
+ static func directUpdateModeForAutoUpdateMode(_ autoUpdateMode: String) -> String {
2157
+ switch autoUpdateMode {
2158
+ case autoUpdateModeInstall, autoUpdateModeLaunch, autoUpdateModeAlways:
2159
+ return autoUpdateMode
2160
+ default:
2161
+ return "false"
2162
+ }
2163
+ }
2164
+
2165
+ static func isAutoUpdateModeEnabled(_ autoUpdateMode: String) -> Bool {
2166
+ autoUpdateMode != autoUpdateModeOff
2167
+ }
2168
+
2169
+ static func shouldAutoUpdateModeSetNextBundle(_ autoUpdateMode: String) -> Bool {
2170
+ isAutoUpdateModeEnabled(autoUpdateMode) && autoUpdateMode != autoUpdateModeOnlyDownload
2171
+ }
2172
+
2173
+ static func isDirectUpdateMode(_ directUpdateMode: String) -> Bool {
2174
+ directUpdateMode == autoUpdateModeInstall || directUpdateMode == autoUpdateModeLaunch || directUpdateMode == autoUpdateModeAlways
2175
+ }
2176
+
2177
+ private func shouldAutoSetNextBundle() -> Bool {
2178
+ Self.shouldAutoUpdateModeSetNextBundle(autoUpdateMode)
2179
+ }
2180
+
2105
2181
  static func shouldConsumeOnLaunchDirectUpdate(directUpdateMode: String, plannedDirectUpdate: Bool) -> Bool {
2106
2182
  plannedDirectUpdate && directUpdateMode == "onLaunch"
2107
2183
  }
@@ -2135,6 +2211,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2135
2211
 
2136
2212
  func configureDirectUpdateModeForTesting(_ directUpdateMode: String, onLaunchDirectUpdateUsed: Bool = false) {
2137
2213
  self.directUpdateMode = directUpdateMode
2214
+ self.autoUpdateMode = Self.autoUpdateModeForLegacyDirectUpdateMode(directUpdateMode)
2215
+ self.autoUpdate = Self.isAutoUpdateModeEnabled(self.autoUpdateMode)
2216
+ self.directUpdate = Self.isDirectUpdateMode(self.directUpdateMode)
2138
2217
  self.setOnLaunchDirectUpdateUsed(onLaunchDirectUpdateUsed)
2139
2218
  }
2140
2219
 
@@ -2142,6 +2221,13 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2142
2221
  self.updateUrl = updateUrl
2143
2222
  }
2144
2223
 
2224
+ func setAutoUpdateModeForTesting(_ autoUpdateMode: String) {
2225
+ self.autoUpdateMode = Self.normalizedAutoUpdateMode(autoUpdateMode)
2226
+ self.autoUpdate = Self.isAutoUpdateModeEnabled(self.autoUpdateMode)
2227
+ self.directUpdateMode = Self.directUpdateModeForAutoUpdateMode(self.autoUpdateMode)
2228
+ self.directUpdate = Self.isDirectUpdateMode(self.directUpdateMode)
2229
+ }
2230
+
2145
2231
  func setCurrentBuildVersionForTesting(_ currentBuildVersion: String) {
2146
2232
  self.currentBuildVersion = currentBuildVersion
2147
2233
  }
@@ -2237,7 +2323,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2237
2323
  plannedDirectUpdate: Bool = false,
2238
2324
  failureAction: String = "download_fail",
2239
2325
  failureEvent: String = "downloadFailed",
2240
- sendStats: Bool = true
2326
+ sendStats: Bool = true,
2327
+ notifyNoNeedUpdate: Bool = true
2241
2328
  ) {
2242
2329
  // Clear download in progress flag - this is called at the end of every download attempt
2243
2330
  // whether it succeeds, fails, or is skipped (e.g., already up to date)
@@ -2254,7 +2341,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2254
2341
  }
2255
2342
  self.notifyListeners(failureEvent, data: ["version": latestVersionName])
2256
2343
  }
2257
- self.notifyListeners("noNeedUpdate", data: ["bundle": current.toJSON()])
2344
+ if notifyNoNeedUpdate {
2345
+ self.notifyListeners("noNeedUpdate", data: ["bundle": current.toJSON()])
2346
+ }
2258
2347
  self.sendReadyToJs(current: current, msg: msg)
2259
2348
  logger.info("endBackGroundTaskWithNotif \(msg) current: \(current.getVersionName()) latestVersionName: \(latestVersionName)")
2260
2349
  self.endBackGroundTask()
@@ -2314,7 +2403,14 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2314
2403
  downloadLock.unlock()
2315
2404
 
2316
2405
  let plannedDirectUpdate = self.shouldUseDirectUpdate()
2317
- let messageUpdate = plannedDirectUpdate ? "Update will occur now." : "Update will occur next time app moves to background."
2406
+ let messageUpdate: String
2407
+ if plannedDirectUpdate {
2408
+ messageUpdate = "Update will occur now."
2409
+ } else if self.shouldAutoSetNextBundle() {
2410
+ messageUpdate = "Update will occur next time app moves to background."
2411
+ } else {
2412
+ messageUpdate = "Update will be downloaded and made available."
2413
+ }
2318
2414
  guard let url = URL(string: self.updateUrl) else {
2319
2415
  logger.error("Error no url or wrong format")
2320
2416
  // Clear the flag if we return early
@@ -2358,7 +2454,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2358
2454
  error: false,
2359
2455
  plannedDirectUpdate: plannedDirectUpdate
2360
2456
  )
2361
- } else {
2457
+ } else if self.shouldAutoSetNextBundle() {
2362
2458
  if plannedDirectUpdate && !directUpdateAllowed {
2363
2459
  self.logger.info("Direct update skipped because splashscreen timeout occurred. Update will apply later.")
2364
2460
  }
@@ -2371,6 +2467,21 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2371
2467
  error: false,
2372
2468
  plannedDirectUpdate: plannedDirectUpdate
2373
2469
  )
2470
+ } else {
2471
+ self.logger.info("autoUpdate is set to onlyDownload, builtin version will not be set as next bundle")
2472
+ let builtinUpdateAvailable = !current.isBuiltin()
2473
+ if builtinUpdateAvailable {
2474
+ let builtinBundle = self.implementation.getBundleInfo(id: BundleInfo.ID_BUILTIN)
2475
+ self.notifyListeners("updateAvailable", data: ["bundle": builtinBundle.toJSON()], retainUntilConsumed: true)
2476
+ }
2477
+ self.endBackGroundTaskWithNotif(
2478
+ msg: "Latest version is builtin, autoUpdate onlyDownload",
2479
+ latestVersionName: res.version,
2480
+ current: current,
2481
+ error: false,
2482
+ plannedDirectUpdate: plannedDirectUpdate,
2483
+ notifyNoNeedUpdate: !builtinUpdateAvailable
2484
+ )
2374
2485
  }
2375
2486
  return
2376
2487
  }
@@ -2483,7 +2594,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2483
2594
  plannedDirectUpdate: plannedDirectUpdate
2484
2595
  )
2485
2596
  }
2486
- } else {
2597
+ } else if self.shouldAutoSetNextBundle() {
2487
2598
  if plannedDirectUpdate && !directUpdateAllowed {
2488
2599
  self.logger.info("Direct update skipped because splashscreen timeout occurred. Update will install on next app background.")
2489
2600
  }
@@ -2496,6 +2607,17 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2496
2607
  error: false,
2497
2608
  plannedDirectUpdate: plannedDirectUpdate
2498
2609
  )
2610
+ } else {
2611
+ self.logger.info("autoUpdate is set to onlyDownload, downloaded update will not be set as next bundle")
2612
+ self.notifyListeners("updateAvailable", data: ["bundle": next.toJSON()], retainUntilConsumed: true)
2613
+ self.endBackGroundTaskWithNotif(
2614
+ msg: "update downloaded, autoUpdate onlyDownload",
2615
+ latestVersionName: latestVersionName,
2616
+ current: current,
2617
+ error: false,
2618
+ plannedDirectUpdate: plannedDirectUpdate,
2619
+ notifyNoNeedUpdate: false
2620
+ )
2499
2621
  }
2500
2622
  return
2501
2623
  } catch {
@@ -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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-updater",
3
- "version": "8.47.1",
3
+ "version": "8.47.3",
4
4
  "license": "MPL-2.0",
5
5
  "description": "Live update for capacitor apps",
6
6
  "main": "dist/plugin.cjs.js",