@capgo/capacitor-updater 8.51.12 → 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.12";
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 {
@@ -369,14 +376,7 @@ public class CryptoCipher {
369
376
  while ((length = inputStream.read(buffer)) != -1) {
370
377
  digest.update(buffer, 0, length);
371
378
  }
372
- byte[] hash = digest.digest();
373
- StringBuilder hexString = new StringBuilder();
374
- for (byte b : hash) {
375
- String hex = Integer.toHexString(0xff & b);
376
- if (hex.length() == 1) hexString.append('0');
377
- hexString.append(hex);
378
- }
379
- return hexString.toString();
379
+ return digestToHex(digest);
380
380
  } catch (IOException e) {
381
381
  logger.error("Cannot calculate checksum");
382
382
  logger.debug("Error: " + e.getMessage());
@@ -384,6 +384,27 @@ public class CryptoCipher {
384
384
  }
385
385
  }
386
386
 
387
+ static String digestToHex(MessageDigest digest) {
388
+ byte[] hash = digest.digest();
389
+ StringBuilder hexString = new StringBuilder(hash.length * 2);
390
+ for (byte b : hash) {
391
+ String hex = Integer.toHexString(0xff & b);
392
+ if (hex.length() == 1) hexString.append('0');
393
+ hexString.append(hex);
394
+ }
395
+ return hexString.toString();
396
+ }
397
+
398
+ static String shortPathKey(String fileName) {
399
+ try {
400
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
401
+ digest.update((fileName == null ? "" : fileName).getBytes(java.nio.charset.StandardCharsets.UTF_8));
402
+ return digestToHex(digest).substring(0, 16);
403
+ } catch (java.security.NoSuchAlgorithmException e) {
404
+ return Integer.toHexString((fileName == null ? "" : fileName).hashCode());
405
+ }
406
+ }
407
+
387
408
  private static byte[] createDEREncoding(int tag, byte[] value) {
388
409
  if (tag < 0 || tag >= 0xFF) {
389
410
  throw new IllegalArgumentException("Currently only single byte tags supported");
@@ -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)
@@ -157,6 +159,7 @@ public class DownloadService extends Worker {
157
159
 
158
160
  // Clean up old temporary files on service initialization
159
161
  cleanupOldTempFiles(getApplicationContext().getCacheDir());
162
+ cleanupOldTempFiles(new File(getApplicationContext().getCacheDir(), "capgo_downloads"));
160
163
  }
161
164
 
162
165
  private void setProgress(int percent) {
@@ -532,7 +535,16 @@ public class DownloadService extends Worker {
532
535
  ) {
533
536
  logger.debug("already cached " + fileName);
534
537
  } else {
535
- downloadAndVerify(downloadUrl, targetFile, cacheFile, finalFileHash, sessionKey, publicKey, finalIsBrotli);
538
+ downloadAndVerify(
539
+ downloadUrl,
540
+ targetFile,
541
+ cacheFile,
542
+ finalFileHash,
543
+ sessionKey,
544
+ publicKey,
545
+ finalIsBrotli,
546
+ fileName
547
+ );
536
548
  }
537
549
 
538
550
  long completed = completedFiles.incrementAndGet();
@@ -827,96 +839,135 @@ public class DownloadService extends Worker {
827
839
  String expectedHash,
828
840
  String sessionKey,
829
841
  String publicKey,
830
- boolean isBrotli
842
+ boolean isBrotli,
843
+ String relativeName
831
844
  ) throws Exception {
832
845
  logger.debug("downloadAndVerify " + downloadUrl);
833
846
 
834
- Request request = new Request.Builder().url(downloadUrl).build();
835
-
836
- // targetFile is already the final destination without .br extension
837
847
  File finalTargetFile = targetFile;
838
-
839
- // Create a temporary file for the compressed data with a unique name to avoid race conditions
840
- // between threads processing files with the same basename in different directories
841
- File compressedFile = new File(
842
- getApplicationContext().getCacheDir(),
843
- "temp_" + java.util.UUID.randomUUID().toString() + "_" + targetFile.getName() + ".tmp"
844
- );
845
-
848
+ File cacheFolder = new File(getApplicationContext().getCacheDir(), "capgo_downloads");
849
+ if (!cacheFolder.exists() && !cacheFolder.mkdirs()) {
850
+ throw new IOException("Failed to create cache directory: " + cacheFolder.getAbsolutePath());
851
+ }
852
+ File partial = manifestPartialFile(cacheFolder, expectedHash, relativeName);
853
+ File workFile = null;
854
+ boolean keepPartial = partial.isFile();
846
855
  try {
847
- try (Response response = sharedClient.newCall(request).execute()) {
848
- if (!response.isSuccessful()) {
856
+ long existing = partial.isFile() ? partial.length() : 0;
857
+ Request.Builder builder = new Request.Builder().url(downloadUrl);
858
+ if (existing > 0) {
859
+ builder.header("Range", "bytes=" + existing + "-");
860
+ }
861
+ try (Response response = sharedClient.newCall(builder.build()).execute()) {
862
+ int code = response.code();
863
+ if (code == 416 && existing > 0) {
864
+ logger.debug("Range not satisfiable, using existing partial " + partial.getName());
865
+ keepPartial = true;
866
+ } else if (code != HttpURLConnection.HTTP_OK && code != HttpURLConnection.HTTP_PARTIAL) {
849
867
  sendStatsAsync("download_manifest_file_fail", getInputData().getString(VERSION) + ":" + finalTargetFile.getName());
850
- throw new IOException("Unexpected response code: " + response.code());
851
- }
852
-
853
- // Download compressed file atomically
854
- ResponseBody responseBody = response.body();
855
- if (responseBody == null) {
856
- throw new IOException("Response body is null");
868
+ throw new IOException("Unexpected response code: " + code);
869
+ } else {
870
+ ResponseBody responseBody = response.body();
871
+ if (responseBody == null) {
872
+ throw new IOException("Response body is null");
873
+ }
874
+ try {
875
+ writeHttpBody(partial, responseBody.byteStream(), code, existing);
876
+ keepPartial = true;
877
+ } catch (Exception e) {
878
+ keepPartial = true;
879
+ throw e;
880
+ }
857
881
  }
882
+ }
858
883
 
859
- // Use OkIO for atomic write
860
- writeFileAtomic(compressedFile, responseBody.byteStream(), null);
861
-
862
- if (publicKey != null && !publicKey.isEmpty() && sessionKey != null && !sessionKey.isEmpty()) {
884
+ boolean needDecrypt = publicKey != null && !publicKey.isEmpty() && sessionKey != null && !sessionKey.isEmpty();
885
+ File source = partial;
886
+ if (needDecrypt) {
887
+ workFile = new File(cacheFolder, "work_" + UUID.randomUUID() + "_" + targetFile.getName() + ".tmp");
888
+ copyFile(partial, workFile);
889
+ try {
863
890
  logger.debug("Decrypting file " + targetFile.getName());
864
- CryptoCipher.decryptFile(compressedFile, publicKey, sessionKey);
891
+ CryptoCipher.decryptFile(workFile, publicKey, sessionKey);
892
+ source = workFile;
893
+ } catch (Exception e) {
894
+ keepPartial = false;
895
+ throw e;
865
896
  }
897
+ }
866
898
 
867
- // Only decompress if file has .br extension
899
+ try {
868
900
  if (isBrotli) {
869
- try {
870
- decompressBrotli(compressedFile, finalTargetFile, targetFile.getName());
871
- } catch (IOException e) {
872
- sendStatsAsync(
873
- "download_manifest_brotli_fail",
874
- getInputData().getString(VERSION) + ":" + finalTargetFile.getName()
875
- );
876
- throw e;
877
- }
901
+ decompressBrotli(source, finalTargetFile, targetFile.getName(), expectedHash);
878
902
  } else {
879
- try (FileInputStream fis = new FileInputStream(compressedFile)) {
880
- writeFileAtomic(finalTargetFile, fis, null);
903
+ try (FileInputStream fis = new FileInputStream(source)) {
904
+ writeFileAtomic(finalTargetFile, fis, expectedHash);
881
905
  }
882
906
  }
883
-
884
- // Delete the compressed file
885
- compressedFile.delete();
886
- String calculatedHash = CryptoCipher.calcChecksum(finalTargetFile);
887
- CryptoCipher.logChecksumInfo("Calculated checksum", calculatedHash);
888
- CryptoCipher.logChecksumInfo("Expected checksum", expectedHash);
889
-
890
- // Verify checksum
891
- if (calculatedHash.equalsIgnoreCase(expectedHash)) {
892
- // Only cache if checksum is correct - use atomic copy
893
- if (cacheFile != null) {
894
- try (FileInputStream fis = new FileInputStream(finalTargetFile)) {
895
- writeFileAtomic(cacheFile, fis, null);
896
- }
897
- }
898
- } else {
899
- finalTargetFile.delete();
907
+ } catch (IOException e) {
908
+ String msg = e.getMessage();
909
+ if (msg != null && msg.contains("Checksum verification failed")) {
900
910
  sendStatsAsync("download_manifest_checksum_fail", getInputData().getString(VERSION) + ":" + finalTargetFile.getName());
901
- throw new IOException(
902
- "Checksum verification failed for: " +
903
- downloadUrl +
904
- " " +
905
- targetFile.getName() +
906
- " expected: " +
907
- expectedHash +
908
- " calculated: " +
909
- calculatedHash
910
- );
911
+ keepPartial = false;
912
+ } else if (isBrotli && msg != null && msg.toLowerCase(java.util.Locale.US).contains("brotli")) {
913
+ sendStatsAsync("download_manifest_brotli_fail", getInputData().getString(VERSION) + ":" + finalTargetFile.getName());
914
+ keepPartial = false;
915
+ }
916
+ throw e;
917
+ }
918
+
919
+ CryptoCipher.logChecksumInfo("Calculated checksum", expectedHash);
920
+ CryptoCipher.logChecksumInfo("Expected checksum", expectedHash);
921
+
922
+ if (cacheFile != null) {
923
+ try (FileInputStream fis = new FileInputStream(finalTargetFile)) {
924
+ writeFileAtomic(cacheFile, fis, null);
911
925
  }
912
926
  }
927
+ keepPartial = false;
913
928
  } catch (Exception e) {
914
- throw new IOException("Error in downloadAndVerify: " + e.getMessage());
929
+ throw new IOException("Error in downloadAndVerify: " + e.getMessage(), e);
915
930
  } finally {
916
- // Always cleanup the compressed temp file if it still exists
917
- if (compressedFile.exists()) {
918
- compressedFile.delete();
931
+ if (workFile != null && workFile.exists() && !workFile.delete()) {
932
+ logger.debug("Failed to delete decrypt work file");
933
+ }
934
+ if (!keepPartial && partial.exists() && !partial.delete()) {
935
+ logger.debug("Failed to delete manifest partial " + partial.getName());
936
+ }
937
+ }
938
+ }
939
+
940
+ static String safePartialToken(String fileName) {
941
+ return CryptoCipher.shortPathKey(fileName);
942
+ }
943
+
944
+ static File manifestPartialFile(File cacheDir, String hash, String fileName) {
945
+ String token = safePartialToken(fileName);
946
+ if (CapgoUpdater.isSafeCacheHash(hash) && hash.length() == 64) {
947
+ return new File(cacheDir, "partial_" + hash + "_" + token + ".tmp");
948
+ }
949
+ String digest = CryptoCipher.shortPathKey((hash == null ? "" : hash) + "\0" + (fileName == null ? "" : fileName));
950
+ return new File(cacheDir, "partial_" + digest + "_" + token + ".tmp");
951
+ }
952
+
953
+ static boolean shouldAppendHttpBody(int statusCode, long existingBytes) {
954
+ return existingBytes > 0 && statusCode == HttpURLConnection.HTTP_PARTIAL;
955
+ }
956
+
957
+ static void writeHttpBody(File dest, InputStream body, int statusCode, long existingBytes) throws IOException {
958
+ boolean append = shouldAppendHttpBody(statusCode, existingBytes);
959
+ byte[] buffer = new byte[CryptoCipher.ioBufferBytes()];
960
+ try (FileOutputStream fos = new FileOutputStream(dest, append)) {
961
+ int n;
962
+ long written = append ? existingBytes : 0;
963
+ while ((n = body.read(buffer)) != -1) {
964
+ fos.write(buffer, 0, n);
965
+ written += n;
966
+ if (written % (1024 * 1024) == 0) {
967
+ fos.flush();
968
+ }
919
969
  }
970
+ fos.flush();
920
971
  }
921
972
  }
922
973
 
@@ -950,13 +1001,17 @@ public class DownloadService extends Worker {
950
1001
  }
951
1002
 
952
1003
  static void decompressBrotli(File input, File output, String fileName) throws IOException {
1004
+ decompressBrotli(input, output, fileName, null);
1005
+ }
1006
+
1007
+ static void decompressBrotli(File input, File output, String fileName, String expectedChecksum) throws IOException {
953
1008
  File parent = output.getParentFile();
954
1009
  if (parent != null) {
955
1010
  parent.mkdirs();
956
1011
  }
957
1012
  long length = input.length();
958
1013
  if (length == 0) {
959
- writeFileAtomic(output, new ByteArrayInputStream(new byte[0]), null);
1014
+ writeFileAtomic(output, new ByteArrayInputStream(new byte[0]), expectedChecksum);
960
1015
  return;
961
1016
  }
962
1017
 
@@ -971,7 +1026,7 @@ public class DownloadService extends Worker {
971
1026
  }
972
1027
 
973
1028
  if (length == 3 && head[0] == 0x1B && head[1] == 0x00 && head[2] == 0x06) {
974
- writeFileAtomic(output, new ByteArrayInputStream(new byte[0]), null);
1029
+ writeFileAtomic(output, new ByteArrayInputStream(new byte[0]), expectedChecksum);
975
1030
  return;
976
1031
  }
977
1032
 
@@ -988,14 +1043,14 @@ public class DownloadService extends Worker {
988
1043
  }
989
1044
  skipped += n;
990
1045
  }
991
- writeFileAtomic(output, new BoundedInputStream(fis, length - 4), null);
1046
+ writeFileAtomic(output, new BoundedInputStream(fis, length - 4), expectedChecksum);
992
1047
  }
993
1048
  return;
994
1049
  }
995
1050
  }
996
1051
 
997
1052
  try (FileInputStream fis = new FileInputStream(input); BrotliInputStream brotliInputStream = new BrotliInputStream(fis)) {
998
- writeFileAtomic(output, brotliInputStream, null);
1053
+ writeFileAtomic(output, brotliInputStream, expectedChecksum);
999
1054
  } catch (IOException e) {
1000
1055
  logger.error("Error: Brotli process failed for " + fileName + ". Status: " + e.getMessage());
1001
1056
  StringBuilder hexDump = new StringBuilder();
@@ -1007,7 +1062,7 @@ public class DownloadService extends Worker {
1007
1062
  }
1008
1063
  }
1009
1064
  logger.error("Error: Raw data (" + fileName + "): " + hexDump);
1010
- throw e;
1065
+ throw new IOException("Brotli process failed for " + fileName + ": " + e.getMessage(), e);
1011
1066
  }
1012
1067
  }
1013
1068
 
@@ -1048,6 +1103,7 @@ public class DownloadService extends Worker {
1048
1103
 
1049
1104
  /**
1050
1105
  * Atomically write a stream to a file using the 256 KiB IO buffer.
1106
+ * When expectedChecksum is set, SHA-256 is hashed during the write.
1051
1107
  */
1052
1108
  static void writeFileAtomic(File targetFile, InputStream inputStream, String expectedChecksum) throws IOException {
1053
1109
  File tempFile = File.createTempFile("capgo-", ".tmp", targetFile.getParentFile());
@@ -1056,26 +1112,39 @@ public class DownloadService extends Worker {
1056
1112
  // Okio's default segment is 8 KiB. Copy with 256 KiB so 8 MiB wrapper unwraps
1057
1113
  // are not 1000 tiny writes.
1058
1114
  byte[] buffer = new byte[CryptoCipher.ioBufferBytes()];
1115
+ MessageDigest digest = null;
1116
+ if (expectedChecksum != null && !expectedChecksum.isEmpty()) {
1117
+ try {
1118
+ digest = MessageDigest.getInstance("SHA-256");
1119
+ } catch (java.security.NoSuchAlgorithmException e) {
1120
+ throw new IOException("SHA-256 algorithm not available", e);
1121
+ }
1122
+ }
1059
1123
  try (FileOutputStream fos = new FileOutputStream(tempFile)) {
1060
1124
  int n;
1061
1125
  while ((n = inputStream.read(buffer)) != -1) {
1126
+ if (digest != null) {
1127
+ digest.update(buffer, 0, n);
1128
+ }
1062
1129
  fos.write(buffer, 0, n);
1063
1130
  }
1064
1131
  }
1065
1132
 
1066
- // Verify checksum if provided
1067
- if (expectedChecksum != null && !expectedChecksum.isEmpty()) {
1068
- String actualChecksum = CryptoCipher.calcChecksum(tempFile);
1133
+ if (digest != null) {
1134
+ String actualChecksum = CryptoCipher.digestToHex(digest);
1069
1135
  if (!expectedChecksum.equalsIgnoreCase(actualChecksum)) {
1070
- tempFile.delete();
1071
- throw new IOException("Checksum verification failed");
1136
+ throw new IOException("Checksum verification failed expected: " + expectedChecksum + " calculated: " + actualChecksum);
1072
1137
  }
1073
1138
  }
1074
1139
 
1075
1140
  // Atomic rename (on same filesystem). renameTo works on API 24; Files.move does not.
1076
1141
  CryptoCipher.replaceFile(tempFile, targetFile);
1142
+ } catch (IOException e) {
1143
+ if (tempFile.exists()) {
1144
+ tempFile.delete();
1145
+ }
1146
+ throw e;
1077
1147
  } catch (Exception e) {
1078
- // Clean up temp file on error
1079
1148
  if (tempFile.exists()) {
1080
1149
  tempFile.delete();
1081
1150
  }
@@ -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.12"
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
@@ -305,9 +305,48 @@ import UIKit
305
305
  return fileManager.fileExists(atPath: fallback.path) ? fallback : nil
306
306
  }
307
307
 
308
- private func storeDownloadedFile(_ downloadedFileURL: URL, at tempPath: URL, existingBytes: Int64, response: HTTPURLResponse?) throws {
308
+ static func shouldAppendHttpBody(statusCode: Int, existingBytes: Int64) -> Bool {
309
+ existingBytes > 0 && statusCode == 206
310
+ }
311
+
312
+ static func safePartialToken(_ fileName: String) -> String {
313
+ CryptoCipher.shortPathKey(fileName)
314
+ }
315
+
316
+ static func manifestPartialURL(cacheFolder: URL, hash: String, fileName: String) -> URL {
317
+ let token = safePartialToken(fileName)
318
+ if isSafeCacheHash(hash) && hash.count == 64 {
319
+ return cacheFolder.appendingPathComponent("partial_\(hash)_\(token).tmp")
320
+ }
321
+ let digest = CryptoCipher.shortPathKey("\(hash)|\(fileName)")
322
+ return cacheFolder.appendingPathComponent("partial_\(digest)_\(token).tmp")
323
+ }
324
+
325
+ private func cleanupOldManifestPartials() {
326
+ let cutoff = Date().addingTimeInterval(-3600)
327
+ guard let files = try? FileManager.default.contentsOfDirectory(
328
+ at: cacheFolder,
329
+ includingPropertiesForKeys: [.contentModificationDateKey],
330
+ options: [.skipsHiddenFiles]
331
+ ) else {
332
+ return
333
+ }
334
+ for url in files {
335
+ let name = url.lastPathComponent
336
+ guard name.hasPrefix("partial_") && name.hasSuffix(".tmp") else {
337
+ continue
338
+ }
339
+ let modified = (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
340
+ if modified < cutoff {
341
+ try? FileManager.default.removeItem(at: url)
342
+ }
343
+ }
344
+ }
345
+
346
+ func storeDownloadedFile(_ downloadedFileURL: URL, at tempPath: URL, existingBytes: Int64, response: HTTPURLResponse?) throws {
309
347
  let fileManager = FileManager.default
310
- if existingBytes > 0 && (response?.statusCode == 206 || response == nil) {
348
+ if Self.shouldAppendHttpBody(statusCode: response?.statusCode ?? 0, existingBytes: existingBytes) ||
349
+ (existingBytes > 0 && response == nil) {
311
350
  let fileHandle = try FileHandle(forWritingTo: tempPath)
312
351
  defer {
313
352
  try? fileHandle.close()
@@ -1342,6 +1381,7 @@ import UIKit
1342
1381
  try checkDiskSpace(estimatedSize: estimatedSize)
1343
1382
 
1344
1383
  try FileManager.default.createDirectory(at: cacheFolder, withIntermediateDirectories: true, attributes: nil)
1384
+ cleanupOldManifestPartials()
1345
1385
  try FileManager.default.createDirectory(at: destFolder, withIntermediateDirectories: true, attributes: nil)
1346
1386
 
1347
1387
  // Create and save BundleInfo before starting the download process
@@ -1550,7 +1590,7 @@ import UIKit
1550
1590
  )
1551
1591
  }
1552
1592
 
1553
- guard let request = createRequest(url: url, method: "GET") else {
1593
+ guard var request = createRequest(url: url, method: "GET") else {
1554
1594
  throw NSError(
1555
1595
  domain: "ManifestDownloadError",
1556
1596
  code: 2,
@@ -1558,6 +1598,18 @@ import UIKit
1558
1598
  )
1559
1599
  }
1560
1600
 
1601
+ try FileManager.default.createDirectory(at: cacheFolder, withIntermediateDirectories: true, attributes: nil)
1602
+ let partialURL = Self.manifestPartialURL(cacheFolder: cacheFolder, hash: fileHash, fileName: fileName)
1603
+ let existingBytes: Int64
1604
+ if FileManager.default.fileExists(atPath: partialURL.path) {
1605
+ existingBytes = Int64((try FileManager.default.attributesOfItem(atPath: partialURL.path)[.size] as? NSNumber)?.int64Value ?? 0)
1606
+ } else {
1607
+ existingBytes = 0
1608
+ }
1609
+ if existingBytes > 0 {
1610
+ request.setValue("bytes=\(existingBytes)-", forHTTPHeaderField: "Range")
1611
+ }
1612
+
1561
1613
  let result = performDownloadRequest(request, label: "downloadManifestFile \(fileName)")
1562
1614
  defer {
1563
1615
  if let fileURL = result.fileURL {
@@ -1566,6 +1618,7 @@ import UIKit
1566
1618
  }
1567
1619
 
1568
1620
  if result.timedOut {
1621
+ persistPartialDownload(result, id: bundleId, tempPath: partialURL, existingBytes: existingBytes)
1569
1622
  self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
1570
1623
  throw NSError(
1571
1624
  domain: NSURLErrorDomain,
@@ -1575,6 +1628,7 @@ import UIKit
1575
1628
  }
1576
1629
 
1577
1630
  if let error = result.error {
1631
+ persistPartialDownload(result, id: bundleId, tempPath: partialURL, existingBytes: existingBytes)
1578
1632
  self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
1579
1633
  self.logger.error("Manifest file download network error")
1580
1634
  self.logger.debug("Bundle: \(bundleId), File: \(fileName), Error: \(error.localizedDescription)")
@@ -1582,12 +1636,24 @@ import UIKit
1582
1636
  }
1583
1637
 
1584
1638
  let statusCode = result.response?.statusCode ?? 200
1585
- if statusCode < 200 || statusCode >= 300 {
1639
+ if statusCode == 416 && existingBytes > 0 {
1640
+ logger.debug("Range not satisfiable, using existing partial \(partialURL.lastPathComponent)")
1641
+ } else if statusCode < 200 || statusCode >= 300 {
1586
1642
  self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
1587
1643
  throw NSError(domain: "StatusCodeError", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid for file \(fileName) at url \(downloadUrl)"])
1644
+ } else {
1645
+ guard let downloadedFileURL = result.fileURL, FileManager.default.fileExists(atPath: downloadedFileURL.path) else {
1646
+ self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
1647
+ throw NSError(
1648
+ domain: "ManifestDownloadError",
1649
+ code: 3,
1650
+ userInfo: [NSLocalizedDescriptionKey: "Manifest file response was empty for \(fileName) at url \(downloadUrl)"]
1651
+ )
1652
+ }
1653
+ try storeDownloadedFile(downloadedFileURL, at: partialURL, existingBytes: existingBytes, response: result.response)
1588
1654
  }
1589
1655
 
1590
- guard let downloadedFileURL = result.fileURL, FileManager.default.fileExists(atPath: downloadedFileURL.path) else {
1656
+ guard FileManager.default.fileExists(atPath: partialURL.path) else {
1591
1657
  self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
1592
1658
  throw NSError(
1593
1659
  domain: "ManifestDownloadError",
@@ -1596,42 +1662,60 @@ import UIKit
1596
1662
  )
1597
1663
  }
1598
1664
 
1665
+ var workURL: URL?
1666
+ defer {
1667
+ if let workURL {
1668
+ try? FileManager.default.removeItem(at: workURL)
1669
+ }
1670
+ }
1671
+
1599
1672
  do {
1600
- // Decrypt in place when a session key is present — streamed, not a whole-file copy.
1673
+ var source = partialURL
1601
1674
  if !self.publicKey.isEmpty && !sessionKey.isEmpty {
1675
+ let work = cacheFolder.appendingPathComponent("work_\(UUID().uuidString)_\((fileName as NSString).lastPathComponent)")
1676
+ try FileManager.default.copyItem(at: partialURL, to: work)
1677
+ workURL = work
1602
1678
  do {
1603
- try CryptoCipher.decryptFile(filePath: downloadedFileURL, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
1679
+ try CryptoCipher.decryptFile(filePath: work, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
1604
1680
  } catch {
1681
+ try? FileManager.default.removeItem(at: partialURL)
1605
1682
  self.sendStats(action: "decrypt_fail", versionName: version)
1606
1683
  throw error
1607
1684
  }
1685
+ source = work
1608
1686
  }
1609
1687
 
1688
+ let calculatedChecksum: String
1610
1689
  if isBrotli {
1611
1690
  do {
1612
- try decompressBrotli(from: downloadedFileURL, to: destFilePath, fileName: fileName)
1691
+ calculatedChecksum = try decompressBrotli(from: source, to: destFilePath, fileName: fileName)
1613
1692
  } catch {
1693
+ try? FileManager.default.removeItem(at: partialURL)
1614
1694
  self.sendStats(action: "download_manifest_brotli_fail", versionName: "\(version):\(destFileName)")
1615
1695
  throw error
1616
1696
  }
1617
1697
  } else {
1618
- try copyItemReplacing(from: downloadedFileURL, to: destFilePath)
1698
+ let handle = try FileHandle(forReadingFrom: source)
1699
+ defer {
1700
+ try? handle.close()
1701
+ }
1702
+ let length = (try FileManager.default.attributesOfItem(atPath: source.path)[.size] as? NSNumber)?.uint64Value ?? 0
1703
+ calculatedChecksum = try streamCopy(from: handle, count: length, to: destFilePath)
1619
1704
  }
1620
1705
 
1621
- // Always verify checksum when file_hash is present
1622
- let calculatedChecksum = CryptoCipher.calcChecksum(filePath: destFilePath)
1623
1706
  CryptoCipher.logChecksumInfo(label: "Calculated checksum", hexChecksum: calculatedChecksum)
1624
1707
  CryptoCipher.logChecksumInfo(label: "Expected checksum", hexChecksum: fileHash)
1625
1708
  if calculatedChecksum != fileHash {
1626
1709
  try? FileManager.default.removeItem(at: destFilePath)
1710
+ try? FileManager.default.removeItem(at: partialURL)
1627
1711
  self.sendStats(action: "download_manifest_checksum_fail", versionName: "\(version):\(destFileName)")
1628
1712
  throw NSError(domain: "ChecksumError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Computed checksum is not equal to required checksum (\(calculatedChecksum) != \(fileHash)) for file \(fileName) at url \(downloadUrl)"])
1629
1713
  }
1630
1714
 
1631
- // Save to cache (replace stale cache entries from partial or concurrent downloads)
1632
1715
  if let cacheFilePath {
1633
1716
  try copyItemAtomically(from: destFilePath, to: cacheFilePath)
1634
1717
  }
1718
+ try? FileManager.default.removeItem(at: partialURL)
1635
1719
 
1636
1720
  self.logger.info("Manifest file downloaded and cached")
1637
1721
  self.logger.debug("Bundle: \(bundleId), File: \(fileName), Brotli: \(isBrotli), Encrypted: \(!self.publicKey.isEmpty && !sessionKey.isEmpty)")
@@ -1701,13 +1785,13 @@ import UIKit
1701
1785
 
1702
1786
  /// Stream Brotli from disk to disk. Peek only the 3-byte header and last byte
1703
1787
  /// for the empty/wrapper special cases; never load the whole file.
1704
- func decompressBrotli(from source: URL, to dest: URL, fileName: String) throws {
1788
+ func decompressBrotli(from source: URL, to dest: URL, fileName: String) throws -> String {
1705
1789
  let fileManager = FileManager.default
1706
1790
  try fileManager.createDirectory(at: dest.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
1707
1791
  let length = (try fileManager.attributesOfItem(atPath: source.path)[.size] as? NSNumber)?.uint64Value ?? 0
1708
1792
  if length == 0 {
1709
1793
  try Data().write(to: dest, options: .atomic)
1710
- return
1794
+ return CryptoCipher.RunningChecksum().hex()
1711
1795
  }
1712
1796
 
1713
1797
  let handle = try FileHandle(forReadingFrom: source)
@@ -1724,7 +1808,7 @@ import UIKit
1724
1808
 
1725
1809
  if length == 3 && head.count == 3 && head[0] == 0x1B && head[1] == 0x00 && head[2] == 0x06 {
1726
1810
  try Data().write(to: dest, options: .atomic)
1727
- return
1811
+ return CryptoCipher.RunningChecksum().hex()
1728
1812
  }
1729
1813
 
1730
1814
  if length > 3 && head.count == 3 && last == 0x03 {
@@ -1732,16 +1816,15 @@ import UIKit
1732
1816
  let isQualityZeroWrapper = head[0] == 0x0b && head[1] == 0x02 && head[2] == 0x80
1733
1817
  if isEmptyWrapper || isQualityZeroWrapper {
1734
1818
  try handle.seek(toOffset: 3)
1735
- try streamCopy(from: handle, count: length - 4, to: dest)
1736
- return
1819
+ return try streamCopy(from: handle, count: length - 4, to: dest)
1737
1820
  }
1738
1821
  }
1739
1822
 
1740
1823
  try handle.seek(toOffset: 0)
1741
- try streamBrotliDecode(from: handle, to: dest, fileName: fileName)
1824
+ return try streamBrotliDecode(from: handle, to: dest, fileName: fileName)
1742
1825
  }
1743
1826
 
1744
- private func streamCopy(from handle: FileHandle, count: UInt64, to dest: URL) throws {
1827
+ func streamCopy(from handle: FileHandle, count: UInt64, to dest: URL) throws -> String {
1745
1828
  let fileManager = FileManager.default
1746
1829
  let tempURL = dest.deletingLastPathComponent().appendingPathComponent("capgo-br-\(UUID().uuidString).tmp")
1747
1830
  fileManager.createFile(atPath: tempURL.path, contents: nil)
@@ -1751,6 +1834,7 @@ import UIKit
1751
1834
  try? fileManager.removeItem(at: tempURL)
1752
1835
  }
1753
1836
 
1837
+ let hasher = CryptoCipher.RunningChecksum()
1754
1838
  var remaining = count
1755
1839
  let chunkSize = CryptoCipher.ioBufferBytes()
1756
1840
  while remaining > 0 {
@@ -1758,6 +1842,7 @@ import UIKit
1758
1842
  let toRead = Int(min(UInt64(chunkSize), remaining))
1759
1843
  let chunk = try handle.read(upToCount: toRead) ?? Data()
1760
1844
  if !chunk.isEmpty {
1845
+ hasher.update(chunk)
1761
1846
  try output.write(contentsOf: chunk)
1762
1847
  }
1763
1848
  return chunk.count
@@ -1769,9 +1854,10 @@ import UIKit
1769
1854
  }
1770
1855
  try output.close()
1771
1856
  try replaceItemAtomically(at: dest, withItemAt: tempURL)
1857
+ return hasher.hex()
1772
1858
  }
1773
1859
 
1774
- private func streamBrotliDecode(from handle: FileHandle, to dest: URL, fileName: String) throws {
1860
+ private func streamBrotliDecode(from handle: FileHandle, to dest: URL, fileName: String) throws -> String {
1775
1861
  let fileManager = FileManager.default
1776
1862
  let tempURL = dest.deletingLastPathComponent().appendingPathComponent("capgo-br-\(UUID().uuidString).tmp")
1777
1863
  fileManager.createFile(atPath: tempURL.path, contents: nil)
@@ -1780,6 +1866,7 @@ import UIKit
1780
1866
  try? output.close()
1781
1867
  try? fileManager.removeItem(at: tempURL)
1782
1868
  }
1869
+ let hasher = CryptoCipher.RunningChecksum()
1783
1870
 
1784
1871
  let chunkSize = max(CryptoCipher.ioBufferBytes(), 65536)
1785
1872
  var inputBuffer = [UInt8](repeating: 0, count: chunkSize)
@@ -1824,7 +1911,9 @@ import UIKit
1824
1911
  status = compression_stream_process(streamPointer, flags)
1825
1912
  let have = chunkSize - streamPointer.pointee.dst_size
1826
1913
  if have > 0 {
1827
- try output.write(contentsOf: Data(bytes: outBase, count: have))
1914
+ let decoded = Data(bytes: outBase, count: have)
1915
+ hasher.update(decoded)
1916
+ try output.write(contentsOf: decoded)
1828
1917
  }
1829
1918
  streamPointer.pointee.dst_ptr = outBase
1830
1919
  streamPointer.pointee.dst_size = chunkSize
@@ -1848,6 +1937,7 @@ import UIKit
1848
1937
 
1849
1938
  try output.close()
1850
1939
  try replaceItemAtomically(at: dest, withItemAt: tempURL)
1940
+ return hasher.hex()
1851
1941
  }
1852
1942
 
1853
1943
  public func download(url: URL, version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
@@ -2165,6 +2255,7 @@ import UIKit
2165
2255
  if !hadRegistry && !hadFolder {
2166
2256
  logger.error("Cannot delete unknown bundle")
2167
2257
  logger.debug("Bundle ID: \(id)")
2258
+ self.dequeuePendingDelete(id: id)
2168
2259
  return false
2169
2260
  }
2170
2261
 
@@ -2224,6 +2315,10 @@ import UIKit
2224
2315
  var pendingIds = Set(self.list(raw: true).filter { $0.isDeleting() }.map { $0.getId() }.filter { !$0.isEmpty })
2225
2316
  pendingIds.formUnion(self.getPendingDeleteIds())
2226
2317
  for id in pendingIds {
2318
+ if Thread.current.isCancelled {
2319
+ logger.warn("drainPendingDeletes was cancelled")
2320
+ return
2321
+ }
2227
2322
  logger.info("Resuming pending delete for bundle: \(id)")
2228
2323
  if self.delete(id: id, removeInfo: true) {
2229
2324
  self.dequeuePendingDelete(id: id)
@@ -2410,11 +2505,16 @@ import UIKit
2410
2505
  logger.debug("Error: \(error.localizedDescription)")
2411
2506
  }
2412
2507
 
2508
+ if let thread = threadToCheck, thread.isCancelled {
2509
+ logger.warn("cleanupOrphanedTempFolders was cancelled")
2510
+ return
2511
+ }
2512
+
2413
2513
  // Also cleanup old download temp files (package_*.tmp and update_*.dat)
2414
- cleanupOldDownloadTempFiles()
2514
+ cleanupOldDownloadTempFiles(threadToCheck: threadToCheck)
2415
2515
  }
2416
2516
 
2417
- private func cleanupOldDownloadTempFiles() {
2517
+ private func cleanupOldDownloadTempFiles(threadToCheck: Thread? = nil) {
2418
2518
  let fileManager = FileManager.default
2419
2519
  guard let documentsDir = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
2420
2520
  return
@@ -2425,6 +2525,10 @@ import UIKit
2425
2525
  let oneHourAgo = Date().addingTimeInterval(-3600)
2426
2526
 
2427
2527
  for url in contents {
2528
+ if let thread = threadToCheck, thread.isCancelled {
2529
+ logger.warn("cleanupOldDownloadTempFiles was cancelled")
2530
+ return
2531
+ }
2428
2532
  let fileName = url.lastPathComponent
2429
2533
  // Only cleanup package_*.tmp and update_*.dat files
2430
2534
  let isDownloadTemp = (fileName.hasPrefix("package_") && fileName.hasSuffix(".tmp")) ||
@@ -198,8 +198,7 @@ public struct CryptoCipher {
198
198
  }
199
199
  }) {}
200
200
 
201
- let digest = sha256.finalize()
202
- return digest.compactMap { String(format: "%02x", $0) }.joined()
201
+ return hexString(from: sha256)
203
202
  } catch {
204
203
  logger.error("Cannot calculate checksum")
205
204
  logger.debug("Path: \(filePath.path), Error: \(error)")
@@ -207,6 +206,32 @@ public struct CryptoCipher {
207
206
  }
208
207
  }
209
208
 
209
+ final class RunningChecksum {
210
+ private var sha256 = SHA256()
211
+
212
+ func update(_ data: Data) {
213
+ guard !data.isEmpty else {
214
+ return
215
+ }
216
+ sha256.update(data: data)
217
+ }
218
+
219
+ func hex() -> String {
220
+ CryptoCipher.hexString(from: sha256)
221
+ }
222
+ }
223
+
224
+ static func hexString(from sha256: SHA256) -> String {
225
+ var copy = sha256
226
+ return copy.finalize().compactMap { String(format: "%02x", $0) }.joined()
227
+ }
228
+
229
+ static func shortPathKey(_ fileName: String) -> String {
230
+ var sha256 = SHA256()
231
+ sha256.update(data: Data(fileName.utf8))
232
+ return String(hexString(from: sha256).prefix(16))
233
+ }
234
+
210
235
  public static func decryptFile(filePath: URL, publicKey: String, sessionKey: String, version: String) throws {
211
236
  if publicKey.isEmpty || sessionKey.isEmpty || sessionKey.components(separatedBy: ":").count != 2 {
212
237
  logger.info("Encryption not set, no public key or session, ignored")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-updater",
3
- "version": "8.51.12",
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",