@capgo/capacitor-updater 8.51.22 → 8.51.24

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/README.md CHANGED
@@ -46,7 +46,7 @@ Perfect for fixing bugs immediately, A/B testing features, and maintaining contr
46
46
  - 🔄 Auto Update: Automatically download and set the latest bundle for the app.
47
47
  - 🛟 Rollback: Reset the app to last working bundle if an incompatible bundle has been set.
48
48
  - 🔁 **Delta Updates**: Make instant updates by only downloading changed files.
49
- - 🔒 **Security**: Encrypt and sign each updates with best in class security standards.
49
+ - 🔒 Security: checksum on every update; encryption optional for high-security apps.
50
50
  - ⚔️ **Battle-Tested**: Used in more than 3000 projects.
51
51
  - 📊 View your deployment statistics
52
52
  - 🔋 Supports Android and iOS
@@ -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.22";
149
+ private final String pluginVersion = "8.51.24";
150
150
  private static final String DELAY_CONDITION_PREFERENCES = "";
151
151
 
152
152
  private SharedPreferences.Editor editor;
@@ -2842,6 +2842,16 @@ public class CapacitorUpdaterPlugin extends Plugin {
2842
2842
  final String checksum,
2843
2843
  final JSONArray manifest
2844
2844
  ) throws IOException {
2845
+ if (!this.implementation.publicKey.isEmpty() && !CryptoCipher.isValidSessionKey(sessionKey)) {
2846
+ logger.error("Public key present but no valid session key provided");
2847
+ this.implementation.sendStats("session_key_required");
2848
+ throw new IOException("Session key required when public key is present");
2849
+ }
2850
+ if (manifest == null && (checksum == null || checksum.isEmpty())) {
2851
+ logger.error("No checksum provided");
2852
+ this.implementation.sendStats("checksum_required");
2853
+ throw new IOException("Checksum required");
2854
+ }
2845
2855
  // Manual/preview downloads must wait too — launch orphan sweep can delete their temps.
2846
2856
  waitForCleanupIfNeeded();
2847
2857
  if (manifest != null) {
@@ -4936,7 +4946,13 @@ public class CapacitorUpdaterPlugin extends Plugin {
4936
4946
  );
4937
4947
  return;
4938
4948
  }
4939
- if (latest.isDownloaded() && BundleStatus.DOWNLOADING != latest.getStatus()) {
4949
+ final String latestSessionKey = jsRes.has("sessionKey") ? jsRes.getString("sessionKey") : "";
4950
+ if (
4951
+ latest.isDownloaded() &&
4952
+ BundleStatus.DOWNLOADING != latest.getStatus() &&
4953
+ (CapacitorUpdaterPlugin.this.implementation.publicKey.isEmpty() ||
4954
+ CryptoCipher.isValidSessionKey(latestSessionKey))
4955
+ ) {
4940
4956
  logger.info("Latest bundle already exists and download is NOT required. " + messageUpdate);
4941
4957
  final boolean directUpdateAllowedNow = CapacitorUpdaterPlugin.this.isDirectUpdateCurrentlyAllowed(
4942
4958
  plannedDirectUpdate
@@ -285,6 +285,22 @@ public class CapgoUpdater {
285
285
  this.cachedKeyId = CryptoCipher.calcKeyId(publicKey);
286
286
  }
287
287
 
288
+ private void requireSessionKeyForEncryptedUpdate(final String sessionKey) throws IOException {
289
+ if (!this.publicKey.isEmpty() && !CryptoCipher.isValidSessionKey(sessionKey)) {
290
+ logger.error("Public key present but no valid session key provided");
291
+ this.sendStats("session_key_required");
292
+ throw new IOException("Session key required when public key is present");
293
+ }
294
+ }
295
+
296
+ private void requireBundleChecksum(final String checksum) throws IOException {
297
+ if (checksum == null || checksum.isEmpty()) {
298
+ logger.error("No checksum provided");
299
+ this.sendStats("checksum_required");
300
+ throw new IOException("Checksum required");
301
+ }
302
+ }
303
+
288
304
  static boolean containsPathTraversalSegment(final String relativePath) {
289
305
  for (final String segment : relativePath.split("/")) {
290
306
  if ("..".equals(segment)) {
@@ -322,6 +338,10 @@ public class CapgoUpdater {
322
338
  return canonicalTarget;
323
339
  }
324
340
 
341
+ static File resolveBundleDirectory(final File documentsDir, final String bundleId) throws IOException {
342
+ return resolvePathInsideDirectory(new File(documentsDir, bundleDirectory), bundleId);
343
+ }
344
+
325
345
  public String getKeyId() {
326
346
  return this.cachedKeyId;
327
347
  }
@@ -389,11 +409,11 @@ public class CapgoUpdater {
389
409
  }
390
410
  }
391
411
 
392
- private void flattenAssets(final File sourceFile, final String dest) throws IOException {
412
+ private void flattenAssets(final File sourceFile, final File destinationFile) throws IOException {
393
413
  if (!sourceFile.exists()) {
394
414
  throw new FileNotFoundException("Source file not found: " + sourceFile.getPath());
395
415
  }
396
- final File destinationFile = new File(this.documentsDir, dest);
416
+ assertPathInsideBundleRoot(destinationFile);
397
417
  Objects.requireNonNull(destinationFile.getParentFile()).mkdirs();
398
418
  final String[] entries = sourceFile.list(this.filter);
399
419
  if (entries == null || entries.length == 0) {
@@ -422,7 +442,13 @@ public class CapgoUpdater {
422
442
  return;
423
443
  }
424
444
 
425
- final File bundleDir = this.getBundleDirectory(id);
445
+ final File bundleDir;
446
+ try {
447
+ bundleDir = this.getBundleDirectory(id);
448
+ } catch (IOException e) {
449
+ logger.debug("Skip delta cache population: invalid bundle id");
450
+ return;
451
+ }
426
452
  if (!bundleDir.exists()) {
427
453
  logger.debug("Skip delta cache population: bundle dir missing");
428
454
  return;
@@ -510,7 +536,10 @@ public class CapgoUpdater {
510
536
  if (fileHash.isEmpty()) {
511
537
  return "";
512
538
  }
513
- if (this.publicKey != null && !this.publicKey.isEmpty() && sessionKey != null && !sessionKey.isEmpty()) {
539
+ if (this.publicKey != null && !this.publicKey.isEmpty()) {
540
+ if (!CryptoCipher.isValidSessionKey(sessionKey)) {
541
+ return "";
542
+ }
514
543
  try {
515
544
  fileHash = CryptoCipher.decryptChecksum(fileHash, this.publicKey);
516
545
  } catch (Exception e) {
@@ -846,31 +875,24 @@ public class CapgoUpdater {
846
875
  String checksum = "";
847
876
 
848
877
  try {
878
+ this.requireSessionKeyForEncryptedUpdate(sessionKey);
849
879
  this.notifyDownload(id, 71);
850
880
  downloaded = new File(this.documentsDir, dest);
851
881
 
852
882
  if (!isManifest) {
853
- String checksumDecrypted = Objects.requireNonNullElse(checksumRes, "");
854
-
855
- // If public key is present but no checksum provided, refuse installation
856
- if (!this.publicKey.isEmpty() && checksumDecrypted.isEmpty()) {
857
- logger.error("Public key present but no checksum provided");
858
- this.sendStats("checksum_required");
859
- throw new IOException("Checksum required when public key is present: " + id);
860
- }
883
+ String expectedChecksum = Objects.requireNonNullElse(checksumRes, "");
884
+ this.requireBundleChecksum(expectedChecksum);
861
885
 
862
- if (!sessionKey.isEmpty()) {
886
+ if (CryptoCipher.isValidSessionKey(sessionKey)) {
863
887
  CryptoCipher.decryptFile(downloaded, publicKey, sessionKey);
864
- checksumDecrypted = CryptoCipher.decryptChecksum(checksumRes, publicKey);
865
- checksum = CryptoCipher.calcChecksum(downloaded);
866
- } else {
867
- checksum = CryptoCipher.calcChecksum(downloaded);
888
+ expectedChecksum = CryptoCipher.decryptChecksum(checksumRes, publicKey);
868
889
  }
890
+ checksum = CryptoCipher.calcChecksum(downloaded);
869
891
  CryptoCipher.logChecksumInfo("Calculated checksum", checksum);
870
- CryptoCipher.logChecksumInfo("Expected checksum", checksumDecrypted);
871
- if ((!checksumDecrypted.isEmpty() || !this.publicKey.isEmpty()) && !checksumDecrypted.equals(checksum)) {
892
+ CryptoCipher.logChecksumInfo("Expected checksum", expectedChecksum);
893
+ if (!expectedChecksum.equals(checksum)) {
872
894
  logger.error("Checksum mismatch");
873
- logger.debug("Expected: " + checksumDecrypted + ", Got: " + checksum);
895
+ logger.debug("Expected: " + expectedChecksum + ", Got: " + checksum);
874
896
  this.sendStats("checksum_fail");
875
897
  throw new IOException("Checksum failed: " + id);
876
898
  }
@@ -898,13 +920,11 @@ public class CapgoUpdater {
898
920
  if (!isManifest) {
899
921
  extractedDir = this.unzip(id, downloaded, TEMP_UNZIP_PREFIX + this.randomString());
900
922
  this.notifyDownload(id, 91);
901
- final String idName = bundleDirectory + "/" + id;
902
- this.flattenAssets(extractedDir, idName);
923
+ this.flattenAssets(extractedDir, this.getBundleDirectory(id));
903
924
  this.cacheBundleFilesAsync(id);
904
925
  } else {
905
926
  this.notifyDownload(id, 91);
906
- final String idName = bundleDirectory + "/" + id;
907
- this.flattenAssets(downloaded, idName);
927
+ this.flattenAssets(downloaded, this.getBundleDirectory(id));
908
928
  downloaded.delete();
909
929
  }
910
930
  // Remove old bundle info and set new one
@@ -1123,18 +1143,44 @@ public class CapgoUpdater {
1123
1143
  }
1124
1144
  }
1125
1145
 
1146
+ private void assertPathInsideDocumentsDir(final File target) throws IOException {
1147
+ if (this.documentsDir == null) {
1148
+ throw new IOException("Documents directory unavailable");
1149
+ }
1150
+ final File canonicalBase = this.documentsDir.getCanonicalFile();
1151
+ final File canonicalTarget = target.getCanonicalFile();
1152
+ final String basePath = canonicalBase.getPath();
1153
+ final String normalizedBasePath = basePath.endsWith(File.separator) ? basePath : basePath + File.separator;
1154
+ final String targetPath = canonicalTarget.getPath();
1155
+ if (!targetPath.equals(basePath) && !targetPath.startsWith(normalizedBasePath)) {
1156
+ throw new IOException("Path escapes updater storage: " + target.getPath());
1157
+ }
1158
+ }
1159
+
1160
+ private void assertPathInsideBundleRoot(final File target) throws IOException {
1161
+ final File bundleRoot = new File(this.documentsDir, bundleDirectory).getCanonicalFile();
1162
+ final File canonicalTarget = target.getCanonicalFile();
1163
+ final String basePath = bundleRoot.getPath();
1164
+ final String normalizedBasePath = basePath.endsWith(File.separator) ? basePath : basePath + File.separator;
1165
+ final String targetPath = canonicalTarget.getPath();
1166
+ if (!targetPath.equals(basePath) && !targetPath.startsWith(normalizedBasePath)) {
1167
+ throw new IOException("Path escapes bundle storage: " + target.getPath());
1168
+ }
1169
+ }
1170
+
1126
1171
  private void safeDelete(final File target) {
1127
1172
  if (target == null || !target.exists()) {
1128
1173
  return;
1129
1174
  }
1130
1175
  try {
1176
+ this.assertPathInsideDocumentsDir(target);
1131
1177
  if (target.isDirectory()) {
1132
1178
  this.deleteDirectory(target);
1133
1179
  } else if (!target.delete()) {
1134
1180
  logger.warn("Failed to delete file: " + target.getAbsolutePath());
1135
1181
  }
1136
1182
  } catch (IOException cleanupError) {
1137
- logger.warn("Cleanup failed for " + target.getAbsolutePath() + ": " + cleanupError.getMessage());
1183
+ logger.warn("Refusing unsafe delete for " + target.getAbsolutePath() + ": " + cleanupError.getMessage());
1138
1184
  }
1139
1185
  }
1140
1186
 
@@ -1408,6 +1454,15 @@ public class CapgoUpdater {
1408
1454
  final JSONArray manifest,
1409
1455
  final boolean setNext
1410
1456
  ) {
1457
+ try {
1458
+ this.requireSessionKeyForEncryptedUpdate(sessionKey);
1459
+ if (manifest == null) {
1460
+ this.requireBundleChecksum(checksum);
1461
+ }
1462
+ } catch (final IOException e) {
1463
+ logger.error("Download blocked: " + e.getMessage());
1464
+ return;
1465
+ }
1411
1466
  if (!this.runDownloadGateQuiet()) {
1412
1467
  return;
1413
1468
  }
@@ -1438,6 +1493,8 @@ public class CapgoUpdater {
1438
1493
  }
1439
1494
 
1440
1495
  public BundleInfo download(final String url, final String version, final String sessionKey, final String checksum) throws IOException {
1496
+ this.requireSessionKeyForEncryptedUpdate(sessionKey);
1497
+ this.requireBundleChecksum(checksum);
1441
1498
  this.runDownloadGate();
1442
1499
  // Check for existing bundle with same version and clean up if in error state
1443
1500
  BundleInfo existingBundle = this.getBundleInfoByName(version);
@@ -1489,6 +1546,7 @@ public class CapgoUpdater {
1489
1546
  final String checksum,
1490
1547
  final JSONArray manifest
1491
1548
  ) throws IOException {
1549
+ this.requireSessionKeyForEncryptedUpdate(sessionKey);
1492
1550
  this.runDownloadGate();
1493
1551
  if (manifest == null) {
1494
1552
  return download(url, version, sessionKey, checksum);
@@ -1570,6 +1628,14 @@ public class CapgoUpdater {
1570
1628
 
1571
1629
  public Boolean delete(final String id, final Boolean removeInfo, final boolean cancelActiveDownload) throws IOException {
1572
1630
  synchronized (this.deleteLock) {
1631
+ final File bundle;
1632
+ try {
1633
+ bundle = this.getBundleDirectory(id);
1634
+ } catch (IOException e) {
1635
+ logger.error("Cannot delete bundle with invalid id");
1636
+ logger.debug("Bundle ID: " + id + ", Error: " + e.getMessage());
1637
+ return false;
1638
+ }
1573
1639
  final BundleInfo deleted = this.getBundleInfo(id);
1574
1640
  if (deleted.isBuiltin() || this.getCurrentBundleId().equals(id)) {
1575
1641
  logger.error("Cannot delete current or builtin bundle");
@@ -1595,7 +1661,6 @@ public class CapgoUpdater {
1595
1661
  return false;
1596
1662
  }
1597
1663
 
1598
- final File bundle = this.getBundleDirectory(id);
1599
1664
  final boolean hadRegistry = this.hasStoredBundleInfo(id);
1600
1665
  final boolean hadFolder = bundle.exists();
1601
1666
  if (!hadRegistry && !hadFolder) {
@@ -1743,12 +1808,17 @@ public class CapgoUpdater {
1743
1808
  this.editor.commit();
1744
1809
  }
1745
1810
 
1746
- private File getBundleDirectory(final String id) {
1747
- return new File(this.documentsDir, bundleDirectory + "/" + id);
1811
+ private File getBundleDirectory(final String id) throws IOException {
1812
+ return resolveBundleDirectory(this.documentsDir, id);
1748
1813
  }
1749
1814
 
1750
1815
  private boolean bundleExists(final String id) {
1751
- final File bundle = this.getBundleDirectory(id);
1816
+ final File bundle;
1817
+ try {
1818
+ bundle = this.getBundleDirectory(id);
1819
+ } catch (IOException e) {
1820
+ return false;
1821
+ }
1752
1822
  final BundleInfo bundleInfo = this.getBundleInfo(id);
1753
1823
  return (
1754
1824
  bundle.isDirectory() &&
@@ -1825,7 +1895,16 @@ public class CapgoUpdater {
1825
1895
  this.reset();
1826
1896
  return true;
1827
1897
  }
1828
- final File bundle = this.getBundleDirectory(id);
1898
+ final File bundle;
1899
+ try {
1900
+ bundle = this.getBundleDirectory(id);
1901
+ } catch (IOException e) {
1902
+ logger.error("Invalid bundle id");
1903
+ logger.debug("Bundle ID: " + id + ", Error: " + e.getMessage());
1904
+ this.setBundleStatus(id, BundleStatus.ERROR);
1905
+ this.sendStats("set_fail", newBundle.getVersionName());
1906
+ return false;
1907
+ }
1829
1908
  logger.info("Setting next active bundle: " + id);
1830
1909
  if (this.bundleExists(id)) {
1831
1910
  var currentBundleName = this.getCurrentBundle().getVersionName();
@@ -1843,8 +1922,12 @@ public class CapgoUpdater {
1843
1922
  if (bundle == null || bundle.isBuiltin() || !this.bundleExists(bundle.getId())) {
1844
1923
  return false;
1845
1924
  }
1846
- this.setCurrentBundle(this.getBundleDirectory(bundle.getId()));
1847
- return true;
1925
+ try {
1926
+ this.setCurrentBundle(this.getBundleDirectory(bundle.getId()));
1927
+ return true;
1928
+ } catch (IOException e) {
1929
+ return false;
1930
+ }
1848
1931
  }
1849
1932
 
1850
1933
  boolean stagePreviewFallbackReload(final BundleInfo bundle) {
@@ -1858,8 +1941,12 @@ public class CapgoUpdater {
1858
1941
  if (!this.bundleExists(bundle.getId())) {
1859
1942
  return false;
1860
1943
  }
1861
- this.setCurrentBundle(this.getBundleDirectory(bundle.getId()));
1862
- return true;
1944
+ try {
1945
+ this.setCurrentBundle(this.getBundleDirectory(bundle.getId()));
1946
+ return true;
1947
+ } catch (IOException e) {
1948
+ return false;
1949
+ }
1863
1950
  }
1864
1951
 
1865
1952
  void finalizePendingReload(final BundleInfo bundle, final String previousBundleName) {
@@ -135,8 +135,16 @@ public class CryptoCipher {
135
135
  return buf;
136
136
  }
137
137
 
138
+ public static boolean isValidSessionKey(final String sessionKey) {
139
+ if (sessionKey == null || sessionKey.isEmpty()) {
140
+ return false;
141
+ }
142
+ String[] sessionKeyParts = sessionKey.split(":", -1);
143
+ return sessionKeyParts.length == 2 && !sessionKeyParts[0].isEmpty() && !sessionKeyParts[1].isEmpty();
144
+ }
145
+
138
146
  public static void decryptFile(final File file, final String publicKey, final String ivSessionKey) throws IOException {
139
- if (publicKey.isEmpty() || ivSessionKey == null || ivSessionKey.isEmpty() || ivSessionKey.split(":").length != 2) {
147
+ if (publicKey.isEmpty() || !isValidSessionKey(ivSessionKey)) {
140
148
  if (logger != null) {
141
149
  logger.info("Encryption not set, no public key or session, ignored");
142
150
  }
@@ -456,7 +456,7 @@ public class DownloadService extends Worker {
456
456
  return createFailureResult("Manifest is null");
457
457
  }
458
458
  } else {
459
- handleSingleFileDownload(url, id, documentsDir, dest, version, sessionKey, checksum);
459
+ handleSingleFileDownload(url, id, documentsDir, dest, version, sessionKey, checksum, publicKey);
460
460
  return createSuccessResult(dest, version, sessionKey, checksum, false);
461
461
  }
462
462
  } catch (DownloadRetryException e) {
@@ -547,6 +547,12 @@ public class DownloadService extends Worker {
547
547
  try {
548
548
  logger.debug("handleManifestDownload");
549
549
 
550
+ if (publicKey != null && !publicKey.isEmpty() && !CryptoCipher.isValidSessionKey(sessionKey)) {
551
+ logger.error("Public key present but no valid session key provided");
552
+ sendStatsAsync("session_key_required", version);
553
+ throw new IOException("Session key required when public key is present");
554
+ }
555
+
550
556
  // Send stats for manifest download start
551
557
  sendStatsAsync("download_manifest_start", version);
552
558
 
@@ -582,7 +588,13 @@ public class DownloadService extends Worker {
582
588
  continue;
583
589
  }
584
590
 
585
- if (publicKey != null && !publicKey.isEmpty() && sessionKey != null && !sessionKey.isEmpty()) {
591
+ if (publicKey != null && !publicKey.isEmpty()) {
592
+ if (!CryptoCipher.isValidSessionKey(sessionKey)) {
593
+ logger.error("Public key present but no valid session key provided");
594
+ sendStatsAsync("session_key_required", version);
595
+ hasError.set(true);
596
+ continue;
597
+ }
586
598
  try {
587
599
  fileHash = CryptoCipher.decryptChecksum(fileHash, publicKey);
588
600
  } catch (Exception e) {
@@ -727,8 +739,20 @@ public class DownloadService extends Worker {
727
739
  String dest,
728
740
  String version,
729
741
  String sessionKey,
730
- String checksum
742
+ String checksum,
743
+ String publicKey
731
744
  ) {
745
+ if (publicKey != null && !publicKey.isEmpty() && !CryptoCipher.isValidSessionKey(sessionKey)) {
746
+ logger.error("Public key present but no valid session key provided");
747
+ sendStatsAsync("session_key_required", version);
748
+ throw new RuntimeException("Session key required when public key is present");
749
+ }
750
+ if (checksum == null || checksum.isEmpty()) {
751
+ logger.error("No checksum provided");
752
+ sendStatsAsync("checksum_required", version);
753
+ throw new RuntimeException("Checksum required");
754
+ }
755
+
732
756
  // Send stats for zip download start
733
757
  sendStatsAsync("download_zip_start", version);
734
758
 
@@ -1110,7 +1134,7 @@ public class DownloadService extends Worker {
1110
1134
  }
1111
1135
  }
1112
1136
 
1113
- boolean needDecrypt = publicKey != null && !publicKey.isEmpty() && sessionKey != null && !sessionKey.isEmpty();
1137
+ boolean needDecrypt = publicKey != null && !publicKey.isEmpty() && CryptoCipher.isValidSessionKey(sessionKey);
1114
1138
  File source = partial;
1115
1139
  if (needDecrypt) {
1116
1140
  workFile = new File(cacheFolder, "work_" + UUID.randomUUID() + "_" + targetFile.getName() + ".tmp");
@@ -5,15 +5,24 @@ import android.util.Log;
5
5
  import androidx.annotation.NonNull;
6
6
  import androidx.annotation.Nullable;
7
7
  import com.getcapacitor.*;
8
+ import java.nio.ByteBuffer;
9
+ import java.nio.CharBuffer;
10
+ import java.nio.charset.CharsetEncoder;
11
+ import java.nio.charset.CoderResult;
12
+ import java.nio.charset.CodingErrorAction;
13
+ import java.nio.charset.StandardCharsets;
8
14
  import java.util.ArrayList;
9
15
  import java.util.Arrays;
10
16
  import java.util.Date;
11
17
  import java.util.Locale;
12
18
  import java.util.Map;
13
19
  import org.jetbrains.annotations.Contract;
20
+ import org.json.JSONObject;
14
21
 
15
22
  public class Logger {
16
23
 
24
+ static final int MAX_WEBVIEW_LOG_PAYLOAD_CHARS = 4096;
25
+
17
26
  private Bridge bridge;
18
27
 
19
28
  public enum LogLevel {
@@ -250,13 +259,63 @@ public class Logger {
250
259
 
251
260
  // Send to JavaScript if webView is available
252
261
  if (bridge != null && bridge.getWebView() != null) {
253
- bridge.eval(
254
- "console." + level.name() + "(\"[" + tag.replace("\"", "\\\"") + "] " + formattedMessage.replace("\"", "\\\"") + "\")",
255
- null
256
- );
262
+ String script = buildWebViewConsoleScript(level, tag, formattedMessage);
263
+ if (script != null) {
264
+ bridge.eval(script, null);
265
+ }
266
+ }
267
+ }
268
+
269
+ @Nullable
270
+ static String consoleMethodForLevel(@NonNull LogLevel level) {
271
+ switch (level) {
272
+ case error:
273
+ return "error";
274
+ case warn:
275
+ return "warn";
276
+ case info:
277
+ return "info";
278
+ case debug:
279
+ return "debug";
280
+ default:
281
+ return null;
257
282
  }
258
283
  }
259
284
 
285
+ @NonNull
286
+ static String toJsStringLiteral(@NonNull String value) {
287
+ return JSONObject.quote(value);
288
+ }
289
+
290
+ @NonNull
291
+ static String capWebViewLogPayload(@NonNull String payload) {
292
+ CharsetEncoder encoder = StandardCharsets.UTF_8.newEncoder()
293
+ .onMalformedInput(CodingErrorAction.REPLACE)
294
+ .onUnmappableCharacter(CodingErrorAction.REPLACE);
295
+ ByteBuffer bytes = ByteBuffer.allocate(MAX_WEBVIEW_LOG_PAYLOAD_CHARS);
296
+ CharBuffer chars = CharBuffer.wrap(payload);
297
+ CoderResult result = encoder.encode(chars, bytes, true);
298
+ if (result.isUnderflow()) {
299
+ result = encoder.flush(bytes);
300
+ }
301
+ if (result.isUnderflow()) {
302
+ return payload;
303
+ }
304
+
305
+ return new String(bytes.array(), 0, bytes.position(), StandardCharsets.UTF_8) + "...";
306
+ }
307
+
308
+ @Nullable
309
+ static String buildWebViewConsoleScript(@NonNull LogLevel level, @NonNull String tag, @NonNull String formattedMessage) {
310
+ String consoleMethod = consoleMethodForLevel(level);
311
+ if (consoleMethod == null) {
312
+ return null;
313
+ }
314
+
315
+ String payload = capWebViewLogPayload("[" + tag + "] " + formattedMessage);
316
+ return "console." + consoleMethod + "(" + toJsStringLiteral(payload) + ")";
317
+ }
318
+
260
319
  public void dir(Object value) {
261
320
  String message;
262
321
 
@@ -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.22"
99
+ private let pluginVersion: String = "8.51.24"
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"
@@ -582,8 +582,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
582
582
  var dest: URL
583
583
  if BundleInfo.ID_BUILTIN == id {
584
584
  dest = Bundle.main.resourceURL!.appendingPathComponent("public")
585
+ } else if let bundleDir = try? self.implementation.getBundleDirectory(id: id) {
586
+ dest = bundleDir
585
587
  } else {
586
- dest = self.implementation.getBundleDirectory(id: id)
588
+ dest = Bundle.main.resourceURL!.appendingPathComponent("public")
587
589
  }
588
590
 
589
591
  if !FileManager.default.fileExists(atPath: dest.path) {
@@ -1609,7 +1611,19 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1609
1611
  throw makePreviewError("Invalid download URL")
1610
1612
  }
1611
1613
 
1614
+ if !self.implementation.publicKey.isEmpty && !CryptoCipher.isValidSessionKey(sessionKey) {
1615
+ self.logger.error("Public key present but no valid session key provided")
1616
+ self.implementation.sendStats(action: "session_key_required", versionName: version)
1617
+ throw makePreviewError("Session key required when public key is present")
1618
+ }
1619
+
1612
1620
  var checksum = rawChecksum
1621
+ if manifestEntries == nil && checksum.isEmpty {
1622
+ self.logger.error("No checksum provided")
1623
+ self.implementation.sendStats(action: "checksum_required", versionName: version)
1624
+ throw makePreviewError("Checksum required")
1625
+ }
1626
+
1613
1627
  let next: BundleInfo
1614
1628
  if let manifestEntries = manifestEntries {
1615
1629
  next = try self.implementation.downloadManifest(manifest: manifestEntries, version: version, sessionKey: sessionKey)
@@ -1617,29 +1631,20 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1617
1631
  next = try self.implementation.download(url: url, version: version, sessionKey: sessionKey)
1618
1632
  }
1619
1633
 
1620
- if self.implementation.publicKey != "" && checksum == "" {
1621
- self.logger.error("Public key present but no checksum provided")
1622
- self.implementation.sendStats(action: "checksum_required", versionName: next.getVersionName())
1623
- let id = next.getId()
1624
- let resDel = self.implementation.delete(id: id)
1625
- if !resDel {
1626
- self.logger.error("Delete failed, id \(id) doesn't exist")
1627
- }
1628
- throw ObjectSavableError.checksum
1629
- }
1630
-
1631
- checksum = try CryptoCipher.decryptChecksum(checksum: checksum, publicKey: self.implementation.publicKey)
1632
- CryptoCipher.logChecksumInfo(label: "Bundle checksum", hexChecksum: next.getChecksum())
1633
- CryptoCipher.logChecksumInfo(label: "Expected checksum", hexChecksum: checksum)
1634
- if (checksum != "" || self.implementation.publicKey != "") && next.getChecksum() != checksum {
1635
- self.logger.error("Error checksum \(next.getChecksum()) \(checksum)")
1636
- self.implementation.sendStats(action: "checksum_fail", versionName: next.getVersionName())
1637
- let id = next.getId()
1638
- let resDel = self.implementation.delete(id: id)
1639
- if !resDel {
1640
- self.logger.error("Delete failed, id \(id) doesn't exist")
1634
+ if manifestEntries == nil {
1635
+ checksum = try CryptoCipher.decryptChecksum(checksum: checksum, publicKey: self.implementation.publicKey)
1636
+ CryptoCipher.logChecksumInfo(label: "Bundle checksum", hexChecksum: next.getChecksum())
1637
+ CryptoCipher.logChecksumInfo(label: "Expected checksum", hexChecksum: checksum)
1638
+ if next.getChecksum() != checksum {
1639
+ self.logger.error("Error checksum \(next.getChecksum()) \(checksum)")
1640
+ self.implementation.sendStats(action: "checksum_fail", versionName: next.getVersionName())
1641
+ let id = next.getId()
1642
+ let resDel = self.implementation.delete(id: id)
1643
+ if !resDel {
1644
+ self.logger.error("Delete failed, id \(id) doesn't exist")
1645
+ }
1646
+ throw ObjectSavableError.checksum
1641
1647
  }
1642
- throw ObjectSavableError.checksum
1643
1648
  }
1644
1649
 
1645
1650
  self.logger.info("Good checksum \(next.getChecksum()) \(checksum)")
@@ -1691,8 +1696,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1691
1696
  let id = self.implementation.getCurrentBundleId()
1692
1697
  if BundleInfo.ID_BUILTIN == id {
1693
1698
  return Bundle.main.resourceURL!.appendingPathComponent("public")
1699
+ } else if let bundleDir = try? self.implementation.getBundleDirectory(id: id) {
1700
+ return bundleDir
1694
1701
  } else {
1695
- return self.implementation.getBundleDirectory(id: id)
1702
+ return Bundle.main.resourceURL!.appendingPathComponent("public")
1696
1703
  }
1697
1704
  }
1698
1705
 
@@ -4172,6 +4179,22 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
4172
4179
  self.updateUrl = updateUrl
4173
4180
  }
4174
4181
 
4182
+ func downloadBundleForTesting(
4183
+ urlString: String,
4184
+ version: String,
4185
+ sessionKey: String,
4186
+ checksum: String,
4187
+ manifestEntries: [ManifestEntry]?
4188
+ ) throws -> BundleInfo {
4189
+ try downloadBundle(
4190
+ urlString: urlString,
4191
+ version: version,
4192
+ sessionKey: sessionKey,
4193
+ checksum: checksum,
4194
+ manifestEntries: manifestEntries
4195
+ )
4196
+ }
4197
+
4175
4198
  func setAutoUpdateModeForTesting(_ autoUpdateMode: String) {
4176
4199
  self.autoUpdateMode = Self.normalizedAutoUpdateMode(autoUpdateMode)
4177
4200
  self.autoUpdate = Self.isAutoUpdateModeEnabled(self.autoUpdateMode)
@@ -4507,6 +4530,17 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
4507
4530
  if latestVersionName != "" && current.getVersionName() != latestVersionName {
4508
4531
  do {
4509
4532
  self.logger.info("New bundle: \(latestVersionName) found. Current is: \(current.getVersionName()). \(messageUpdate)")
4533
+ if !self.implementation.publicKey.isEmpty && !CryptoCipher.isValidSessionKey(sessionKey) {
4534
+ self.logger.error("Public key present but no valid session key provided")
4535
+ self.implementation.sendStats(action: "session_key_required", versionName: latestVersionName)
4536
+ self.endBackGroundTaskWithNotif(
4537
+ msg: "Session key required when public key is present",
4538
+ latestVersionName: latestVersionName,
4539
+ current: current,
4540
+ plannedDirectUpdate: plannedDirectUpdate
4541
+ )
4542
+ return
4543
+ }
4510
4544
  var nextImpl = self.implementation.getBundleInfoByVersionName(version: latestVersionName)
4511
4545
  let needsDownload = nextImpl.map(Self.shouldRetryDownloadForExistingBundle) ?? true
4512
4546
  if needsDownload {
@@ -4524,6 +4558,17 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
4524
4558
  if res.manifest != nil {
4525
4559
  nextImpl = try self.implementation.downloadManifest(manifest: res.manifest!, version: latestVersionName, sessionKey: sessionKey, link: res.link, comment: res.comment)
4526
4560
  } else {
4561
+ if res.checksum.isEmpty {
4562
+ self.logger.error("No checksum provided")
4563
+ self.implementation.sendStats(action: "checksum_required", versionName: latestVersionName)
4564
+ self.endBackGroundTaskWithNotif(
4565
+ msg: "Checksum required",
4566
+ latestVersionName: latestVersionName,
4567
+ current: current,
4568
+ plannedDirectUpdate: plannedDirectUpdate
4569
+ )
4570
+ return
4571
+ }
4527
4572
  nextImpl = try self.implementation.download(url: downloadUrl, version: latestVersionName, sessionKey: sessionKey, link: res.link, comment: res.comment)
4528
4573
  }
4529
4574
  }
@@ -4552,24 +4597,26 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
4552
4597
  self.endBackGroundTask()
4553
4598
  return
4554
4599
  }
4555
- res.checksum = try CryptoCipher.decryptChecksum(checksum: res.checksum, publicKey: self.implementation.publicKey)
4556
- CryptoCipher.logChecksumInfo(label: "Bundle checksum", hexChecksum: next.getChecksum())
4557
- CryptoCipher.logChecksumInfo(label: "Expected checksum", hexChecksum: res.checksum)
4558
- if res.checksum != "" && next.getChecksum() != res.checksum && res.manifest == nil {
4559
- self.logger.error("Error checksum \(next.getChecksum()) \(res.checksum)")
4560
- self.implementation.sendStats(action: "checksum_fail", versionName: next.getVersionName())
4561
- let id = next.getId()
4562
- let resDel = self.implementation.delete(id: id)
4563
- if !resDel {
4564
- self.logger.error("Delete failed, id \(id) doesn't exist")
4600
+ if res.manifest == nil {
4601
+ res.checksum = try CryptoCipher.decryptChecksum(checksum: res.checksum, publicKey: self.implementation.publicKey)
4602
+ CryptoCipher.logChecksumInfo(label: "Bundle checksum", hexChecksum: next.getChecksum())
4603
+ CryptoCipher.logChecksumInfo(label: "Expected checksum", hexChecksum: res.checksum)
4604
+ if next.getChecksum() != res.checksum {
4605
+ self.logger.error("Error checksum \(next.getChecksum()) \(res.checksum)")
4606
+ self.implementation.sendStats(action: "checksum_fail", versionName: next.getVersionName())
4607
+ let id = next.getId()
4608
+ let resDel = self.implementation.delete(id: id)
4609
+ if !resDel {
4610
+ self.logger.error("Delete failed, id \(id) doesn't exist")
4611
+ }
4612
+ self.endBackGroundTaskWithNotif(
4613
+ msg: "Error checksum",
4614
+ latestVersionName: latestVersionName,
4615
+ current: current,
4616
+ plannedDirectUpdate: plannedDirectUpdate
4617
+ )
4618
+ return
4565
4619
  }
4566
- self.endBackGroundTaskWithNotif(
4567
- msg: "Error checksum",
4568
- latestVersionName: latestVersionName,
4569
- current: current,
4570
- plannedDirectUpdate: plannedDirectUpdate
4571
- )
4572
- return
4573
4620
  }
4574
4621
  if self.shouldBlockAutoUpdateForPreviewSession() {
4575
4622
  self.clearDownloadInProgressState()
@@ -166,6 +166,11 @@ import UIKit
166
166
  return canonicalTarget
167
167
  }
168
168
 
169
+ static func resolveBundleDirectory(libraryDir: URL, bundleId: String) throws -> URL {
170
+ let bundleRoot = libraryDir.appendingPathComponent("NoCloud/ionic_built_snapshots")
171
+ return try resolvePathInsideDirectory(baseDirectory: bundleRoot, relativePath: bundleId)
172
+ }
173
+
169
174
  static func resolveManifestTargetPath(baseDirectory: URL, fileName: String) throws -> URL {
170
175
  let isBrotli = fileName.hasSuffix(".br")
171
176
  let targetFileName = isBrotli ? String(fileName.dropLast(3)) : fileName
@@ -456,6 +461,18 @@ import UIKit
456
461
  return String((0..<length).map { _ in letters.randomElement()! })
457
462
  }
458
463
 
464
+ private func requireSessionKeyForEncryptedUpdate(sessionKey: String, versionName: String? = nil) throws {
465
+ if !self.publicKey.isEmpty && !CryptoCipher.isValidSessionKey(sessionKey) {
466
+ logger.error("Public key present but no valid session key provided")
467
+ self.sendStats(action: "session_key_required", versionName: versionName)
468
+ throw NSError(
469
+ domain: "CapgoUpdater",
470
+ code: 1,
471
+ userInfo: [NSLocalizedDescriptionKey: "Session key required when public key is present"]
472
+ )
473
+ }
474
+ }
475
+
459
476
  public func setPublicKey(_ publicKey: String) {
460
477
  // Empty string means no encryption - proceed normally
461
478
  if publicKey.isEmpty {
@@ -987,7 +1004,13 @@ import UIKit
987
1004
  /// `manifest` must only contain entries the caller already checksum-verified
988
1005
  /// (as `downloadManifest` does) — the hashes are trusted as-is, not re-checked.
989
1006
  func populateDeltaCache(for id: String, manifest: [ManifestEntry]? = nil, sessionKey: String = "") {
990
- let bundleDir = self.getBundleDirectory(id: id)
1007
+ let bundleDir: URL
1008
+ do {
1009
+ bundleDir = try self.getBundleDirectory(id: id)
1010
+ } catch {
1011
+ logger.debug("Skip delta cache population: invalid bundle id")
1012
+ return
1013
+ }
991
1014
  let fileManager = FileManager.default
992
1015
 
993
1016
  guard fileManager.fileExists(atPath: bundleDir.path) else {
@@ -1247,7 +1270,10 @@ import UIKit
1247
1270
  guard var fileHash = entry.file_hash, !fileHash.isEmpty else {
1248
1271
  return nil
1249
1272
  }
1250
- if !self.publicKey.isEmpty && !sessionKey.isEmpty {
1273
+ if !self.publicKey.isEmpty {
1274
+ if !CryptoCipher.isValidSessionKey(sessionKey) {
1275
+ return nil
1276
+ }
1251
1277
  do {
1252
1278
  fileHash = try CryptoCipher.decryptChecksum(checksum: fileHash, publicKey: self.publicKey)
1253
1279
  } catch {
@@ -1408,10 +1434,11 @@ import UIKit
1408
1434
  }
1409
1435
 
1410
1436
  public func downloadManifest(manifest: [ManifestEntry], version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
1437
+ try self.requireSessionKeyForEncryptedUpdate(sessionKey: sessionKey, versionName: version)
1411
1438
  try self.runBeforeDownload()
1412
1439
  let id = self.randomString(length: 10)
1413
1440
  logger.info("downloadManifest start \(id)")
1414
- let destFolder = self.getBundleDirectory(id: id)
1441
+ let destFolder = try self.getBundleDirectory(id: id)
1415
1442
  let builtinFolder = self.builtinFolderURL()
1416
1443
 
1417
1444
  // Check disk space before starting manifest download (estimate 100KB per file, minimum 50MB)
@@ -1482,7 +1509,23 @@ import UIKit
1482
1509
  var fileHash = entryFileHash
1483
1510
 
1484
1511
  // Decrypt checksum if needed (done before creating operation)
1485
- if !self.publicKey.isEmpty && !sessionKey.isEmpty {
1512
+ if !self.publicKey.isEmpty {
1513
+ if !CryptoCipher.isValidSessionKey(sessionKey) {
1514
+ let error = NSError(
1515
+ domain: "CapgoUpdater",
1516
+ code: 1,
1517
+ userInfo: [NSLocalizedDescriptionKey: "Session key required when public key is present"]
1518
+ )
1519
+ self.sendStats(action: "session_key_required", versionName: version)
1520
+ errorLock.lock()
1521
+ if downloadError == nil {
1522
+ downloadError = error
1523
+ }
1524
+ errorLock.unlock()
1525
+ hasError.value = true
1526
+ logger.error("Public key present but no valid session key provided")
1527
+ continue
1528
+ }
1486
1529
  do {
1487
1530
  fileHash = try CryptoCipher.decryptChecksum(checksum: fileHash, publicKey: self.publicKey)
1488
1531
  } catch {
@@ -1762,7 +1805,7 @@ import UIKit
1762
1805
 
1763
1806
  do {
1764
1807
  var source = partialURL
1765
- if !self.publicKey.isEmpty && !sessionKey.isEmpty {
1808
+ if !self.publicKey.isEmpty && CryptoCipher.isValidSessionKey(sessionKey) {
1766
1809
  let work = cacheFolder.appendingPathComponent("work_\(UUID().uuidString)_\((fileName as NSString).lastPathComponent)")
1767
1810
  try FileManager.default.copyItem(at: partialURL, to: work)
1768
1811
  workURL = work
@@ -2064,6 +2107,7 @@ import UIKit
2064
2107
  }
2065
2108
 
2066
2109
  public func download(url: URL, version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
2110
+ try self.requireSessionKeyForEncryptedUpdate(sessionKey: sessionKey, versionName: version)
2067
2111
  try self.runBeforeDownload()
2068
2112
  let id: String = self.randomString(length: 10)
2069
2113
  // Each download uses its own temp files keyed by bundle ID to prevent collisions
@@ -2344,6 +2388,15 @@ import UIKit
2344
2388
  self.deleteLock.lock()
2345
2389
  defer { self.deleteLock.unlock() }
2346
2390
 
2391
+ let destPersist: URL
2392
+ do {
2393
+ destPersist = try self.getBundleDirectory(id: id)
2394
+ } catch {
2395
+ logger.error("Cannot delete bundle with invalid id")
2396
+ logger.debug("Bundle ID: \(id), Error: \(error.localizedDescription)")
2397
+ return false
2398
+ }
2399
+
2347
2400
  let deleted: BundleInfo = self.getBundleInfo(id: id)
2348
2401
  if deleted.isBuiltin() || self.getCurrentBundleId() == id {
2349
2402
  logger.info("Cannot delete current or builtin bundle")
@@ -2372,7 +2425,6 @@ import UIKit
2372
2425
  return false
2373
2426
  }
2374
2427
 
2375
- let destPersist: URL = libraryDir.appendingPathComponent(bundleDirectory).appendingPathComponent(id)
2376
2428
  let hadRegistry = self.hasStoredBundleInfo(id: id)
2377
2429
  let hadFolder = FileManager.default.fileExists(atPath: destPersist.path)
2378
2430
  if !hadRegistry && !hadFolder {
@@ -2676,8 +2728,8 @@ import UIKit
2676
2728
  }
2677
2729
  }
2678
2730
 
2679
- public func getBundleDirectory(id: String) -> URL {
2680
- return libraryDir.appendingPathComponent(self.bundleDirectory).appendingPathComponent(id)
2731
+ public func getBundleDirectory(id: String) throws -> URL {
2732
+ return try Self.resolveBundleDirectory(libraryDir: libraryDir, bundleId: id)
2681
2733
  }
2682
2734
 
2683
2735
  struct ResetState {
@@ -2731,7 +2783,12 @@ import UIKit
2731
2783
  }
2732
2784
 
2733
2785
  private func bundleExists(id: String) -> Bool {
2734
- let destPersist: URL = self.getBundleDirectory(id: id)
2786
+ let destPersist: URL
2787
+ do {
2788
+ destPersist = try self.getBundleDirectory(id: id)
2789
+ } catch {
2790
+ return false
2791
+ }
2735
2792
  let indexPersist: URL = destPersist.appendingPathComponent("index.html")
2736
2793
  let bundleIndo: BundleInfo = self.getBundleInfo(id: id)
2737
2794
  if
@@ -2754,7 +2811,12 @@ import UIKit
2754
2811
  }
2755
2812
  if bundleExists(id: id) {
2756
2813
  let currentBundleName = self.getCurrentBundle().getVersionName()
2757
- self.setCurrentBundle(bundle: self.getBundleDirectory(id: id).path)
2814
+ guard let bundleDir = try? self.getBundleDirectory(id: id) else {
2815
+ self.setBundleStatus(id: id, status: BundleStatus.ERROR)
2816
+ self.sendStats(action: "set_fail", versionName: newBundle.getVersionName())
2817
+ return false
2818
+ }
2819
+ self.setCurrentBundle(bundle: bundleDir.path)
2758
2820
  self.setBundleStatus(id: id, status: BundleStatus.PENDING)
2759
2821
  self.sendStats(action: "set", versionName: newBundle.getVersionName(), oldVersionName: currentBundleName)
2760
2822
  return true
@@ -2768,7 +2830,10 @@ import UIKit
2768
2830
  guard !bundle.isBuiltin(), bundleExists(id: bundle.getId()) else {
2769
2831
  return false
2770
2832
  }
2771
- self.setCurrentBundle(bundle: self.getBundleDirectory(id: bundle.getId()).path)
2833
+ guard let bundleDir = try? self.getBundleDirectory(id: bundle.getId()) else {
2834
+ return false
2835
+ }
2836
+ self.setCurrentBundle(bundle: bundleDir.path)
2772
2837
  return true
2773
2838
  }
2774
2839
 
@@ -2783,7 +2848,10 @@ import UIKit
2783
2848
  guard bundleExists(id: bundle.getId()) else {
2784
2849
  return false
2785
2850
  }
2786
- self.setCurrentBundle(bundle: self.getBundleDirectory(id: bundle.getId()).path)
2851
+ guard let bundleDir = try? self.getBundleDirectory(id: bundle.getId()) else {
2852
+ return false
2853
+ }
2854
+ self.setCurrentBundle(bundle: bundleDir.path)
2787
2855
  return true
2788
2856
  }
2789
2857
 
@@ -225,8 +225,16 @@ public struct CryptoCipher {
225
225
  return String(hexString(from: sha256).prefix(16))
226
226
  }
227
227
 
228
+ public static func isValidSessionKey(_ sessionKey: String) -> Bool {
229
+ if sessionKey.isEmpty {
230
+ return false
231
+ }
232
+ let sessionKeyParts = sessionKey.components(separatedBy: ":")
233
+ return sessionKeyParts.count == 2 && !sessionKeyParts[0].isEmpty && !sessionKeyParts[1].isEmpty
234
+ }
235
+
228
236
  public static func decryptFile(filePath: URL, publicKey: String, sessionKey: String, version: String) throws {
229
- if publicKey.isEmpty || sessionKey.isEmpty || sessionKey.components(separatedBy: ":").count != 2 {
237
+ if publicKey.isEmpty || !isValidSessionKey(sessionKey) {
230
238
  logger.info("Encryption not set, no public key or session, ignored")
231
239
  return
232
240
  }
@@ -4,6 +4,8 @@ import os.log
4
4
  import WebKit
5
5
 
6
6
  public class Logger {
7
+ static let maxWebViewLogPayloadChars = 4096
8
+
7
9
  public enum LogLevel: Int {
8
10
  case silent = 0
9
11
  case error
@@ -199,17 +201,85 @@ public class Logger {
199
201
  // This will never fail, but we have to keep swift happy
200
202
  if let label = _labels[level] {
201
203
  log(atLevel: level, label: label, tag: tag, message: message)
202
- if let webView = self.webView {
204
+ if let webView = self.webView, let script = buildWebViewConsoleScript(level: level, label: label, tag: tag, message: message) {
203
205
  DispatchQueue.main.async {
204
- let combined = "\(label) \(self.tag) : \(message)"
205
- let jsArg = self.toJSStringLiteral(combined)
206
- webView.evaluateJavaScript("console.\(level.asString())(\(jsArg))", completionHandler: nil)
206
+ webView.evaluateJavaScript(script, completionHandler: nil)
207
207
  }
208
208
  }
209
209
  }
210
210
  }
211
211
 
212
- private func toJSStringLiteral(_ value: String) -> String {
212
+ func consoleMethodName(for level: LogLevel) -> String? {
213
+ switch level {
214
+ case .error:
215
+ return "error"
216
+ case .warn:
217
+ return "warn"
218
+ case .info:
219
+ return "info"
220
+ case .debug:
221
+ return "debug"
222
+ case .silent:
223
+ return nil
224
+ }
225
+ }
226
+
227
+ private func utf8SequenceLength(_ lead: UInt8) -> Int {
228
+ if (lead & 0x80) == 0 {
229
+ return 1
230
+ }
231
+ if (lead & 0xE0) == 0xC0 {
232
+ return 2
233
+ }
234
+ if (lead & 0xF0) == 0xE0 {
235
+ return 3
236
+ }
237
+ if (lead & 0xF8) == 0xF0 {
238
+ return 4
239
+ }
240
+ return 0
241
+ }
242
+
243
+ func capWebViewLogPayload(_ payload: String) -> String {
244
+ let suffix = "..."
245
+ let maxPayloadBytes = Logger.maxWebViewLogPayloadChars
246
+ let payloadBytes = Array(payload.utf8.prefix(maxPayloadBytes + 1))
247
+ if payloadBytes.count <= maxPayloadBytes {
248
+ return payload
249
+ }
250
+
251
+ var end = maxPayloadBytes
252
+ while end > 0 && (payloadBytes[end - 1] & 0xC0) == 0x80 {
253
+ end -= 1
254
+ }
255
+ while end > 0 {
256
+ let leadIndex = end - 1
257
+ let lead = payloadBytes[leadIndex]
258
+ let seqLength = utf8SequenceLength(lead)
259
+ if seqLength == 0 {
260
+ end -= 1
261
+ continue
262
+ }
263
+ if leadIndex + seqLength <= maxPayloadBytes {
264
+ end = leadIndex + seqLength
265
+ break
266
+ }
267
+ end -= 1
268
+ }
269
+
270
+ return String(decoding: payloadBytes[0..<end], as: UTF8.self) + suffix
271
+ }
272
+
273
+ func buildWebViewConsoleScript(level: LogLevel, label: String, tag: String, message: String) -> String? {
274
+ guard let consoleMethod = consoleMethodName(for: level) else {
275
+ return nil
276
+ }
277
+
278
+ let payload = capWebViewLogPayload("\(label) \(tag) : \(message)")
279
+ return "console.\(consoleMethod)(\(toJSStringLiteral(payload)))"
280
+ }
281
+
282
+ func toJSStringLiteral(_ value: String) -> String {
213
283
  // Prefer JSON encoding to produce a valid JS string literal
214
284
  if let data = try? JSONEncoder().encode(value),
215
285
  let encoded = String(data: data, encoding: .utf8) {
@@ -359,7 +359,10 @@ extension UIWindow {
359
359
  if !next.isBuiltin() {
360
360
  plugin.logger.info("Resetting to: \(next.toString())")
361
361
  _ = updater.set(bundle: next)
362
- let destHot = updater.getBundleDirectory(id: next.getId())
362
+ guard let destHot = try? updater.getBundleDirectory(id: next.getId()) else {
363
+ resetBuiltin()
364
+ return
365
+ }
363
366
  plugin.logger.info("Reloading \(next.toString())")
364
367
  bridge.setServerBasePath(destHot.path)
365
368
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-updater",
3
- "version": "8.51.22",
3
+ "version": "8.51.24",
4
4
  "license": "MPL-2.0",
5
5
  "description": "Live update for capacitor apps",
6
6
  "main": "dist/plugin.cjs.js",