@otakit/capacitor-updater 1.1.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,15 +4,21 @@ 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
11
11
  - checks for updates on cold start and app resume (configurable interval)
12
12
  - also supports fully manual update prompts when the app wants control
13
+ - supports optional `runtimeVersion` lanes for native compatibility boundaries
13
14
  - requires `notifyAppReady()` as the success handshake
14
15
  - rolls back automatically if the new bundle does not prove healthy
15
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
+
16
22
  For normal app code, the main public methods are:
17
23
 
18
24
  ```ts
@@ -46,6 +52,7 @@ plugins: {
46
52
  appReadyTimeout: 10000,
47
53
  // Optional:
48
54
  // channel: "staging",
55
+ // runtimeVersion: "2026.04",
49
56
  // updateMode: "next-resume",
50
57
  // updateMode: "manual",
51
58
  // updateMode: "immediate",
@@ -55,22 +62,40 @@ plugins: {
55
62
 
56
63
  Advanced overrides for self-hosting or custom trust only:
57
64
 
58
- - `serverUrl`
65
+ - `cdnUrl` for manifest and bundle delivery
66
+ - `serverUrl` for stats and control-plane requests
59
67
  - `manifestKeys`
60
68
  - `allowInsecureUrls`
61
69
 
62
- Hosted OtaKit already points at `https://otakit.app/api/v1` and already trusts
63
- 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.
72
+
73
+ ## Channels vs runtimeVersion
74
+
75
+ - `channel` answers "who should get this rollout?"
76
+ - `runtimeVersion` answers "which native app shell can safely run this bundle?"
77
+
78
+ Use channels for rollout tracks such as `beta`, `staging`, or `production`.
79
+
80
+ Use `runtimeVersion` when a new store build creates a new compatibility boundary and you do not
81
+ want devices on that new native shell to keep receiving older OTA bundles.
82
+
83
+ When `runtimeVersion` is set:
84
+
85
+ - the plugin requests bundle updates only for that runtime lane
86
+ - bundle uploads inherit the same runtime value automatically through the CLI
87
+ - releases stay simple: publish the bundle, and it naturally stays inside its own runtime lane
64
88
 
65
89
  ## Trust model
66
90
 
67
91
  The plugin does not just download from a URL and trust the result.
68
92
 
69
- 1. it fetches a manifest from the server
93
+ 1. it fetches the latest manifest from the CDN for its app + channel + runtimeVersion lane
70
94
  2. it verifies the manifest signature when manifest keys are configured
71
- 3. it downloads the bundle zip
72
- 4. it verifies the zip against the manifest `sha256`
73
- 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
74
99
 
75
100
  In the hosted path, managed signing keys are already built in.
76
101
 
