@otakit/capacitor-updater 1.0.0 → 1.2.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
@@ -7,8 +7,10 @@ Capacitor OTA updater plugin for OtaKit.
7
7
  - checks the manifest endpoint for a newer bundle
8
8
  - downloads and verifies OTA bundles
9
9
  - stages updates safely
10
- - activates them on the next launch or immediately during startup
10
+ - activates them on the next launch, next resume, or immediately
11
+ - checks for updates on cold start and app resume (configurable interval)
11
12
  - also supports fully manual update prompts when the app wants control
13
+ - supports optional `runtimeVersion` lanes for native compatibility boundaries
12
14
  - requires `notifyAppReady()` as the success handshake
13
15
  - rolls back automatically if the new bundle does not prove healthy
14
16
 
@@ -45,6 +47,8 @@ plugins: {
45
47
  appReadyTimeout: 10000,
46
48
  // Optional:
47
49
  // channel: "staging",
50
+ // runtimeVersion: "2026.04",
51
+ // updateMode: "next-resume",
48
52
  // updateMode: "manual",
49
53
  // updateMode: "immediate",
50
54
  }
@@ -60,6 +64,22 @@ Advanced overrides for self-hosting or custom trust only:
60
64
  Hosted OtaKit already points at `https://otakit.app/api/v1` and already trusts
61
65
  the managed manifest signing keys.
62
66
 
67
+ ## Channels vs runtimeVersion
68
+
69
+ - `channel` answers "who should get this rollout?"
70
+ - `runtimeVersion` answers "which native app shell can safely run this bundle?"
71
+
72
+ Use channels for rollout tracks such as `beta`, `staging`, or `production`.
73
+
74
+ Use `runtimeVersion` when a new store build creates a new compatibility boundary and you do not
75
+ want devices on that new native shell to keep receiving older OTA bundles.
76
+
77
+ When `runtimeVersion` is set:
78
+
79
+ - the plugin requests bundle updates only for that runtime lane
80
+ - bundle uploads inherit the same runtime value automatically through the CLI
81
+ - releases stay simple: publish the bundle, and it naturally stays inside its own runtime lane
82
+
63
83
  ## Trust model
64
84
 
65
85
  The plugin does not just download from a URL and trust the result.
@@ -74,14 +94,32 @@ In the hosted path, managed signing keys are already built in.
74
94
 
75
95
  ## Update modes
76
96
 
97
+ All automatic modes check for updates on **cold start** (always) and **app
98
+ resume**.
99
+
100
+ ### Production modes
101
+
102
+ - `next-launch` (default)
103
+ check and download in the background on cold start and resume.
104
+ activate the staged bundle only on the next cold start.
105
+ zero disruption during a session — the user never sees a surprise reload.
106
+
107
+ - `next-resume` (more eager)
108
+ check and download in the background on cold start and resume.
109
+ activate the staged bundle on the next resume or cold start.
110
+
111
+ ### Manual mode
112
+
77
113
  - `manual`
78
- no automatic startup check and no automatic staged activation
79
- - `next-launch`
80
- automatic startup check, download in the background, activate on the next cold launch
114
+ no automatic checks, no automatic staged activation.
115
+ the app integration drives everything via `check()`, `download()`, `apply()`, or `update()`.
116
+
117
+ ### Dev/debug mode
118
+
81
119
  - `immediate`
82
- automatic startup check, then download and activate during startup
120
+ checks, downloads, and activates in one shot as soon as possible on cold start and resume (the user may briefly see the previous version before a reload).
121
+ primarily for development and testing — not recommended for production.
83
122
 
84
- The default is `next-launch`.
85
123
 
86
124
  ## Runtime model
87
125
 
