@capgo/capacitor-updater 8.51.11 → 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.
- package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +1 -1
- package/android/src/main/java/ee/forgr/capacitor_updater/CryptoCipher.java +29 -71
- package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +151 -82
- package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +1 -1
- package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +110 -21
- package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +37 -31
- package/package.json +1 -1
|
@@ -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.
|
|
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;
|
|
@@ -12,11 +12,9 @@ package ee.forgr.capacitor_updater;
|
|
|
12
12
|
* references: http://stackoverflow.com/questions/12471999/rsa-encryption-decryption-in-android
|
|
13
13
|
*/
|
|
14
14
|
import android.util.Base64;
|
|
15
|
-
import java.io.BufferedReader;
|
|
16
15
|
import java.io.File;
|
|
17
16
|
import java.io.FileInputStream;
|
|
18
17
|
import java.io.FileOutputStream;
|
|
19
|
-
import java.io.FileReader;
|
|
20
18
|
import java.io.IOException;
|
|
21
19
|
import java.io.InputStream;
|
|
22
20
|
import java.security.GeneralSecurityException;
|
|
@@ -174,8 +172,7 @@ public class CryptoCipher {
|
|
|
174
172
|
File tempFile = File.createTempFile("capgo-aes-", ".tmp", file.getParentFile());
|
|
175
173
|
try {
|
|
176
174
|
byte[] inBuf = new byte[ioBufferBytes()];
|
|
177
|
-
// Reuse one output buffer. cipher.update(in) allocates a new byte[] per chunk
|
|
178
|
-
// and 64 workers * 5MB was why AES barely won on heap in local benches.
|
|
175
|
+
// Reuse one output buffer. cipher.update(in) allocates a new byte[] per chunk.
|
|
179
176
|
byte[] outBuf = new byte[inBuf.length + 16];
|
|
180
177
|
try (FileInputStream fis = new FileInputStream(file); FileOutputStream fos = new FileOutputStream(tempFile)) {
|
|
181
178
|
int n;
|
|
@@ -330,73 +327,20 @@ public class CryptoCipher {
|
|
|
330
327
|
}
|
|
331
328
|
}
|
|
332
329
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
private static final long EIGHT_GIB = 8L * 1024 * 1024 * 1024;
|
|
337
|
-
private static final int FLAGSHIP_IO_BUFFER_BYTES = 5 * 1024 * 1024;
|
|
338
|
-
private static volatile long cachedPhysicalRamBytes = -1;
|
|
339
|
-
|
|
340
|
-
// Checksum and copy share one ladder. 64-wide peak RAM = 64 * buffer.
|
|
341
|
-
// <2GB: 64KB. <3GB: 256KB. <4GB: 512KB. <8GB: 1MB. Else 5MB (flagship / unknown).
|
|
342
|
-
static int ioBufferBytes(long physicalRamBytes) {
|
|
343
|
-
if (physicalRamBytes <= 0) {
|
|
344
|
-
return FLAGSHIP_IO_BUFFER_BYTES;
|
|
345
|
-
}
|
|
346
|
-
if (physicalRamBytes < TWO_GIB) {
|
|
347
|
-
return 64 * 1024;
|
|
348
|
-
}
|
|
349
|
-
if (physicalRamBytes < THREE_GIB) {
|
|
350
|
-
return 256 * 1024;
|
|
351
|
-
}
|
|
352
|
-
if (physicalRamBytes < FOUR_GIB) {
|
|
353
|
-
return 512 * 1024;
|
|
354
|
-
}
|
|
355
|
-
if (physicalRamBytes < EIGHT_GIB) {
|
|
356
|
-
return 1024 * 1024;
|
|
357
|
-
}
|
|
358
|
-
return FLAGSHIP_IO_BUFFER_BYTES;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
static int checksumBufferBytes(long physicalRamBytes) {
|
|
362
|
-
return ioBufferBytes(physicalRamBytes);
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
static int copyBufferBytes(long physicalRamBytes) {
|
|
366
|
-
return ioBufferBytes(physicalRamBytes);
|
|
367
|
-
}
|
|
330
|
+
// 256 KiB: one size for checksum, copy, and decode.
|
|
331
|
+
// 64 workers * 256 KiB = 16 MiB for one buffer; AES/Brotli hold two (~32 MiB).
|
|
332
|
+
static final int IO_BUFFER_BYTES = 256 * 1024;
|
|
368
333
|
|
|
369
334
|
static int ioBufferBytes() {
|
|
370
|
-
return
|
|
335
|
+
return IO_BUFFER_BYTES;
|
|
371
336
|
}
|
|
372
337
|
|
|
373
338
|
static int checksumBufferBytes() {
|
|
374
|
-
return
|
|
339
|
+
return IO_BUFFER_BYTES;
|
|
375
340
|
}
|
|
376
341
|
|
|
377
342
|
static int copyBufferBytes() {
|
|
378
|
-
return
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
static long physicalRamBytes() {
|
|
382
|
-
long cached = cachedPhysicalRamBytes;
|
|
383
|
-
if (cached >= 0) {
|
|
384
|
-
return cached;
|
|
385
|
-
}
|
|
386
|
-
long parsed = 0;
|
|
387
|
-
try (BufferedReader reader = new BufferedReader(new FileReader("/proc/meminfo"))) {
|
|
388
|
-
String line = reader.readLine();
|
|
389
|
-
if (line != null && line.startsWith("MemTotal:")) {
|
|
390
|
-
String[] parts = line.split("\\s+");
|
|
391
|
-
if (parts.length >= 2) {
|
|
392
|
-
parsed = Long.parseLong(parts[1]) * 1024L;
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
} catch (Exception ignored) {
|
|
396
|
-
parsed = 0;
|
|
397
|
-
}
|
|
398
|
-
cachedPhysicalRamBytes = parsed;
|
|
399
|
-
return parsed;
|
|
343
|
+
return IO_BUFFER_BYTES;
|
|
400
344
|
}
|
|
401
345
|
|
|
402
346
|
public static String calcChecksum(File file) {
|
|
@@ -425,14 +369,7 @@ public class CryptoCipher {
|
|
|
425
369
|
while ((length = inputStream.read(buffer)) != -1) {
|
|
426
370
|
digest.update(buffer, 0, length);
|
|
427
371
|
}
|
|
428
|
-
|
|
429
|
-
StringBuilder hexString = new StringBuilder();
|
|
430
|
-
for (byte b : hash) {
|
|
431
|
-
String hex = Integer.toHexString(0xff & b);
|
|
432
|
-
if (hex.length() == 1) hexString.append('0');
|
|
433
|
-
hexString.append(hex);
|
|
434
|
-
}
|
|
435
|
-
return hexString.toString();
|
|
372
|
+
return digestToHex(digest);
|
|
436
373
|
} catch (IOException e) {
|
|
437
374
|
logger.error("Cannot calculate checksum");
|
|
438
375
|
logger.debug("Error: " + e.getMessage());
|
|
@@ -440,6 +377,27 @@ public class CryptoCipher {
|
|
|
440
377
|
}
|
|
441
378
|
}
|
|
442
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
|
+
|
|
443
401
|
private static byte[] createDEREncoding(int tag, byte[] value) {
|
|
444
402
|
if (tag < 0 || tag >= 0xFF) {
|
|
445
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(
|
|
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
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
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
|
-
|
|
848
|
-
|
|
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: " +
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
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
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
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(
|
|
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
|
-
|
|
897
|
+
try {
|
|
868
898
|
if (isBrotli) {
|
|
869
|
-
|
|
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(
|
|
880
|
-
writeFileAtomic(finalTargetFile, fis,
|
|
901
|
+
try (FileInputStream fis = new FileInputStream(source)) {
|
|
902
|
+
writeFileAtomic(finalTargetFile, fis, expectedHash);
|
|
881
903
|
}
|
|
882
904
|
}
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
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
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
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
|
-
|
|
917
|
-
|
|
918
|
-
|
|
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]),
|
|
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]),
|
|
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),
|
|
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,
|
|
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();
|
|
@@ -1047,35 +1102,49 @@ public class DownloadService extends Worker {
|
|
|
1047
1102
|
}
|
|
1048
1103
|
|
|
1049
1104
|
/**
|
|
1050
|
-
* Atomically write a stream to a file using the
|
|
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());
|
|
1054
1110
|
|
|
1055
1111
|
try {
|
|
1056
|
-
// Okio's default segment is
|
|
1057
|
-
//
|
|
1112
|
+
// Okio's default segment is 8 KiB. Copy with 256 KiB so 8 MiB wrapper unwraps
|
|
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
|
-
|
|
1067
|
-
|
|
1068
|
-
String actualChecksum = CryptoCipher.calcChecksum(tempFile);
|
|
1133
|
+
if (digest != null) {
|
|
1134
|
+
String actualChecksum = CryptoCipher.digestToHex(digest);
|
|
1069
1135
|
if (!expectedChecksum.equalsIgnoreCase(actualChecksum)) {
|
|
1070
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
@@ -141,39 +141,20 @@ public struct CryptoCipher {
|
|
|
141
141
|
}
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
/// Checksum and copy share one ladder. 64-wide peak RAM = 64 * buffer.
|
|
151
|
-
/// <2GB: 64KB. <3GB: 256KB. <4GB: 512KB. <8GB: 1MB. Else 5MB (flagship / unknown).
|
|
152
|
-
static func ioBufferBytes(_ physicalRamBytes: UInt64 = ProcessInfo.processInfo.physicalMemory) -> Int {
|
|
153
|
-
if physicalRamBytes == 0 {
|
|
154
|
-
return flagshipIoBufferBytes
|
|
155
|
-
}
|
|
156
|
-
if physicalRamBytes < twoGiB {
|
|
157
|
-
return 64 * 1024
|
|
158
|
-
}
|
|
159
|
-
if physicalRamBytes < threeGiB {
|
|
160
|
-
return 256 * 1024
|
|
161
|
-
}
|
|
162
|
-
if physicalRamBytes < fourGiB {
|
|
163
|
-
return 512 * 1024
|
|
164
|
-
}
|
|
165
|
-
if physicalRamBytes < eightGiB {
|
|
166
|
-
return 1024 * 1024
|
|
167
|
-
}
|
|
168
|
-
return flagshipIoBufferBytes
|
|
144
|
+
/// 256 KiB: one size for checksum, copy, and decode.
|
|
145
|
+
/// 64 workers * 256 KiB = 16 MiB for one buffer; AES/Brotli hold two (~32 MiB).
|
|
146
|
+
static let ioBufferBytesValue = 256 * 1024
|
|
147
|
+
|
|
148
|
+
static func ioBufferBytes() -> Int {
|
|
149
|
+
return ioBufferBytesValue
|
|
169
150
|
}
|
|
170
151
|
|
|
171
|
-
static func checksumBufferBytes(
|
|
172
|
-
return
|
|
152
|
+
static func checksumBufferBytes() -> Int {
|
|
153
|
+
return ioBufferBytesValue
|
|
173
154
|
}
|
|
174
155
|
|
|
175
|
-
static func copyBufferBytes(
|
|
176
|
-
return
|
|
156
|
+
static func copyBufferBytes() -> Int {
|
|
157
|
+
return ioBufferBytesValue
|
|
177
158
|
}
|
|
178
159
|
|
|
179
160
|
public static func calcChecksum(filePath: URL) -> String {
|
|
@@ -217,8 +198,7 @@ public struct CryptoCipher {
|
|
|
217
198
|
}
|
|
218
199
|
}) {}
|
|
219
200
|
|
|
220
|
-
|
|
221
|
-
return digest.compactMap { String(format: "%02x", $0) }.joined()
|
|
201
|
+
return hexString(from: sha256)
|
|
222
202
|
} catch {
|
|
223
203
|
logger.error("Cannot calculate checksum")
|
|
224
204
|
logger.debug("Path: \(filePath.path), Error: \(error)")
|
|
@@ -226,6 +206,32 @@ public struct CryptoCipher {
|
|
|
226
206
|
}
|
|
227
207
|
}
|
|
228
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
|
+
|
|
229
235
|
public static func decryptFile(filePath: URL, publicKey: String, sessionKey: String, version: String) throws {
|
|
230
236
|
if publicKey.isEmpty || sessionKey.isEmpty || sessionKey.components(separatedBy: ":").count != 2 {
|
|
231
237
|
logger.info("Encryption not set, no public key or session, ignored")
|