@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.
- package/android/src/main/java/ee/forgr/capacitor_updater/BundleInfo.java +1 -1
- package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +26 -14
- package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +43 -12
- package/android/src/main/java/ee/forgr/capacitor_updater/CryptoCipher.java +30 -9
- package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +152 -83
- package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +3 -0
- package/ios/Sources/CapacitorUpdaterPlugin/AES.swift +5 -0
- package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +27 -7
- package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +127 -23
- package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +27 -2
- package/package.json +1 -1
|
@@ -99,7 +99,7 @@ public class BundleInfo {
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
public boolean isDownloaded() {
|
|
102
|
-
return
|
|
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.
|
|
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
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
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
|
-
|
|
1773
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
2962
|
-
|
|
2963
|
-
out
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
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
|
-
|
|
848
|
-
|
|
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: " +
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
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
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
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(
|
|
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
|
-
|
|
899
|
+
try {
|
|
868
900
|
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
|
-
}
|
|
901
|
+
decompressBrotli(source, finalTargetFile, targetFile.getName(), expectedHash);
|
|
878
902
|
} else {
|
|
879
|
-
try (FileInputStream fis = new FileInputStream(
|
|
880
|
-
writeFileAtomic(finalTargetFile, fis,
|
|
903
|
+
try (FileInputStream fis = new FileInputStream(source)) {
|
|
904
|
+
writeFileAtomic(finalTargetFile, fis, expectedHash);
|
|
881
905
|
}
|
|
882
906
|
}
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
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
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
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
|
-
|
|
917
|
-
|
|
918
|
-
|
|
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]),
|
|
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();
|
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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")
|