@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.
- package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +1 -1
- package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +10 -2
- package/android/src/main/java/ee/forgr/capacitor_updater/CryptoCipher.java +14 -3
- package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +175 -3
- package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +1 -1
- package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +6 -2
- 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.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
|
-
|
|
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
|
|
422
|
+
try {
|
|
412
423
|
byte[] buffer = new byte[BUFFER_SIZE];
|
|
413
424
|
int length;
|
|
414
|
-
while ((length =
|
|
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("
|
|
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
|
-
//
|
|
76
|
-
private static final int MANIFEST_MAX_CONCURRENT_FILES =
|
|
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 (
|
|
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.
|
|
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
|
-
///
|
|
29
|
-
|
|
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()
|