@otakit/capacitor-updater 2.1.1 → 2.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/Package.swift ADDED
@@ -0,0 +1,27 @@
1
+ // swift-tools-version: 5.9
2
+ import PackageDescription
3
+
4
+ let package = Package(
5
+ name: "OtakitCapacitorUpdater",
6
+ platforms: [.iOS(.v15)],
7
+ products: [
8
+ .library(
9
+ name: "OtakitCapacitorUpdater",
10
+ targets: ["UpdaterPlugin"])
11
+ ],
12
+ dependencies: [
13
+ .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", "7.0.0"..<"9.0.0"),
14
+ .package(url: "https://github.com/weichsel/ZIPFoundation.git", .upToNextMajor(from: "0.9.0"))
15
+ ],
16
+ targets: [
17
+ .target(
18
+ name: "UpdaterPlugin",
19
+ dependencies: [
20
+ .product(name: "Capacitor", package: "capacitor-swift-pm"),
21
+ .product(name: "Cordova", package: "capacitor-swift-pm"),
22
+ .product(name: "ZIPFoundation", package: "ZIPFoundation")
23
+ ],
24
+ path: "ios/Sources/UpdaterPlugin",
25
+ exclude: ["UpdaterPlugin.m"])
26
+ ]
27
+ )
package/README.md CHANGED
@@ -98,6 +98,25 @@ resumePolicy = 'off';
98
98
  runtimePolicy = 'off';
99
99
  ```
100
100
 
101
+ ## Force-immediate releases
102
+
103
+ A release (or revert) can be marked **force immediate** in the dashboard or
104
+ with `otakit release --force-immediate`. The flag is baked into the signed
105
+ manifest; when a device's automatic flow sees it, `shadow` and `apply-staged`
106
+ events escalate to the `immediate` behavior for that release: download, apply,
107
+ and reload on that event.
108
+
109
+ Bounds to keep in mind:
110
+
111
+ - It is "immediate on the next event/check", not push — delivery is bounded by
112
+ lifecycle events and `checkInterval` (static CDN, no server→device channel).
113
+ - `off` policies never fetch a manifest, so they never see the flag. `off`
114
+ stays the device-owned kill switch, and the manual APIs are unchanged.
115
+ - Trial and rollback still apply: a forced bundle that never calls
116
+ `notifyAppReady()` rolls back like any other.
117
+ - It reloads the app under the user, possibly mid-task. Use it for broken
118
+ releases, not routine rollouts.
119
+
101
120
  ## Check interval
102
121
 
103
122
  `checkInterval` defaults to 10 minutes and only applies to background resume
@@ -210,8 +229,42 @@ of splitting it into separate `download()` and `apply()` calls.
210
229
  - they do not resolve back into the old JS context
211
230
  - call `notifyAppReady()` from normal startup after the reloaded app boots
212
231
 
213
- There is no listener/event API in this refactor. If an app later needs a
214
- smaller reactive surface, that can be added intentionally.
232
+ ## Events
233
+
234
+ The plugin emits lifecycle events alongside the pull APIs:
235
+
236
+ ```ts
237
+ OtaKit.addListener('updateAvailable', (latest) => {}); // newer bundle found, before download
238
+ OtaKit.addListener('updateStaged', ({ bundle }) => {}); // downloaded + verified + staged
239
+ OtaKit.addListener('updateApplied', ({ bundle }) => {}); // new bundle confirmed healthy
240
+ OtaKit.addListener('downloadFailed', (failure) => {}); // non-terminal download/verify error
241
+ OtaKit.addListener('rollback', (failure) => {}); // applied bundle reverted (notify timeout)
242
+ OtaKit.removeAllListeners();
243
+ ```
244
+
245
+ Events fire only while the app process is alive — there is no buffering or
246
+ replay. Reconcile on startup:
247
+
248
+ - a bundle staged in a previous session: `getState().staged`
249
+ - a startup rollback (app restarted before `notifyAppReady()`): it happens
250
+ before any JS runs, so it never reaches a listener — check
251
+ `getLastFailure()`
252
+
253
+ `apply()` reloads the WebView and destroys the JS context, so `updateApplied`
254
+ fires in the _reloaded_ bundle. Attach `updateApplied`/`rollback` listeners
255
+ early in app startup, not in the restart click handler.
256
+
257
+ Download and stage are atomic in this plugin — there is a single
258
+ `updateStaged` event, not separate "downloaded" and "staged" events.
259
+
260
+ The headline pattern — background download via `shadow` policies, prompt to
261
+ restart:
262
+
263
+ ```ts
264
+ const state = await OtaKit.getState();
265
+ if (state.staged) showRestartPrompt(state.staged);
266
+ OtaKit.addListener('updateStaged', ({ bundle }) => showRestartPrompt(bundle));
267
+ ```
215
268
 
216
269
  ## Example manual flow
217
270
 
@@ -238,6 +291,42 @@ After the app reloads and starts again, call:
238
291
  await OtaKit.notifyAppReady();
239
292
  ```
