@bundle-pilot/capacitor 0.0.0-stage → 0.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
@@ -1,3 +1,271 @@
1
- # Temporary Holding Version
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
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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).
@@ -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;
@@ -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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
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.0"
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
  }