@otakit/capacitor-updater 1.2.0 → 2.0.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 CHANGED
@@ -4,7 +4,7 @@ Capacitor OTA updater plugin for OtaKit.
4
4
 
5
5
  ## What it does
6
6
 
7
- - checks the manifest endpoint for a newer bundle
7
+ - fetches the latest manifest for its release lane from the CDN
8
8
  - downloads and verifies OTA bundles
9
9
  - stages updates safely
10
10
  - activates them on the next launch, next resume, or immediately
@@ -14,6 +14,11 @@ Capacitor OTA updater plugin for OtaKit.
14
14
  - requires `notifyAppReady()` as the success handshake
15
15
  - rolls back automatically if the new bundle does not prove healthy
16
16
 
17
+ OtaKit publishes signed static manifests into object storage behind a CDN. The
18
+ plugin fetches the manifest for its `appId + channel + runtimeVersion` lane,
19
+ verifies it, compares it against the current and staged bundle locally, and
20
+ only downloads when the manifest actually points at something newer.
21
+
17
22
  For normal app code, the main public methods are:
18
23
 
19
24
  ```ts
@@ -57,12 +62,13 @@ plugins: {
57
62
 
58
63
  Advanced overrides for self-hosting or custom trust only:
59
64
 
60
- - `serverUrl`
65
+ - `cdnUrl` for manifest and bundle delivery
66
+ - `serverUrl` for stats and control-plane requests
61
67
  - `manifestKeys`
62
68
  - `allowInsecureUrls`
63
69
 
64
- Hosted OtaKit already points at `https://otakit.app/api/v1` and already trusts
65
- the managed manifest signing keys.
70
+ Hosted OtaKit already points at the managed control-plane API and CDN and already
71
+ trusts the managed manifest signing keys.
66
72
 
67
73
  ## Channels vs runtimeVersion
68
74
 
@@ -84,11 +90,12 @@ When `runtimeVersion` is set:
84
90
 
85
91
  The plugin does not just download from a URL and trust the result.
86
92
 
87
- 1. it fetches a manifest from the server
93
+ 1. it fetches the latest manifest from the CDN for its app + channel + runtimeVersion lane
88
94
  2. it verifies the manifest signature when manifest keys are configured
89
- 3. it downloads the bundle zip
90
- 4. it verifies the zip against the manifest `sha256`
91
- 5. it stages and activates the bundle
95
+ 3. it compares that manifest against the current and staged bundle already on the device
96
+ 4. if the manifest is newer, it downloads the bundle zip
97
+ 5. it verifies the zip against the manifest `sha256`
98
+ 6. it stages and activates the bundle
92
99
 
93
100
  In the hosted path, managed signing keys are already built in.
94
101
 