240
293
 
294
+ ## Runtime channel switching
295
+
296
+ `setChannel()` overrides the configured channel at runtime — for example a
297
+ "Join beta" toggle in settings — without rebuilding the app:
298
+
299
+ ```ts
300
+ // Opt into the beta channel; takes effect on the next check/download cycle.
301
+ await OtaKit.setChannel({ channel: 'beta' });
302
+
303
+ // Back to the channel from capacitor.config.ts (or the base channel).
304
+ await OtaKit.setChannel({ channel: null });
305
+
306
+ const { channel, source } = await OtaKit.getChannel();
307
+ // source is 'override' after setChannel(), 'config' otherwise
308
+ ```
309
+
310
+ The override is persisted across launches and slots into channel resolution
311
+ as: explicit call argument → persisted override → config `channel` → base.
312
+ `setChannel()` itself never checks, downloads, or reloads anything — call
313
+ `OtaKit.download()` / `OtaKit.update()` afterwards if you want the switch to
314
+ take effect immediately.
315
+
316
+ Channel names must match `^[A-Za-z0-9._-]{1,64}$`, must not contain `..`, and
317
+ must not be the reserved names `base` or `default` (matching server-side
318
+ validation). Invalid names reject without persisting.
319
+
320
+ Limitations to be aware of:
321
+
322
+ - **Channels are public CDN paths.** A channel name is not a secret and this
323
+ cannot enforce private distribution — anyone who guesses the name can fetch
324
+ that channel's manifest.
325
+ - **The backend is not aware of the switch.** There is no server-side record
326
+ of which device is on which channel; the switch is purely client-side.
327
+ - **The channel must exist and have a release.** Switching to a channel with
328
+ no published manifest yields `no_update` until something is released there.
329
+
241
330
  ## Compatibility lanes
242
331
 
243
332
  - `channel` answers "who should get this rollout?"
@@ -257,8 +346,57 @@ The plugin does not just download arbitrary zips from a URL.
257
346
  2. it verifies the manifest signature when keys are configured
258
347
  3. it compares the manifest with current, staged, and last-failed local state
259
348
  4. it downloads only when a newer usable bundle exists
260
- 5. it verifies the zip against the manifest `sha256`
261
- 6. it stages and later applies the bundle
349
+ 5. it verifies the downloaded object against the manifest `sha256`
350
+ 6. if the bundle is encrypted, it decrypts it (AES-256-GCM; the tag authenticates the plaintext)
351
+ 7. it stages and later applies the bundle
352
+
353
+ ## Bundle encryption (optional)
354
+
355
+ Bundles can be end-to-end encrypted so that object storage and the CDN only
356
+ ever hold ciphertext. Generate a key with `otakit generate-encryption-key`,
357
+ put it in CI as `OTAKIT_ENCRYPTION_KEY` (the CLI then encrypts uploads), and
358
+ ship the same key in the app:
359
+
360
+ ```ts
361
+ plugins: {
362
+ OtaKit: {
363
+ // Inject from an env var at build time — never commit the key.
364
+ bundleKeys: [{ kid: process.env.OTAKIT_ENCRYPTION_KID!, key: process.env.OTAKIT_ENCRYPTION_KEY! }],
365
+ },
366
+ },
367
+ ```
368
+
369
+ Per upload the CLI generates a random data key (DEK), encrypts the zip with
370
+ AES-256-GCM, and wraps the DEK under your app key; the wrapped DEK and nonces
371
+ travel in the signed manifest. The server never sees the key and cannot
372
+ decrypt your bundles.
373
+
374
+ **Threat model — confidentiality, not DRM.** The decryption key ships inside
375
+ the app binary, so a determined attacker who reverse-engineers the app can
376
+ extract it — true of every client-side encryption scheme. What it protects
377
+ against: leaked or guessed CDN URLs, bucket misconfiguration, and casual
378
+ inspection of stored objects. Update _forgery_ is independently blocked by
379
+ the ES256 manifest signature — which is also why encryption should only be
380
+ used with manifest signing enabled (hosted default): the encryption
381
+ parameters are covered by the signature.
382
+
383
+ Operational rules:
384
+
385
+ - **Rollout order:** ship a store build containing `bundleKeys` **before**
386
+ releasing encrypted bundles. Installed apps without the key cannot decrypt
387
+ and will stay on their current version (they keep running; the failure is
388
+ recorded as `download_error` telemetry and, if you listen for it, the
389
+ `downloadFailed` event — it does not appear in `getLastFailure()`, which
390
+ reports rollbacks only).
391
+ - **Rotation:** `bundleKeys` is an array — ship old + new keys together
392
+ during a transition, then drop the old key in a later store build.
393
+ - **Key custody:** back the key up. Losing it means installed apps cannot
394
+ receive updates until a store build ships a new key.
395
+ - Decryption failures are never fatal: they behave like download failures —
396
+ the running bundle is untouched and nothing unverified is ever applied.
397
+ - **Memory:** decryption buffers the bundle in memory (roughly 2–3× the
398
+ bundle size at peak). Keep encrypted bundles comfortably under ~100 MB,
399
+ especially for low-end Android devices.
262
400
 
