@capgo/capacitor-updater 8.51.9 → 8.51.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -146,7 +146,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
146
146
  static final int APPLICATION_EXIT_REASON_USER_REQUESTED = 10;
147
147
  static final int APPLICATION_EXIT_REASON_DEPENDENCY_DIED = 12;
148
148
 
149
- private final String pluginVersion = "8.51.9";
149
+ private final String pluginVersion = "8.51.11";
150
150
  private static final String DELAY_CONDITION_PREFERENCES = "";
151
151
 
152
152
  private SharedPreferences.Editor editor;
@@ -515,11 +515,19 @@ public class CapgoUpdater {
515
515
  return false;
516
516
  }
517
517
 
518
- final File builtinFile = new File(this.activity.getFilesDir(), "public/" + fileName);
519
- if (verifyChecksum(builtinFile, fileHash)) {
518
+ if (DownloadService.builtinAssetMatches(this.activity.getAssets(), fileName, fileHash)) {
520
519
  return true;
521
520
  }
522
521
 
522
+ try {
523
+ final File builtinFile = DownloadService.resolveManifestBuiltinFile(new File(this.activity.getFilesDir(), "public"), fileName);
524
+ if (verifyChecksum(builtinFile, fileHash)) {
525
+ return true;
526
+ }
527
+ } catch (IOException ignored) {
528
+ // Invalid path; fall through to cache lookup.
529
+ }
530
+
523
531
  final boolean isBrotli = fileName.endsWith(".br");
524
532
  final String fileNameWithoutPath = new File(fileName).getName();
525
533
  final String cacheBaseName = isBrotli ? fileNameWithoutPath.substring(0, fileNameWithoutPath.length() - 3) : fileNameWithoutPath;
@@ -18,6 +18,7 @@ import java.io.FileInputStream;
18
18
  import java.io.FileOutputStream;
19
19
  import java.io.FileReader;
20
20
  import java.io.IOException;
21
+ import java.io.InputStream;
21
22
  import java.security.GeneralSecurityException;
22
23
  import java.security.InvalidAlgorithmParameterException;
23
24
  import java.security.InvalidKeyException;
@@ -399,6 +400,16 @@ public class CryptoCipher {
399
400
  }
400
401
 
401
402
  public static String calcChecksum(File file) {
403
+ try (FileInputStream fis = new FileInputStream(file)) {
404
+ return calcChecksum(fis);
405
+ } catch (IOException e) {
406
+ logger.error("Cannot calculate checksum");
407
+ logger.debug("Path: " + file.getPath() + ", Error: " + e.getMessage());
408
+ return "";
409
+ }
410
+ }
411
+
412
+ public static String calcChecksum(InputStream inputStream) {
402
413
  final int BUFFER_SIZE = checksumBufferBytes();
403
414
  MessageDigest digest;
404
415
  try {
@@ -408,10 +419,10 @@ public class CryptoCipher {
408
419
  return "";
409
420
  }
410
421
 
411
- try (FileInputStream fis = new FileInputStream(file)) {
422
+ try {
412
423
  byte[] buffer = new byte[BUFFER_SIZE];
413
424
  int length;
414
- while ((length = fis.read(buffer)) != -1) {
425
+ while ((length = inputStream.read(buffer)) != -1) {
415
426
  digest.update(buffer, 0, length);
416
427
  }
417
428
  byte[] hash = digest.digest();
@@ -424,7 +435,7 @@ public class CryptoCipher {
424
435
  return hexString.toString();
425
436
  } catch (IOException e) {
426
437
  logger.error("Cannot calculate checksum");
427
- logger.debug("Path: " + file.getPath() + ", Error: " + e.getMessage());
438
+ logger.debug("Error: " + e.getMessage());
428
439
  return "";
429
440
  }
430
441
  }
@@ -6,6 +6,7 @@
6
6
  package ee.forgr.capacitor_updater;
7
7
 
8
8
  import android.content.Context;
9
+ import android.content.res.AssetManager;
9
10
  import androidx.annotation.NonNull;
10
11
  import androidx.work.Data;
11
12
  import androidx.work.Worker;
@@ -18,8 +19,11 @@ import java.nio.channels.FileChannel;
18
19
  import java.security.MessageDigest;
19
20
  import java.util.ArrayList;
20
21
  import java.util.Arrays;
22
+ import java.util.HashSet;
21
23
  import java.util.List;
22
24
  import java.util.Objects;
25
+ import java.util.Set;
26
+ import java.util.UUID;
23
27
  import java.util.concurrent.ExecutorService;
24
28
  import java.util.concurrent.Executors;
25
29
  import java.util.concurrent.Future;
@@ -72,8 +76,8 @@ public class DownloadService extends Worker {
72
76
  public static final String DEFAULT_CHANNEL = "default_channel";
73
77
  public static final String IS_PROD = "is_prod";
74
78
  public static final String IS_EMULATOR = "is_emulator";
75
- // Match HTTP dispatcher so 64 workers actually fetch in parallel (HTTP/2 multiplexes).
76
- private static final int MANIFEST_MAX_CONCURRENT_FILES = 64;
79
+ // HTTP + decode share one pool. Cap by CPU: 8 on 4 cores, 16 on 8 cores, 64 max.
80
+ private static final int MANIFEST_MAX_CONCURRENT_FILES = manifestMaxConcurrentFiles();
77
81
  private static final String UPDATE_FILE = "update.dat";
78
82
 
79
83
  // Shared OkHttpClient to prevent resource leaks
@@ -99,6 +103,15 @@ public class DownloadService extends Worker {
99
103
  .build();
100
104
  }
101
105
 
106
+ static int manifestMaxConcurrentFiles() {
107
+ return manifestMaxConcurrentFiles(Runtime.getRuntime().availableProcessors());
108
+ }
109
+
110
+ static int manifestMaxConcurrentFiles(int processors) {
111
+ int cores = Math.max(1, processors);
112
+ return Math.min(64, Math.max(8, cores * 2));
113
+ }
114
+
102
115
  static String buildUserAgent(String appId, String pluginVersion, String versionOs) {
103
116
  return (
104
117
  "CapacitorUpdater/" +
@@ -179,6 +192,131 @@ public class DownloadService extends Worker {
179
192
  return CapgoUpdater.resolvePathInsideDirectory(builtinFolder, resolvedName);
180
193
  }
181
194
 
195
+ /** APK web assets live in assets/public/; strip .br so store files match. */
196
+ static String resolveBuiltinAssetPath(final String fileName) throws IOException {
197
+ final File base = new File("/capgo-builtin-assets");
198
+ final File resolved = resolveManifestBuiltinFile(base, fileName);
199
+ final String basePath = base.getCanonicalPath();
200
+ final String resolvedPath = resolved.getCanonicalPath();
201
+ final String normalizedBasePath = basePath.endsWith(File.separator) ? basePath : basePath + File.separator;
202
+ if (!resolvedPath.startsWith(normalizedBasePath)) {
203
+ throw new IOException("Invalid manifest file path: " + fileName);
204
+ }
205
+ return "public/" + resolvedPath.substring(normalizedBasePath.length()).replace(File.separatorChar, '/');
206
+ }
207
+
208
+ static boolean copyStreamIfChecksumMatches(final InputStream input, final File dest, final String expectedHash) throws IOException {
209
+ if (expectedHash == null || expectedHash.isEmpty()) {
210
+ return false;
211
+ }
212
+ final File parent = dest.getParentFile();
213
+ if (parent == null) {
214
+ throw new IOException("Destination has no parent: " + dest.getAbsolutePath());
215
+ }
216
+ if (!parent.exists() && !parent.mkdirs()) {
217
+ throw new IOException("Failed to create parent directory: " + parent.getAbsolutePath());
218
+ }
219
+
220
+ final MessageDigest digest;
221
+ try {
222
+ digest = MessageDigest.getInstance("SHA-256");
223
+ } catch (java.security.NoSuchAlgorithmException e) {
224
+ throw new IOException("SHA-256 algorithm not available", e);
225
+ }
226
+
227
+ final File tempFile = File.createTempFile("capgo_asset_", ".tmp", parent);
228
+ try {
229
+ try (FileOutputStream outStream = new FileOutputStream(tempFile)) {
230
+ final byte[] buffer = new byte[CryptoCipher.ioBufferBytes()];
231
+ int length;
232
+ while ((length = input.read(buffer)) != -1) {
233
+ digest.update(buffer, 0, length);
234
+ outStream.write(buffer, 0, length);
235
+ }
236
+ }
237
+ if (!expectedHash.equalsIgnoreCase(sha256Hex(digest))) {
238
+ return false;
239
+ }
240
+ return replaceFile(tempFile, dest);
241
+ } finally {
242
+ deleteQuietly(tempFile);
243
+ }
244
+ }
245
+
246
+ private static String sha256Hex(final MessageDigest digest) {
247
+ final byte[] hash = digest.digest();
248
+ final StringBuilder hexString = new StringBuilder(hash.length * 2);
249
+ for (final byte b : hash) {
250
+ final String hex = Integer.toHexString(0xff & b);
251
+ if (hex.length() == 1) {
252
+ hexString.append('0');
253
+ }
254
+ hexString.append(hex);
255
+ }
256
+ return hexString.toString();
257
+ }
258
+
259
+ private static void deleteQuietly(final File file) {
260
+ if (file.exists() && !file.delete()) {
261
+ file.deleteOnExit();
262
+ }
263
+ }
264
+
265
+ static boolean replaceFile(final File tempFile, final File dest) {
266
+ if (tempFile.renameTo(dest)) {
267
+ return true;
268
+ }
269
+ final File parent = dest.getParentFile();
270
+ if (parent == null) {
271
+ return false;
272
+ }
273
+ final File backup = new File(parent, ".capgo_bak_" + UUID.randomUUID());
274
+ deleteQuietly(backup);
275
+ if (dest.exists() && !dest.renameTo(backup)) {
276
+ return false;
277
+ }
278
+ if (!tempFile.renameTo(dest)) {
279
+ if (backup.exists()) {
280
+ backup.renameTo(dest);
281
+ }
282
+ return false;
283
+ }
284
+ deleteQuietly(backup);
285
+ return true;
286
+ }
287
+
288
+ static boolean rememberManifestTarget(final Set<String> seenTargets, final File targetFile) throws IOException {
289
+ return seenTargets.add(targetFile.getCanonicalPath());
290
+ }
291
+
292
+ static boolean tryCopyBuiltinAsset(final AssetManager assets, final String fileName, final File dest, final String expectedHash) {
293
+ if (assets == null || fileName == null || dest == null) {
294
+ return false;
295
+ }
296
+ try {
297
+ final String assetPath = resolveBuiltinAssetPath(fileName);
298
+ try (InputStream in = assets.open(assetPath)) {
299
+ return copyStreamIfChecksumMatches(in, dest, expectedHash);
300
+ }
301
+ } catch (IOException e) {
302
+ return false;
303
+ }
304
+ }
305
+
306
+ static boolean builtinAssetMatches(final AssetManager assets, final String fileName, final String expectedHash) {
307
+ if (assets == null || fileName == null || expectedHash == null || expectedHash.isEmpty()) {
308
+ return false;
309
+ }
310
+ try {
311
+ final String assetPath = resolveBuiltinAssetPath(fileName);
312
+ try (InputStream in = assets.open(assetPath)) {
313
+ return expectedHash.equalsIgnoreCase(CryptoCipher.calcChecksum(in));
314
+ }
315
+ } catch (IOException e) {
316
+ return false;
317
+ }
318
+ }
319
+
182
320
  private String getInputString(String key, String fallback) {
183
321
  String value = getInputData().getString(key);
184
322
  return value != null ? value : fallback;
@@ -302,11 +440,13 @@ public class DownloadService extends Worker {
302
440
  File destFolder = new File(documentsDir, dest);
303
441
  File cacheFolder = new File(getApplicationContext().getCacheDir(), "capgo_downloads");
304
442
  File builtinFolder = new File(getApplicationContext().getFilesDir(), "public");
443
+ AssetManager assets = getApplicationContext().getAssets();
305
444
 
306
445
  // Ensure directories are created
307
446
  if (!destFolder.exists() && !destFolder.mkdirs()) {
308
447
  throw new IOException("Failed to create destination directory: " + destFolder.getAbsolutePath());
309
448
  }
449
+ cleanupOrphanedAssetTemps(destFolder);
310
450
  if (!cacheFolder.exists() && !cacheFolder.mkdirs()) {
311
451
  throw new IOException("Failed to create cache directory: " + cacheFolder.getAbsolutePath());
312
452
  }
@@ -317,6 +457,7 @@ public class DownloadService extends Worker {
317
457
 
318
458
  ExecutorService executor = Executors.newFixedThreadPool(Math.min(MANIFEST_MAX_CONCURRENT_FILES, Math.max(1, totalFiles)));
319
459
  List<Future<?>> futures = new ArrayList<>();
460
+ final Set<String> seenTargets = new HashSet<>();
320
461
 
321
462
  for (int i = 0; i < totalFiles; i++) {
322
463
  JSONObject entry = manifest.getJSONObject(i);
@@ -351,6 +492,12 @@ public class DownloadService extends Worker {
351
492
  try {
352
493
  targetFile = resolveManifestTargetFile(destFolder, fileName);
353
494
  builtinFile = resolveManifestBuiltinFile(builtinFolder, fileName);
495
+ if (!rememberManifestTarget(seenTargets, targetFile)) {
496
+ logger.error("Duplicate manifest target path: " + fileName);
497
+ sendStatsAsync("manifest_path_fail", version + ":" + fileName);
498
+ hasError.set(true);
499
+ continue;
500
+ }
354
501
  } catch (IOException e) {
355
502
  logger.error("Invalid manifest file path: " + fileName);
356
503
  sendStatsAsync("manifest_path_fail", version + ":" + fileName);
@@ -374,7 +521,9 @@ public class DownloadService extends Worker {
374
521
  final boolean finalIsBrotli = isBrotli;
375
522
  Future<?> future = executor.submit(() -> {
376
523
  try {
377
- if (builtinFile.exists() && verifyChecksum(builtinFile, finalFileHash)) {
524
+ if (tryCopyBuiltinAsset(assets, fileName, targetFile, finalFileHash)) {
525
+ logger.debug("using builtin asset " + fileName);
526
+ } else if (builtinFile.exists() && verifyChecksum(builtinFile, finalFileHash)) {
378
527
  copyFile(builtinFile, targetFile);
379
528
  logger.debug("using builtin file " + fileName);
380
529
  } else if (
@@ -952,4 +1101,27 @@ public class DownloadService extends Worker {
952
1101
  }
953
1102
  }
954
1103
  }
1104
+
1105
+ private void cleanupOrphanedAssetTemps(final File directory) {
1106
+ if (directory == null || !directory.isDirectory()) {
1107
+ return;
1108
+ }
1109
+ final File[] children = directory.listFiles();
1110
+ if (children == null) {
1111
+ return;
1112
+ }
1113
+ final long oneHourAgo = System.currentTimeMillis() - 3600000;
1114
+ for (final File child : children) {
1115
+ if (child.isDirectory()) {
1116
+ cleanupOrphanedAssetTemps(child);
1117
+ continue;
1118
+ }
1119
+ final String name = child.getName();
1120
+ final boolean orphanedAssetTemp = name.startsWith("capgo_asset_") && name.endsWith(".tmp");
1121
+ final boolean orphanedBackup = name.startsWith(".capgo_bak_");
1122
+ if ((orphanedAssetTemp || orphanedBackup) && child.lastModified() < oneHourAgo) {
1123
+ child.delete();
1124
+ }
1125
+ }
1126
+ }
955
1127
  }
@@ -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.9"
99
+ private let pluginVersion: String = "8.51.11"
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"
@@ -25,8 +25,12 @@ import UIKit
25
25
  private let PENDING_DELETE_IDS: String = "pendingDeleteIds"
26
26
  private var unzipPercent = 0
27
27
  private let TEMP_UNZIP_PREFIX: String = "capgo_unzip_"
28
- /// Match URLSession per-host limit so 64 workers actually fetch in parallel.
29
- private static let manifestMaxConcurrentFiles = 64
28
+ /// HTTP + decode share one pool. Cap by CPU: 8 on 4 cores, 16 on 8 cores, 64 max.
29
+ static let manifestMaxConcurrentFiles = clampedManifestConcurrency(processorCount: ProcessInfo.processInfo.processorCount)
30
+
31
+ static func clampedManifestConcurrency(processorCount: Int) -> Int {
32
+ min(64, max(8, max(1, processorCount) * 2))
33
+ }
30
34
  private static let emptySha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
31
35
  private let deletePaceSeconds: TimeInterval = 0.075
32
36
  private let deleteLock = NSLock()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-updater",
3
- "version": "8.51.9",
3
+ "version": "8.51.11",
4
4
  "license": "MPL-2.0",
5
5
  "description": "Live update for capacitor apps",
6
6
  "main": "dist/plugin.cjs.js",