@bundle-pilot/capacitor 0.0.0-stage → 0.2.1
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 +270 -2
- package/dist/index.d.ts +29 -0
- package/dist/index.js +144 -0
- package/dist/manifest.d.ts +14 -0
- package/dist/manifest.js +40 -0
- package/package.json +41 -3
package/README.md
CHANGED
|
@@ -1,3 +1,271 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/zenkiet/bundle-pilot/main/frontend/src/lib/assets/favicon.svg" alt="Bundle Pilot" width="96">
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
<h1 align="center">@bundle-pilot/capacitor</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
Signed over-the-air updates for Capacitor apps, rolled out with <a href="https://github.com/zenkiet/bundle-pilot">Bundle Pilot</a>.
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
<p align="center">
|
|
12
|
+
<a href="https://www.npmjs.com/package/@bundle-pilot/capacitor"><img src="https://img.shields.io/npm/v/@bundle-pilot/capacitor?color=0f2a3a&label=npm" alt="npm version"></a>
|
|
13
|
+
<a href="https://www.npmjs.com/package/@bundle-pilot/capacitor"><img src="https://img.shields.io/npm/dm/@bundle-pilot/capacitor?color=f5b400" alt="npm downloads"></a>
|
|
14
|
+
<img src="https://img.shields.io/badge/Capacitor-8-119EFF?logo=capacitor&logoColor=white" alt="Capacitor 8">
|
|
15
|
+
<img src="https://img.shields.io/badge/updater-Capgo-7c3aed" alt="Capgo updater">
|
|
16
|
+
<img src="https://img.shields.io/badge/signatures-Ed25519-3fb950" alt="Ed25519 signatures">
|
|
17
|
+
<img src="https://img.shields.io/badge/min%2Bgzip-1.9%20kB%20%2B%20core-3fb950" alt="1.9 kB min+gzip plus core">
|
|
18
|
+
<a href="https://github.com/zenkiet/bundle-pilot/blob/main/libs/capacitor/package.json"><img src="https://img.shields.io/npm/l/@bundle-pilot/capacitor?color=0f2a3a" alt="license"></a>
|
|
19
|
+
</p>
|
|
20
|
+
|
|
21
|
+
<p align="center">
|
|
22
|
+
<a href="#install">Install</a> ·
|
|
23
|
+
<a href="#quick-start">Quick start</a> ·
|
|
24
|
+
<a href="#use-cases">Use cases</a> ·
|
|
25
|
+
<a href="#publishing-a-bundle">Publishing a bundle</a> ·
|
|
26
|
+
<a href="#api">API</a> ·
|
|
27
|
+
<a href="#how-it-works">How it works</a>
|
|
28
|
+
</p>
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
A Capacitor app runs its web code from files on the device, so a page reload
|
|
33
|
+
cannot move it to a new build. This package gives native apps the same rollout
|
|
34
|
+
as the web. It asks the Bundle Pilot gateway which bundle the device should
|
|
35
|
+
run, using the same facts and rules as the web. When that is another bundle,
|
|
36
|
+
it checks the bundle's signature, downloads it with
|
|
37
|
+
[Capgo's updater](https://github.com/Cap-go/capacitor-updater), and restarts
|
|
38
|
+
into it.
|
|
39
|
+
|
|
40
|
+
- **Signed end to end.** The bundle's manifest must verify against your
|
|
41
|
+
Ed25519 key before anything is downloaded, and Capgo then checks every file
|
|
42
|
+
against that manifest. It is the same signature the gateway checks for the
|
|
43
|
+
web.
|
|
44
|
+
- **Rolls back by itself.** A bundle that never starts is rolled back by Capgo,
|
|
45
|
+
and one that fails to bootstrap is reset by `startOta().failed()`. A bundle
|
|
46
|
+
that rolled back is not offered again.
|
|
47
|
+
- **Never slows a start.** The last answer is remembered. While it needs no
|
|
48
|
+
update, the app starts at once and asks again in the background.
|
|
49
|
+
- **Knows the native app.** The app's native version goes along with every
|
|
50
|
+
question, so a bundle that needs newer native plugins never reaches an app
|
|
51
|
+
that cannot run it.
|
|
52
|
+
- **Fails safe.** No network, an unsigned bundle or a failed download all keep
|
|
53
|
+
the bundle the device already has.
|
|
54
|
+
|
|
55
|
+
## Install
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
npm install @bundle-pilot/capacitor @capgo/capacitor-updater @capacitor/app
|
|
59
|
+
npx cap sync
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The updater is driven by this package, so turn off Capgo's own update flow:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
// capacitor.config.ts
|
|
66
|
+
plugins: {
|
|
67
|
+
CapacitorUpdater: {
|
|
68
|
+
autoUpdate: 'off',
|
|
69
|
+
updateUrl: '',
|
|
70
|
+
statsUrl: '',
|
|
71
|
+
channelUrl: '',
|
|
72
|
+
responseTimeout: 120, // seconds; on iOS it bounds the whole download
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Requires Capacitor 8, `@capgo/capacitor-updater` 8 and `@capacitor/app` 8, and
|
|
78
|
+
a Bundle Pilot gateway that has `BUNDLE_PUBKEY` set, so that every bundle it
|
|
79
|
+
holds is signed.
|
|
80
|
+
|
|
81
|
+
## Quick start
|
|
82
|
+
|
|
83
|
+
**1. Confirm each start, in `main.ts`.** This tells Capgo the new bundle works,
|
|
84
|
+
and rolls back one that cannot bootstrap:
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
import { startOta } from '@bundle-pilot/capacitor';
|
|
88
|
+
|
|
89
|
+
const ota = startOta(environment.version);
|
|
90
|
+
|
|
91
|
+
bootstrapApplication(AppComponent, appConfig).catch(async (err) => {
|
|
92
|
+
console.error(err);
|
|
93
|
+
await ota.failed();
|
|
94
|
+
});
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
**2. Guard your routes.** With Angular, give the over-the-air pilot to
|
|
98
|
+
[`@bundle-pilot/angular`](https://www.npmjs.com/package/@bundle-pilot/angular):
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
import { provideBundlePilot } from '@bundle-pilot/angular';
|
|
102
|
+
import { createOtaPilot } from '@bundle-pilot/capacitor';
|
|
103
|
+
|
|
104
|
+
provideBundlePilot({
|
|
105
|
+
facts: () => {
|
|
106
|
+
const store = inject(StoreService);
|
|
107
|
+
return store.id ? { storeID: store.id, backend: store.posVersion } : null;
|
|
108
|
+
},
|
|
109
|
+
pilot: () =>
|
|
110
|
+
createOtaPilot({
|
|
111
|
+
gateway: 'https://ota.example.com',
|
|
112
|
+
publicKey: environment.bundleKey, // base64 Ed25519 public key
|
|
113
|
+
version: environment.version, // the bundle this build is
|
|
114
|
+
}),
|
|
115
|
+
});
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Without Angular, call the pilot before showing a screen that depends on the
|
|
119
|
+
facts:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
const pilot = createOtaPilot({ gateway, publicKey, version });
|
|
123
|
+
|
|
124
|
+
if (await pilot.check({ storeID })) showHome(); // false: an update is installing
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
In a browser, or when `gateway` or `publicKey` is empty, the pilot lets every
|
|
128
|
+
navigation through. The same code therefore runs in `ng serve` and on the web.
|
|
129
|
+
|
|
130
|
+
## Use cases
|
|
131
|
+
|
|
132
|
+
### Staged rollout to the field
|
|
133
|
+
|
|
134
|
+
Upload `4.80.0` to the gateway, and add a rule such as
|
|
135
|
+
`{ "when": { "storeID": ["2020210"] }, "bundle": "4.80.0" }`. Devices of that
|
|
136
|
+
store pick it up at their next navigation, while everyone else stays put. To
|
|
137
|
+
widen the rollout, change the rule. To back out, block the bundle in the admin
|
|
138
|
+
UI.
|
|
139
|
+
|
|
140
|
+
### A bundle that needs a newer native app
|
|
141
|
+
|
|
142
|
+
A bundle that uses a new native plugin says so in its `index.html`:
|
|
143
|
+
|
|
144
|
+
```html
|
|
145
|
+
<meta name="bundle-pilot:native" content=">=4.102.0">
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Apps older than 4.102.0 never receive it. They get the closest older bundle
|
|
149
|
+
they can run instead, so you can publish web code ahead of the store review.
|
|
150
|
+
|
|
151
|
+
### Progress and a restart screen
|
|
152
|
+
|
|
153
|
+
`onProgress` reports the download, and `beforeSwitch` gives you a moment before
|
|
154
|
+
the restart:
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
pilot: () => {
|
|
158
|
+
const splash = inject(SplashScreenService);
|
|
159
|
+
return createOtaPilot({
|
|
160
|
+
gateway,
|
|
161
|
+
publicKey,
|
|
162
|
+
version,
|
|
163
|
+
onProgress: (percent) =>
|
|
164
|
+
percent === null ? splash.hide() : splash.progress('Downloading update', percent),
|
|
165
|
+
beforeSwitch: async () => {
|
|
166
|
+
splash.progress('Installing update', 100);
|
|
167
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
168
|
+
splash.restarting();
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
},
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`percent` runs from 1 to 100. It creeps forward on its own, so the bar never
|
|
175
|
+
looks stuck on a slow network, and only the real end of the download reaches
|
|
176
|
+
100. `null` means the update gave up and the app stays where it is.
|
|
177
|
+
|
|
178
|
+
## Publishing a bundle
|
|
179
|
+
|
|
180
|
+
Build the app, write a `sha256sum` manifest of its files, sign it with your
|
|
181
|
+
Ed25519 key, zip it, and upload it with an API token that has `upload` scope:
|
|
182
|
+
|
|
183
|
+
```sh
|
|
184
|
+
cd dist/browser
|
|
185
|
+
find . -type f ! -path '*/.*' ! -name 'bundle.sha256*' -exec sha256sum {} + > bundle.sha256
|
|
186
|
+
openssl pkeyutl -sign -inkey bundle-key.pem -rawin -in bundle.sha256 | od -An -v -tx1 | tr -d ' \n' > bundle.sha256.sig
|
|
187
|
+
zip -qr -9 -X ../4.80.0.zip .
|
|
188
|
+
curl --fail -T ../4.80.0.zip -H "Authorization: Bearer $BUNDLE_PILOT_TOKEN" https://ota.example.com/__gateway/bundles/4.80.0.zip
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
The app's `publicKey` is the same key as the gateway's `BUNDLE_PUBKEY`, written
|
|
192
|
+
in base64. Key generation and precompressed files are covered in the
|
|
193
|
+
[gateway reference](https://github.com/zenkiet/bundle-pilot/blob/main/docs/reference.md#signing-bundles).
|
|
194
|
+
|
|
195
|
+
## API
|
|
196
|
+
|
|
197
|
+
### `createOtaPilot(options)`
|
|
198
|
+
|
|
199
|
+
Returns a `Pilot` (see [`@bundle-pilot/core`](https://www.npmjs.com/package/@bundle-pilot/core#api)).
|
|
200
|
+
|
|
201
|
+
| Option | Type | |
|
|
202
|
+
|---|---|---|
|
|
203
|
+
| `gateway` | `string` | the OTA gateway's origin, such as `https://ota.example.com` |
|
|
204
|
+
| `publicKey` | `string` | base64 Ed25519 public key that signs each `bundle.sha256` |
|
|
205
|
+
| `version` | `string` | the bundle this app runs |
|
|
206
|
+
| `onProgress` | `(percent: number \| null) => void` | 1 to 100 while an update downloads; `null` when it gives up |
|
|
207
|
+
| `beforeSwitch` | `() => unknown` | awaited after the download, right before the app restarts into it |
|
|
208
|
+
|
|
209
|
+
`pilot.check(facts)` returns `true` to go on. While an update downloads and
|
|
210
|
+
installs, it returns a promise that never resolves, because the app restarts.
|
|
211
|
+
|
|
212
|
+
### `startOta(version)`
|
|
213
|
+
|
|
214
|
+
Call it once, before bootstrapping. It returns `{ failed() }`: call `failed()`
|
|
215
|
+
when bootstrapping throws, to mark the running bundle as bad and reset to the
|
|
216
|
+
built-in one. Off the native platform it does nothing.
|
|
217
|
+
|
|
218
|
+
## How it works
|
|
219
|
+
|
|
220
|
+
1. **Ask.** The first `check` for a set of facts posts them, along with the
|
|
221
|
+
native app version, to `POST /__gateway/data`. This happens once per
|
|
222
|
+
session, through Capacitor's native HTTP, so CORS does not apply.
|
|
223
|
+
2. **Answer from memory.** The last answer is kept. While it names the running
|
|
224
|
+
bundle, or one that rolled back, `check` returns `true` at once and the
|
|
225
|
+
question runs in the background. An update it finds installs at the next
|
|
226
|
+
navigation.
|
|
227
|
+
3. **Verify.** The pilot fetches `bundle.sha256` and its signature, and checks
|
|
228
|
+
them with WebCrypto Ed25519. An unsigned or tampered bundle is never
|
|
229
|
+
downloaded.
|
|
230
|
+
4. **Download.** Capgo fetches the files listed in the manifest and checks each
|
|
231
|
+
one's SHA-256. A bundle already downloaded with the same manifest is reused.
|
|
232
|
+
5. **Switch.** The pilot runs `beforeSwitch`, then `CapacitorUpdater.set()`
|
|
233
|
+
restarts the app into the new bundle. On the next start, `startOta` confirms
|
|
234
|
+
it, or records a rollback.
|
|
235
|
+
|
|
236
|
+
Requests time out after 5 s and the download after 60 s. Any failure keeps the
|
|
237
|
+
current bundle and is not retried until the next start.
|
|
238
|
+
|
|
239
|
+
<details>
|
|
240
|
+
<summary>Keys kept in <code>localStorage</code></summary>
|
|
241
|
+
|
|
242
|
+
| Key | |
|
|
243
|
+
|---|---|
|
|
244
|
+
| `ota.decision` | the gateway's last answer, so a start never waits on it |
|
|
245
|
+
| `ota.trying` | the bundle being started, to detect a rollback |
|
|
246
|
+
| `ota.failed` | the last bundle that rolled back; it is not offered again |
|
|
247
|
+
| `ota.manifest.<id>` | the digest of the manifest a downloaded bundle came from |
|
|
248
|
+
|
|
249
|
+
</details>
|
|
250
|
+
|
|
251
|
+
## Requirements and caveats
|
|
252
|
+
|
|
253
|
+
- Ed25519 in WebCrypto needs Android System WebView 137 or later, or iOS 17 or
|
|
254
|
+
later. Older devices cannot verify a signature, so they keep their bundle.
|
|
255
|
+
- Keep `autoUpdate: 'off'`. Otherwise Capgo's own update flow competes with
|
|
256
|
+
this one.
|
|
257
|
+
- Runtime config such as `env.js` ships inside each bundle, so keep it in every
|
|
258
|
+
version you upload.
|
|
259
|
+
|
|
260
|
+
## Compatibility
|
|
261
|
+
|
|
262
|
+
| | |
|
|
263
|
+
|---|---|
|
|
264
|
+
| Capacitor | 8, Android and iOS |
|
|
265
|
+
| `@capgo/capacitor-updater` | 8 |
|
|
266
|
+
| Gateway | released together with it: `@bundle-pilot/capacitor` 1.4.0 goes with gateway 1.4.0 |
|
|
267
|
+
|
|
268
|
+
## License
|
|
269
|
+
|
|
270
|
+
[MIT](https://github.com/zenkiet/bundle-pilot/blob/main/libs/capacitor/package.json).
|
|
271
|
+
Part of [Bundle Pilot](https://github.com/zenkiet/bundle-pilot).
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type Pilot } from '@bundle-pilot/core';
|
|
2
|
+
export interface OtaOptions {
|
|
3
|
+
/** The OTA gateway's origin, such as https://ota.example.com. */
|
|
4
|
+
gateway: string;
|
|
5
|
+
/** Base64 Ed25519 public key that signs each bundle's bundle.sha256. */
|
|
6
|
+
publicKey: string;
|
|
7
|
+
/** The bundle this app runs. */
|
|
8
|
+
version: string;
|
|
9
|
+
/** 1 to 100 while an update downloads, smoothed so it never looks stuck; null when the update gives up. */
|
|
10
|
+
onProgress?: (percent: number | null) => void;
|
|
11
|
+
/** Awaited once the update is downloaded, right before the app reloads into it. */
|
|
12
|
+
beforeSwitch?: () => unknown;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* A Pilot for native apps: asks the OTA gateway which bundle these facts get, verifies its signed
|
|
16
|
+
* manifest, downloads it with Capgo and reloads into it. Off the native platform it always stays.
|
|
17
|
+
*
|
|
18
|
+
* One question per facts per session. While the last answer for these facts needs no update, check()
|
|
19
|
+
* goes on at once and the question revalidates in the background; an update it finds installs at the
|
|
20
|
+
* next navigation. A failure stays and is not retried that session.
|
|
21
|
+
*/
|
|
22
|
+
export declare function createOtaPilot(o: OtaOptions): Pilot;
|
|
23
|
+
/**
|
|
24
|
+
* Call once before bootstrapping: tells Capgo this bundle started, and remembers a bundle it rolled back.
|
|
25
|
+
* failed() is for a bootstrap error: a bundle that started but cannot run would otherwise never roll back.
|
|
26
|
+
*/
|
|
27
|
+
export declare function startOta(version: string): {
|
|
28
|
+
failed(): Promise<void>;
|
|
29
|
+
};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { factsKey } from '@bundle-pilot/core';
|
|
2
|
+
import { App } from '@capacitor/app';
|
|
3
|
+
import { Capacitor, CapacitorHttp } from '@capacitor/core';
|
|
4
|
+
import { CapacitorUpdater } from '@capgo/capacitor-updater';
|
|
5
|
+
import { entries, verifyManifest } from './manifest.js';
|
|
6
|
+
const TIMEOUT = 5000; // per request
|
|
7
|
+
const DOWNLOAD_TIMEOUT = 60_000;
|
|
8
|
+
// The first three are kept from the app this code came from, so devices in the field keep their state.
|
|
9
|
+
const TRYING = 'ota.trying'; // the bundle set() is about to start
|
|
10
|
+
const FAILED = 'ota.failed'; // a bundle Capgo rolled back: never offered again
|
|
11
|
+
const MANIFEST = 'ota.manifest.'; // + bundle id: digest of the manifest it was downloaded with
|
|
12
|
+
const DECISION = 'ota.decision'; // the gateway's last answer, { key, bundle }, so a start never waits on it
|
|
13
|
+
/**
|
|
14
|
+
* A Pilot for native apps: asks the OTA gateway which bundle these facts get, verifies its signed
|
|
15
|
+
* manifest, downloads it with Capgo and reloads into it. Off the native platform it always stays.
|
|
16
|
+
*
|
|
17
|
+
* One question per facts per session. While the last answer for these facts needs no update, check()
|
|
18
|
+
* goes on at once and the question revalidates in the background; an update it finds installs at the
|
|
19
|
+
* next navigation. A failure stays and is not retried that session.
|
|
20
|
+
*/
|
|
21
|
+
export function createOtaPilot(o) {
|
|
22
|
+
if (!Capacitor.isNativePlatform() || !o.gateway || !o.publicKey)
|
|
23
|
+
return { version: o.version, check: () => true };
|
|
24
|
+
const api = `${o.gateway.replace(/\/+$/, '')}/__gateway`;
|
|
25
|
+
const stays = (v) => !v || v === o.version || v === localStorage.getItem(FAILED);
|
|
26
|
+
let last = null;
|
|
27
|
+
try {
|
|
28
|
+
last = JSON.parse(localStorage.getItem(DECISION) ?? 'null');
|
|
29
|
+
}
|
|
30
|
+
catch { }
|
|
31
|
+
const asked = new Map();
|
|
32
|
+
let installing;
|
|
33
|
+
const ask = (k, facts) => App.getInfo()
|
|
34
|
+
.then(({ version: native }) => CapacitorHttp.post({
|
|
35
|
+
url: `${api}/data`,
|
|
36
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37
|
+
data: { ...facts, native },
|
|
38
|
+
connectTimeout: TIMEOUT,
|
|
39
|
+
readTimeout: TIMEOUT
|
|
40
|
+
}))
|
|
41
|
+
.then((res) => {
|
|
42
|
+
const v = res.status === 200 ? res.data?.bundle : undefined;
|
|
43
|
+
if (typeof v !== 'string' || !v)
|
|
44
|
+
return null;
|
|
45
|
+
last = { key: k, bundle: v };
|
|
46
|
+
localStorage.setItem(DECISION, JSON.stringify(last));
|
|
47
|
+
return v;
|
|
48
|
+
})
|
|
49
|
+
.catch(() => null);
|
|
50
|
+
return {
|
|
51
|
+
version: o.version,
|
|
52
|
+
check(facts) {
|
|
53
|
+
const k = factsKey(facts);
|
|
54
|
+
if (!k)
|
|
55
|
+
return true;
|
|
56
|
+
let answer = asked.get(k);
|
|
57
|
+
if (!answer)
|
|
58
|
+
asked.set(k, (answer = ask(k, facts)));
|
|
59
|
+
if (last?.key === k && stays(last.bundle))
|
|
60
|
+
return true;
|
|
61
|
+
return answer.then((v) => stays(v) ||
|
|
62
|
+
(installing ??= install(api, o, v).catch((e) => {
|
|
63
|
+
console.warn('[bundle-pilot] update failed, staying:', e);
|
|
64
|
+
localStorage.removeItem(TRYING);
|
|
65
|
+
o.onProgress?.(null);
|
|
66
|
+
return true;
|
|
67
|
+
})));
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/** Installs bundle v; resolves true to stay, and never resolves once the app reloads into it. */
|
|
72
|
+
async function install(api, o, v) {
|
|
73
|
+
const base = `${api}/bundles/${encodeURIComponent(v)}`;
|
|
74
|
+
const [text, sig] = await Promise.all([fetchBytes(`${base}/bundle.sha256`), fetchBytes(`${base}/bundle.sha256.sig`)]);
|
|
75
|
+
const manifest = text && sig && (await verifyManifest(bytes(o.publicKey), text, sig)) ? entries(text, base) : undefined;
|
|
76
|
+
if (!text || !manifest)
|
|
77
|
+
throw new Error(`${v} is not signed`);
|
|
78
|
+
// Capgo keeps no checksum for manifest downloads: the manifest's digest names the bundle.
|
|
79
|
+
const digest = btoa(String.fromCharCode(...new Uint8Array(await crypto.subtle.digest('SHA-256', text))));
|
|
80
|
+
// Progress creeps toward 99 so the bar never looks stuck; real events pull it up, only the end reaches 100.
|
|
81
|
+
let real = 0;
|
|
82
|
+
let shown = 1;
|
|
83
|
+
o.onProgress?.(shown);
|
|
84
|
+
const onDownload = await CapacitorUpdater.addListener('download', ({ percent, bundle }) => {
|
|
85
|
+
if (bundle?.version === v)
|
|
86
|
+
real = Math.min(percent, 99);
|
|
87
|
+
});
|
|
88
|
+
const tick = setInterval(() => o.onProgress?.((shown += Math.max((99 - shown) * 0.03, (real - shown) * 0.25))), 250);
|
|
89
|
+
let bundle;
|
|
90
|
+
try {
|
|
91
|
+
const { bundles } = await CapacitorUpdater.list();
|
|
92
|
+
bundle = bundles.find((b) => b.version === v && localStorage.getItem(MANIFEST + b.id) === digest && (b.status === 'pending' || b.status === 'success'));
|
|
93
|
+
if (!bundle) {
|
|
94
|
+
for (const b of bundles.filter((b) => b.version === v)) {
|
|
95
|
+
localStorage.removeItem(MANIFEST + b.id);
|
|
96
|
+
await CapacitorUpdater.delete({ id: b.id }).catch(() => undefined);
|
|
97
|
+
}
|
|
98
|
+
// Recorded even after the timeout, which stops waiting but not the native download.
|
|
99
|
+
const done = CapacitorUpdater.download({ url: `${base}.zip`, version: v, manifest }).then((b) => {
|
|
100
|
+
localStorage.setItem(MANIFEST + b.id, digest);
|
|
101
|
+
return b;
|
|
102
|
+
});
|
|
103
|
+
bundle = await within(DOWNLOAD_TIMEOUT, done);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
clearInterval(tick);
|
|
108
|
+
void onDownload.remove();
|
|
109
|
+
}
|
|
110
|
+
o.onProgress?.(100);
|
|
111
|
+
await o.beforeSwitch?.();
|
|
112
|
+
localStorage.setItem(TRYING, v);
|
|
113
|
+
await CapacitorUpdater.set({ id: bundle.id }); // reloads; never resolves on success
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Call once before bootstrapping: tells Capgo this bundle started, and remembers a bundle it rolled back.
|
|
118
|
+
* failed() is for a bootstrap error: a bundle that started but cannot run would otherwise never roll back.
|
|
119
|
+
*/
|
|
120
|
+
export function startOta(version) {
|
|
121
|
+
if (!Capacitor.isNativePlatform())
|
|
122
|
+
return { failed: async () => { } };
|
|
123
|
+
const trying = localStorage.getItem(TRYING);
|
|
124
|
+
localStorage.removeItem(TRYING);
|
|
125
|
+
if (trying && trying !== version)
|
|
126
|
+
localStorage.setItem(FAILED, trying);
|
|
127
|
+
void CapacitorUpdater.notifyAppReady();
|
|
128
|
+
return {
|
|
129
|
+
async failed() {
|
|
130
|
+
const { bundle } = await CapacitorUpdater.current();
|
|
131
|
+
if (bundle.id === 'builtin')
|
|
132
|
+
return;
|
|
133
|
+
localStorage.setItem(FAILED, bundle.version);
|
|
134
|
+
await CapacitorUpdater.reset();
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
// Android's text mode drops the last newline, which breaks the signature: fetch bytes (base64).
|
|
139
|
+
async function fetchBytes(url) {
|
|
140
|
+
const res = await CapacitorHttp.get({ url, responseType: 'arraybuffer', connectTimeout: TIMEOUT, readTimeout: TIMEOUT });
|
|
141
|
+
return res.status === 200 && typeof res.data === 'string' ? bytes(res.data) : undefined;
|
|
142
|
+
}
|
|
143
|
+
const bytes = (b64) => Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
|
144
|
+
const within = (ms, p) => Promise.race([p, new Promise((_, reject) => setTimeout(() => reject(new Error('download timeout')), ms))]);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** What CapacitorUpdater.download({ manifest }) takes; the plugin checks each file's SHA-256. */
|
|
2
|
+
export interface ManifestEntry {
|
|
3
|
+
file_name: string;
|
|
4
|
+
file_hash: string;
|
|
5
|
+
download_url: string;
|
|
6
|
+
}
|
|
7
|
+
/** Checks bundle.sha256 against its Ed25519 signature, hex or raw as the gateway accepts it. */
|
|
8
|
+
export declare function verifyManifest(key: Uint8Array, text: Uint8Array, sig: Uint8Array): Promise<boolean>;
|
|
9
|
+
/**
|
|
10
|
+
* Turns a signed bundle.sha256 (`<hash> *./path` lines) into Capgo entries, or undefined when a line is
|
|
11
|
+
* malformed or index.html is missing. Precompressed `x.br`/`x.gz` copies of a listed `x` are dropped:
|
|
12
|
+
* the gateway does not serve them as files, and Capgo would see duplicate targets.
|
|
13
|
+
*/
|
|
14
|
+
export declare function entries(text: Uint8Array, base: string): ManifestEntry[] | undefined;
|
package/dist/manifest.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// ponytail: WebCrypto Ed25519 needs WebView 137+ / iOS 17+; older devices fail closed. Add @noble/ed25519 if they matter.
|
|
2
|
+
/** Checks bundle.sha256 against its Ed25519 signature, hex or raw as the gateway accepts it. */
|
|
3
|
+
export async function verifyManifest(key, text, sig) {
|
|
4
|
+
const hex = new TextDecoder().decode(sig).trim();
|
|
5
|
+
const raw = /^[0-9a-f]{128}$/i.test(hex) ? Uint8Array.from(hex.match(/../g), (h) => parseInt(h, 16)) : sig;
|
|
6
|
+
const k = await crypto.subtle.importKey('raw', key, { name: 'Ed25519' }, false, ['verify']);
|
|
7
|
+
return crypto.subtle.verify({ name: 'Ed25519' }, k, raw, text);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Turns a signed bundle.sha256 (`<hash> *./path` lines) into Capgo entries, or undefined when a line is
|
|
11
|
+
* malformed or index.html is missing. Precompressed `x.br`/`x.gz` copies of a listed `x` are dropped:
|
|
12
|
+
* the gateway does not serve them as files, and Capgo would see duplicate targets.
|
|
13
|
+
*/
|
|
14
|
+
export function entries(text, base) {
|
|
15
|
+
const files = new Map();
|
|
16
|
+
for (const line of new TextDecoder().decode(text).split('\n').map((l) => l.trim())) {
|
|
17
|
+
if (!line)
|
|
18
|
+
continue;
|
|
19
|
+
const i = line.indexOf(' ');
|
|
20
|
+
if (i < 0)
|
|
21
|
+
return undefined;
|
|
22
|
+
const hash = line.slice(0, i).toLowerCase();
|
|
23
|
+
const name = line
|
|
24
|
+
.slice(i + 1)
|
|
25
|
+
.replace(/^[ *]+/, '')
|
|
26
|
+
.replace(/^\.\//, '');
|
|
27
|
+
if (!/^[0-9a-f]{64}$/.test(hash) || name.split('/').some((p) => !p || p === '.' || p === '..'))
|
|
28
|
+
return undefined;
|
|
29
|
+
files.set(name, hash);
|
|
30
|
+
}
|
|
31
|
+
if (!files.has('index.html'))
|
|
32
|
+
return undefined;
|
|
33
|
+
return [...files]
|
|
34
|
+
.filter(([name]) => !/\.(br|gz)$/.test(name) || !files.has(name.slice(0, -3)))
|
|
35
|
+
.map(([name, hash]) => ({
|
|
36
|
+
file_name: name,
|
|
37
|
+
file_hash: hash,
|
|
38
|
+
download_url: `${base}/${name.split('/').map(encodeURIComponent).join('/')}`
|
|
39
|
+
}));
|
|
40
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bundle-pilot/capacitor",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Over-the-air updates for Capacitor apps: install the signed bundle Bundle Pilot picks, with Capgo.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/zenkiet/bundle-pilot.git",
|
|
9
|
+
"directory": "libs/capacitor"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/zenkiet/bundle-pilot#readme",
|
|
12
|
+
"type": "module",
|
|
13
|
+
"sideEffects": false,
|
|
14
|
+
"main": "./dist/index.js",
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"default": "./dist/index.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist"
|
|
24
|
+
],
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@bundle-pilot/core": "0.2.1"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@capacitor/app": ">=8.0.0",
|
|
30
|
+
"@capacitor/core": ">=8.0.0",
|
|
31
|
+
"@capgo/capacitor-updater": ">=8.0.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@capacitor/app": "^8.1.1",
|
|
35
|
+
"@capacitor/core": "^8.5.2",
|
|
36
|
+
"@capgo/capacitor-updater": "^8.52.1",
|
|
37
|
+
"typescript": "~6.0.3"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "tsc -p tsconfig.json",
|
|
41
|
+
"check": "tsc -p tsconfig.json --noEmit",
|
|
42
|
+
"test": "node --test src/manifest.test.ts"
|
|
43
|
+
}
|
|
6
44
|
}
|