@capgo/capacitor-updater 8.51.12 → 8.51.13

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.
@@ -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.13";
150
150
  private static final String DELAY_CONDITION_PREFERENCES = "";
151
151
 
152
152
  private SharedPreferences.Editor editor;
@@ -369,14 +369,7 @@ public class CryptoCipher {
369
369
  while ((length = inputStream.read(buffer)) != -1) {
370
370
  digest.update(buffer, 0, length);
371
371
  }
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();
372
+ return digestToHex(digest);
380
373
  } catch (IOException e) {
381
374
  logger.error("Cannot calculate checksum");
382
375
  logger.debug("Error: " + e.getMessage());
@@ -384,6 +377,27 @@ public class CryptoCipher {
384
377
  }
385
378
  }
386
379
 
380
+ static String digestToHex(MessageDigest digest) {
381
+ byte[] hash = digest.digest();
382
+ StringBuilder hexString = new StringBuilder(hash.length * 2);
383
+ for (byte b : hash) {
384
+ String hex = Integer.toHexString(0xff & b);
385
+ if (hex.length() == 1) hexString.append('0');
386
+ hexString.append(hex);
387
+ }
388
+ return hexString.toString();
389
+ }
390
+
391
+ static String shortPathKey(String fileName) {
392
+ try {
393
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
394
+ digest.update((fileName == null ? "" : fileName).getBytes(java.nio.charset.StandardCharsets.UTF_8));
395
+ return digestToHex(digest).substring(0, 16);
396
+ } catch (java.security.NoSuchAlgorithmException e) {
397
+ return Integer.toHexString((fileName == null ? "" : fileName).hashCode());
398
+ }
399
+ }
400
+
387
401
  private static byte[] createDEREncoding(int tag, byte[] value) {
388
402
  if (tag < 0 || tag >= 0xFF) {
389
403
  throw new IllegalArgumentException("Currently only single byte tags supported");
@@ -157,6 +157,7 @@ public class DownloadService extends Worker {
157
157
 
158
158
  // Clean up old temporary files on service initialization
159
159
  cleanupOldTempFiles(getApplicationContext().getCacheDir());
160
+ cleanupOldTempFiles(new File(getApplicationContext().getCacheDir(), "capgo_downloads"));
160
161
  }
161
162
 
162
163
  private void setProgress(int percent) {
@@ -532,7 +533,16 @@ public class DownloadService extends Worker {
532
533
  ) {
533
534
  logger.debug("already cached " + fileName);
534
535
  } else {
535
- downloadAndVerify(downloadUrl, targetFile, cacheFile, finalFileHash, sessionKey, publicKey, finalIsBrotli);
536
+ downloadAndVerify(
537
+ downloadUrl,
538
+ targetFile,
539
+ cacheFile,
540
+ finalFileHash,
541
+ sessionKey,
542
+ publicKey,
543
+ finalIsBrotli,
544
+ fileName
545
+ );
536
546
  }
537
547
 
538
548
  long completed = completedFiles.incrementAndGet();
@@ -827,96 +837,137 @@ public class DownloadService extends Worker {
827
837
  String expectedHash,
828
838
  String sessionKey,
829
839
  String publicKey,
830
- boolean isBrotli
840
+ boolean isBrotli,
841
+ String relativeName
831
842
  ) throws Exception {
832
843
  logger.debug("downloadAndVerify " + downloadUrl);
833
844
 
834
- Request request = new Request.Builder().url(downloadUrl).build();
835
-
836
- // targetFile is already the final destination without .br extension
837
845
  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
-
846
+ File cacheFolder = new File(getApplicationContext().getCacheDir(), "capgo_downloads");
847
+ if (!cacheFolder.exists() && !cacheFolder.mkdirs()) {
848
+ throw new IOException("Failed to create cache directory: " + cacheFolder.getAbsolutePath());
849
+ }
850
+ File partial = manifestPartialFile(cacheFolder, expectedHash, relativeName);
851
+ File workFile = null;
852
+ boolean keepPartial = partial.isFile();
846
853
  try {
847
- try (Response response = sharedClient.newCall(request).execute()) {
848
- if (!response.isSuccessful()) {
854
+ long existing = partial.isFile() ? partial.length() : 0;
855
+ Request.Builder builder = new Request.Builder().url(downloadUrl);
856
+ if (existing > 0) {
857
+ builder.header("Range", "bytes=" + existing + "-");
858
+ }
859
+ try (Response response = sharedClient.newCall(builder.build()).execute()) {
860
+ int code = response.code();
861
+ if (code == 416 && existing > 0) {
862
+ logger.debug("Range not satisfiable, using existing partial " + partial.getName());
863
+ keepPartial = true;
864
+ } else if (code != HttpURLConnection.HTTP_OK && code != HttpURLConnection.HTTP_PARTIAL) {
849
865
  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");
866
+ throw new IOException("Unexpected response code: " + code);
867
+ } else {
868
+ ResponseBody responseBody = response.body();
869
+ if (responseBody == null) {
870
+ throw new IOException("Response body is null");
871
+ }
872
+ try {
873
+ writeHttpBody(partial, responseBody.byteStream(), code, existing);
874
+ keepPartial = true;
875
+ } catch (Exception e) {
876
+ keepPartial = true;
877
+ throw e;
878
+ }
857
879
  }
880
+ }
858
881
 
859
- // Use OkIO for atomic write
860
- writeFileAtomic(compressedFile, responseBody.byteStream(), null);
861
-
862
- if (publicKey != null && !publicKey.isEmpty() && sessionKey != null && !sessionKey.isEmpty()) {
882
+ boolean needDecrypt = publicKey != null && !publicKey.isEmpty() && sessionKey != null && !sessionKey.isEmpty();
883
+ File source = partial;
884
+ if (needDecrypt) {
885
+ workFile = new File(cacheFolder, "work_" + UUID.randomUUID() + "_" + targetFile.getName() + ".tmp");
886
+ copyFile(partial, workFile);
887
+ try {
863
888
  logger.debug("Decrypting file " + targetFile.getName());
864
- CryptoCipher.decryptFile(compressedFile, publicKey, sessionKey);
889
+ CryptoCipher.decryptFile(workFile, publicKey, sessionKey);
890
+ source = workFile;
891
+ } catch (Exception e) {
892
+ keepPartial = false;
893
+ throw e;
865
894
  }
895
+ }
866
896
 
867
- // Only decompress if file has .br extension
897
+ try {
868
898
  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
- }
899
+ decompressBrotli(source, finalTargetFile, targetFile.getName(), expectedHash);
878
900
  } else {
879
- try (FileInputStream fis = new FileInputStream(compressedFile)) {
880
- writeFileAtomic(finalTargetFile, fis, null);
901
+ try (FileInputStream fis = new FileInputStream(source)) {
902
+ writeFileAtomic(finalTargetFile, fis, expectedHash);
881
903
  }
882
904
  }
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
- }
905
+ } catch (IOException e) {
906
+ String msg = e.getMessage();
907
+ if (msg != null && msg.contains("Checksum verification failed")) {
908
+ if (finalTargetFile.exists() && !finalTargetFile.delete()) {
909
+ logger.debug("Failed to delete dest after checksum mismatch");
897
910
  }
898
- } else {
899
- finalTargetFile.delete();
900
911
  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
- );
912
+ keepPartial = false;
913
+ } else if (isBrotli) {
914
+ sendStatsAsync("download_manifest_brotli_fail", getInputData().getString(VERSION) + ":" + finalTargetFile.getName());
915
+ keepPartial = false;
916
+ }
917
+ throw e;
918
+ }
919
+
920
+ CryptoCipher.logChecksumInfo("Calculated checksum", expectedHash);
921
+ CryptoCipher.logChecksumInfo("Expected checksum", expectedHash);
922
+
923
+ if (cacheFile != null) {
924
+ try (FileInputStream fis = new FileInputStream(finalTargetFile)) {
925
+ writeFileAtomic(cacheFile, fis, null);
911
926
  }
912
927
  }
928
+ keepPartial = false;
913
929
  } catch (Exception e) {
914
- throw new IOException("Error in downloadAndVerify: " + e.getMessage());
930
+ throw new IOException("Error in downloadAndVerify: " + e.getMessage(), e);
915
931
  } finally {
916
- // Always cleanup the compressed temp file if it still exists
917
- if (compressedFile.exists()) {
918
- compressedFile.delete();
932
+ if (workFile != null && workFile.exists() && !workFile.delete()) {
933
+ logger.debug("Failed to delete decrypt work file");
934
+ }
935
+ if (!keepPartial && partial.exists() && !partial.delete()) {
936
+ logger.debug("Failed to delete manifest partial " + partial.getName());
937
+ }
938
+ }
939
+ }
940
+
941
+ static String safePartialToken(String fileName) {
942
+ return CryptoCipher.shortPathKey(fileName);
943
+ }
944
+
945
+ static File manifestPartialFile(File cacheDir, String hash, String fileName) {
946
+ String token = safePartialToken(fileName);
947
+ if (CapgoUpdater.isSafeCacheHash(hash) && hash.length() == 64) {
948
+ return new File(cacheDir, "partial_" + hash + "_" + token + ".tmp");
949
+ }
950
+ return new File(cacheDir, "temp_" + UUID.randomUUID() + "_" + 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();
@@ -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
  }
@@ -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.13"
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"
@@ -305,9 +305,47 @@ 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
+ return cacheFolder.appendingPathComponent("temp_\(UUID().uuidString)_\(token).tmp")
322
+ }
323
+
324
+ private func cleanupOldManifestPartials() {
325
+ let cutoff = Date().addingTimeInterval(-3600)
326
+ guard let files = try? FileManager.default.contentsOfDirectory(
327
+ at: cacheFolder,
328
+ includingPropertiesForKeys: [.contentModificationDateKey],
329
+ options: [.skipsHiddenFiles]
330
+ ) else {
331
+ return
332
+ }
333
+ for url in files {
334
+ let name = url.lastPathComponent
335
+ guard name.hasPrefix("partial_") && name.hasSuffix(".tmp") else {
336
+ continue
337
+ }
338
+ let modified = (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
339
+ if modified < cutoff {
340
+ try? FileManager.default.removeItem(at: url)
341
+ }
342
+ }
343
+ }
344
+
345
+ func storeDownloadedFile(_ downloadedFileURL: URL, at tempPath: URL, existingBytes: Int64, response: HTTPURLResponse?) throws {
309
346
  let fileManager = FileManager.default
310
- if existingBytes > 0 && (response?.statusCode == 206 || response == nil) {
347
+ if Self.shouldAppendHttpBody(statusCode: response?.statusCode ?? 0, existingBytes: existingBytes) ||
348
+ (existingBytes > 0 && response == nil) {
311
349
  let fileHandle = try FileHandle(forWritingTo: tempPath)
312
350
  defer {
313
351
  try? fileHandle.close()
@@ -1342,6 +1380,7 @@ import UIKit
1342
1380
  try checkDiskSpace(estimatedSize: estimatedSize)
1343
1381
 
1344
1382
  try FileManager.default.createDirectory(at: cacheFolder, withIntermediateDirectories: true, attributes: nil)
1383
+ cleanupOldManifestPartials()
1345
1384
  try FileManager.default.createDirectory(at: destFolder, withIntermediateDirectories: true, attributes: nil)
1346
1385
 
1347
1386
  // Create and save BundleInfo before starting the download process
@@ -1550,7 +1589,7 @@ import UIKit
1550
1589
  )
1551
1590
  }
1552
1591
 
1553
- guard let request = createRequest(url: url, method: "GET") else {
1592
+ guard var request = createRequest(url: url, method: "GET") else {
1554
1593
  throw NSError(
1555
1594
  domain: "ManifestDownloadError",
1556
1595
  code: 2,
@@ -1558,6 +1597,18 @@ import UIKit
1558
1597
  )
1559
1598
  }
1560
1599
 
1600
+ try FileManager.default.createDirectory(at: cacheFolder, withIntermediateDirectories: true, attributes: nil)
1601
+ let partialURL = Self.manifestPartialURL(cacheFolder: cacheFolder, hash: fileHash, fileName: fileName)
1602
+ let existingBytes: Int64
1603
+ if FileManager.default.fileExists(atPath: partialURL.path) {
1604
+ existingBytes = Int64((try FileManager.default.attributesOfItem(atPath: partialURL.path)[.size] as? NSNumber)?.int64Value ?? 0)
1605
+ } else {
1606
+ existingBytes = 0
1607
+ }
1608
+ if existingBytes > 0 {
1609
+ request.setValue("bytes=\(existingBytes)-", forHTTPHeaderField: "Range")
1610
+ }
1611
+
1561
1612
  let result = performDownloadRequest(request, label: "downloadManifestFile \(fileName)")
1562
1613
  defer {
1563
1614
  if let fileURL = result.fileURL {
@@ -1566,6 +1617,7 @@ import UIKit
1566
1617
  }
1567
1618
 
1568
1619
  if result.timedOut {
1620
+ persistPartialDownload(result, id: bundleId, tempPath: partialURL, existingBytes: existingBytes)
1569
1621
  self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
1570
1622
  throw NSError(
1571
1623
  domain: NSURLErrorDomain,
@@ -1575,6 +1627,7 @@ import UIKit
1575
1627
  }
1576
1628
 
1577
1629
  if let error = result.error {
1630
+ persistPartialDownload(result, id: bundleId, tempPath: partialURL, existingBytes: existingBytes)
1578
1631
  self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
1579
1632
  self.logger.error("Manifest file download network error")
1580
1633
  self.logger.debug("Bundle: \(bundleId), File: \(fileName), Error: \(error.localizedDescription)")
@@ -1582,12 +1635,24 @@ import UIKit
1582
1635
  }
1583
1636
 
1584
1637
  let statusCode = result.response?.statusCode ?? 200
1585
- if statusCode < 200 || statusCode >= 300 {
1638
+ if statusCode == 416 && existingBytes > 0 {
1639
+ logger.debug("Range not satisfiable, using existing partial \(partialURL.lastPathComponent)")
1640
+ } else if statusCode < 200 || statusCode >= 300 {
1586
1641
  self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
1587
1642
  throw NSError(domain: "StatusCodeError", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid for file \(fileName) at url \(downloadUrl)"])
1643
+ } else {
1644
+ guard let downloadedFileURL = result.fileURL, FileManager.default.fileExists(atPath: downloadedFileURL.path) else {
1645
+ self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
1646
+ throw NSError(
1647
+ domain: "ManifestDownloadError",
1648
+ code: 3,
1649
+ userInfo: [NSLocalizedDescriptionKey: "Manifest file response was empty for \(fileName) at url \(downloadUrl)"]
1650
+ )
1651
+ }
1652
+ try storeDownloadedFile(downloadedFileURL, at: partialURL, existingBytes: existingBytes, response: result.response)
1588
1653
  }
1589
1654
 
1590
- guard let downloadedFileURL = result.fileURL, FileManager.default.fileExists(atPath: downloadedFileURL.path) else {
1655
+ guard FileManager.default.fileExists(atPath: partialURL.path) else {
1591
1656
  self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
1592
1657
  throw NSError(
1593
1658
  domain: "ManifestDownloadError",
@@ -1596,42 +1661,60 @@ import UIKit
1596
1661
  )
1597
1662
  }
1598
1663
 
1664
+ var workURL: URL?
1665
+ defer {
1666
+ if let workURL {
1667
+ try? FileManager.default.removeItem(at: workURL)
1668
+ }
1669
+ }
1670
+
1599
1671
  do {
1600
- // Decrypt in place when a session key is present — streamed, not a whole-file copy.
1672
+ var source = partialURL
1601
1673
  if !self.publicKey.isEmpty && !sessionKey.isEmpty {
1674
+ let work = cacheFolder.appendingPathComponent("work_\(UUID().uuidString)_\((fileName as NSString).lastPathComponent)")
1675
+ try FileManager.default.copyItem(at: partialURL, to: work)
1676
+ workURL = work
1602
1677
  do {
1603
- try CryptoCipher.decryptFile(filePath: downloadedFileURL, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
1678
+ try CryptoCipher.decryptFile(filePath: work, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
1604
1679
  } catch {
1680
+ try? FileManager.default.removeItem(at: partialURL)
1605
1681
  self.sendStats(action: "decrypt_fail", versionName: version)
1606
1682
  throw error
1607
1683
  }
1684
+ source = work
1608
1685
  }
1609
1686
 
1687
+ let calculatedChecksum: String
1610
1688
  if isBrotli {
1611
1689
  do {
1612
- try decompressBrotli(from: downloadedFileURL, to: destFilePath, fileName: fileName)
1690
+ calculatedChecksum = try decompressBrotli(from: source, to: destFilePath, fileName: fileName)
1613
1691
  } catch {
1692
+ try? FileManager.default.removeItem(at: partialURL)
1614
1693
  self.sendStats(action: "download_manifest_brotli_fail", versionName: "\(version):\(destFileName)")
1615
1694
  throw error
1616
1695
  }
1617
1696
  } else {
1618
- try copyItemReplacing(from: downloadedFileURL, to: destFilePath)
1697
+ let handle = try FileHandle(forReadingFrom: source)
1698
+ defer {
1699
+ try? handle.close()
1700
+ }
1701
+ let length = (try FileManager.default.attributesOfItem(atPath: source.path)[.size] as? NSNumber)?.uint64Value ?? 0
1702
+ calculatedChecksum = try streamCopy(from: handle, count: length, to: destFilePath)
1619
1703
  }
1620
1704
 
1621
- // Always verify checksum when file_hash is present
1622
- let calculatedChecksum = CryptoCipher.calcChecksum(filePath: destFilePath)
1623
1705
  CryptoCipher.logChecksumInfo(label: "Calculated checksum", hexChecksum: calculatedChecksum)
1624
1706
  CryptoCipher.logChecksumInfo(label: "Expected checksum", hexChecksum: fileHash)
1625
1707
  if calculatedChecksum != fileHash {
1626
1708
  try? FileManager.default.removeItem(at: destFilePath)
1709
+ try? FileManager.default.removeItem(at: partialURL)
1627
1710
  self.sendStats(action: "download_manifest_checksum_fail", versionName: "\(version):\(destFileName)")
1628
1711
  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
1712
  }
1630
1713
 
1631
- // Save to cache (replace stale cache entries from partial or concurrent downloads)
1632
1714
  if let cacheFilePath {
1633
1715
  try copyItemAtomically(from: destFilePath, to: cacheFilePath)
1634
1716
  }
1717
+ try? FileManager.default.removeItem(at: partialURL)
1635
1718
 
1636
1719
  self.logger.info("Manifest file downloaded and cached")
1637
1720
  self.logger.debug("Bundle: \(bundleId), File: \(fileName), Brotli: \(isBrotli), Encrypted: \(!self.publicKey.isEmpty && !sessionKey.isEmpty)")
@@ -1701,13 +1784,13 @@ import UIKit
1701
1784
 
1702
1785
  /// Stream Brotli from disk to disk. Peek only the 3-byte header and last byte
1703
1786
  /// for the empty/wrapper special cases; never load the whole file.
1704
- func decompressBrotli(from source: URL, to dest: URL, fileName: String) throws {
1787
+ func decompressBrotli(from source: URL, to dest: URL, fileName: String) throws -> String {
1705
1788
  let fileManager = FileManager.default
1706
1789
  try fileManager.createDirectory(at: dest.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
1707
1790
  let length = (try fileManager.attributesOfItem(atPath: source.path)[.size] as? NSNumber)?.uint64Value ?? 0
1708
1791
  if length == 0 {
1709
1792
  try Data().write(to: dest, options: .atomic)
1710
- return
1793
+ return CryptoCipher.RunningChecksum().hex()
1711
1794
  }
1712
1795
 
1713
1796
  let handle = try FileHandle(forReadingFrom: source)
@@ -1724,7 +1807,7 @@ import UIKit
1724
1807
 
1725
1808
  if length == 3 && head.count == 3 && head[0] == 0x1B && head[1] == 0x00 && head[2] == 0x06 {
1726
1809
  try Data().write(to: dest, options: .atomic)
1727
- return
1810
+ return CryptoCipher.RunningChecksum().hex()
1728
1811
  }
1729
1812
 
1730
1813
  if length > 3 && head.count == 3 && last == 0x03 {
@@ -1732,16 +1815,15 @@ import UIKit
1732
1815
  let isQualityZeroWrapper = head[0] == 0x0b && head[1] == 0x02 && head[2] == 0x80
1733
1816
  if isEmptyWrapper || isQualityZeroWrapper {
1734
1817
  try handle.seek(toOffset: 3)
1735
- try streamCopy(from: handle, count: length - 4, to: dest)
1736
- return
1818
+ return try streamCopy(from: handle, count: length - 4, to: dest)
1737
1819
  }
1738
1820
  }
1739
1821
 
1740
1822
  try handle.seek(toOffset: 0)
1741
- try streamBrotliDecode(from: handle, to: dest, fileName: fileName)
1823
+ return try streamBrotliDecode(from: handle, to: dest, fileName: fileName)
1742
1824
  }
1743
1825
 
1744
- private func streamCopy(from handle: FileHandle, count: UInt64, to dest: URL) throws {
1826
+ func streamCopy(from handle: FileHandle, count: UInt64, to dest: URL) throws -> String {
1745
1827
  let fileManager = FileManager.default
1746
1828
  let tempURL = dest.deletingLastPathComponent().appendingPathComponent("capgo-br-\(UUID().uuidString).tmp")
1747
1829
  fileManager.createFile(atPath: tempURL.path, contents: nil)
@@ -1751,6 +1833,7 @@ import UIKit
1751
1833
  try? fileManager.removeItem(at: tempURL)
1752
1834
  }
1753
1835
 
1836
+ let hasher = CryptoCipher.RunningChecksum()
1754
1837
  var remaining = count
1755
1838
  let chunkSize = CryptoCipher.ioBufferBytes()
1756
1839
  while remaining > 0 {
@@ -1758,6 +1841,7 @@ import UIKit
1758
1841
  let toRead = Int(min(UInt64(chunkSize), remaining))
1759
1842
  let chunk = try handle.read(upToCount: toRead) ?? Data()
1760
1843
  if !chunk.isEmpty {
1844
+ hasher.update(chunk)
1761
1845
  try output.write(contentsOf: chunk)
1762
1846
  }
1763
1847
  return chunk.count
@@ -1769,9 +1853,10 @@ import UIKit
1769
1853
  }
1770
1854
  try output.close()
1771
1855
  try replaceItemAtomically(at: dest, withItemAt: tempURL)
1856
+ return hasher.hex()
1772
1857
  }
1773
1858
 
1774
- private func streamBrotliDecode(from handle: FileHandle, to dest: URL, fileName: String) throws {
1859
+ private func streamBrotliDecode(from handle: FileHandle, to dest: URL, fileName: String) throws -> String {
1775
1860
  let fileManager = FileManager.default
1776
1861
  let tempURL = dest.deletingLastPathComponent().appendingPathComponent("capgo-br-\(UUID().uuidString).tmp")
1777
1862
  fileManager.createFile(atPath: tempURL.path, contents: nil)
@@ -1780,6 +1865,7 @@ import UIKit
1780
1865
  try? output.close()
1781
1866
  try? fileManager.removeItem(at: tempURL)
1782
1867
  }
1868
+ let hasher = CryptoCipher.RunningChecksum()
1783
1869
 
1784
1870
  let chunkSize = max(CryptoCipher.ioBufferBytes(), 65536)
1785
1871
  var inputBuffer = [UInt8](repeating: 0, count: chunkSize)
@@ -1824,7 +1910,9 @@ import UIKit
1824
1910
  status = compression_stream_process(streamPointer, flags)
1825
1911
  let have = chunkSize - streamPointer.pointee.dst_size
1826
1912
  if have > 0 {
1827
- try output.write(contentsOf: Data(bytes: outBase, count: have))
1913
+ let decoded = Data(bytes: outBase, count: have)
1914
+ hasher.update(decoded)
1915
+ try output.write(contentsOf: decoded)
1828
1916
  }
1829
1917
  streamPointer.pointee.dst_ptr = outBase
1830
1918
  streamPointer.pointee.dst_size = chunkSize
@@ -1848,6 +1936,7 @@ import UIKit
1848
1936
 
1849
1937
  try output.close()
1850
1938
  try replaceItemAtomically(at: dest, withItemAt: tempURL)
1939
+ return hasher.hex()
1851
1940
  }
1852
1941
 
1853
1942
  public func download(url: URL, version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
@@ -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.13",
4
4
  "license": "MPL-2.0",
5
5
  "description": "Live update for capacitor apps",
6
6
  "main": "dist/plugin.cjs.js",