@otakit/capacitor-updater 2.1.2 → 2.3.0
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 +142 -4
- package/android/src/main/java/com/otakit/updater/BundleCrypto.java +81 -0
- package/android/src/main/java/com/otakit/updater/BundleStore.java +26 -0
- package/android/src/main/java/com/otakit/updater/DeltaAssembler.java +416 -0
- package/android/src/main/java/com/otakit/updater/HashUtils.java +16 -0
- package/android/src/main/java/com/otakit/updater/ManifestClient.java +136 -4
- package/android/src/main/java/com/otakit/updater/ManifestVerifier.java +43 -0
- package/android/src/main/java/com/otakit/updater/UpdaterPlugin.java +457 -20
- package/dist/esm/definitions.d.ts +104 -4
- package/dist/esm/definitions.d.ts.map +1 -1
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +6 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/web.d.ts +7 -1
- package/dist/esm/web.d.ts.map +1 -1
- package/dist/esm/web.js +25 -0
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +31 -0
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +31 -0
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/UpdaterPlugin/BundleCrypto.swift +91 -0
- package/ios/Sources/UpdaterPlugin/BundleStore.swift +30 -0
- package/ios/Sources/UpdaterPlugin/DeltaAssembler.swift +316 -0
- package/ios/Sources/UpdaterPlugin/ManifestClient.swift +98 -6
- package/ios/Sources/UpdaterPlugin/ManifestVerifier.swift +29 -0
- package/ios/Sources/UpdaterPlugin/UpdaterPlugin.swift +422 -21
- package/package.json +1 -1
|
@@ -68,18 +68,24 @@ public class UpdaterPlugin extends Plugin {
|
|
|
68
68
|
|
|
69
69
|
final String kind;
|
|
70
70
|
final BundleInfo bundle;
|
|
71
|
+
final boolean forceImmediate;
|
|
71
72
|
|
|
72
|
-
private DownloadResolution(String kind, BundleInfo bundle) {
|
|
73
|
+
private DownloadResolution(String kind, BundleInfo bundle, boolean forceImmediate) {
|
|
73
74
|
this.kind = kind;
|
|
74
75
|
this.bundle = bundle;
|
|
76
|
+
this.forceImmediate = forceImmediate;
|
|
75
77
|
}
|
|
76
78
|
|
|
77
79
|
static DownloadResolution noUpdate() {
|
|
78
|
-
return new DownloadResolution("no_update", null);
|
|
80
|
+
return new DownloadResolution("no_update", null, false);
|
|
79
81
|
}
|
|
80
82
|
|
|
81
|
-
static DownloadResolution staged(BundleInfo bundle) {
|
|
82
|
-
return new DownloadResolution("staged", bundle);
|
|
83
|
+
static DownloadResolution staged(BundleInfo bundle, boolean forceImmediate) {
|
|
84
|
+
return new DownloadResolution("staged", bundle, forceImmediate);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
boolean isForcedStaged() {
|
|
88
|
+
return "staged".equals(kind) && forceImmediate;
|
|
83
89
|
}
|
|
84
90
|
}
|
|
85
91
|
|
|
@@ -107,6 +113,7 @@ public class UpdaterPlugin extends Plugin {
|
|
|
107
113
|
private String channel;
|
|
108
114
|
private String runtimeVersion;
|
|
109
115
|
private java.util.List<ManifestVerifier.KeyEntry> manifestKeys = new java.util.ArrayList<>();
|
|
116
|
+
private final java.util.Map<String, byte[]> bundleKeys = new java.util.HashMap<>();
|
|
110
117
|
private long checkIntervalMs = 600_000;
|
|
111
118
|
private boolean coldStartInProgress = false;
|
|
112
119
|
private static final String DEFAULT_INGEST_URL = "https://ingest.otakit.app/v1";
|
|
@@ -115,6 +122,8 @@ public class UpdaterPlugin extends Plugin {
|
|
|
115
122
|
private static final String KEY_LAST_CHECK_TIMESTAMP = "last_check_timestamp";
|
|
116
123
|
private static final String DEFAULT_RUNTIME_KEY = "__default__";
|
|
117
124
|
private static final String BUILTIN_ASSET_PATH = "public";
|
|
125
|
+
private static final java.util.regex.Pattern CHANNEL_NAME_PATTERN =
|
|
126
|
+
java.util.regex.Pattern.compile("^[A-Za-z0-9._-]{1,64}$");
|
|
118
127
|
private UpdaterCoordinator.StartupPreparation pendingStartupPreparation;
|
|
119
128
|
|
|
120
129
|
@Override
|
|
@@ -187,6 +196,38 @@ public class UpdaterPlugin extends Plugin {
|
|
|
187
196
|
manifestKeys.addAll(HostedManifestKeys.createDefaultKeys());
|
|
188
197
|
}
|
|
189
198
|
|
|
199
|
+
try {
|
|
200
|
+
org.json.JSONArray rawBundleKeys = getConfig().getConfigJSON().optJSONArray("bundleKeys");
|
|
201
|
+
if (rawBundleKeys != null && rawBundleKeys.length() > 0) {
|
|
202
|
+
for (int i = 0; i < rawBundleKeys.length(); i++) {
|
|
203
|
+
org.json.JSONObject entry = rawBundleKeys.optJSONObject(i);
|
|
204
|
+
if (entry == null) {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
String kid = entry.optString("kid", null);
|
|
208
|
+
String keyBase64 = entry.optString("key", null);
|
|
209
|
+
if (kid != null && keyBase64 != null) {
|
|
210
|
+
byte[] keyBytes = android.util.Base64.decode(keyBase64, android.util.Base64.DEFAULT);
|
|
211
|
+
if (keyBytes.length == 32) {
|
|
212
|
+
bundleKeys.put(kid, keyBytes);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (bundleKeys.isEmpty()) {
|
|
217
|
+
android.util.Log.e(
|
|
218
|
+
"OtaKit",
|
|
219
|
+
"bundleKeys configured but all entries are invalid. Encrypted bundles cannot be decrypted."
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
} catch (Exception e) {
|
|
224
|
+
android.util.Log.e(
|
|
225
|
+
"OtaKit",
|
|
226
|
+
"Failed to parse bundleKeys. Encrypted bundles cannot be decrypted.",
|
|
227
|
+
e
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
190
231
|
this.appReadyTimeoutMs = Math.max(1000, getConfig().getInt("appReadyTimeout", 10_000));
|
|
191
232
|
this.checkIntervalMs = getConfig().getInt("checkInterval", 600_000);
|
|
192
233
|
|
|
@@ -290,14 +331,20 @@ public class UpdaterPlugin extends Plugin {
|
|
|
290
331
|
return;
|
|
291
332
|
}
|
|
292
333
|
executeAutomaticUpdate("runtime apply-staged fallback", () -> {
|
|
293
|
-
downloadLatest(false, null);
|
|
334
|
+
DownloadResolution result = downloadLatest(false, null);
|
|
294
335
|
resolveCurrentRuntimeKey();
|
|
336
|
+
if (result.isForcedStaged()) {
|
|
337
|
+
requireApplyStaged(true);
|
|
338
|
+
}
|
|
295
339
|
});
|
|
296
340
|
return;
|
|
297
341
|
case SHADOW:
|
|
298
342
|
executeAutomaticUpdate("runtime shadow", () -> {
|
|
299
|
-
downloadLatest(false, null);
|
|
343
|
+
DownloadResolution result = downloadLatest(false, null);
|
|
300
344
|
resolveCurrentRuntimeKey();
|
|
345
|
+
if (result.isForcedStaged()) {
|
|
346
|
+
requireApplyStaged(true);
|
|
347
|
+
}
|
|
301
348
|
});
|
|
302
349
|
return;
|
|
303
350
|
case IMMEDIATE:
|
|
@@ -327,10 +374,20 @@ public class UpdaterPlugin extends Plugin {
|
|
|
327
374
|
android.util.Log.w("OtaKit", "launch apply-staged failed", e);
|
|
328
375
|
return;
|
|
329
376
|
}
|
|
330
|
-
executeAutomaticUpdate("launch apply-staged fallback", () ->
|
|
377
|
+
executeAutomaticUpdate("launch apply-staged fallback", () -> {
|
|
378
|
+
DownloadResolution result = downloadLatest(false, null);
|
|
379
|
+
if (result.isForcedStaged()) {
|
|
380
|
+
requireApplyStaged(true);
|
|
381
|
+
}
|
|
382
|
+
});
|
|
331
383
|
return;
|
|
332
384
|
case SHADOW:
|
|
333
|
-
executeAutomaticUpdate("launch shadow", () ->
|
|
385
|
+
executeAutomaticUpdate("launch shadow", () -> {
|
|
386
|
+
DownloadResolution result = downloadLatest(false, null);
|
|
387
|
+
if (result.isForcedStaged()) {
|
|
388
|
+
requireApplyStaged(true);
|
|
389
|
+
}
|
|
390
|
+
});
|
|
334
391
|
return;
|
|
335
392
|
case IMMEDIATE:
|
|
336
393
|
executeAutomaticUpdate("launch immediate", () -> {
|
|
@@ -352,11 +409,19 @@ public class UpdaterPlugin extends Plugin {
|
|
|
352
409
|
if (applyStaged(true)) {
|
|
353
410
|
return;
|
|
354
411
|
}
|
|
355
|
-
downloadLatest(true, null);
|
|
412
|
+
DownloadResolution result = downloadLatest(true, null);
|
|
413
|
+
if (result.isForcedStaged()) {
|
|
414
|
+
requireApplyStaged(true);
|
|
415
|
+
}
|
|
356
416
|
});
|
|
357
417
|
return;
|
|
358
418
|
case SHADOW:
|
|
359
|
-
executeAutomaticUpdate("resume shadow", () ->
|
|
419
|
+
executeAutomaticUpdate("resume shadow", () -> {
|
|
420
|
+
DownloadResolution result = downloadLatest(true, null);
|
|
421
|
+
if (result.isForcedStaged()) {
|
|
422
|
+
requireApplyStaged(true);
|
|
423
|
+
}
|
|
424
|
+
});
|
|
360
425
|
return;
|
|
361
426
|
case IMMEDIATE:
|
|
362
427
|
executeAutomaticUpdate("resume immediate", () -> {
|
|
@@ -512,6 +577,11 @@ public class UpdaterPlugin extends Plugin {
|
|
|
512
577
|
coordinator.cleanupBundles(preparation.cleanupBundleIds);
|
|
513
578
|
if (preparation.eventPayload != null) {
|
|
514
579
|
sendDeviceEvent(preparation.eventPayload);
|
|
580
|
+
// eventPayload is non-null only on a genuine trial -> success transition,
|
|
581
|
+
// so repeat notifyAppReady() calls never double-emit.
|
|
582
|
+
JSObject appliedData = new JSObject();
|
|
583
|
+
appliedData.put("bundle", store.getCurrentBundle().toJSObject());
|
|
584
|
+
emitEvent("updateApplied", appliedData);
|
|
515
585
|
}
|
|
516
586
|
call.resolve();
|
|
517
587
|
}
|
|
@@ -526,6 +596,63 @@ public class UpdaterPlugin extends Plugin {
|
|
|
526
596
|
call.resolve(failed.toJSObject());
|
|
527
597
|
}
|
|
528
598
|
|
|
599
|
+
@PluginMethod
|
|
600
|
+
public void setChannel(PluginCall call) {
|
|
601
|
+
Object raw = call.getData().opt("channel");
|
|
602
|
+
if (raw == null || raw == org.json.JSONObject.NULL) {
|
|
603
|
+
store.setOverrideChannel(null);
|
|
604
|
+
call.resolve();
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
if (!(raw instanceof String)) {
|
|
608
|
+
call.reject("channel must be a string or null");
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
String name = (String) raw;
|
|
612
|
+
if (!isValidChannelName(name)) {
|
|
613
|
+
call.reject(
|
|
614
|
+
"Invalid channel name '" +
|
|
615
|
+
name +
|
|
616
|
+
"': use 1-64 letters, numbers, '.', '_' or '-' (reserved names: base, default)"
|
|
617
|
+
);
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
store.setOverrideChannel(name);
|
|
621
|
+
call.resolve();
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
@PluginMethod
|
|
625
|
+
public void getChannel(PluginCall call) {
|
|
626
|
+
JSObject result = new JSObject();
|
|
627
|
+
String override = store.getOverrideChannel();
|
|
628
|
+
if (override != null) {
|
|
629
|
+
result.put("channel", override);
|
|
630
|
+
result.put("source", "override");
|
|
631
|
+
call.resolve(result);
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
result.put("channel", channel != null ? channel : org.json.JSONObject.NULL);
|
|
635
|
+
result.put("source", "config");
|
|
636
|
+
call.resolve(result);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Mirrors the server's isValidChannelName (console/lib/validation.ts):
|
|
641
|
+
* charset regex plus reserved names. The channel is interpolated into the
|
|
642
|
+
* manifest CDN path, so anything outside this charset (or a ".." sequence)
|
|
643
|
+
* is rejected before it is persisted or used.
|
|
644
|
+
*/
|
|
645
|
+
private boolean isValidChannelName(String name) {
|
|
646
|
+
if (!CHANNEL_NAME_PATTERN.matcher(name).matches()) {
|
|
647
|
+
return false;
|
|
648
|
+
}
|
|
649
|
+
if (name.contains("..") || ".".equals(name)) {
|
|
650
|
+
return false;
|
|
651
|
+
}
|
|
652
|
+
String lower = name.toLowerCase(java.util.Locale.ROOT);
|
|
653
|
+
return !"base".equals(lower) && !"default".equals(lower);
|
|
654
|
+
}
|
|
655
|
+
|
|
529
656
|
private ManifestClient.LatestManifest fetchLatest(String channel) throws Exception {
|
|
530
657
|
if (appId == null || appId.trim().isEmpty()) {
|
|
531
658
|
throw new IllegalStateException("Missing appId in plugin config");
|
|
@@ -558,6 +685,10 @@ public class UpdaterPlugin extends Plugin {
|
|
|
558
685
|
|
|
559
686
|
CheckResolution resolution = classifyLatestManifest(latest, targetChannel);
|
|
560
687
|
|
|
688
|
+
if ("update_available".equals(resolution.kind) && resolution.latest != null) {
|
|
689
|
+
emitEvent("updateAvailable", manifestToJSObject(resolution.latest));
|
|
690
|
+
}
|
|
691
|
+
|
|
561
692
|
if (respectInterval) {
|
|
562
693
|
recordCheckTimestamp();
|
|
563
694
|
}
|
|
@@ -572,10 +703,13 @@ public class UpdaterPlugin extends Plugin {
|
|
|
572
703
|
case "no_update":
|
|
573
704
|
return DownloadResolution.noUpdate();
|
|
574
705
|
case "already_staged":
|
|
575
|
-
return DownloadResolution.staged(result.bundle);
|
|
706
|
+
return DownloadResolution.staged(result.bundle, result.latest.forceImmediate);
|
|
576
707
|
case "update_available":
|
|
577
708
|
try {
|
|
578
|
-
return DownloadResolution.staged(
|
|
709
|
+
return DownloadResolution.staged(
|
|
710
|
+
downloadLatestManifest(result.latest, targetChannel),
|
|
711
|
+
result.latest.forceImmediate
|
|
712
|
+
);
|
|
579
713
|
} catch (Exception e) {
|
|
580
714
|
if (!isExpiredURLError(e)) {
|
|
581
715
|
throw e;
|
|
@@ -591,10 +725,14 @@ public class UpdaterPlugin extends Plugin {
|
|
|
591
725
|
case "no_update":
|
|
592
726
|
return DownloadResolution.noUpdate();
|
|
593
727
|
case "already_staged":
|
|
594
|
-
return DownloadResolution.staged(
|
|
728
|
+
return DownloadResolution.staged(
|
|
729
|
+
refreshedResolution.bundle,
|
|
730
|
+
refreshedResolution.latest.forceImmediate
|
|
731
|
+
);
|
|
595
732
|
case "update_available":
|
|
596
733
|
return DownloadResolution.staged(
|
|
597
|
-
downloadLatestManifest(refreshedResolution.latest, targetChannel)
|
|
734
|
+
downloadLatestManifest(refreshedResolution.latest, targetChannel),
|
|
735
|
+
refreshedResolution.latest.forceImmediate
|
|
598
736
|
);
|
|
599
737
|
default:
|
|
600
738
|
throw new IllegalStateException(
|
|
@@ -665,11 +803,15 @@ public class UpdaterPlugin extends Plugin {
|
|
|
665
803
|
int expectedSize,
|
|
666
804
|
String runtimeVersion,
|
|
667
805
|
String channel,
|
|
668
|
-
String releaseId
|
|
806
|
+
String releaseId,
|
|
807
|
+
ManifestClient.ManifestEncryption encryption
|
|
669
808
|
) throws Exception {
|
|
670
809
|
// Check disk space before downloading
|
|
671
810
|
if (expectedSize > 0) {
|
|
672
|
-
|
|
811
|
+
// zip + extracted + buffer; encrypted bundles keep an extra decrypted
|
|
812
|
+
// zip copy on disk between decrypt and extract.
|
|
813
|
+
double multiplier = encryption != null ? 3.5 : 2.5;
|
|
814
|
+
long requiredSpace = (long) (expectedSize * multiplier);
|
|
673
815
|
long availableSpace = getFreeDiskSpace();
|
|
674
816
|
if (availableSpace < requiredSpace) {
|
|
675
817
|
sendDeviceEvent(
|
|
@@ -680,19 +822,50 @@ public class UpdaterPlugin extends Plugin {
|
|
|
680
822
|
releaseId,
|
|
681
823
|
"insufficient_disk_space"
|
|
682
824
|
);
|
|
825
|
+
emitEvent(
|
|
826
|
+
"downloadFailed",
|
|
827
|
+
failureEventData(version, runtimeVersion, channel, releaseId, "insufficient_disk_space")
|
|
828
|
+
);
|
|
683
829
|
throw new IllegalStateException("Insufficient disk space");
|
|
684
830
|
}
|
|
685
831
|
}
|
|
686
832
|
|
|
687
833
|
File downloadedZip = null;
|
|
834
|
+
File decryptedZip = null;
|
|
688
835
|
File extractedDirectory = null;
|
|
689
836
|
|
|
690
837
|
try {
|
|
691
838
|
downloadedZip = downloadZip(url);
|
|
839
|
+
// The manifest sha256 covers the downloaded object as-is — the
|
|
840
|
+
// ciphertext when the bundle is encrypted.
|
|
692
841
|
if (!HashUtils.verify(downloadedZip, expectedSha256)) {
|
|
693
842
|
throw new IllegalStateException("Downloaded bundle hash mismatch");
|
|
694
843
|
}
|
|
695
844
|
|
|
845
|
+
File zipToExtract = downloadedZip;
|
|
846
|
+
if (encryption != null) {
|
|
847
|
+
byte[] kek = bundleKeys.get(encryption.kid);
|
|
848
|
+
if (kek == null) {
|
|
849
|
+
throw new IllegalStateException("no matching bundle key (kid " + encryption.kid + ")");
|
|
850
|
+
}
|
|
851
|
+
byte[] dek = BundleCrypto.unwrapDek(
|
|
852
|
+
kek,
|
|
853
|
+
android.util.Base64.decode(encryption.wrapNonce, android.util.Base64.DEFAULT),
|
|
854
|
+
android.util.Base64.decode(encryption.wrappedDek, android.util.Base64.DEFAULT)
|
|
855
|
+
);
|
|
856
|
+
decryptedZip = new File(
|
|
857
|
+
getContext().getCacheDir(),
|
|
858
|
+
"otakit-decrypted-" + System.currentTimeMillis() + ".zip"
|
|
859
|
+
);
|
|
860
|
+
BundleCrypto.decryptFile(
|
|
861
|
+
dek,
|
|
862
|
+
android.util.Base64.decode(encryption.nonce, android.util.Base64.DEFAULT),
|
|
863
|
+
downloadedZip,
|
|
864
|
+
decryptedZip
|
|
865
|
+
);
|
|
866
|
+
zipToExtract = decryptedZip;
|
|
867
|
+
}
|
|
868
|
+
|
|
696
869
|
extractedDirectory = new File(
|
|
697
870
|
getContext().getCacheDir(),
|
|
698
871
|
"otakit-extract-" + System.currentTimeMillis()
|
|
@@ -701,7 +874,7 @@ public class UpdaterPlugin extends Plugin {
|
|
|
701
874
|
throw new IllegalStateException("Cannot create temporary extraction directory");
|
|
702
875
|
}
|
|
703
876
|
|
|
704
|
-
zipUtils.extractSecurely(
|
|
877
|
+
zipUtils.extractSecurely(zipToExtract, extractedDirectory);
|
|
705
878
|
File bundleRoot = resolveBundleRoot(extractedDirectory);
|
|
706
879
|
|
|
707
880
|
String bundleId = buildBundleId(version, releaseId, expectedSha256);
|
|
@@ -726,6 +899,9 @@ public class UpdaterPlugin extends Plugin {
|
|
|
726
899
|
coordinator.cleanupBundles(cleanupBundleIds);
|
|
727
900
|
|
|
728
901
|
sendDeviceEvent("downloaded", version, runtimeVersion, channel, releaseId, null);
|
|
902
|
+
JSObject stagedData = new JSObject();
|
|
903
|
+
stagedData.put("bundle", info.toJSObject());
|
|
904
|
+
emitEvent("updateStaged", stagedData);
|
|
729
905
|
return info;
|
|
730
906
|
} catch (Exception e) {
|
|
731
907
|
sendDeviceEvent(
|
|
@@ -736,12 +912,20 @@ public class UpdaterPlugin extends Plugin {
|
|
|
736
912
|
releaseId,
|
|
737
913
|
e.getMessage()
|
|
738
914
|
);
|
|
915
|
+
emitEvent(
|
|
916
|
+
"downloadFailed",
|
|
917
|
+
failureEventData(version, runtimeVersion, channel, releaseId, failureReason(e))
|
|
918
|
+
);
|
|
739
919
|
throw e;
|
|
740
920
|
} finally {
|
|
741
921
|
if (downloadedZip != null && downloadedZip.exists()) {
|
|
742
922
|
//noinspection ResultOfMethodCallIgnored
|
|
743
923
|
downloadedZip.delete();
|
|
744
924
|
}
|
|
925
|
+
if (decryptedZip != null && decryptedZip.exists()) {
|
|
926
|
+
//noinspection ResultOfMethodCallIgnored
|
|
927
|
+
decryptedZip.delete();
|
|
928
|
+
}
|
|
745
929
|
if (extractedDirectory != null && extractedDirectory.exists()) {
|
|
746
930
|
try {
|
|
747
931
|
deleteRecursively(extractedDirectory);
|
|
@@ -856,6 +1040,18 @@ public class UpdaterPlugin extends Plugin {
|
|
|
856
1040
|
coordinator.cleanupBundles(preparation.cleanupBundleIds);
|
|
857
1041
|
if (preparation.eventPayload != null) {
|
|
858
1042
|
sendDeviceEvent(preparation.eventPayload);
|
|
1043
|
+
emitEvent(
|
|
1044
|
+
"rollback",
|
|
1045
|
+
failureEventData(
|
|
1046
|
+
preparation.eventPayload.bundleVersion != null
|
|
1047
|
+
? preparation.eventPayload.bundleVersion
|
|
1048
|
+
: "",
|
|
1049
|
+
preparation.eventPayload.runtimeVersion,
|
|
1050
|
+
preparation.eventPayload.channel,
|
|
1051
|
+
preparation.eventPayload.releaseId,
|
|
1052
|
+
reason
|
|
1053
|
+
)
|
|
1054
|
+
);
|
|
859
1055
|
}
|
|
860
1056
|
|
|
861
1057
|
try {
|
|
@@ -928,13 +1124,17 @@ public class UpdaterPlugin extends Plugin {
|
|
|
928
1124
|
private JSObject manifestToJSObject(ManifestClient.LatestManifest latest) {
|
|
929
1125
|
JSObject object = new JSObject();
|
|
930
1126
|
object.put("version", latest.version);
|
|
931
|
-
|
|
1127
|
+
if (latest.url != null) {
|
|
1128
|
+
object.put("url", latest.url);
|
|
1129
|
+
}
|
|
932
1130
|
object.put("sha256", latest.sha256);
|
|
933
1131
|
object.put("size", latest.size);
|
|
1132
|
+
object.put("strategy", latest.strategy);
|
|
934
1133
|
if (latest.runtimeVersion != null) {
|
|
935
1134
|
object.put("runtimeVersion", latest.runtimeVersion);
|
|
936
1135
|
}
|
|
937
1136
|
object.put("releaseId", latest.releaseId);
|
|
1137
|
+
object.put("forceImmediate", latest.forceImmediate);
|
|
938
1138
|
return object;
|
|
939
1139
|
}
|
|
940
1140
|
|
|
@@ -942,6 +1142,14 @@ public class UpdaterPlugin extends Plugin {
|
|
|
942
1142
|
ManifestClient.LatestManifest latest,
|
|
943
1143
|
String targetChannel
|
|
944
1144
|
) throws Exception {
|
|
1145
|
+
if ("deltas".equals(latest.strategy)) {
|
|
1146
|
+
return assembleAndStage(latest, targetChannel);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
if (latest.url == null) {
|
|
1150
|
+
throw new IllegalStateException("Invalid download URL from manifest");
|
|
1151
|
+
}
|
|
1152
|
+
|
|
945
1153
|
return downloadAndStage(
|
|
946
1154
|
new URL(latest.url),
|
|
947
1155
|
latest.version,
|
|
@@ -949,10 +1157,179 @@ public class UpdaterPlugin extends Plugin {
|
|
|
949
1157
|
latest.size,
|
|
950
1158
|
latest.runtimeVersion,
|
|
951
1159
|
targetChannel,
|
|
952
|
-
latest.releaseId
|
|
1160
|
+
latest.releaseId,
|
|
1161
|
+
latest.encryption
|
|
953
1162
|
);
|
|
954
1163
|
}
|
|
955
1164
|
|
|
1165
|
+
private static final String BUNDLE_FILE_LIST_NAME = "otakit_files.json";
|
|
1166
|
+
|
|
1167
|
+
/**
|
|
1168
|
+
* Deltas strategy: fill content-cache misses and assemble the bundle from
|
|
1169
|
+
* the cache, then hand it to the same staging path the zip flow uses.
|
|
1170
|
+
*/
|
|
1171
|
+
private BundleInfo assembleAndStage(ManifestClient.LatestManifest manifest, String targetChannel)
|
|
1172
|
+
throws Exception {
|
|
1173
|
+
// Same conservative disk-space guard as the zip path.
|
|
1174
|
+
if (manifest.size > 0) {
|
|
1175
|
+
long requiredSpace = (long) (manifest.size * 2.5);
|
|
1176
|
+
if (getFreeDiskSpace() < requiredSpace) {
|
|
1177
|
+
sendDeviceEvent(
|
|
1178
|
+
"download_error",
|
|
1179
|
+
manifest.version,
|
|
1180
|
+
manifest.runtimeVersion,
|
|
1181
|
+
targetChannel,
|
|
1182
|
+
manifest.releaseId,
|
|
1183
|
+
"insufficient_disk_space"
|
|
1184
|
+
);
|
|
1185
|
+
emitEvent(
|
|
1186
|
+
"downloadFailed",
|
|
1187
|
+
failureEventData(
|
|
1188
|
+
manifest.version,
|
|
1189
|
+
manifest.runtimeVersion,
|
|
1190
|
+
targetChannel,
|
|
1191
|
+
manifest.releaseId,
|
|
1192
|
+
"insufficient_disk_space"
|
|
1193
|
+
)
|
|
1194
|
+
);
|
|
1195
|
+
throw new IllegalStateException("Insufficient disk space");
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
File assembleDirectory = new File(
|
|
1200
|
+
getContext().getCacheDir(),
|
|
1201
|
+
"otakit-assemble-" + System.currentTimeMillis()
|
|
1202
|
+
);
|
|
1203
|
+
|
|
1204
|
+
try {
|
|
1205
|
+
if (manifest.files == null || manifest.files.isEmpty()) {
|
|
1206
|
+
throw new IllegalStateException("Delta manifest is missing its file list");
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
DeltaAssembler assembler = new DeltaAssembler(
|
|
1210
|
+
store.getFilesCacheDirectory(),
|
|
1211
|
+
allowInsecureUrls
|
|
1212
|
+
);
|
|
1213
|
+
assembler.validate(manifest.files, manifest.sha256);
|
|
1214
|
+
assembler.seedFromBuiltinIfNeeded(getContext(), BUILTIN_ASSET_PATH, store.getNativeBuild());
|
|
1215
|
+
assembler.assemble(manifest.files, assembleDirectory, getContext());
|
|
1216
|
+
|
|
1217
|
+
String bundleId = buildBundleId(manifest.version, manifest.releaseId, manifest.sha256);
|
|
1218
|
+
File destination = coordinator.bundleDirectory(bundleId);
|
|
1219
|
+
if (destination.exists()) {
|
|
1220
|
+
deleteRecursively(destination);
|
|
1221
|
+
}
|
|
1222
|
+
moveDirectory(assembleDirectory, destination);
|
|
1223
|
+
|
|
1224
|
+
// Record this bundle's content hashes for cache pruning.
|
|
1225
|
+
try {
|
|
1226
|
+
org.json.JSONArray hashes = new org.json.JSONArray();
|
|
1227
|
+
for (ManifestClient.ManifestFileEntry entry : manifest.files) {
|
|
1228
|
+
hashes.put(entry.sha256.toLowerCase());
|
|
1229
|
+
}
|
|
1230
|
+
try (
|
|
1231
|
+
FileOutputStream output = new FileOutputStream(
|
|
1232
|
+
new File(destination, BUNDLE_FILE_LIST_NAME)
|
|
1233
|
+
)
|
|
1234
|
+
) {
|
|
1235
|
+
output.write(hashes.toString().getBytes(StandardCharsets.UTF_8));
|
|
1236
|
+
}
|
|
1237
|
+
} catch (Exception ignored) {}
|
|
1238
|
+
|
|
1239
|
+
BundleInfo info = new BundleInfo(
|
|
1240
|
+
bundleId,
|
|
1241
|
+
manifest.version,
|
|
1242
|
+
manifest.runtimeVersion,
|
|
1243
|
+
BundleStatus.PENDING,
|
|
1244
|
+
System.currentTimeMillis(),
|
|
1245
|
+
manifest.sha256,
|
|
1246
|
+
destination.getAbsolutePath(),
|
|
1247
|
+
targetChannel,
|
|
1248
|
+
manifest.releaseId
|
|
1249
|
+
);
|
|
1250
|
+
java.util.List<String> cleanupBundleIds = coordinator.stageDownloadedBundle(info);
|
|
1251
|
+
coordinator.cleanupBundles(cleanupBundleIds);
|
|
1252
|
+
|
|
1253
|
+
pruneDeltaCache(assembler);
|
|
1254
|
+
|
|
1255
|
+
sendDeviceEvent(
|
|
1256
|
+
"downloaded",
|
|
1257
|
+
manifest.version,
|
|
1258
|
+
manifest.runtimeVersion,
|
|
1259
|
+
targetChannel,
|
|
1260
|
+
manifest.releaseId,
|
|
1261
|
+
null
|
|
1262
|
+
);
|
|
1263
|
+
JSObject stagedData = new JSObject();
|
|
1264
|
+
stagedData.put("bundle", info.toJSObject());
|
|
1265
|
+
emitEvent("updateStaged", stagedData);
|
|
1266
|
+
return info;
|
|
1267
|
+
} catch (Exception e) {
|
|
1268
|
+
sendDeviceEvent(
|
|
1269
|
+
"download_error",
|
|
1270
|
+
manifest.version,
|
|
1271
|
+
manifest.runtimeVersion,
|
|
1272
|
+
targetChannel,
|
|
1273
|
+
manifest.releaseId,
|
|
1274
|
+
e.getMessage()
|
|
1275
|
+
);
|
|
1276
|
+
emitEvent(
|
|
1277
|
+
"downloadFailed",
|
|
1278
|
+
failureEventData(
|
|
1279
|
+
manifest.version,
|
|
1280
|
+
manifest.runtimeVersion,
|
|
1281
|
+
targetChannel,
|
|
1282
|
+
manifest.releaseId,
|
|
1283
|
+
failureReason(e)
|
|
1284
|
+
)
|
|
1285
|
+
);
|
|
1286
|
+
throw e;
|
|
1287
|
+
} finally {
|
|
1288
|
+
if (assembleDirectory.exists()) {
|
|
1289
|
+
try {
|
|
1290
|
+
deleteRecursively(assembleDirectory);
|
|
1291
|
+
} catch (Exception ignored) {}
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
/** Keep only cache entries referenced by live bundles (plus the builtin seed). */
|
|
1297
|
+
private void pruneDeltaCache(DeltaAssembler assembler) {
|
|
1298
|
+
java.util.Set<String> referenced = new java.util.HashSet<>();
|
|
1299
|
+
String[] liveBundleIds = new String[] {
|
|
1300
|
+
store.getCurrentBundleId(),
|
|
1301
|
+
store.getFallbackBundleId(),
|
|
1302
|
+
store.getStagedBundleId(),
|
|
1303
|
+
};
|
|
1304
|
+
for (String bundleId : liveBundleIds) {
|
|
1305
|
+
if (bundleId == null) {
|
|
1306
|
+
continue;
|
|
1307
|
+
}
|
|
1308
|
+
File listFile = new File(store.bundleDirectory(bundleId), BUNDLE_FILE_LIST_NAME);
|
|
1309
|
+
if (!listFile.exists()) {
|
|
1310
|
+
continue;
|
|
1311
|
+
}
|
|
1312
|
+
try (FileInputStream input = new FileInputStream(listFile)) {
|
|
1313
|
+
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
|
|
1314
|
+
byte[] buffer = new byte[8192];
|
|
1315
|
+
int read;
|
|
1316
|
+
while ((read = input.read(buffer)) > 0) {
|
|
1317
|
+
out.write(buffer, 0, read);
|
|
1318
|
+
}
|
|
1319
|
+
org.json.JSONArray hashes = new org.json.JSONArray(
|
|
1320
|
+
new String(out.toByteArray(), StandardCharsets.UTF_8)
|
|
1321
|
+
);
|
|
1322
|
+
for (int index = 0; index < hashes.length(); index++) {
|
|
1323
|
+
String hash = hashes.optString(index, null);
|
|
1324
|
+
if (hash != null) {
|
|
1325
|
+
referenced.add(hash.toLowerCase());
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
} catch (Exception ignored) {}
|
|
1329
|
+
}
|
|
1330
|
+
assembler.pruneCache(referenced);
|
|
1331
|
+
}
|
|
1332
|
+
|
|
956
1333
|
private void moveDirectory(File source, File destination) throws Exception {
|
|
957
1334
|
if (source.renameTo(destination)) {
|
|
958
1335
|
return;
|
|
@@ -1092,7 +1469,67 @@ public class UpdaterPlugin extends Plugin {
|
|
|
1092
1469
|
|
|
1093
1470
|
private String resolveTargetChannel(String channel) {
|
|
1094
1471
|
String resolved = trimToNull(channel);
|
|
1095
|
-
|
|
1472
|
+
if (resolved != null) {
|
|
1473
|
+
return resolved;
|
|
1474
|
+
}
|
|
1475
|
+
String override = store.getOverrideChannel();
|
|
1476
|
+
if (override != null) {
|
|
1477
|
+
return override;
|
|
1478
|
+
}
|
|
1479
|
+
return this.channel;
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
/**
|
|
1483
|
+
* Emit a JS lifecycle event. notifyListeners marshals to the bridge
|
|
1484
|
+
* safely from any thread, so no manual dispatch is needed.
|
|
1485
|
+
*/
|
|
1486
|
+
private void emitEvent(String name, JSObject data) {
|
|
1487
|
+
notifyListeners(name, data);
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
private JSObject failureEventData(
|
|
1491
|
+
String version,
|
|
1492
|
+
String runtimeVersion,
|
|
1493
|
+
String channel,
|
|
1494
|
+
String releaseId,
|
|
1495
|
+
String reason
|
|
1496
|
+
) {
|
|
1497
|
+
JSObject data = new JSObject();
|
|
1498
|
+
data.put("version", version);
|
|
1499
|
+
data.put("reason", reason);
|
|
1500
|
+
if (trimToNull(runtimeVersion) != null) {
|
|
1501
|
+
data.put("runtimeVersion", runtimeVersion.trim());
|
|
1502
|
+
}
|
|
1503
|
+
if (trimToNull(channel) != null) {
|
|
1504
|
+
data.put("channel", channel.trim());
|
|
1505
|
+
}
|
|
1506
|
+
if (trimToNull(releaseId) != null) {
|
|
1507
|
+
data.put("releaseId", releaseId.trim());
|
|
1508
|
+
}
|
|
1509
|
+
return data;
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
/** Map a native error to a stable event reason string. */
|
|
1513
|
+
private String failureReason(Exception e) {
|
|
1514
|
+
String message = e.getMessage() != null ? e.getMessage().toLowerCase() : "";
|
|
1515
|
+
if (message.contains("hash mismatch")) {
|
|
1516
|
+
return "hash_mismatch";
|
|
1517
|
+
}
|
|
1518
|
+
if (message.contains("disk space")) {
|
|
1519
|
+
return "insufficient_disk_space";
|
|
1520
|
+
}
|
|
1521
|
+
if (message.contains("index.html")) {
|
|
1522
|
+
return "invalid_bundle";
|
|
1523
|
+
}
|
|
1524
|
+
// ZipUtils throws SecurityException for guard violations and prefixes
|
|
1525
|
+
// its messages with "Zip ". Don't match ".zip" anywhere in the message:
|
|
1526
|
+
// network failures often carry the temp file path (…/otakit-….zip).
|
|
1527
|
+
if (
|
|
1528
|
+
e instanceof SecurityException || message.startsWith("zip ") || message.contains("extract")
|
|
1529
|
+
) {
|
|
1530
|
+
return "extract_failed";
|
|
1531
|
+
}
|
|
1532
|
+
return "download_failed";
|
|
1096
1533
|
}
|
|
1097
1534
|
|
|
1098
1535
|
private void sendDeviceEvent(
|