@@ -127,7 +165,8 @@ await OtaKit.notifyAppReady();
127
165
  ```
128
166
 
129
167
  The plugin handles checking, downloading, activation, and rollback based on
130
- `updateMode`.
168
+ `updateMode`. It checks on cold start and every time the app comes back from
169
+ the background (throttled by `checkInterval`).
131
170
 
132
171
  For most apps, this is the entire runtime integration.
133
172
 
@@ -154,6 +193,13 @@ await OtaKit.apply();
154
193
  Use the split flow when the app wants to download in the background and switch
155
194
  only after explicit user confirmation.
156
195
 
196
+ ## Throttle
197
+
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.
202
+
157
203
  ## Retention and deletion
158
204
 
159
205
  - the builtin, current, fallback, and staged bundles are protected
@@ -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
@@ -48,10 +50,15 @@ final class BundleStore {
48
50
  return builtinVersion;
49
51
  }
50
52
 
53
+ SharedPreferences getPrefs() {
54
+ return prefs;
55
+ }
56
+
51
57
  BundleInfo builtinBundle() {
52
58
  return new BundleInfo(
53
59
  "builtin",
54
60
  builtinVersion,
61
+ appRuntimeVersion,
55
62
  BundleStatus.BUILTIN,
56
63
  null,
57
64
  null,
@@ -225,7 +232,7 @@ final class BundleStore {
225
232
  }
226
233
  }
227
234
 
228
- synchronized JSArray listDownloadedBundles() {
235
+ synchronized List<BundleInfo> listDownloadedBundleInfos() {
229
236
  List<BundleInfo> result = new ArrayList<>();
230
237
  File[] entries = bundlesDirectory.listFiles();
231
238
  if (entries != null) {
@@ -237,7 +244,11 @@ final class BundleStore {
237
244
  }
238
245
  }
239
246
  }
247
+ return result;
248
+ }
240
249
 
250
+ synchronized JSArray listDownloadedBundles() {
251
+ List<BundleInfo> result = listDownloadedBundleInfos();
241
252
  JSArray array = new JSArray();
242
253
  for (BundleInfo bundle : result) {
243
254
  array.put(bundle.toJSObject());
@@ -30,26 +30,23 @@ final class ManifestClient {
30
30
  final String url;
31
31
  final String sha256;
32
32
  final int size;
33
- final Integer minNativeBuild;
33
+ final String runtimeVersion;
34
34
  final String releaseId;
35
- final ManifestSignature signature;
36
35
 
37
36
  LatestManifest(
38
37
  String version,
39
38
  String url,
40
39
  String sha256,
41
40
  int size,
42
- Integer minNativeBuild,
43
- String releaseId,
44
- ManifestSignature signature
41
+ String runtimeVersion,
42
+ String releaseId
45
43
  ) {
46
44
  this.version = version;
47
45
  this.url = url;
48
46
  this.sha256 = sha256;
49
47
  this.size = size;
50
- this.minNativeBuild = minNativeBuild;
48
+ this.runtimeVersion = runtimeVersion;
51
49
  this.releaseId = releaseId;
52
- this.signature = signature;
53
50
  }
54
51
  }
55
52
 
@@ -71,7 +68,7 @@ final class ManifestClient {
71
68
  String channel,
72
69
  String currentVersion,
73
70
  String currentReleaseId,
74
- String nativeBuild,
71
+ String runtimeVersion,
75
72
  String platform,
76
73
  boolean allowInsecureUrls,
77
74
  java.util.List<ManifestVerifier.KeyEntry> manifestKeys
@@ -93,7 +90,9 @@ final class ManifestClient {
93
90
  if (currentReleaseId != null && !currentReleaseId.trim().isEmpty()) {
94
91
  connection.setRequestProperty("X-Release-Id", currentReleaseId);
95
92
  }
96
- connection.setRequestProperty("X-Native-Build", nativeBuild);
93
+ if (runtimeVersion != null && !runtimeVersion.trim().isEmpty()) {
94
+ connection.setRequestProperty("X-Runtime-Version", runtimeVersion);
95
+ }
97
96
  connection.setConnectTimeout(15_000);
98
97
  connection.setReadTimeout(30_000);
99
98
 
@@ -118,31 +117,15 @@ final class ManifestClient {
118
117
  String sha256 = json.getString("sha256");
119
118
  int size = json.getInt("size");
120
119
 
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
- }
120
+ String responseRuntimeVersion = json.has("runtimeVersion") && !json.isNull("runtimeVersion")
121
+ ? json.getString("runtimeVersion").trim()
122
+ : null;
123
+ if (responseRuntimeVersion != null && responseRuntimeVersion.isEmpty()) {
124
+ responseRuntimeVersion = null;
132
125
  }
133
126
 
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
- }
127
+ ManifestSignature signature = parseSignature(json.optJSONObject("signature"));
128
+ ManifestSignature signatureV2 = parseSignature(json.optJSONObject("signatureV2"));
146
129
 
147
130
  String releaseId = null;
148
131
  if (json.has("releaseId") && !json.isNull("releaseId")) {
@@ -161,22 +144,34 @@ final class ManifestClient {
161
144
 
162
145
  // Verify manifest signature if signing keys are configured
163
146
  if (manifestKeys != null && !manifestKeys.isEmpty()) {
164
- if (signature == null) {
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 {
165
171
  throw new IllegalStateException(
166
172
  "Manifest signature missing but signing keys are configured"
167
173
  );
168
174
  }
169
- ManifestVerifier.verify(
170
- appId,
171
- channel,
172
- platform,
173
- version,
174
- sha256,
175
- size,
176
- minNativeBuild,
177
- signature,
178
- manifestKeys
179
- );
180
175
  }
181
176
 
182
177
  return new LatestManifest(
@@ -184,9 +179,8 @@ final class ManifestClient {
184
179
  downloadUrl,
185
180
  sha256,
186
181
  size,
187
- minNativeBuild,
188
- releaseId,
189
- signature
182
+ responseRuntimeVersion,
183
+ releaseId
190
184
  );
191
185
  } finally {
192
186
  connection.disconnect();
@@ -206,4 +200,19 @@ final class ManifestClient {
206
200
  return new String(out.toByteArray(), StandardCharsets.UTF_8);
207
201
  }
208
202
  }
203
+
204
+ private static ManifestSignature parseSignature(JSONObject sigObj) {
205
+ if (sigObj == null) {
206
+ return null;
207
+ }
208
+ if (!sigObj.has("kid") || !sigObj.has("sig") || !sigObj.has("iat") || !sigObj.has("exp")) {
209
+ return null;
210
+ }
211
+ return new ManifestSignature(
212
+ sigObj.getString("kid"),
213
+ sigObj.getString("sig"),
214
+ sigObj.getInt("iat"),
215
+ sigObj.getInt("exp")
216
+ );
217
+ }
209
218
  }
@@ -34,7 +34,51 @@ final class ManifestVerifier {
34
34
  String version,
35
35
  String sha256,
36
36
  int size,
37
- Integer minNativeBuild,
37
+ String runtimeVersion,
38
+ ManifestClient.ManifestSignature signature,
39
+ List<KeyEntry> trustedKeys
40
+ ) throws Exception {
41
+ String payload = buildCanonicalPayload(
42
+ appId,
43
+ channel,
44
+ platform,
45
+ version,
46
+ sha256,
47
+ size,
48
+ runtimeVersion,
49
+ signature.kid,
50
+ signature.iat,
51
+ signature.exp
52
+ );
53
+ verifyPayload(payload, signature, trustedKeys);
54
+ }
55
+
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
+ private static void verifyPayload(
81
+ String payload,
38
82
  ManifestClient.ManifestSignature signature,
39
83
  List<KeyEntry> trustedKeys
40
84
  ) throws Exception {
@@ -56,20 +100,6 @@ final class ManifestVerifier {
56
100
  throw new IllegalStateException("Unknown signing key ID: " + signature.kid);
57
101
  }
58
102
 
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
103
  // Decode base64url signature
74
104
  byte[] sigBytes = base64UrlDecode(signature.sig);
75
105
 
@@ -94,12 +124,56 @@ final class ManifestVerifier {
94
124
  String version,
95
125
  String sha256,
96
126
  int size,
97
- Integer minNativeBuild,
127
+ String runtimeVersion,
128
+ String kid,
129
+ int iat,
130
+ int exp
131
+ ) {
132
+ return (
133
+ "MANIFEST_V2\n" +
134
+ "appId:" +
135
+ appId +
136
+ "\n" +
137
+ "channel:" +
138
+ (channel != null ? channel : "null") +
139
+ "\n" +
140
+ "platform:" +
141
+ platform +
142
+ "\n" +
143
+ "version:" +
144
+ version +
145
+ "\n" +
146
+ "sha256:" +
147
+ sha256 +
148
+ "\n" +
149
+ "size:" +
150
+ size +
151
+ "\n" +
152
+ "runtimeVersion:" +
153
+ (runtimeVersion != null ? runtimeVersion : "null") +
154
+ "\n" +
155
+ "kid:" +
156
+ kid +
157
+ "\n" +
158
+ "iat:" +
159
+ iat +
160
+ "\n" +
161
+ "exp:" +
162
+ exp
163
+ );
164
+ }
165
+
166
+ private static String buildLegacyCanonicalPayload(
167
+ String appId,
168
+ String channel,
169
+ String platform,
170
+ String version,
171
+ String sha256,
172
+ int size,
98
173
  String kid,
99
174
  int iat,
100
175
  int exp
101
176
  ) {
102
- String minBuildStr = minNativeBuild != null ? String.valueOf(minNativeBuild) : "null";
103
177
  return (
104
178
  "MANIFEST_V1\n" +
105
179
  "appId:" +
@@ -120,8 +194,7 @@ final class ManifestVerifier {
120
194
  "size:" +
121
195
  size +
122
196
  "\n" +
123
- "minNativeBuild:" +
124
- minBuildStr +
197
+ "minNativeBuild:null" +
125
198
  "\n" +
126
199
  "kid:" +
127
200
  kid +