@@ -165,8 +172,9 @@ await OtaKit.notifyAppReady();
165
172
  ```
166
173
 
167
174
  The plugin handles checking, downloading, activation, and rollback based on
168
- `updateMode`. It checks on cold start and every time the app comes back from
169
- the background (throttled by `checkInterval`).
175
+ `updateMode`. In `next-launch` and `next-resume`, it checks on cold start and
176
+ every time the app comes back from the background, throttled by `checkInterval`.
177
+ `immediate` bypasses that throttle.
170
178
 
171
179
  For most apps, this is the entire runtime integration.
172
180
 
@@ -195,10 +203,9 @@ only after explicit user confirmation.
195
203
 
196
204
  ## Throttle
197
205
 
198
- All server checks are rate-limited by `checkInterval` (default 10 min).
199
- This applies to automatic resume checks and to manual `check()` / `download()`
200
- calls. Within the interval, calls return the staged bundle if one exists, or
201
- null.
206
+ `checkInterval` (default 10 min) only applies to automatic checks in
207
+ `next-launch` and `next-resume`. Manual `check()` / `download()` calls are
208
+ always live, and `immediate` mode ignores the interval entirely.
202
209
 
203
210
  ## Retention and deletion
204
211
 
@@ -7,18 +7,17 @@ import java.util.Locale;
7
7
 
8
8
  final class HostedManifestKeys {
9
9
 
10
- private static final String MANAGED_SERVER_URL = "https://www.otakit.app/api/v1";
10
+ private static final String MANAGED_CDN_URL = "https://cdn.otakit.app";
11
11
 
12
12
  private HostedManifestKeys() {}
13
13
 
14
- static boolean matchesManagedServer(String updateUrl) {
15
- if (updateUrl == null) {
14
+ static boolean matchesManagedManifestUrl(String cdnUrl) {
15
+ if (cdnUrl == null) {
16
16
  return false;
17
17
  }
18
18
 
19
- String normalized = updateUrl.trim().replaceAll("/+$", "").toLowerCase(Locale.ROOT);
20
- return normalized.equals(MANAGED_SERVER_URL)
21
- || normalized.equals("https://otakit.app/api/v1");
19
+ String normalized = cdnUrl.trim().replaceAll("/+$", "").toLowerCase(Locale.ROOT);
20
+ return normalized.equals(MANAGED_CDN_URL) || normalized.equals("https://www.otakit.app");
22
21
  }
23
22
 
24
23
  static List<ManifestVerifier.KeyEntry> createDefaultKeys() {
@@ -9,6 +9,9 @@ import org.json.JSONObject;
9
9
 
10
10
  final class ManifestClient {
11
11
 
12
+ private static final String BASE_CHANNEL_KEY = "__base__";
13
+ private static final String DEFAULT_RUNTIME_KEY = "__default__";
14
+
12
15
  static final class ManifestSignature {
13
16
 
14
17
  final String kid;
@@ -63,41 +66,33 @@ final class ManifestClient {
63
66
  }
64
67
 
65
68
  static LatestManifest fetchLatest(
66
- String updateUrl,
69
+ String cdnUrl,
67
70
  String appId,
68
71
  String channel,
69
- String currentVersion,
70
- String currentReleaseId,
71
72
  String runtimeVersion,
72
- String platform,
73
73
  boolean allowInsecureUrls,
74
74
  java.util.List<ManifestVerifier.KeyEntry> manifestKeys
75
75
  ) throws Exception {
76
- String base = updateUrl.replaceAll("/+$", "");
77
- URL url = new URL(base + "/manifest");
76
+ String base = cdnUrl.replaceAll("/+$", "");
77
+ String channelKey = channel != null && !channel.trim().isEmpty() ? channel.trim() : BASE_CHANNEL_KEY;
78
+ String runtimeKey =
79
+ runtimeVersion != null && !runtimeVersion.trim().isEmpty()
80
+ ? runtimeVersion.trim()
81
+ : DEFAULT_RUNTIME_KEY;
82
+ URL url = new URL(
83
+ base + "/manifests/" + appId + "/" + channelKey + "/" + runtimeKey + "/manifest.json"
84
+ );
78
85
 
79
86
  requireHTTPS(url, allowInsecureUrls);
80
87
 
81
88
  HttpURLConnection connection = (HttpURLConnection) url.openConnection();
82
89
  try {
83
90
  connection.setRequestMethod("GET");
84
- connection.setRequestProperty("X-App-Id", appId);
85
- connection.setRequestProperty("X-Platform", platform);
86
- if (channel != null && !channel.trim().isEmpty()) {
87
- connection.setRequestProperty("X-Channel", channel);
88
- }
89
- connection.setRequestProperty("X-Current-Version", currentVersion);
90
- if (currentReleaseId != null && !currentReleaseId.trim().isEmpty()) {
91
- connection.setRequestProperty("X-Release-Id", currentReleaseId);
92
- }
93
- if (runtimeVersion != null && !runtimeVersion.trim().isEmpty()) {
94
- connection.setRequestProperty("X-Runtime-Version", runtimeVersion);
95
- }
96
91
  connection.setConnectTimeout(15_000);
97
92
  connection.setReadTimeout(30_000);
98
93
 
99
94
  int status = connection.getResponseCode();
100
- if (status == 204) {
95
+ if (status == 404 || status == 204) {
101
96
  return null;
102
97
  }
103
98
  if (status != 200) {
@@ -125,14 +120,12 @@ final class ManifestClient {
125
120
  }
126
121
 
127
122
  ManifestSignature signature = parseSignature(json.optJSONObject("signature"));
128
- ManifestSignature signatureV2 = parseSignature(json.optJSONObject("signatureV2"));
129
123
 
130
124
  String releaseId = null;
131
125
  if (json.has("releaseId") && !json.isNull("releaseId")) {
132
126
  releaseId = json.getString("releaseId");
133
127
  }
134
128
 
135
- // Validate download URL scheme
136
129
  requireHTTPS(new URL(downloadUrl), allowInsecureUrls);
137
130
 
138
131
  if (manifestKeys == null || manifestKeys.isEmpty()) {
@@ -142,36 +135,23 @@ final class ManifestClient {
142
135
  );
143
136
  }
144
137
 
145
- // Verify manifest signature if signing keys are configured
146
138
  if (manifestKeys != null && !manifestKeys.isEmpty()) {
147
- if (signatureV2 != null) {
148
- ManifestVerifier.verify(
149
- appId,
150
- channel,
151
- platform,
152
- version,
153
- sha256,
154
- size,
155
- responseRuntimeVersion,
156
- signatureV2,
157
- manifestKeys
158
- );
159
- } else if (signature != null) {
160
- ManifestVerifier.verifyLegacy(
161
- appId,
162
- channel,
163
- platform,
164
- version,
165
- sha256,
166
- size,
167
- signature,
168
- manifestKeys
169
- );
170
- } else {
139
+ if (signature == null) {
171
140
  throw new IllegalStateException(
172
141
  "Manifest signature missing but signing keys are configured"
173
142
  );
174
143
  }
144
+
145
+ ManifestVerifier.verify(
146
+ appId,
147
+ channel,
148
+ version,
149
+ sha256,
150
+ size,
151
+ responseRuntimeVersion,
152
+ signature,
153
+ manifestKeys
154
+ );
175
155
  }
176
156
 
177
157
  return new LatestManifest(
@@ -30,7 +30,6 @@ final class ManifestVerifier {
30
30
  static void verify(
31
31
  String appId,
32
32
  String channel,
33
- String platform,
34
33
  String version,
35
34
  String sha256,
36
35
  int size,
@@ -41,7 +40,6 @@ final class ManifestVerifier {
41
40
  String payload = buildCanonicalPayload(
42
41
  appId,
43
42
  channel,
44
- platform,
45
43
  version,
46
44
  sha256,
47
45
  size,
@@ -53,30 +51,6 @@ final class ManifestVerifier {
53
51
  verifyPayload(payload, signature, trustedKeys);
54
52
  }
55
53
 
56
- static void verifyLegacy(
57
- String appId,
58
- String channel,
59
- String platform,
60
- String version,
61
- String sha256,
62
- int size,
63
- ManifestClient.ManifestSignature signature,
64
- List<KeyEntry> trustedKeys
65
- ) throws Exception {
66
- String payload = buildLegacyCanonicalPayload(
67
- appId,
68
- channel,
69
- platform,
70
- version,
71
- sha256,
72
- size,
73
- signature.kid,
74
- signature.iat,
75
- signature.exp
76
- );
77
- verifyPayload(payload, signature, trustedKeys);
78
- }
79
-
80
54
  private static void verifyPayload(
81
55
  String payload,
82
56
  ManifestClient.ManifestSignature signature,
@@ -120,7 +94,6 @@ final class ManifestVerifier {
120
94
  private static String buildCanonicalPayload(
121
95
  String appId,
122
96
  String channel,
123
- String platform,
124
97
  String version,
125
98
  String sha256,
126
99
  int size,
@@ -130,16 +103,13 @@ final class ManifestVerifier {
130
103
  int exp
131
104
  ) {
132
105
  return (
133
- "MANIFEST_V2\n" +
106
+ "MANIFEST\n" +
134
107
  "appId:" +
135
108
  appId +
136
109
  "\n" +
137
110
  "channel:" +
138
111
  (channel != null ? channel : "null") +
139
112
  "\n" +
140
- "platform:" +
141
- platform +
142
- "\n" +
143
113
  "version:" +
144
114
  version +
145
115
  "\n" +
@@ -163,50 +133,6 @@ final class ManifestVerifier {
163
133
  );
164
134
  }
165
135
 
166
- private static String buildLegacyCanonicalPayload(
167
- String appId,
168
- String channel,
169
- String platform,
170
- String version,
171
- String sha256,
172
- int size,
173
- String kid,
174
- int iat,
175
- int exp
176
- ) {
177
- return (
178
- "MANIFEST_V1\n" +
179
- "appId:" +
180
- appId +
181
- "\n" +
182
- "channel:" +
183
- (channel != null ? channel : "null") +
184
- "\n" +
185
- "platform:" +
186
- platform +
187
- "\n" +
188
- "version:" +
189
- version +
190
- "\n" +
191
- "sha256:" +
192
- sha256 +
193
- "\n" +
194
- "size:" +
195
- size +
196
- "\n" +
197
- "minNativeBuild:null" +
198
- "\n" +
199
- "kid:" +
200
- kid +
201
- "\n" +
202
- "iat:" +
203
- iat +
204
- "\n" +
205
- "exp:" +
206
- exp
207
- );
208
- }
209
-
210
136
  private static byte[] base64UrlDecode(String input) {
211
137
  // Convert base64url to standard base64
212
138
  String base64 = input.replace('-', '+').replace('_', '/');
@@ -37,6 +37,7 @@ public class UpdaterPlugin extends Plugin {
37
37
  private boolean allowInsecureUrls = false;
38
38
  private String updateMode = UPDATE_MODE_NEXT_LAUNCH;
39
39
  private String updateUrl;
40
+ private String cdnUrl;
40
41
  private String appId;
41
42
  private String channel;
42
43
  private String runtimeVersion;
@@ -44,6 +45,7 @@ public class UpdaterPlugin extends Plugin {
44
45
  private long checkIntervalMs = 600_000;
45
46
  private final AtomicBoolean checkInProgress = new AtomicBoolean(false);
46
47
  private static final String DEFAULT_UPDATE_URL = "https://www.otakit.app/api/v1";
48
+ private static final String DEFAULT_CDN_URL = "https://cdn.otakit.app";
47
49
  private static final String API_PATH_SUFFIX = "/api/v1";
48
50
  private static final String UPDATE_MODE_MANUAL = "manual";
49
51
  private static final String UPDATE_MODE_NEXT_LAUNCH = "next-launch";
@@ -76,6 +78,11 @@ public class UpdaterPlugin extends Plugin {
76
78
  getConfig().getString("serverUrl"),
77
79
  System.getenv("OTAKIT_SERVER_URL")
78
80
  );
81
+ this.cdnUrl = resolveCdnUrl(
82
+ getConfig().getString("cdnUrl"),
83
+ System.getenv("OTAKIT_CDN_URL"),
84
+ this.updateUrl
85
+ );
79
86
  this.appId = getConfig().getString("appId");
80
87
  this.channel = trimToNull(getConfig().getString("channel"));
81
88
  this.runtimeVersion = trimToNull(getConfig().getString("runtimeVersion"));
@@ -116,7 +123,7 @@ public class UpdaterPlugin extends Plugin {
116
123
  manifestKeys.add(new ManifestVerifier.KeyEntry("_invalid_", new byte[0]));
117
124
  }
118
125
 
119
- if (manifestKeys.isEmpty() && HostedManifestKeys.matchesManagedServer(updateUrl)) {
126
+ if (manifestKeys.isEmpty() && HostedManifestKeys.matchesManagedManifestUrl(cdnUrl)) {
120
127
  manifestKeys.addAll(HostedManifestKeys.createDefaultKeys());
121
128
  }
122
129
 
@@ -174,7 +181,15 @@ public class UpdaterPlugin extends Plugin {
174
181
  }
175
182
  }
176
183
 
177
- if (shouldThrottleCheck()) return;
184
+ if (!UPDATE_MODE_IMMEDIATE.equals(updateMode) && shouldThrottleCheck()) return;
185
+ }
186
+
187
+ if (
188
+ TRIGGER_LAUNCH.equals(trigger) &&
189
+ !UPDATE_MODE_IMMEDIATE.equals(updateMode) &&
190
+ shouldThrottleCheck()
191
+ ) {
192
+ return;
178
193
  }
179
194
 
180
195
  // Acquire in-flight guard (submit-time)
@@ -185,7 +200,6 @@ public class UpdaterPlugin extends Plugin {
185
200
  executor.execute(() -> {
186
201
  try {
187
202
  BundleInfo result = performCheckAndDownload(null, true);
188
- recordCheckTimestamp();
189
203
  if (result != null) {
190
204
  activateStagedBundleForReload();
191
205
  reloadWebView();
@@ -282,8 +296,7 @@ public class UpdaterPlugin extends Plugin {
282
296
 
283
297
  @PluginMethod
284
298
  public void check(PluginCall call) {
285
- // Respect throttle — return staged bundle as a LatestVersion if available, else null
286
- if (shouldThrottleCheck() || !checkInProgress.compareAndSet(false, true)) {
299
+ if (!checkInProgress.compareAndSet(false, true)) {
287
300
  String stagedId = store.getStagedBundleId();
288
301
  if (stagedId != null) {
289
302
  BundleInfo staged = store.getBundle(stagedId);
@@ -311,10 +324,10 @@ public class UpdaterPlugin extends Plugin {
311
324
  executor.execute(() -> {
312
325
  try {
313
326
  ManifestClient.LatestManifest latest = fetchLatest(targetChannel);
314
- // check() does NOT record timestamp — only download() and automatic paths do.
315
- // This keeps check() -> download() working without the throttle blocking download().
316
327
  if (latest == null) {
317
328
  call.resolve((JSObject) null);
329
+ } else if (isCurrentBundleLatest(latest, targetChannel)) {
330
+ call.resolve((JSObject) null);
318
331
  } else {
319
332
  BundleInfo staged = findMatchingStagedBundle(latest, targetChannel);
320
333
  call.resolve(manifestToJSObject(latest, staged != null));
@@ -329,8 +342,7 @@ public class UpdaterPlugin extends Plugin {
329
342
 
330
343
  @PluginMethod
331
344
  public void download(PluginCall call) {
332
- // Respect throttle — return staged info if available, else null
333
- if (shouldThrottleCheck() || !checkInProgress.compareAndSet(false, true)) {
345
+ if (!checkInProgress.compareAndSet(false, true)) {
334
346
  String stagedId = store.getStagedBundleId();
335
347
  if (stagedId != null) {
336
348
  BundleInfo staged = store.getBundle(stagedId);
@@ -345,7 +357,6 @@ public class UpdaterPlugin extends Plugin {
345
357
  executor.execute(() -> {
346
358
  try {
347
359
  BundleInfo bundle = performCheckAndDownload(null, true);
348
- recordCheckTimestamp();
349
360
  if (bundle == null) {
350
361
  call.resolve((JSObject) null);
351
362
  } else {
@@ -359,42 +370,6 @@ public class UpdaterPlugin extends Plugin {
359
370
  });
360
371
  }
361
372
 
362
- @PluginMethod
363
- public void debugCheck(PluginCall call) {
364
- String requestedChannel = call.getString("channel");
365
- String targetChannel = resolveTargetChannel(requestedChannel);
366
- executor.execute(() -> {
367
- try {
368
- ManifestClient.LatestManifest latest = fetchLatest(targetChannel);
369
- if (latest == null) {
370
- call.resolve((JSObject) null);
371
- } else {
372
- BundleInfo staged = findMatchingStagedBundle(latest, targetChannel);
373
- call.resolve(manifestToJSObject(latest, staged != null));
374
- }
375
- } catch (Exception e) {
376
- call.reject("debugCheck failed: " + e.getMessage());
377
- }
378
- });
379
- }
380
-
381
- @PluginMethod
382
- public void debugDownload(PluginCall call) {
383
- String requestedChannel = call.getString("channel");
384
- executor.execute(() -> {
385
- try {
386
- BundleInfo bundle = performCheckAndDownload(requestedChannel, true);
387
- if (bundle == null) {
388
- call.resolve((JSObject) null);
389
- } else {
390
- call.resolve(bundle.toJSObject());
391
- }
392
- } catch (Exception e) {
393
- call.reject("debugDownload failed: " + e.getMessage());
394
- }
395
- });
396
- }
397
-
398
373
  @PluginMethod
399
374
  public void apply(PluginCall call) {
400
375
  String stagedId = store.getStagedBundleId();
@@ -512,16 +487,11 @@ public class UpdaterPlugin extends Plugin {
512
487
  throw new IllegalStateException("Missing appId in plugin config");
513
488
  }
514
489
 
515
- BundleInfo current = store.getCurrentBundle();
516
-
517
490
  return ManifestClient.fetchLatest(
518
- updateUrl,
491
+ cdnUrl,
519
492
  appId,
520
493
  channel,
521
- current.version,
522
- current.releaseId,
523
494
  runtimeVersion,
524
- "android",
525
495
  allowInsecureUrls,
526
496
  manifestKeys
527
497
  );
@@ -543,6 +513,13 @@ public class UpdaterPlugin extends Plugin {
543
513
  );
544
514
  }
545
515
 
516
+ if (isCurrentBundleLatest(latest, targetChannel)) {
517
+ if (emitEvents) {
518
+ notifyListeners("noUpdateAvailable", new JSObject());
519
+ }
520
+ return null;
521
+ }
522
+
546
523
  BundleInfo staged = findMatchingStagedBundle(latest, targetChannel);
547
524
  if (emitEvents) {
548
525
  notifyListeners("updateAvailable", manifestToJSObject(latest, staged != null));
@@ -912,6 +889,54 @@ public class UpdaterPlugin extends Plugin {
912
889
  return object;
913
890
  }
914
891
 
892
+ private boolean isCurrentBundleLatest(
893
+ ManifestClient.LatestManifest latest,
894
+ String targetChannel
895
+ ) {
896
+ return doesBundleMatchLatest(store.getCurrentBundle(), latest, targetChannel);
897
+ }
898
+
899
+ private boolean doesBundleMatchLatest(
900
+ BundleInfo bundle,
901
+ ManifestClient.LatestManifest latest,
902
+ String targetChannel
903
+ ) {
904
+ if (bundle == null) {
905
+ return false;
906
+ }
907
+
908
+ if (!java.util.Objects.equals(trimToNull(bundle.channel), targetChannel)) {
909
+ return false;
910
+ }
911
+
912
+ if (
913
+ !java.util.Objects.equals(
914
+ trimToNull(bundle.runtimeVersion),
915
+ trimToNull(latest.runtimeVersion)
916
+ )
917
+ ) {
918
+ return false;
919
+ }
920
+
921
+ if (
922
+ latest.releaseId != null &&
923
+ bundle.releaseId != null &&
924
+ latest.releaseId.equals(bundle.releaseId)
925
+ ) {
926
+ return true;
927
+ }
928
+
929
+ if (
930
+ latest.sha256 != null &&
931
+ bundle.sha256 != null &&
932
+ latest.sha256.equals(bundle.sha256)
933
+ ) {
934
+ return true;
935
+ }
936
+
937
+ return latest.version != null && latest.version.equals(bundle.version);
938
+ }
939
+
915
940
  private BundleInfo findMatchingStagedBundle(
916
941
  ManifestClient.LatestManifest latest,
917
942
  String targetChannel
@@ -935,24 +960,7 @@ public class UpdaterPlugin extends Plugin {
935
960
  return null;
936
961
  }
937
962
 
938
- if (
939
- latest.releaseId != null &&
940
- staged.releaseId != null &&
941
- latest.releaseId.equals(staged.releaseId)
942
- ) {
943
- return staged;
944
- }
945
-
946
- if (
947
- latest.version != null &&
948
- latest.version.equals(staged.version) &&
949
- latest.sha256 != null &&
950
- latest.sha256.equals(staged.sha256)
951
- ) {
952
- return staged;
953
- }
954
-
955
- return null;
963
+ return doesBundleMatchLatest(staged, latest, targetChannel) ? staged : null;
956
964
  }
957
965
 
958
966
  private void moveDirectory(File source, File destination) throws Exception {
@@ -1049,6 +1057,28 @@ public class UpdaterPlugin extends Plugin {
1049
1057
  return DEFAULT_UPDATE_URL;
1050
1058
  }
1051
1059
 
1060
+ private String resolveCdnUrl(String configured, String env, String resolvedUpdateUrl) {
1061
+ String configuredValue = trimToNull(configured);
1062
+ if (configuredValue != null) {
1063
+ return normalizeCdnUrl(configuredValue);
1064
+ }
1065
+
1066
+ String envValue = trimToNull(env);
1067
+ if (envValue != null) {
1068
+ return normalizeCdnUrl(envValue);
1069
+ }
1070
+
1071
+ if (
1072
+ resolvedUpdateUrl != null &&
1073
+ !DEFAULT_UPDATE_URL.equalsIgnoreCase(resolvedUpdateUrl) &&
1074
+ resolvedUpdateUrl.toLowerCase(java.util.Locale.ROOT).endsWith(API_PATH_SUFFIX)
1075
+ ) {
1076
+ return resolvedUpdateUrl.substring(0, resolvedUpdateUrl.length() - API_PATH_SUFFIX.length());
1077
+ }
1078
+
1079
+ return DEFAULT_CDN_URL;
1080
+ }
1081
+
1052
1082
  private String normalizeUpdateUrl(String raw) {
1053
1083
  String trimmed = raw.trim().replaceAll("/+$", "");
1054
1084
  if (trimmed.toLowerCase(java.util.Locale.ROOT).endsWith(API_PATH_SUFFIX)) {
@@ -1057,6 +1087,10 @@ public class UpdaterPlugin extends Plugin {
1057
1087
  return trimmed + API_PATH_SUFFIX;
1058
1088
  }
1059
1089
 
1090
+ private String normalizeCdnUrl(String raw) {
1091
+ return raw.trim().replaceAll("/+$", "");
1092
+ }
1093
+
1060
1094
  private String trimToNull(String value) {
1061
1095
  if (value == null) {
1062
1096
  return null;
@@ -78,34 +78,22 @@ export interface OtaKitConfig {
78
78
  updateMode?: OtaKitUpdateMode;
79
79
  /**
80
80
  * Minimum milliseconds between automatic update checks.
81
- * Applies to both resume-triggered and manual check()/download() calls.
82
- * Debug APIs bypass this throttle. Defaults to 600000 (10 min).
81
+ * Applies only to automatic checks in `next-launch` and `next-resume`.
82
+ * Manual APIs and `immediate` mode bypass this throttle. Defaults to 600000 (10 min).
83
83
  */
84
84
  checkInterval?: number;
85
85
  /** Milliseconds to wait for notifyAppReady(). Defaults to 10000. */
86
86
  appReadyTimeout?: number;
87
- /** Custom API base URL for self-hosted or custom servers. */
87
+ /** Custom API base URL for stats and other control-plane requests. */
88
88
  serverUrl?: string;
89
+ /** Custom CDN base URL for manifest and bundle delivery. */
90
+ cdnUrl?: string;
89
91
  /** Custom manifest verification keys for self-hosted or custom trust. */
90
92
  manifestKeys?: OtaKitManifestKey[];
91
93
  /** Allow HTTP only for localhost development. Defaults to false. */
92
94
  allowInsecureUrls?: boolean;
93
95
  }
94
96
  export interface OtaKitDebugApi {
95
- /**
96
- * Check the server for a newer version without downloading it.
97
- * You can optionally pass { channel } for a one-off debug override.
98
- */
99
- check(options?: {
100
- channel?: string;
101
- }): Promise<LatestVersion | null>;
102
- /**
103
- * Check the server and download the latest bundle if available.
104
- * Ensures the latest bundle is staged for later activation.
105
- */
106
- download(options?: {
107
- channel?: string;
108
- }): Promise<BundleInfo | null>;
109
97
  /**
110
98
  * Reset to the builtin bundle and reload the WebView.
111
99
  *
@@ -206,12 +194,6 @@ export interface OtaKitBridgePlugin {
206
194
  apply(): Promise<void>;
207
195
  notifyAppReady(): Promise<void>;
208
196
  debugGetState(): Promise<OtaKitDebugState>;
209
- debugCheck(options?: {
210
- channel?: string;
211
- }): Promise<LatestVersion | null>;
212
- debugDownload(options?: {
213
- channel?: string;
214
- }): Promise<BundleInfo | null>;
215
197
  debugReset(): Promise<void>;
216
198
  debugListBundles(): Promise<BundleListResult>;
217
199
  debugDeleteBundle(options: {