@@ -147,8 +172,9 @@ await OtaKit.notifyAppReady();
147
172
  ```
148
173
 
149
174
  The plugin handles checking, downloading, activation, and rollback based on
150
- `updateMode`. It checks on cold start and every time the app comes back from
151
- 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.
152
178
 
153
179
  For most apps, this is the entire runtime integration.
154
180
 
@@ -177,10 +203,9 @@ only after explicit user confirmation.
177
203
 
178
204
  ## Throttle
179
205
 
180
- All server checks are rate-limited by `checkInterval` (default 10 min).
181
- This applies to automatic resume checks and to manual `check()` / `download()`
182
- calls. Within the interval, calls return the staged bundle if one exists, or
183
- 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.
184
209
 
185
210
  ## Retention and deletion
186
211
 
@@ -8,6 +8,7 @@ class BundleInfo {
8
8
 
9
9
  final String id;
10
10
  final String version;
11
+ final String runtimeVersion;
11
12
  final BundleStatus status;
12
13
  final Long downloadedAt;
13
14
  final String sha256;
@@ -18,6 +19,7 @@ class BundleInfo {
18
19
  BundleInfo(
19
20
  String id,
20
21
  String version,
22
+ String runtimeVersion,
21
23
  BundleStatus status,
22
24
  Long downloadedAt,
23
25
  String sha256,
@@ -27,6 +29,7 @@ class BundleInfo {
27
29
  ) {
28
30
  this.id = id;
29
31
  this.version = version;
32
+ this.runtimeVersion = runtimeVersion;
30
33
  this.status = status;
31
34
  this.downloadedAt = downloadedAt;
32
35
  this.sha256 = sha256;
@@ -43,6 +46,9 @@ class BundleInfo {
43
46
  JSObject object = new JSObject();
44
47
  object.put("id", id);
45
48
  object.put("version", version);
49
+ if (runtimeVersion != null) {
50
+ object.put("runtimeVersion", runtimeVersion);
51
+ }
46
52
  object.put("status", status.value());
47
53
  if (downloadedAt != null) {
48
54
  object.put("downloadedAt", DateUtils.toIsoString(downloadedAt));
@@ -63,6 +69,9 @@ class BundleInfo {
63
69
  JSONObject object = new JSONObject();
64
70
  object.put("id", id);
65
71
  object.put("version", version);
72
+ if (runtimeVersion != null) {
73
+ object.put("runtimeVersion", runtimeVersion);
74
+ }
66
75
  object.put("status", status.value());
67
76
  if (downloadedAt != null) {
68
77
  object.put("downloadedAt", downloadedAt);
@@ -85,16 +94,39 @@ class BundleInfo {
85
94
  static BundleInfo fromJSONObject(JSONObject object) {
86
95
  String id = object.optString("id", "builtin");
87
96
  String version = object.optString("version", "0.0.0");
97
+ String runtimeVersion = object.has("runtimeVersion")
98
+ ? object.optString("runtimeVersion", null)
99
+ : null;
88
100
  BundleStatus status = BundleStatus.from(object.optString("status", "pending"));
89
101
  Long downloadedAt = object.has("downloadedAt") ? object.optLong("downloadedAt") : null;
90
102
  String sha256 = object.has("sha256") ? object.optString("sha256", null) : null;
91
103
  String channel = object.has("channel") ? object.optString("channel", null) : null;
92
104
  String releaseId = object.has("releaseId") ? object.optString("releaseId", null) : null;
93
105
  String path = object.has("path") ? object.optString("path", null) : null;
94
- return new BundleInfo(id, version, status, downloadedAt, sha256, path, channel, releaseId);
106
+ return new BundleInfo(
107
+ id,
108
+ version,
109
+ runtimeVersion,
110
+ status,
111
+ downloadedAt,
112
+ sha256,
113
+ path,
114
+ channel,
115
+ releaseId
116
+ );
95
117
  }
96
118
 
97
119
  BundleInfo withStatus(BundleStatus nextStatus) {
98
- return new BundleInfo(id, version, nextStatus, downloadedAt, sha256, path, channel, releaseId);
120
+ return new BundleInfo(
121
+ id,
122
+ version,
123
+ runtimeVersion,
124
+ nextStatus,
125
+ downloadedAt,
126
+ sha256,
127
+ path,
128
+ channel,
129
+ releaseId
130
+ );
99
131
  }
100
132
  }
@@ -27,12 +27,14 @@ final class BundleStore {
27
27
  private final File bundlesDirectory;
28
28
  private final String builtinVersion;
29
29
  private final String nativeBuild;
30
+ private final String appRuntimeVersion;
30
31
 
31
- BundleStore(Context context, String builtinVersion, String nativeBuild) {
32
+ BundleStore(Context context, String builtinVersion, String nativeBuild, String appRuntimeVersion) {
32
33
  this.context = context.getApplicationContext();
33
34
  this.prefs = this.context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
34
35
  this.builtinVersion = builtinVersion;
35
36
  this.nativeBuild = nativeBuild;
37
+ this.appRuntimeVersion = appRuntimeVersion;
36
38
  this.bundlesDirectory = new File(this.context.getFilesDir(), "otakit_bundles");
37
39
  if (!bundlesDirectory.exists()) {
38
40
  //noinspection ResultOfMethodCallIgnored
@@ -56,6 +58,7 @@ final class BundleStore {
56
58
  return new BundleInfo(
57
59
  "builtin",
58
60
  builtinVersion,
61
+ appRuntimeVersion,
59
62
  BundleStatus.BUILTIN,
60
63
  null,
61
64
  null,
@@ -229,7 +232,7 @@ final class BundleStore {
229
232
  }
230
233
  }
231
234
 
232
- synchronized JSArray listDownloadedBundles() {
235
+ synchronized List<BundleInfo> listDownloadedBundleInfos() {
233
236
  List<BundleInfo> result = new ArrayList<>();
234
237
  File[] entries = bundlesDirectory.listFiles();
235
238
  if (entries != null) {
@@ -241,7 +244,11 @@ final class BundleStore {
241
244
  }
242
245
  }
243
246
  }
247
+ return result;
248
+ }
244
249
 
250
+ synchronized JSArray listDownloadedBundles() {
251
+ List<BundleInfo> result = listDownloadedBundleInfos();
245
252
  JSArray array = new JSArray();
246
253
  for (BundleInfo bundle : result) {
247
254
  array.put(bundle.toJSObject());
@@ -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;
@@ -30,26 +33,23 @@ final class ManifestClient {
30
33
  final String url;
31
34
  final String sha256;
32
35
  final int size;
33
- final Integer minNativeBuild;
36
+ final String runtimeVersion;
34
37
  final String releaseId;
35
- final ManifestSignature signature;
36
38
 
37
39
  LatestManifest(
38
40
  String version,
39
41
  String url,
40
42
  String sha256,
41
43
  int size,
42
- Integer minNativeBuild,
43
- String releaseId,
44
- ManifestSignature signature
44
+ String runtimeVersion,
45
+ String releaseId
45
46
  ) {
46
47
  this.version = version;
47
48
  this.url = url;
48
49
  this.sha256 = sha256;
49
50
  this.size = size;
50
- this.minNativeBuild = minNativeBuild;
51
+ this.runtimeVersion = runtimeVersion;
51
52
  this.releaseId = releaseId;
52
- this.signature = signature;
53
53
  }
54
54
  }
55
55
 
@@ -66,39 +66,33 @@ final class ManifestClient {
66
66
  }
67
67
 
68
68
  static LatestManifest fetchLatest(
69
- String updateUrl,
69
+ String cdnUrl,
70
70
  String appId,
71
71
  String channel,
72
- String currentVersion,
73
- String currentReleaseId,
74
- String nativeBuild,
75
- String platform,
72
+ String runtimeVersion,
76
73
  boolean allowInsecureUrls,
77
74
  java.util.List<ManifestVerifier.KeyEntry> manifestKeys
78
75
  ) throws Exception {
79
- String base = updateUrl.replaceAll("/+$", "");
80
- 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
+ );
81
85
 
82
86
  requireHTTPS(url, allowInsecureUrls);
83
87
 
84
88
  HttpURLConnection connection = (HttpURLConnection) url.openConnection();
85
89
  try {
86
90
  connection.setRequestMethod("GET");
87
- connection.setRequestProperty("X-App-Id", appId);
88
- connection.setRequestProperty("X-Platform", platform);
89
- if (channel != null && !channel.trim().isEmpty()) {
90
- connection.setRequestProperty("X-Channel", channel);
91
- }
92
- connection.setRequestProperty("X-Current-Version", currentVersion);
93
- if (currentReleaseId != null && !currentReleaseId.trim().isEmpty()) {
94
- connection.setRequestProperty("X-Release-Id", currentReleaseId);
95
- }
96
- connection.setRequestProperty("X-Native-Build", nativeBuild);
97
91
  connection.setConnectTimeout(15_000);
98
92
  connection.setReadTimeout(30_000);
99
93
 
100
94
  int status = connection.getResponseCode();
101
- if (status == 204) {
95
+ if (status == 404 || status == 204) {
102
96
  return null;
103
97
  }
104
98
  if (status != 200) {
@@ -118,38 +112,20 @@ final class ManifestClient {
118
112
  String sha256 = json.getString("sha256");
119
113
  int size = json.getInt("size");
120
114
 
121
- Integer minNativeBuild = null;
122
- if (json.has("minNativeBuild") && !json.isNull("minNativeBuild")) {
123
- Object raw = json.get("minNativeBuild");
124
- if (raw instanceof Number) {
125
- minNativeBuild = ((Number) raw).intValue();
126
- } else if (raw instanceof String) {
127
- String value = ((String) raw).trim();
128
- if (!value.isEmpty()) {
129
- minNativeBuild = Integer.parseInt(value);
130
- }
131
- }
115
+ String responseRuntimeVersion = json.has("runtimeVersion") && !json.isNull("runtimeVersion")
116
+ ? json.getString("runtimeVersion").trim()
117
+ : null;
118
+ if (responseRuntimeVersion != null && responseRuntimeVersion.isEmpty()) {
119
+ responseRuntimeVersion = null;
132
120
  }
133
121
 
134
- ManifestSignature signature = null;
135
- if (json.has("signature") && !json.isNull("signature")) {
136
- JSONObject sigObj = json.getJSONObject("signature");
137
- if (sigObj.has("kid") && sigObj.has("sig") && sigObj.has("iat") && sigObj.has("exp")) {
138
- signature = new ManifestSignature(
139
- sigObj.getString("kid"),
140
- sigObj.getString("sig"),
141
- sigObj.getInt("iat"),
142
- sigObj.getInt("exp")
143
- );
144
- }
145
- }
122
+ ManifestSignature signature = parseSignature(json.optJSONObject("signature"));
146
123
 
147
124
  String releaseId = null;
148
125
  if (json.has("releaseId") && !json.isNull("releaseId")) {
149
126
  releaseId = json.getString("releaseId");
150
127
  }
151
128
 
152
- // Validate download URL scheme
153
129
  requireHTTPS(new URL(downloadUrl), allowInsecureUrls);
154
130
 
155
131
  if (manifestKeys == null || manifestKeys.isEmpty()) {
@@ -159,21 +135,20 @@ final class ManifestClient {
159
135
  );
160
136
  }
161
137
 
162
- // Verify manifest signature if signing keys are configured
163
138
  if (manifestKeys != null && !manifestKeys.isEmpty()) {
164
139
  if (signature == null) {
165
140
  throw new IllegalStateException(
166
141
  "Manifest signature missing but signing keys are configured"
167
142
  );
168
143
  }
144
+
169
145
  ManifestVerifier.verify(
170
146
  appId,
171
147
  channel,
172
- platform,
173
148
  version,
174
149
  sha256,
175
150
  size,
176
- minNativeBuild,
151
+ responseRuntimeVersion,
177
152
  signature,
178
153
  manifestKeys
179
154
  );
@@ -184,9 +159,8 @@ final class ManifestClient {
184
159
  downloadUrl,
185
160
  sha256,
186
161
  size,
187
- minNativeBuild,
188
- releaseId,
189
- signature
162
+ responseRuntimeVersion,
163
+ releaseId
190
164
  );
191
165
  } finally {
192
166
  connection.disconnect();
@@ -206,4 +180,19 @@ final class ManifestClient {
206
180
  return new String(out.toByteArray(), StandardCharsets.UTF_8);
207
181
  }
208
182
  }
183
+
184
+ private static ManifestSignature parseSignature(JSONObject sigObj) {
185
+ if (sigObj == null) {
186
+ return null;
187
+ }
188
+ if (!sigObj.has("kid") || !sigObj.has("sig") || !sigObj.has("iat") || !sigObj.has("exp")) {
189
+ return null;
190
+ }
191
+ return new ManifestSignature(
192
+ sigObj.getString("kid"),
193
+ sigObj.getString("sig"),
194
+ sigObj.getInt("iat"),
195
+ sigObj.getInt("exp")
196
+ );
197
+ }
209
198
  }
@@ -30,11 +30,29 @@ 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,
37
- Integer minNativeBuild,
36
+ String runtimeVersion,
37
+ ManifestClient.ManifestSignature signature,
38
+ List<KeyEntry> trustedKeys
39
+ ) throws Exception {
40
+ String payload = buildCanonicalPayload(
41
+ appId,
42
+ channel,
43
+ version,
44
+ sha256,
45
+ size,
46
+ runtimeVersion,
47
+ signature.kid,
48
+ signature.iat,
49
+ signature.exp
50
+ );
51
+ verifyPayload(payload, signature, trustedKeys);
52
+ }
53
+
54
+ private static void verifyPayload(
55
+ String payload,
38
56
  ManifestClient.ManifestSignature signature,
39
57
  List<KeyEntry> trustedKeys
40
58
  ) throws Exception {
@@ -56,20 +74,6 @@ final class ManifestVerifier {
56
74
  throw new IllegalStateException("Unknown signing key ID: " + signature.kid);
57
75
  }
58
76
 
59
- // Build canonical payload (must match server exactly)
60
- String payload = buildCanonicalPayload(
61
- appId,
62
- channel,
63
- platform,
64
- version,
65
- sha256,
66
- size,
67
- minNativeBuild,
68
- signature.kid,
69
- signature.iat,
70
- signature.exp
71
- );
72
-
73
77
  // Decode base64url signature
74
78
  byte[] sigBytes = base64UrlDecode(signature.sig);
75
79
 
@@ -90,27 +94,22 @@ final class ManifestVerifier {
90
94
  private static String buildCanonicalPayload(
91
95
  String appId,
92
96
  String channel,
93
- String platform,
94
97
  String version,
95
98
  String sha256,
96
99
  int size,
97
- Integer minNativeBuild,
100
+ String runtimeVersion,
98
101
  String kid,
99
102
  int iat,
100
103
  int exp
101
104
  ) {
102
- String minBuildStr = minNativeBuild != null ? String.valueOf(minNativeBuild) : "null";
103
105
  return (
104
- "MANIFEST_V1\n" +
106
+ "MANIFEST\n" +
105
107
  "appId:" +
106
108
  appId +
107
109
  "\n" +
108
110
  "channel:" +
109
111
  (channel != null ? channel : "null") +
110
112
  "\n" +
111
- "platform:" +
112
- platform +
113
- "\n" +
114
113
  "version:" +
115
114
  version +
116
115
  "\n" +
@@ -120,8 +119,8 @@ final class ManifestVerifier {
120
119
  "size:" +
121
120
  size +
122
121
  "\n" +
123
- "minNativeBuild:" +
124
- minBuildStr +
122
+ "runtimeVersion:" +
123
+ (runtimeVersion != null ? runtimeVersion : "null") +
125
124
  "\n" +
126
125
  "kid:" +
127
126
  kid +