263
401
  ## Source areas
264
402
 
@@ -0,0 +1,81 @@
1
+ package com.otakit.updater;
2
+
3
+ import java.io.ByteArrayOutputStream;
4
+ import java.io.File;
5
+ import java.io.FileInputStream;
6
+ import java.io.FileOutputStream;
7
+ import java.io.InputStream;
8
+ import javax.crypto.Cipher;
9
+ import javax.crypto.spec.GCMParameterSpec;
10
+ import javax.crypto.spec.SecretKeySpec;
11
+
12
+ /**
13
+ * AES-256-GCM bundle decryption.
14
+ *
15
+ * The CLI encrypts the zip with a random per-bundle DEK and wraps the DEK
16
+ * under the app KEK; both ciphertexts carry the 16-byte GCM tag appended
17
+ * (Java's AES/GCM/NoPadding expects exactly that layout).
18
+ */
19
+ final class BundleCrypto {
20
+
21
+ private static final int GCM_TAG_BITS = 128;
22
+ private static final int KEY_LENGTH = 32;
23
+ private static final int TAG_LENGTH = 16;
24
+
25
+ private BundleCrypto() {}
26
+
27
+ static byte[] unwrapDek(byte[] kek, byte[] wrapNonce, byte[] wrappedDek) throws Exception {
28
+ if (kek == null || kek.length != KEY_LENGTH) {
29
+ throw new IllegalStateException("invalid bundle encryption parameter: bundle key length");
30
+ }
31
+ if (wrappedDek == null || wrappedDek.length != KEY_LENGTH + TAG_LENGTH) {
32
+ throw new IllegalStateException("invalid bundle encryption parameter: wrappedDek");
33
+ }
34
+ Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
35
+ cipher.init(
36
+ Cipher.DECRYPT_MODE,
37
+ new SecretKeySpec(kek, "AES"),
38
+ new GCMParameterSpec(GCM_TAG_BITS, wrapNonce)
39
+ );
40
+ byte[] dek = cipher.doFinal(wrappedDek);
41
+ if (dek.length != KEY_LENGTH) {
42
+ throw new IllegalStateException("bundle decryption failed: unexpected DEK length");
43
+ }
44
+ return dek;
45
+ }
46
+
47
+ static void decryptFile(byte[] dek, byte[] nonce, File input, File output) throws Exception {
48
+ Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
49
+ cipher.init(
50
+ Cipher.DECRYPT_MODE,
51
+ new SecretKeySpec(dek, "AES"),
52
+ new GCMParameterSpec(GCM_TAG_BITS, nonce)
53
+ );
54
+
55
+ // Explicit doFinal (not CipherOutputStream) so a GCM tag failure always
56
+ // throws instead of depending on close() behavior. GCM decryption
57
+ // buffers the full input internally anyway before releasing plaintext.
58
+ byte[] ciphertext = readAllBytes(input);
59
+ if (ciphertext.length <= TAG_LENGTH) {
60
+ throw new IllegalStateException("invalid bundle encryption parameter: ciphertext too short");
61
+ }
62
+ byte[] plaintext = cipher.doFinal(ciphertext);
63
+ try (FileOutputStream out = new FileOutputStream(output)) {
64
+ out.write(plaintext);
65
+ }
66
+ }
67
+
68
+ private static byte[] readAllBytes(File file) throws Exception {
69
+ try (
70
+ InputStream in = new FileInputStream(file);
71
+ ByteArrayOutputStream out = new ByteArrayOutputStream()
72
+ ) {
73
+ byte[] buffer = new byte[8192];
74
+ int read;
75
+ while ((read = in.read(buffer)) > 0) {
76
+ out.write(buffer, 0, read);
77
+ }
78
+ return out.toByteArray();
79
+ }
80
+ }
81
+ }
@@ -22,6 +22,7 @@ final class BundleStore {
22
22
  private static final String KEY_STAGED = "staged_bundle_id";
23
23
  private static final String KEY_LAST_FAILED_BUNDLE_INFO = "last_failed_bundle_info";
24
24
  private static final String KEY_LAST_RESOLVED_RUNTIME_KEY = "last_resolved_runtime_key";
25
+ private static final String KEY_OVERRIDE_CHANNEL = "override_channel";
25
26
 
26
27
  private final Context context;
27
28
  private final SharedPreferences prefs;
@@ -217,6 +218,20 @@ final class BundleStore {
217
218
  }
218
219
  }
