@capgo/capacitor-updater 8.51.13 → 8.51.14

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.
@@ -99,7 +99,7 @@ public class BundleInfo {
99
99
  }
100
100
 
101
101
  public boolean isDownloaded() {
102
- return (!this.isBuiltin() && this.downloaded != null && !this.downloaded.isEmpty() && !this.isDeleted() && !this.isDeleting());
102
+ return !this.isBuiltin() && this.downloaded != null && !this.downloaded.isEmpty() && !this.isDeleted() && !this.isDeleting();
103
103
  }
104
104
 
105
105
  public String getDownloaded() {
@@ -146,7 +146,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
146
146
  static final int APPLICATION_EXIT_REASON_USER_REQUESTED = 10;
147
147
  static final int APPLICATION_EXIT_REASON_DEPENDENCY_DIED = 12;
148
148
 
149
- private final String pluginVersion = "8.51.13";
149
+ private final String pluginVersion = "8.51.14";
150
150
  private static final String DELAY_CONDITION_PREFERENCES = "";
151
151
 
152
152
  private SharedPreferences.Editor editor;
@@ -250,6 +250,8 @@ public class CapacitorUpdaterPlugin extends Plugin {
250
250
  private volatile long webViewPageStartedAtMs = 0;
251
251
  private volatile boolean launchStartReported = false;
252
252
  private volatile boolean launchReadyReported = false;
253
+ private volatile boolean launchTimeoutReported = false;
254
+ private final Object launchReportLock = new Object();
253
255
  private FrameLayout splashscreenLoaderOverlay;
254
256
  private Runnable splashscreenTimeoutRunnable;
255
257
  private FrameLayout previewTransitionLoaderOverlay;
@@ -1751,16 +1753,18 @@ public class CapacitorUpdaterPlugin extends Plugin {
1751
1753
  }
1752
1754
 
1753
1755
  private void reportAppLaunchReady(final BundleInfo bundle) {
1754
- if (
1755
- this.implementation == null ||
1756
- this.implementation.statsUrl == null ||
1757
- this.implementation.statsUrl.isEmpty() ||
1758
- this.launchReadyReported
1759
- ) {
1760
- return;
1756
+ synchronized (this.launchReportLock) {
1757
+ if (
1758
+ this.implementation == null ||
1759
+ this.implementation.statsUrl == null ||
1760
+ this.implementation.statsUrl.isEmpty() ||
1761
+ this.launchReadyReported ||
1762
+ this.launchTimeoutReported
1763
+ ) {
1764
+ return;
1765
+ }
1766
+ this.launchReadyReported = true;
1761
1767
  }
1762
-
1763
- this.launchReadyReported = true;
1764
1768
  final Map<String, String> metadata = new HashMap<>();
1765
1769
  metadata.put("duration_ms", Long.toString(Math.max(0, System.currentTimeMillis() - this.launchStartedAtMs)));
1766
1770
  metadata.put("launch_started_at", Long.toString(this.launchStartedAtMs));
@@ -1769,14 +1773,22 @@ public class CapacitorUpdaterPlugin extends Plugin {
1769
1773
  }
1770
1774
 
1771
1775
  private void reportAppLaunchTimeout(final BundleInfo bundle) {
1772
- if (this.implementation == null || this.implementation.statsUrl == null || this.implementation.statsUrl.isEmpty()) {
1773
- return;
1776
+ synchronized (this.launchReportLock) {
1777
+ if (
1778
+ this.implementation == null ||
1779
+ this.implementation.statsUrl == null ||
1780
+ this.implementation.statsUrl.isEmpty() ||
1781
+ this.launchReadyReported ||
1782
+ this.launchTimeoutReported
1783
+ ) {
1784
+ return;
1785
+ }
1786
+ this.launchTimeoutReported = true;
1774
1787
  }
1775
-
1776
1788
  final Map<String, String> metadata = new HashMap<>();
1777
1789
  metadata.put("duration_ms", Long.toString(Math.max(0, System.currentTimeMillis() - this.launchStartedAtMs)));
1778
1790
  metadata.put("launch_started_at", Long.toString(this.launchStartedAtMs));
1779
- metadata.put("timeout_ms", Long.toString(this.appReadyTimeout));
1791
+ metadata.put("timeout_ms", Long.toString(this.resolveAppReadyCheckTimeoutMs()));
1780
1792
  metadata.put("source", "app_ready_timeout");
1781
1793
  this.implementation.sendStats("app_launch_timeout", bundle == null ? "" : bundle.getVersionName(), "", metadata);
1782
1794
  }
@@ -2854,7 +2854,17 @@ public class CapgoUpdater {
2854
2854
 
2855
2855
  public void restorePendingStats() {
2856
2856
  File file = pendingStatsFile();
2857
- if (file == null || !file.exists()) {
2857
+ if (file == null) {
2858
+ return;
2859
+ }
2860
+ File backup = new File(file.getAbsolutePath() + ".bak");
2861
+ if (!file.exists() && backup.exists() && !backup.renameTo(file)) {
2862
+ if (logger != null) {
2863
+ logger.error("Failed to restore stats backup");
2864
+ }
2865
+ return;
2866
+ }
2867
+ if (!file.exists()) {
2858
2868
  return;
2859
2869
  }
2860
2870
  try {
@@ -2958,18 +2968,39 @@ public class CapgoUpdater {
2958
2968
 
2959
2969
  private static void writeFileAtomically(final File file, final byte[] bytes) throws IOException {
2960
2970
  final File tmp = new File(file.getAbsolutePath() + ".tmp");
2961
- try (FileOutputStream out = new FileOutputStream(tmp)) {
2962
- out.write(bytes);
2963
- out.flush();
2964
- }
2965
- if (tmp.renameTo(file)) {
2966
- return;
2967
- }
2968
- if (file.exists() && !file.delete()) {
2969
- throw new IOException("Failed to replace " + file.getAbsolutePath());
2970
- }
2971
- if (!tmp.renameTo(file)) {
2971
+ File backup = null;
2972
+ try {
2973
+ try (FileOutputStream out = new FileOutputStream(tmp)) {
2974
+ out.write(bytes);
2975
+ out.flush();
2976
+ }
2977
+ if (tmp.renameTo(file)) {
2978
+ return;
2979
+ }
2980
+ if (file.exists()) {
2981
+ backup = new File(file.getAbsolutePath() + ".bak");
2982
+ if (backup.exists() && !backup.delete()) {
2983
+ throw new IOException("Failed to replace " + file.getAbsolutePath());
2984
+ }
2985
+ if (!file.renameTo(backup)) {
2986
+ throw new IOException("Failed to replace " + file.getAbsolutePath());
2987
+ }
2988
+ }
2989
+ if (tmp.renameTo(file)) {
2990
+ if (backup != null && backup.exists() && !backup.delete()) {
2991
+ backup.deleteOnExit();
2992
+ }
2993
+ backup = null;
2994
+ return;
2995
+ }
2972
2996
  throw new IOException("Failed to persist " + file.getAbsolutePath());
2997
+ } finally {
2998
+ if (backup != null && !file.exists()) {
2999
+ backup.renameTo(file);
3000
+ }
3001
+ if (tmp.exists() && !tmp.delete()) {
3002
+ tmp.deleteOnExit();
3003
+ }
2973
3004
  }
2974
3005
  }
2975
3006
 
@@ -169,7 +169,11 @@ public class CryptoCipher {
169
169
  }
170
170
  Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
171
171
  cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key.getEncoded(), "AES"), new IvParameterSpec(iv));
172
- File tempFile = File.createTempFile("capgo-aes-", ".tmp", file.getParentFile());
172
+ File parent = file.getAbsoluteFile().getParentFile();
173
+ if (parent == null) {
174
+ throw new IOException("Cannot create temp file for " + file.getAbsolutePath());
175
+ }
176
+ File tempFile = File.createTempFile("capgo-aes-", ".tmp", parent);
173
177
  try {
174
178
  byte[] inBuf = new byte[ioBufferBytes()];
175
179
  // Reuse one output buffer. cipher.update(in) allocates a new byte[] per chunk.
@@ -187,6 +191,9 @@ public class CryptoCipher {
187
191
  fos.write(outBuf, 0, last);
188
192
  }
189
193
  }
194
+ if (tempFile.length() == 0) {
195
+ throw new IOException("Empty decrypted data");
196
+ }
190
197
  replaceFile(tempFile, file);
191
198
  tempFile = null;
192
199
  } finally {
@@ -76,8 +76,10 @@ public class DownloadService extends Worker {
76
76
  public static final String DEFAULT_CHANNEL = "default_channel";
77
77
  public static final String IS_PROD = "is_prod";
78
78
  public static final String IS_EMULATOR = "is_emulator";
79
- // HTTP + decode share one pool. Cap by CPU: 8 on 4 cores, 16 on 8 cores, 64 max.
79
+ // HTTP + decode share one pool. Cap per host by CPU: 8 on 4 cores, 16 on 8 cores.
80
+ // Keep the global cap at 64 so API calls on another host are not starved by manifest downloads.
80
81
  private static final int MANIFEST_MAX_CONCURRENT_FILES = manifestMaxConcurrentFiles();
82
+ private static final int SHARED_MAX_REQUESTS = 64;
81
83
  private static final String UPDATE_FILE = "update.dat";
82
84
 
83
85
  // Shared OkHttpClient to prevent resource leaks
@@ -89,7 +91,7 @@ public class DownloadService extends Worker {
89
91
  // Initialize shared client with User-Agent interceptor
90
92
  static {
91
93
  Dispatcher dispatcher = new Dispatcher();
92
- dispatcher.setMaxRequests(MANIFEST_MAX_CONCURRENT_FILES);
94
+ dispatcher.setMaxRequests(SHARED_MAX_REQUESTS);
93
95
  dispatcher.setMaxRequestsPerHost(MANIFEST_MAX_CONCURRENT_FILES);
94
96
  sharedClient = new OkHttpClient.Builder()
95
97
  .dispatcher(dispatcher)
@@ -905,12 +907,9 @@ public class DownloadService extends Worker {
905
907
  } catch (IOException e) {
906
908
  String msg = e.getMessage();
907
909
  if (msg != null && msg.contains("Checksum verification failed")) {
908
- if (finalTargetFile.exists() && !finalTargetFile.delete()) {
909
- logger.debug("Failed to delete dest after checksum mismatch");
910
- }
911
910
  sendStatsAsync("download_manifest_checksum_fail", getInputData().getString(VERSION) + ":" + finalTargetFile.getName());
912
911
  keepPartial = false;
913
- } else if (isBrotli) {
912
+ } else if (isBrotli && msg != null && msg.toLowerCase(java.util.Locale.US).contains("brotli")) {
914
913
  sendStatsAsync("download_manifest_brotli_fail", getInputData().getString(VERSION) + ":" + finalTargetFile.getName());
915
914
  keepPartial = false;
916
915
  }
@@ -947,7 +946,8 @@ public class DownloadService extends Worker {
947
946
  if (CapgoUpdater.isSafeCacheHash(hash) && hash.length() == 64) {
948
947
  return new File(cacheDir, "partial_" + hash + "_" + token + ".tmp");
949
948
  }
950
- return new File(cacheDir, "temp_" + UUID.randomUUID() + "_" + token + ".tmp");
949
+ String digest = CryptoCipher.shortPathKey((hash == null ? "" : hash) + "\0" + (fileName == null ? "" : fileName));
950
+ return new File(cacheDir, "partial_" + digest + "_" + token + ".tmp");
951
951
  }
952
952
 
953
953
  static boolean shouldAppendHttpBody(int statusCode, long existingBytes) {
@@ -1062,7 +1062,7 @@ public class DownloadService extends Worker {
1062
1062
  }
1063
1063
  }
1064
1064
  logger.error("Error: Raw data (" + fileName + "): " + hexDump);
1065
- throw e;
1065
+ throw new IOException("Brotli process failed for " + fileName + ": " + e.getMessage(), e);
1066
1066
  }
1067
1067
  }
1068
1068
 
@@ -238,6 +238,9 @@ public class ShakeMenu implements ShakeDetector.Listener, ThreeFingerPinchDetect
238
238
 
239
239
  private void setPreviewMenuButtonsEnabled(List<Button> buttons, boolean enabled) {
240
240
  for (Button button : buttons) {
241
+ if (!enabled && "Close menu".equals(button.getText().toString())) {
242
+ continue;
243
+ }
241
244
  button.setEnabled(enabled);
242
245
  }
243
246
  }
@@ -180,6 +180,11 @@ public struct AES128Key {
180
180
  }
181
181
  try output.close()
182
182
 
183
+ let decryptedSize = (try fileManager.attributesOfItem(atPath: tempURL.path)[.size] as? NSNumber)?.uint64Value ?? 0
184
+ if decryptedSize == 0 {
185
+ throw NSError(domain: "Empty decrypted data", code: 7, userInfo: nil)
186
+ }
187
+
183
188
  do {
184
189
  _ = try fileManager.replaceItemAt(destination, withItemAt: tempURL)
185
190
  } catch {
@@ -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.13"
99
+ private let pluginVersion: String = "8.51.14"
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"
@@ -1096,6 +1096,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1096
1096
  }
1097
1097
  let res = self.implementation.list()
1098
1098
  for version in res {
1099
+ if Thread.current.isCancelled {
1100
+ return
1101
+ }
1099
1102
  self.logger.info("Deleting obsolete bundle: \(version.getId())")
1100
1103
  let deleted = self.implementation.delete(id: version.getId())
1101
1104
  if !deleted {
@@ -1103,17 +1106,28 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1103
1106
  }
1104
1107
  Thread.sleep(forTimeInterval: 0.075)
1105
1108
  }
1106
- self.implementation.cleanupDeltaCache()
1109
+ self.implementation.cleanupDeltaCache(threadToCheck: Thread.current)
1110
+ }
1111
+
1112
+ if Thread.current.isCancelled {
1113
+ return
1107
1114
  }
1108
1115
 
1109
1116
  // Resume any DELETING leftovers from prior kills, one-by-one.
1110
1117
  self.implementation.drainPendingDeletes()
1111
1118
 
1119
+ if Thread.current.isCancelled {
1120
+ return
1121
+ }
1122
+
1112
1123
  // Always sweep orphan directories so incomplete prior cleanups (or failed deletes)
1113
1124
  // cannot leave hundreds of MB behind across launches.
1114
1125
  let allowedIds = self.implementation.allowedBundleIdsForCleanup()
1115
- self.implementation.cleanupDownloadDirectories(allowedIds: allowedIds)
1116
- self.implementation.cleanupOrphanedTempFolders(threadToCheck: nil)
1126
+ self.implementation.cleanupDownloadDirectories(allowedIds: allowedIds, threadToCheck: Thread.current)
1127
+ if Thread.current.isCancelled {
1128
+ return
1129
+ }
1130
+ self.implementation.cleanupOrphanedTempFolders(threadToCheck: Thread.current)
1117
1131
 
1118
1132
  if self.defaultChannelCleanupMustRetry {
1119
1133
  self.logger.warn("Keeping the previous native build version so default channel cleanup retries")
@@ -1131,7 +1145,13 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1131
1145
  }
1132
1146
 
1133
1147
  logger.info("Waiting for cleanup to complete before starting download...")
1134
- cleanupGroup.wait()
1148
+ let result = cleanupGroup.wait(timeout: .now() + .seconds(60))
1149
+ if result == .timedOut {
1150
+ logger.warn("Cleanup wait timed out after 60s, cancelling leftover cleanup")
1151
+ cleanupThread?.cancel()
1152
+ _ = cleanupGroup.wait(timeout: .now() + .seconds(60))
1153
+ return
1154
+ }
1135
1155
  logger.info("Cleanup finished, proceeding with download")
1136
1156
  }
1137
1157
 
@@ -2749,7 +2769,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2749
2769
  guard self.previewSessionEnabled else {
2750
2770
  return
2751
2771
  }
2752
- if let topVC = UIApplication.topViewController(),
2772
+ if let topVC = UIApplication.topViewController(self.bridge?.viewController),
2753
2773
  topVC.isKind(of: UIAlertController.self) {
2754
2774
  self.previewSessionAlertPending = true
2755
2775
  UserDefaults.standard.set(true, forKey: self.previewSessionAlertPendingDefaultsKey)
@@ -2763,7 +2783,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2763
2783
  preferredStyle: .alert
2764
2784
  )
2765
2785
  alert.addAction(UIAlertAction(title: "Got it", style: .default))
2766
- if let topVC = UIApplication.topViewController() {
2786
+ if let topVC = UIApplication.topViewController(self.bridge?.viewController) {
2767
2787
  topVC.present(alert, animated: true)
2768
2788
  } else {
2769
2789
  self.previewSessionAlertPending = true
@@ -318,7 +318,8 @@ import UIKit
318
318
  if isSafeCacheHash(hash) && hash.count == 64 {
319
319
  return cacheFolder.appendingPathComponent("partial_\(hash)_\(token).tmp")
320
320
  }
321
- return cacheFolder.appendingPathComponent("temp_\(UUID().uuidString)_\(token).tmp")
321
+ let digest = CryptoCipher.shortPathKey("\(hash)|\(fileName)")
322
+ return cacheFolder.appendingPathComponent("partial_\(digest)_\(token).tmp")
322
323
  }
323
324
 
324
325
  private func cleanupOldManifestPartials() {
@@ -2254,6 +2255,7 @@ import UIKit
2254
2255
  if !hadRegistry && !hadFolder {
2255
2256
  logger.error("Cannot delete unknown bundle")
2256
2257
  logger.debug("Bundle ID: \(id)")
2258
+ self.dequeuePendingDelete(id: id)
2257
2259
  return false
2258
2260
  }
2259
2261
 
@@ -2313,6 +2315,10 @@ import UIKit
2313
2315
  var pendingIds = Set(self.list(raw: true).filter { $0.isDeleting() }.map { $0.getId() }.filter { !$0.isEmpty })
2314
2316
  pendingIds.formUnion(self.getPendingDeleteIds())
2315
2317
  for id in pendingIds {
2318
+ if Thread.current.isCancelled {
2319
+ logger.warn("drainPendingDeletes was cancelled")
2320
+ return
2321
+ }
2316
2322
  logger.info("Resuming pending delete for bundle: \(id)")
2317
2323
  if self.delete(id: id, removeInfo: true) {
2318
2324
  self.dequeuePendingDelete(id: id)
@@ -2499,11 +2505,16 @@ import UIKit
2499
2505
  logger.debug("Error: \(error.localizedDescription)")
2500
2506
  }
2501
2507
 
2508
+ if let thread = threadToCheck, thread.isCancelled {
2509
+ logger.warn("cleanupOrphanedTempFolders was cancelled")
2510
+ return
2511
+ }
2512
+
2502
2513
  // Also cleanup old download temp files (package_*.tmp and update_*.dat)
2503
- cleanupOldDownloadTempFiles()
2514
+ cleanupOldDownloadTempFiles(threadToCheck: threadToCheck)
2504
2515
  }
2505
2516
 
2506
- private func cleanupOldDownloadTempFiles() {
2517
+ private func cleanupOldDownloadTempFiles(threadToCheck: Thread? = nil) {
2507
2518
  let fileManager = FileManager.default
2508
2519
  guard let documentsDir = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
2509
2520
  return
@@ -2514,6 +2525,10 @@ import UIKit
2514
2525
  let oneHourAgo = Date().addingTimeInterval(-3600)
2515
2526
 
2516
2527
  for url in contents {
2528
+ if let thread = threadToCheck, thread.isCancelled {
2529
+ logger.warn("cleanupOldDownloadTempFiles was cancelled")
2530
+ return
2531
+ }
2517
2532
  let fileName = url.lastPathComponent
2518
2533
  // Only cleanup package_*.tmp and update_*.dat files
2519
2534
  let isDownloadTemp = (fileName.hasPrefix("package_") && fileName.hasSuffix(".tmp")) ||
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-updater",
3
- "version": "8.51.13",
3
+ "version": "8.51.14",
4
4
  "license": "MPL-2.0",
5
5
  "description": "Live update for capacitor apps",
6
6
  "main": "dist/plugin.cjs.js",