@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
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
|
-
|
|
214
|
-
|
|
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
|
|
261
|
-
6. it
|
|
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,10 +22,12 @@ 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;
|
|
28
29
|
private final File bundlesDirectory;
|
|
30
|
+
private final File filesCacheDirectory;
|
|
29
31
|
private final String builtinVersion;
|
|
30
32
|
private final String nativeBuild;
|
|
31
33
|
private final String appRuntimeVersion;
|
|
@@ -46,6 +48,16 @@ final class BundleStore {
|
|
|
46
48
|
//noinspection ResultOfMethodCallIgnored
|
|
47
49
|
bundlesDirectory.mkdirs();
|
|
48
50
|
}
|
|
51
|
+
this.filesCacheDirectory = new File(this.context.getFilesDir(), "otakit_files");
|
|
52
|
+
if (!filesCacheDirectory.exists()) {
|
|
53
|
+
//noinspection ResultOfMethodCallIgnored
|
|
54
|
+
filesCacheDirectory.mkdirs();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Content-addressed file cache for the deltas strategy ({@code otakit_files/<sha256>}). */
|
|
59
|
+
File getFilesCacheDirectory() {
|
|
60
|
+
return filesCacheDirectory;
|
|
49
61
|
}
|
|
50
62
|
|
|
51
63
|
String getNativeBuild() {
|
|
@@ -217,6 +229,20 @@ final class BundleStore {
|
|
|
217
229
|
}
|
|
218
230
|
}
|
|
219
231
|
|
|
232
|
+
synchronized String getOverrideChannel() {
|
|
233
|
+
return prefs.getString(KEY_OVERRIDE_CHANNEL, null);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
synchronized void setOverrideChannel(String channel) {
|
|
237
|
+
SharedPreferences.Editor editor = prefs.edit();
|
|
238
|
+
if (channel == null) {
|
|
239
|
+
editor.remove(KEY_OVERRIDE_CHANNEL);
|
|
240
|
+
} else {
|
|
241
|
+
editor.putString(KEY_OVERRIDE_CHANNEL, channel);
|
|
242
|
+
}
|
|
243
|
+
editor.commit();
|
|
244
|
+
}
|
|
245
|
+
|
|
220
246
|
synchronized String getLastResolvedRuntimeKey() {
|
|
221
247
|
return prefs.getString(KEY_LAST_RESOLVED_RUNTIME_KEY, null);
|
|
222
248
|
}
|