219
220
 
221
+ synchronized String getOverrideChannel() {
222
+ return prefs.getString(KEY_OVERRIDE_CHANNEL, null);
223
+ }
224
+
225
+ synchronized void setOverrideChannel(String channel) {
226
+ SharedPreferences.Editor editor = prefs.edit();
227
+ if (channel == null) {
228
+ editor.remove(KEY_OVERRIDE_CHANNEL);
229
+ } else {
230
+ editor.putString(KEY_OVERRIDE_CHANNEL, channel);
231
+ }
232
+ editor.commit();
233
+ }
234
+
220
235
  synchronized String getLastResolvedRuntimeKey() {
221
236
  return prefs.getString(KEY_LAST_RESOLVED_RUNTIME_KEY, null);
222
237
  }
@@ -28,6 +28,23 @@ final class ManifestClient {
28
28
  }
29
29
  }
30
30
 
31
+ static final class ManifestEncryption {
32
+
33
+ final String alg;
34
+ final String kid;
35
+ final String wrapNonce;
36
+ final String wrappedDek;
37
+ final String nonce;
38
+
39
+ ManifestEncryption(String alg, String kid, String wrapNonce, String wrappedDek, String nonce) {
40
+ this.alg = alg;
41
+ this.kid = kid;
42
+ this.wrapNonce = wrapNonce;
43
+ this.wrappedDek = wrappedDek;
44
+ this.nonce = nonce;
45
+ }
46
+ }
47
+
31
48
  static final class LatestManifest {
32
49
 
33
50
  final String version;
@@ -36,6 +53,9 @@ final class ManifestClient {
36
53
  final int size;
37
54
  final String runtimeVersion;
38
55
  final String releaseId;
56
+ final String strategy;
57
+ final boolean forceImmediate;
58
+ final ManifestEncryption encryption;
39
59
 
40
60
  LatestManifest(
41
61
  String version,
@@ -43,7 +63,10 @@ final class ManifestClient {
43
63
  String sha256,
44
64
  int size,
45
65
  String runtimeVersion,
46
- String releaseId
66
+ String releaseId,
67
+ String strategy,
68
+ boolean forceImmediate,
69
+ ManifestEncryption encryption
47
70
  ) {
48
71
  this.version = version;
49
72
  this.url = url;
@@ -51,6 +74,9 @@ final class ManifestClient {
51
74
  this.size = size;
52
75
  this.runtimeVersion = runtimeVersion;
53
76
  this.releaseId = releaseId;
77
+ this.strategy = strategy;
78
+ this.forceImmediate = forceImmediate;
79
+ this.encryption = encryption;
54
80
  }
55
81
  }
56
82
 
@@ -145,6 +171,18 @@ final class ManifestClient {
145
171
  throw new IllegalStateException("Manifest response missing required releaseId");
146
172
  }
147
173
 
174
+ String strategy = "zip";
175
+ if (json.has("strategy") && !json.isNull("strategy")) {
176
+ String rawStrategy = json.getString("strategy").trim();
177
+ if (!rawStrategy.isEmpty()) {
178
+ strategy = rawStrategy;
179
+ }
180
+ }
181
+ // Strict boolean (no string coercion) to match the iOS parser.
182
+ Object rawForceImmediate = json.opt("forceImmediate");
183
+ boolean forceImmediate = Boolean.TRUE.equals(rawForceImmediate);
184
+ ManifestEncryption encryption = parseEncryption(json);
185
+
148
186
  requireHTTPS(new URL(downloadUrl), allowInsecureUrls);
149
187
 
150
188
  if (manifestKeys == null || manifestKeys.isEmpty()) {
@@ -168,6 +206,9 @@ final class ManifestClient {
168
206
  sha256,
169
207
  size,
170
208
  responseRuntimeVersion,
209
+ strategy,
210
+ forceImmediate,
211
+ encryption,
171
212
  signature,
172
213
  manifestKeys
173
214
  );
@@ -179,13 +220,39 @@ final class ManifestClient {
179
220
  sha256,
180
221
  size,
181
222
  responseRuntimeVersion,
182
- releaseId
223
+ releaseId,
224
+ strategy,
225
+ forceImmediate,
226
+ encryption
183
227
  );
184
228
  } finally {
185
229
  connection.disconnect();
186
230
  }
187
231
  }
188
232
 
233
+ private static ManifestEncryption parseEncryption(JSONObject json) throws Exception {
234
+ if (!json.has("encryption") || json.isNull("encryption")) {
235
+ return null;
236
+ }
237
+ JSONObject encObj = json.getJSONObject("encryption");
238
+ if (
239
+ !encObj.has("alg") ||
240
+ !encObj.has("kid") ||
241
+ !encObj.has("wrapNonce") ||
242
+ !encObj.has("wrappedDek") ||
243
+ !encObj.has("nonce")
244
+ ) {
245
+ throw new IllegalStateException("Manifest encryption block is missing required fields");
246
+ }
247
+ return new ManifestEncryption(
248
+ encObj.getString("alg"),
249
+ encObj.getString("kid"),
250
+ encObj.getString("wrapNonce"),
251
+ encObj.getString("wrappedDek"),
252
+ encObj.getString("nonce")
253
+ );
254
+ }
255
+
189
256
  private static String readStream(InputStream input) throws Exception {
190
257
  if (input == null) {
191
258
  return "";
@@ -34,6 +34,9 @@ final class ManifestVerifier {
34
34
  String sha256,
35
35
  int size,
36
36
  String runtimeVersion,
37
+ String strategy,
38
+ boolean forceImmediate,
39
+ ManifestClient.ManifestEncryption encryption,
37
40
  ManifestClient.ManifestSignature signature,
38
41
  List<KeyEntry> trustedKeys
39
42
  ) throws Exception {
@@ -44,6 +47,9 @@ final class ManifestVerifier {
44
47
  sha256,
45
48
  size,
46
49
  runtimeVersion,
50
+ strategy,
51
+ forceImmediate,
52
+ encryption,
47
53
  signature.kid,
48
54
  signature.iat,
49
55
  signature.exp
@@ -91,6 +97,31 @@ final class ManifestVerifier {
91
97
  }
92
98
  }
93
99
 
100
+ /**
101
+ * Encode the encryption block for the canonical payload.
102
+ * Must match the server's encodeEncryptionForPayload exactly.
103
+ */
104
+ private static String encodeEncryptionForPayload(ManifestClient.ManifestEncryption encryption) {
105
+ if (encryption == null) {
106
+ return "null";
107
+ }
108
+ return (
109
+ encryption.alg +
110
+ "|" +
111
+ encryption.kid +
112
+ "|" +
113
+ encryption.wrapNonce +
114
+ "|" +
115
+ encryption.wrappedDek +
116
+ "|" +
117
+ encryption.nonce
118
+ );
119
+ }
120
+
121
+ /**
122
+ * Canonical payload v2 — must match the server's buildCanonicalPayload
123
+ * (console/lib/manifest-signing.ts) and the iOS mirror byte-for-byte.
124
+ */
94
125
  private static String buildCanonicalPayload(
95
126
  String appId,
96
127
  String channel,
@@ -98,6 +129,9 @@ final class ManifestVerifier {
98
129
  String sha256,
99
130
  int size,
100
131
  String runtimeVersion,
132
+ String strategy,
133
+ boolean forceImmediate,
134
+ ManifestClient.ManifestEncryption encryption,
101
135
  String kid,
102
136
  int iat,
103
137
  int exp
@@ -122,6 +156,15 @@ final class ManifestVerifier {
122
156
  "runtimeVersion:" +
123
157
  (runtimeVersion != null ? runtimeVersion : "null") +
124
158
  "\n" +
159
+ "strategy:" +
160
+ strategy +
161
+ "\n" +
162
+ "forceImmediate:" +
163
+ (forceImmediate ? "true" : "false") +
164
+ "\n" +
165
+ "encryption:" +
166
+ encodeEncryptionForPayload(encryption) +
167
+ "\n" +
125
168
  "kid:" +
126
169
  kid +
127
170
  "\n" +