@capgo/capacitor-updater 8.51.21 → 8.51.22
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/CapgoUpdater.java +14 -1
- package/android/src/main/java/ee/forgr/capacitor_updater/CryptoCipher.java +42 -17
- package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +75 -24
- package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +130 -53
- package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +97 -20
- 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.22";
|
|
150
150
|
private static final String DELAY_CONDITION_PREFERENCES = "";
|
|
151
151
|
|
|
152
152
|
private SharedPreferences.Editor editor;
|
|
@@ -285,6 +285,15 @@ public class CapgoUpdater {
|
|
|
285
285
|
this.cachedKeyId = CryptoCipher.calcKeyId(publicKey);
|
|
286
286
|
}
|
|
287
287
|
|
|
288
|
+
static boolean containsPathTraversalSegment(final String relativePath) {
|
|
289
|
+
for (final String segment : relativePath.split("/")) {
|
|
290
|
+
if ("..".equals(segment)) {
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
|
|
288
297
|
static File resolvePathInsideDirectory(final File baseDirectory, final String relativePath) throws IOException {
|
|
289
298
|
if (relativePath == null || relativePath.isEmpty()) {
|
|
290
299
|
throw new IOException("Invalid empty path");
|
|
@@ -292,6 +301,9 @@ public class CapgoUpdater {
|
|
|
292
301
|
if (relativePath.contains("\\") || relativePath.indexOf('\0') >= 0) {
|
|
293
302
|
throw new IOException("Invalid path separator");
|
|
294
303
|
}
|
|
304
|
+
if (containsPathTraversalSegment(relativePath)) {
|
|
305
|
+
throw new IOException("Path traversal segments are not allowed");
|
|
306
|
+
}
|
|
295
307
|
if (new File(relativePath).isAbsolute()) {
|
|
296
308
|
throw new IOException("Absolute paths are not allowed");
|
|
297
309
|
}
|
|
@@ -302,7 +314,8 @@ public class CapgoUpdater {
|
|
|
302
314
|
final String targetPath = canonicalTarget.getPath();
|
|
303
315
|
final String normalizedBasePath = basePath.endsWith(File.separator) ? basePath : basePath + File.separator;
|
|
304
316
|
|
|
305
|
-
|
|
317
|
+
// Require a strict child of the base. Equality would accept "." and wipe/write the root.
|
|
318
|
+
if (!targetPath.startsWith(normalizedBasePath)) {
|
|
306
319
|
throw new IOException("Path escapes base directory: " + relativePath);
|
|
307
320
|
}
|
|
308
321
|
|
|
@@ -50,17 +50,15 @@ public class CryptoCipher {
|
|
|
50
50
|
return decryptedBytes;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
public static byte[] decryptAES(byte[] cipherText, SecretKey key, byte[] iv) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
|
|
57
|
-
SecretKeySpec keySpec = new SecretKeySpec(key.getEncoded(), "AES");
|
|
58
|
-
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivParameterSpec);
|
|
59
|
-
return cipher.doFinal(cipherText);
|
|
60
|
-
} catch (Exception e) {
|
|
61
|
-
e.printStackTrace();
|
|
53
|
+
public static byte[] decryptAES(byte[] cipherText, SecretKey key, byte[] iv) throws GeneralSecurityException {
|
|
54
|
+
if (key == null || iv == null) {
|
|
55
|
+
throw new IllegalArgumentException("AES key and IV must not be null");
|
|
62
56
|
}
|
|
63
|
-
|
|
57
|
+
IvParameterSpec ivParameterSpec = new IvParameterSpec(iv);
|
|
58
|
+
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
|
|
59
|
+
SecretKeySpec keySpec = new SecretKeySpec(key.getEncoded(), "AES");
|
|
60
|
+
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivParameterSpec);
|
|
61
|
+
return cipher.doFinal(cipherText);
|
|
64
62
|
}
|
|
65
63
|
|
|
66
64
|
public static SecretKey byteToSessionKey(byte[] sessionKey) {
|
|
@@ -139,11 +137,15 @@ public class CryptoCipher {
|
|
|
139
137
|
|
|
140
138
|
public static void decryptFile(final File file, final String publicKey, final String ivSessionKey) throws IOException {
|
|
141
139
|
if (publicKey.isEmpty() || ivSessionKey == null || ivSessionKey.isEmpty() || ivSessionKey.split(":").length != 2) {
|
|
142
|
-
logger
|
|
140
|
+
if (logger != null) {
|
|
141
|
+
logger.info("Encryption not set, no public key or session, ignored");
|
|
142
|
+
}
|
|
143
143
|
return;
|
|
144
144
|
}
|
|
145
145
|
if (!publicKey.startsWith("-----BEGIN RSA PUBLIC KEY-----")) {
|
|
146
|
-
logger
|
|
146
|
+
if (logger != null) {
|
|
147
|
+
logger.error("The public key is not a valid RSA Public key");
|
|
148
|
+
}
|
|
147
149
|
return;
|
|
148
150
|
}
|
|
149
151
|
|
|
@@ -152,29 +154,50 @@ public class CryptoCipher {
|
|
|
152
154
|
String sessionKeyB64 = ivSessionKey.split(":")[1];
|
|
153
155
|
byte[] iv = Base64.decode(ivB64.getBytes(), Base64.DEFAULT);
|
|
154
156
|
byte[] sessionKey = Base64.decode(sessionKeyB64.getBytes(), Base64.DEFAULT);
|
|
157
|
+
if (iv.length != 16) {
|
|
158
|
+
throw new IOException("AES file decryption failed: IV must be 16 bytes");
|
|
159
|
+
}
|
|
155
160
|
PublicKey pKey = CryptoCipher.stringToPublicKey(publicKey);
|
|
156
161
|
byte[] decryptedSessionKey = CryptoCipher.decryptRSA(sessionKey, pKey);
|
|
162
|
+
if (decryptedSessionKey == null || decryptedSessionKey.length != 16) {
|
|
163
|
+
throw new IOException("AES file decryption failed: decrypted session key must be 16 bytes");
|
|
164
|
+
}
|
|
157
165
|
|
|
158
166
|
SecretKey sKey = CryptoCipher.byteToSessionKey(decryptedSessionKey);
|
|
159
167
|
decryptAesFile(file, sKey, iv);
|
|
160
168
|
} catch (GeneralSecurityException e) {
|
|
161
|
-
logger
|
|
162
|
-
|
|
169
|
+
if (logger != null) {
|
|
170
|
+
logger.info("decryptFile fail");
|
|
171
|
+
}
|
|
172
|
+
throw new IOException("AES file decryption failed: " + e.getMessage(), e);
|
|
163
173
|
}
|
|
164
174
|
}
|
|
165
175
|
|
|
166
|
-
static void decryptAesFile(File file, SecretKey key, byte[] iv) throws IOException
|
|
176
|
+
static void decryptAesFile(File file, SecretKey key, byte[] iv) throws IOException {
|
|
177
|
+
if (key == null) {
|
|
178
|
+
throw new IOException("AES file decryption failed: missing session key");
|
|
179
|
+
}
|
|
180
|
+
if (iv == null) {
|
|
181
|
+
throw new IOException("AES file decryption failed: missing IV");
|
|
182
|
+
}
|
|
183
|
+
if (iv.length != 16) {
|
|
184
|
+
throw new IOException("AES file decryption failed: IV must be 16 bytes");
|
|
185
|
+
}
|
|
186
|
+
byte[] keyBytes = key.getEncoded();
|
|
187
|
+
if (keyBytes == null || keyBytes.length != 16) {
|
|
188
|
+
throw new IOException("AES file decryption failed: session key must be 16 bytes");
|
|
189
|
+
}
|
|
167
190
|
if (file.length() == 0) {
|
|
168
191
|
throw new IOException("Empty encrypted data");
|
|
169
192
|
}
|
|
170
|
-
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
|
|
171
|
-
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key.getEncoded(), "AES"), new IvParameterSpec(iv));
|
|
172
193
|
File parent = file.getAbsoluteFile().getParentFile();
|
|
173
194
|
if (parent == null) {
|
|
174
195
|
throw new IOException("Cannot create temp file for " + file.getAbsolutePath());
|
|
175
196
|
}
|
|
176
197
|
File tempFile = File.createTempFile("capgo-aes-", ".tmp", parent);
|
|
177
198
|
try {
|
|
199
|
+
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
|
|
200
|
+
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(keyBytes, "AES"), new IvParameterSpec(iv));
|
|
178
201
|
byte[] inBuf = new byte[ioBufferBytes()];
|
|
179
202
|
// Reuse one output buffer. cipher.update(in) allocates a new byte[] per chunk.
|
|
180
203
|
byte[] outBuf = new byte[inBuf.length + 16];
|
|
@@ -196,6 +219,8 @@ public class CryptoCipher {
|
|
|
196
219
|
}
|
|
197
220
|
replaceFile(tempFile, file);
|
|
198
221
|
tempFile = null;
|
|
222
|
+
} catch (GeneralSecurityException e) {
|
|
223
|
+
throw new IOException("AES file decryption failed: " + e.getMessage(), e);
|
|
199
224
|
} finally {
|
|
200
225
|
if (tempFile != null && tempFile.exists()) {
|
|
201
226
|
tempFile.delete();
|
|
@@ -363,6 +363,38 @@ public class DownloadService extends Worker {
|
|
|
363
363
|
return seenTargets.add(targetFile.getCanonicalPath());
|
|
364
364
|
}
|
|
365
365
|
|
|
366
|
+
private static final class ManifestDownloadTask {
|
|
367
|
+
|
|
368
|
+
final String fileName;
|
|
369
|
+
final String finalFileHash;
|
|
370
|
+
final String downloadUrl;
|
|
371
|
+
final boolean isBrotli;
|
|
372
|
+
final File targetFile;
|
|
373
|
+
final File builtinFile;
|
|
374
|
+
final File cacheFile;
|
|
375
|
+
final File legacyCacheFile;
|
|
376
|
+
|
|
377
|
+
ManifestDownloadTask(
|
|
378
|
+
final String fileName,
|
|
379
|
+
final String finalFileHash,
|
|
380
|
+
final String downloadUrl,
|
|
381
|
+
final boolean isBrotli,
|
|
382
|
+
final File targetFile,
|
|
383
|
+
final File builtinFile,
|
|
384
|
+
final File cacheFile,
|
|
385
|
+
final File legacyCacheFile
|
|
386
|
+
) {
|
|
387
|
+
this.fileName = fileName;
|
|
388
|
+
this.finalFileHash = finalFileHash;
|
|
389
|
+
this.downloadUrl = downloadUrl;
|
|
390
|
+
this.isBrotli = isBrotli;
|
|
391
|
+
this.targetFile = targetFile;
|
|
392
|
+
this.builtinFile = builtinFile;
|
|
393
|
+
this.cacheFile = cacheFile;
|
|
394
|
+
this.legacyCacheFile = legacyCacheFile;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
366
398
|
static boolean tryCopyBuiltinAsset(final AssetManager assets, final String fileName, final File dest, final String expectedHash) {
|
|
367
399
|
if (assets == null || fileName == null || dest == null) {
|
|
368
400
|
return false;
|
|
@@ -535,9 +567,7 @@ public class DownloadService extends Worker {
|
|
|
535
567
|
int totalFiles = manifest.length();
|
|
536
568
|
final AtomicLong completedFiles = new AtomicLong(0);
|
|
537
569
|
final AtomicBoolean hasError = new AtomicBoolean(false);
|
|
538
|
-
|
|
539
|
-
ExecutorService executor = Executors.newFixedThreadPool(Math.min(MANIFEST_MAX_CONCURRENT_FILES, Math.max(1, totalFiles)));
|
|
540
|
-
List<Future<?>> futures = new ArrayList<>();
|
|
570
|
+
final List<ManifestDownloadTask> tasks = new ArrayList<>();
|
|
541
571
|
final Set<String> seenTargets = new HashSet<>();
|
|
542
572
|
|
|
543
573
|
for (int i = 0; i < totalFiles; i++) {
|
|
@@ -592,35 +622,56 @@ public class DownloadService extends Worker {
|
|
|
592
622
|
final File legacyCacheFile =
|
|
593
623
|
isBrotli && cacheFile != null ? new File(cacheFolder, finalFileHash + "_" + new File(fileName).getName()) : null;
|
|
594
624
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
625
|
+
tasks.add(
|
|
626
|
+
new ManifestDownloadTask(
|
|
627
|
+
fileName,
|
|
628
|
+
finalFileHash,
|
|
629
|
+
downloadUrl,
|
|
630
|
+
isBrotli,
|
|
631
|
+
targetFile,
|
|
632
|
+
builtinFile,
|
|
633
|
+
cacheFile,
|
|
634
|
+
legacyCacheFile
|
|
635
|
+
)
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
if (hasError.get()) {
|
|
640
|
+
throw new IOException("Manifest contains invalid or duplicate file paths");
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
for (final ManifestDownloadTask task : tasks) {
|
|
644
|
+
if (!Objects.requireNonNull(task.targetFile.getParentFile()).exists() && !task.targetFile.getParentFile().mkdirs()) {
|
|
645
|
+
logger.error("Failed to create parent directory for: " + task.targetFile.getAbsolutePath());
|
|
646
|
+
throw new IOException("Failed to create parent directory for: " + task.targetFile.getAbsolutePath());
|
|
600
647
|
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
ExecutorService executor = Executors.newFixedThreadPool(Math.min(MANIFEST_MAX_CONCURRENT_FILES, Math.max(1, totalFiles)));
|
|
651
|
+
List<Future<?>> futures = new ArrayList<>();
|
|
601
652
|
|
|
602
|
-
|
|
653
|
+
for (final ManifestDownloadTask task : tasks) {
|
|
603
654
|
Future<?> future = executor.submit(() -> {
|
|
604
655
|
try {
|
|
605
|
-
if (tryCopyBuiltinAsset(assets, fileName, targetFile, finalFileHash)) {
|
|
606
|
-
logger.debug("using builtin asset " + fileName);
|
|
607
|
-
} else if (tryCopyBuiltinFile(builtinFile, targetFile, finalFileHash)) {
|
|
608
|
-
logger.debug("using builtin file " + fileName);
|
|
656
|
+
if (tryCopyBuiltinAsset(assets, task.fileName, task.targetFile, task.finalFileHash)) {
|
|
657
|
+
logger.debug("using builtin asset " + task.fileName);
|
|
658
|
+
} else if (tryCopyBuiltinFile(task.builtinFile, task.targetFile, task.finalFileHash)) {
|
|
659
|
+
logger.debug("using builtin file " + task.fileName);
|
|
609
660
|
} else if (
|
|
610
|
-
tryCopyFromCache(cacheFile, targetFile, finalFileHash) ||
|
|
611
|
-
(legacyCacheFile != null && tryCopyFromCache(legacyCacheFile, targetFile, finalFileHash))
|
|
661
|
+
tryCopyFromCache(task.cacheFile, task.targetFile, task.finalFileHash) ||
|
|
662
|
+
(task.legacyCacheFile != null && tryCopyFromCache(task.legacyCacheFile, task.targetFile, task.finalFileHash))
|
|
612
663
|
) {
|
|
613
|
-
logger.debug("already cached " + fileName);
|
|
664
|
+
logger.debug("already cached " + task.fileName);
|
|
614
665
|
} else {
|
|
615
666
|
downloadAndVerify(
|
|
616
|
-
downloadUrl,
|
|
617
|
-
targetFile,
|
|
618
|
-
cacheFile,
|
|
619
|
-
finalFileHash,
|
|
667
|
+
task.downloadUrl,
|
|
668
|
+
task.targetFile,
|
|
669
|
+
task.cacheFile,
|
|
670
|
+
task.finalFileHash,
|
|
620
671
|
sessionKey,
|
|
621
672
|
publicKey,
|
|
622
|
-
|
|
623
|
-
fileName
|
|
673
|
+
task.isBrotli,
|
|
674
|
+
task.fileName
|
|
624
675
|
);
|
|
625
676
|
}
|
|
626
677
|
|
|
@@ -628,8 +679,8 @@ public class DownloadService extends Worker {
|
|
|
628
679
|
int percent = calcTotalPercent(completed, totalFiles);
|
|
629
680
|
setProgress(percent);
|
|
630
681
|
} catch (Exception e) {
|
|
631
|
-
logger.error("Error processing file: " + fileName + " " + e.getMessage());
|
|
632
|
-
sendStatsAsync("download_manifest_file_fail", version + ":" + fileName);
|
|
682
|
+
logger.error("Error processing file: " + task.fileName + " " + e.getMessage());
|
|
683
|
+
sendStatsAsync("download_manifest_file_fail", version + ":" + task.fileName);
|
|
633
684
|
hasError.set(true);
|
|
634
685
|
}
|
|
635
686
|
});
|
|
@@ -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.22"
|
|
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"
|
|
@@ -3496,13 +3496,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
3496
3496
|
}
|
|
3497
3497
|
|
|
3498
3498
|
private func performHideSplashscreen() {
|
|
3499
|
-
self.cancelSplashscreenTimeout()
|
|
3500
3499
|
self.removeSplashscreenLoader()
|
|
3501
3500
|
self.splashscreenInvocationToken += 1
|
|
3502
|
-
self.
|
|
3503
|
-
|
|
3504
|
-
callbackId: "autoHideSplashscreen",
|
|
3505
|
-
options: self.splashscreenOptions(methodName: "hide"),
|
|
3501
|
+
self.invokeSplashscreenAction(
|
|
3502
|
+
.hide,
|
|
3506
3503
|
retriesRemaining: self.splashscreenMaxRetries,
|
|
3507
3504
|
requestToken: self.splashscreenInvocationToken
|
|
3508
3505
|
)
|
|
@@ -3522,10 +3519,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
3522
3519
|
self.cancelSplashscreenTimeout()
|
|
3523
3520
|
self.autoSplashscreenTimedOut = false
|
|
3524
3521
|
self.splashscreenInvocationToken += 1
|
|
3525
|
-
self.
|
|
3526
|
-
|
|
3527
|
-
callbackId: "autoShowSplashscreen",
|
|
3528
|
-
options: self.splashscreenOptions(methodName: "show"),
|
|
3522
|
+
self.invokeSplashscreenAction(
|
|
3523
|
+
.show,
|
|
3529
3524
|
retriesRemaining: self.splashscreenMaxRetries,
|
|
3530
3525
|
requestToken: self.splashscreenInvocationToken
|
|
3531
3526
|
)
|
|
@@ -3535,7 +3530,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
3535
3530
|
}
|
|
3536
3531
|
|
|
3537
3532
|
private func splashscreenOptions(methodName: String) -> [String: Any] {
|
|
3538
|
-
methodName == "show" ? ["autoHide": false] : [:]
|
|
3533
|
+
methodName == "show" ? ["autoHide": false, "fadeInDuration": 0] : [:]
|
|
3539
3534
|
}
|
|
3540
3535
|
|
|
3541
3536
|
private func splashscreenCompletedMessage(methodName: String) -> String {
|
|
@@ -3554,20 +3549,59 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
3554
3549
|
self.splashscreenInvocationToken += 1
|
|
3555
3550
|
}
|
|
3556
3551
|
|
|
3557
|
-
private
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3552
|
+
private enum SplashscreenAction {
|
|
3553
|
+
case show
|
|
3554
|
+
case hide
|
|
3555
|
+
|
|
3556
|
+
var methodName: String {
|
|
3557
|
+
switch self {
|
|
3558
|
+
case .show:
|
|
3559
|
+
return "show"
|
|
3560
|
+
case .hide:
|
|
3561
|
+
return "hide"
|
|
3562
|
+
}
|
|
3563
|
+
}
|
|
3564
|
+
|
|
3565
|
+
var unavailableMessageVerb: String {
|
|
3566
|
+
switch self {
|
|
3567
|
+
case .show:
|
|
3568
|
+
return "showing"
|
|
3569
|
+
case .hide:
|
|
3570
|
+
return "hiding"
|
|
3571
|
+
}
|
|
3572
|
+
}
|
|
3573
|
+
}
|
|
3574
|
+
|
|
3575
|
+
private func splashscreenOptionsJSON(methodName: String) -> String {
|
|
3576
|
+
let options = self.splashscreenOptions(methodName: methodName)
|
|
3577
|
+
guard !options.isEmpty,
|
|
3578
|
+
let data = try? JSONSerialization.data(withJSONObject: options),
|
|
3579
|
+
let json = String(data: data, encoding: .utf8) else {
|
|
3580
|
+
return "{}"
|
|
3581
|
+
}
|
|
3582
|
+
return json
|
|
3583
|
+
}
|
|
3584
|
+
|
|
3585
|
+
private func splashscreenPluginExposesMethod(_ methodName: String, on plugin: CAPPlugin) -> Bool {
|
|
3586
|
+
guard let bridgedPlugin = plugin as? CAPBridgedPlugin else {
|
|
3587
|
+
return false
|
|
3588
|
+
}
|
|
3589
|
+
return bridgedPlugin.pluginMethods.contains { $0.name == methodName }
|
|
3590
|
+
}
|
|
3591
|
+
|
|
3592
|
+
private func splashscreenBridgeScript(for action: SplashscreenAction) -> String {
|
|
3593
|
+
let optionsJSON = self.splashscreenOptionsJSON(methodName: action.methodName)
|
|
3594
|
+
return """
|
|
3595
|
+
var cap = window.Capacitor;
|
|
3596
|
+
if (!cap || typeof cap.nativePromise !== 'function') {
|
|
3597
|
+
throw new Error('Capacitor bridge not ready');
|
|
3598
|
+
}
|
|
3599
|
+
return await cap.nativePromise('\(self.splashscreenPluginName)', '\(action.methodName)', \(optionsJSON));
|
|
3600
|
+
"""
|
|
3565
3601
|
}
|
|
3566
3602
|
|
|
3567
|
-
private func
|
|
3568
|
-
|
|
3569
|
-
callbackId: String,
|
|
3570
|
-
options: [String: Any],
|
|
3603
|
+
private func invokeSplashscreenAction(
|
|
3604
|
+
_ action: SplashscreenAction,
|
|
3571
3605
|
retriesRemaining: Int,
|
|
3572
3606
|
requestToken: Int
|
|
3573
3607
|
) {
|
|
@@ -3576,22 +3610,18 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
3576
3610
|
}
|
|
3577
3611
|
|
|
3578
3612
|
guard let bridge = self.bridge else {
|
|
3579
|
-
self.
|
|
3580
|
-
|
|
3581
|
-
callbackId: callbackId,
|
|
3582
|
-
options: options,
|
|
3613
|
+
self.retrySplashscreenAction(
|
|
3614
|
+
action,
|
|
3583
3615
|
retriesRemaining: retriesRemaining,
|
|
3584
3616
|
requestToken: requestToken,
|
|
3585
|
-
message: "Bridge not available for \(
|
|
3617
|
+
message: "Bridge not available for \(action.unavailableMessageVerb) splashscreen with autoSplashscreen"
|
|
3586
3618
|
)
|
|
3587
3619
|
return
|
|
3588
3620
|
}
|
|
3589
3621
|
|
|
3590
3622
|
guard let splashScreenPlugin = bridge.plugin(withName: self.splashscreenPluginName) else {
|
|
3591
|
-
self.
|
|
3592
|
-
|
|
3593
|
-
callbackId: callbackId,
|
|
3594
|
-
options: options,
|
|
3623
|
+
self.retrySplashscreenAction(
|
|
3624
|
+
action,
|
|
3595
3625
|
retriesRemaining: retriesRemaining,
|
|
3596
3626
|
requestToken: requestToken,
|
|
3597
3627
|
message: "autoSplashscreen: SplashScreen plugin not found. Install @capacitor/splash-screen plugin."
|
|
@@ -3599,37 +3629,86 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
3599
3629
|
return
|
|
3600
3630
|
}
|
|
3601
3631
|
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
|
|
3632
|
+
guard self.splashscreenPluginExposesMethod(action.methodName, on: splashScreenPlugin) else {
|
|
3633
|
+
self.retrySplashscreenAction(
|
|
3634
|
+
action,
|
|
3635
|
+
retriesRemaining: retriesRemaining,
|
|
3636
|
+
requestToken: requestToken,
|
|
3637
|
+
message: "autoSplashscreen: SplashScreen plugin does not expose \(action.methodName)(). Make sure @capacitor/splash-screen plugin is properly installed."
|
|
3638
|
+
)
|
|
3639
|
+
return
|
|
3640
|
+
}
|
|
3641
|
+
|
|
3642
|
+
guard let webView = bridge.webView else {
|
|
3643
|
+
self.retrySplashscreenAction(
|
|
3644
|
+
action,
|
|
3608
3645
|
retriesRemaining: retriesRemaining,
|
|
3609
3646
|
requestToken: requestToken,
|
|
3610
|
-
message: "
|
|
3647
|
+
message: "WebView not available for \(action.unavailableMessageVerb) splashscreen with autoSplashscreen"
|
|
3611
3648
|
)
|
|
3612
3649
|
return
|
|
3613
3650
|
}
|
|
3614
3651
|
|
|
3615
|
-
let
|
|
3616
|
-
|
|
3617
|
-
|
|
3652
|
+
let script = self.splashscreenBridgeScript(for: action)
|
|
3653
|
+
let runInvocation = { [weak self] in
|
|
3654
|
+
guard let self = self, requestToken == self.splashscreenInvocationToken else {
|
|
3655
|
+
return
|
|
3656
|
+
}
|
|
3657
|
+
webView.callAsyncJavaScript(
|
|
3658
|
+
script,
|
|
3659
|
+
arguments: [:],
|
|
3660
|
+
in: nil,
|
|
3661
|
+
in: .page
|
|
3662
|
+
) { [weak self] (result: Result<Any, Error>) in
|
|
3663
|
+
guard let self = self, requestToken == self.splashscreenInvocationToken else {
|
|
3664
|
+
return
|
|
3665
|
+
}
|
|
3666
|
+
if case .failure(let error) = result {
|
|
3667
|
+
self.retrySplashscreenAction(
|
|
3668
|
+
action,
|
|
3669
|
+
retriesRemaining: retriesRemaining,
|
|
3670
|
+
requestToken: requestToken,
|
|
3671
|
+
message: "Failed to invoke SplashScreen \(action.methodName) via Capacitor bridge: \(error.localizedDescription)"
|
|
3672
|
+
)
|
|
3673
|
+
return
|
|
3674
|
+
}
|
|
3675
|
+
if action == .hide {
|
|
3676
|
+
self.cancelSplashscreenTimeout()
|
|
3677
|
+
}
|
|
3678
|
+
self.logger.info("Called SplashScreen \(action.methodName) method")
|
|
3679
|
+
self.logger.info(self.splashscreenCompletedMessage(methodName: action.methodName))
|
|
3680
|
+
}
|
|
3681
|
+
}
|
|
3682
|
+
|
|
3683
|
+
if Thread.isMainThread {
|
|
3684
|
+
runInvocation()
|
|
3685
|
+
} else {
|
|
3686
|
+
DispatchQueue.main.async(execute: runInvocation)
|
|
3687
|
+
}
|
|
3618
3688
|
}
|
|
3619
3689
|
|
|
3620
|
-
private func
|
|
3621
|
-
|
|
3622
|
-
callbackId: String,
|
|
3623
|
-
options: [String: Any],
|
|
3690
|
+
private func retrySplashscreenAction(
|
|
3691
|
+
_ action: SplashscreenAction,
|
|
3624
3692
|
retriesRemaining: Int,
|
|
3625
3693
|
requestToken: Int,
|
|
3626
3694
|
message: String
|
|
3627
3695
|
) {
|
|
3628
3696
|
guard retriesRemaining > 0 else {
|
|
3629
|
-
if
|
|
3697
|
+
if action == .show {
|
|
3630
3698
|
self.logger.warn(message)
|
|
3631
|
-
|
|
3632
|
-
|
|
3699
|
+
return
|
|
3700
|
+
}
|
|
3701
|
+
|
|
3702
|
+
self.logger.error("\(message). Scheduling another hide retry.")
|
|
3703
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(self.splashscreenRetryDelayMilliseconds)) { [weak self] in
|
|
3704
|
+
guard let self = self, requestToken == self.splashscreenInvocationToken else {
|
|
3705
|
+
return
|
|
3706
|
+
}
|
|
3707
|
+
self.invokeSplashscreenAction(
|
|
3708
|
+
action,
|
|
3709
|
+
retriesRemaining: self.splashscreenMaxRetries,
|
|
3710
|
+
requestToken: requestToken
|
|
3711
|
+
)
|
|
3633
3712
|
}
|
|
3634
3713
|
return
|
|
3635
3714
|
}
|
|
@@ -3639,10 +3718,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
3639
3718
|
guard let self = self, requestToken == self.splashscreenInvocationToken else {
|
|
3640
3719
|
return
|
|
3641
3720
|
}
|
|
3642
|
-
self.
|
|
3643
|
-
|
|
3644
|
-
callbackId: callbackId,
|
|
3645
|
-
options: options,
|
|
3721
|
+
self.invokeSplashscreenAction(
|
|
3722
|
+
action,
|
|
3646
3723
|
retriesRemaining: retriesRemaining - 1,
|
|
3647
3724
|
requestToken: requestToken
|
|
3648
3725
|
)
|
|
@@ -134,6 +134,10 @@ import UIKit
|
|
|
134
134
|
case pathTraversal
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
static func containsPathTraversalSegment(_ relativePath: String) -> Bool {
|
|
138
|
+
return relativePath.split(separator: "/").contains(where: { $0 == ".." })
|
|
139
|
+
}
|
|
140
|
+
|
|
137
141
|
static func resolvePathInsideDirectory(baseDirectory: URL, relativePath: String) throws -> URL {
|
|
138
142
|
if relativePath.isEmpty {
|
|
139
143
|
throw SecurePathError.emptyPath
|
|
@@ -141,6 +145,9 @@ import UIKit
|
|
|
141
145
|
if relativePath.contains("\\") || relativePath.contains("\0") {
|
|
142
146
|
throw SecurePathError.windowsPath
|
|
143
147
|
}
|
|
148
|
+
if containsPathTraversalSegment(relativePath) {
|
|
149
|
+
throw SecurePathError.pathTraversal
|
|
150
|
+
}
|
|
144
151
|
if (relativePath as NSString).isAbsolutePath {
|
|
145
152
|
throw SecurePathError.absolutePath
|
|
146
153
|
}
|
|
@@ -151,7 +158,8 @@ import UIKit
|
|
|
151
158
|
let canonicalTarget = canonicalBase.appendingPathComponent(relativePath).standardizedFileURL
|
|
152
159
|
let canonicalTargetPath = canonicalTarget.path
|
|
153
160
|
|
|
154
|
-
|
|
161
|
+
// Require a strict child of the base. Equality would accept "." and wipe/write the root.
|
|
162
|
+
if !canonicalTargetPath.hasPrefix(normalizedBasePath) {
|
|
155
163
|
throw SecurePathError.pathTraversal
|
|
156
164
|
}
|
|
157
165
|
|
|
@@ -164,6 +172,22 @@ import UIKit
|
|
|
164
172
|
return try resolvePathInsideDirectory(baseDirectory: baseDirectory, relativePath: targetFileName)
|
|
165
173
|
}
|
|
166
174
|
|
|
175
|
+
static func rememberManifestTarget(_ seenTargets: inout Set<String>, targetFile: URL) -> Bool {
|
|
176
|
+
return seenTargets.insert(targetFile.standardizedFileURL.path).inserted
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private struct ManifestDownloadTask {
|
|
180
|
+
let fileName: String
|
|
181
|
+
let downloadUrl: String
|
|
182
|
+
let finalFileHash: String
|
|
183
|
+
let isBrotli: Bool
|
|
184
|
+
let destFileName: String
|
|
185
|
+
let destFilePath: URL
|
|
186
|
+
let builtinFilePath: URL
|
|
187
|
+
let cacheFilePath: URL?
|
|
188
|
+
let legacyCacheFilePath: URL?
|
|
189
|
+
}
|
|
190
|
+
|
|
167
191
|
private func isTimedOutError(_ error: Error?) -> Bool {
|
|
168
192
|
guard let nsError = error as NSError? else {
|
|
169
193
|
return false
|
|
@@ -1416,8 +1440,8 @@ import UIKit
|
|
|
1416
1440
|
var downloadError: Error?
|
|
1417
1441
|
let errorLock = NSLock()
|
|
1418
1442
|
|
|
1419
|
-
|
|
1420
|
-
var
|
|
1443
|
+
var tasks: [ManifestDownloadTask] = []
|
|
1444
|
+
var seenTargets = Set<String>()
|
|
1421
1445
|
|
|
1422
1446
|
for entry in manifest {
|
|
1423
1447
|
guard let fileName = entry.file_name,
|
|
@@ -1489,6 +1513,24 @@ import UIKit
|
|
|
1489
1513
|
do {
|
|
1490
1514
|
destFilePath = try Self.resolveManifestTargetPath(baseDirectory: destFolder, fileName: fileName)
|
|
1491
1515
|
builtinFilePath = try Self.resolveManifestTargetPath(baseDirectory: builtinFolder, fileName: fileName)
|
|
1516
|
+
if !Self.rememberManifestTarget(&seenTargets, targetFile: destFilePath) {
|
|
1517
|
+
logger.error("Duplicate manifest target path: \(fileName)")
|
|
1518
|
+
self.sendStats(action: "manifest_path_fail", versionName: "\(version):\(fileName)")
|
|
1519
|
+
let error = NSError(
|
|
1520
|
+
domain: "ManifestEntryError",
|
|
1521
|
+
code: 3,
|
|
1522
|
+
userInfo: [
|
|
1523
|
+
NSLocalizedDescriptionKey: "Duplicate manifest target path for \(fileName)"
|
|
1524
|
+
]
|
|
1525
|
+
)
|
|
1526
|
+
errorLock.lock()
|
|
1527
|
+
if downloadError == nil {
|
|
1528
|
+
downloadError = error
|
|
1529
|
+
}
|
|
1530
|
+
errorLock.unlock()
|
|
1531
|
+
hasError.value = true
|
|
1532
|
+
continue
|
|
1533
|
+
}
|
|
1492
1534
|
} catch {
|
|
1493
1535
|
logger.error("Invalid manifest file path: \(fileName)")
|
|
1494
1536
|
self.sendStats(action: "manifest_path_fail", versionName: "\(version):\(fileName)")
|
|
@@ -1501,8 +1543,40 @@ import UIKit
|
|
|
1501
1543
|
continue
|
|
1502
1544
|
}
|
|
1503
1545
|
|
|
1504
|
-
|
|
1505
|
-
|
|
1546
|
+
tasks.append(
|
|
1547
|
+
ManifestDownloadTask(
|
|
1548
|
+
fileName: fileName,
|
|
1549
|
+
downloadUrl: downloadUrl,
|
|
1550
|
+
finalFileHash: finalFileHash,
|
|
1551
|
+
isBrotli: isBrotli,
|
|
1552
|
+
destFileName: destFileName,
|
|
1553
|
+
destFilePath: destFilePath,
|
|
1554
|
+
builtinFilePath: builtinFilePath,
|
|
1555
|
+
cacheFilePath: cacheFilePath,
|
|
1556
|
+
legacyCacheFilePath: legacyCacheFilePath
|
|
1557
|
+
)
|
|
1558
|
+
)
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
if hasError.value {
|
|
1562
|
+
let resolvedError = downloadError ?? NSError(
|
|
1563
|
+
domain: "ManifestDownloadError",
|
|
1564
|
+
code: 1,
|
|
1565
|
+
userInfo: [NSLocalizedDescriptionKey: "Manifest download failed due to invalid or missing entries"]
|
|
1566
|
+
)
|
|
1567
|
+
let errorBundle = bundleInfo.setStatus(status: BundleStatus.ERROR.storedValue)
|
|
1568
|
+
self.saveBundleInfo(id: id, bundle: errorBundle)
|
|
1569
|
+
throw resolvedError
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
var operations: [Operation] = []
|
|
1573
|
+
|
|
1574
|
+
for task in tasks {
|
|
1575
|
+
try FileManager.default.createDirectory(
|
|
1576
|
+
at: task.destFilePath.deletingLastPathComponent(),
|
|
1577
|
+
withIntermediateDirectories: true,
|
|
1578
|
+
attributes: nil
|
|
1579
|
+
)
|
|
1506
1580
|
|
|
1507
1581
|
let operation = BlockOperation { [weak self] in
|
|
1508
1582
|
guard let self = self else { return }
|
|
@@ -1510,26 +1584,29 @@ import UIKit
|
|
|
1510
1584
|
|
|
1511
1585
|
do {
|
|
1512
1586
|
// Try builtin first
|
|
1513
|
-
if FileManager.default.fileExists(atPath: builtinFilePath.path) &&
|
|
1514
|
-
|
|
1515
|
-
self.
|
|
1587
|
+
if FileManager.default.fileExists(atPath: task.builtinFilePath.path) &&
|
|
1588
|
+
self.verifyChecksum(file: task.builtinFilePath, expectedHash: task.finalFileHash) {
|
|
1589
|
+
try self.copyItemReplacing(from: task.builtinFilePath, to: task.destFilePath)
|
|
1590
|
+
self.logger.info("downloadManifest \(task.fileName) using builtin file \(id)")
|
|
1516
1591
|
}
|
|
1517
1592
|
// Try cache
|
|
1518
1593
|
else if
|
|
1519
|
-
(cacheFilePath != nil &&
|
|
1520
|
-
|
|
1521
|
-
|
|
1594
|
+
(task.cacheFilePath != nil &&
|
|
1595
|
+
self.tryCopyFromCache(from: task.cacheFilePath!, to: task.destFilePath, expectedHash: task.finalFileHash)) ||
|
|
1596
|
+
(task.legacyCacheFilePath != nil &&
|
|
1597
|
+
self.tryCopyFromCache(from: task.legacyCacheFilePath!, to: task.destFilePath, expectedHash: task.finalFileHash)) {
|
|
1598
|
+
self.logger.info("downloadManifest \(task.fileName) copy from cache \(id)")
|
|
1522
1599
|
}
|
|
1523
1600
|
// Download
|
|
1524
1601
|
else {
|
|
1525
1602
|
try self.downloadManifestFile(
|
|
1526
|
-
downloadUrl: downloadUrl,
|
|
1527
|
-
destFilePath: destFilePath,
|
|
1528
|
-
cacheFilePath: cacheFilePath,
|
|
1529
|
-
fileHash: finalFileHash,
|
|
1530
|
-
fileName: fileName,
|
|
1531
|
-
destFileName: destFileName,
|
|
1532
|
-
isBrotli: isBrotli,
|
|
1603
|
+
downloadUrl: task.downloadUrl,
|
|
1604
|
+
destFilePath: task.destFilePath,
|
|
1605
|
+
cacheFilePath: task.cacheFilePath,
|
|
1606
|
+
fileHash: task.finalFileHash,
|
|
1607
|
+
fileName: task.fileName,
|
|
1608
|
+
destFileName: task.destFileName,
|
|
1609
|
+
isBrotli: task.isBrotli,
|
|
1533
1610
|
sessionKey: sessionKey,
|
|
1534
1611
|
version: version,
|
|
1535
1612
|
bundleId: id
|
|
@@ -1547,8 +1624,8 @@ import UIKit
|
|
|
1547
1624
|
}
|
|
1548
1625
|
errorLock.unlock()
|
|
1549
1626
|
hasError.value = true
|
|
1550
|
-
self.logger.error("Manifest file download failed: \(fileName)")
|
|
1551
|
-
self.logger.debug("Bundle: \(id), File: \(fileName), Error: \(error.localizedDescription)")
|
|
1627
|
+
self.logger.error("Manifest file download failed: \(task.fileName)")
|
|
1628
|
+
self.logger.debug("Bundle: \(id), File: \(task.fileName), Error: \(error.localizedDescription)")
|
|
1552
1629
|
}
|
|
1553
1630
|
}
|
|
1554
1631
|
|