react-native-bspatch 4.1.0 → 5.0.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 +83 -27
- package/android/build.gradle +25 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/vn/reactnativebspatch/BsPatchModule.kt +86 -6
- package/android/src/main/res/values/bspatch_public_key.xml +5 -0
- package/cpp/bsdiff/bspatch_wrapper.c +34 -0
- package/cpp/bsdiff/bspatch_wrapper.h +2 -0
- package/cpp/bspatch_jni.cpp +18 -0
- package/ios/RNBsPatch.mm +142 -1
- package/lib/commonjs/NativeBsPatch.js.map +1 -1
- package/lib/commonjs/index.js +9 -2
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/ota/OtaProvider.js +116 -20
- package/lib/commonjs/ota/OtaProvider.js.map +1 -1
- package/lib/commonjs/ota/eligibility.js +40 -0
- package/lib/commonjs/ota/eligibility.js.map +1 -0
- package/lib/commonjs/ota/entrySignature.js +23 -0
- package/lib/commonjs/ota/entrySignature.js.map +1 -0
- package/lib/commonjs/ota/installId.js +27 -0
- package/lib/commonjs/ota/installId.js.map +1 -0
- package/lib/commonjs/ota/otaCore.js +96 -21
- package/lib/commonjs/ota/otaCore.js.map +1 -1
- package/lib/commonjs/ota/storage/{GitHubRawStorage.js → R2Storage.js} +20 -15
- package/lib/commonjs/ota/storage/R2Storage.js.map +1 -0
- package/lib/commonjs/ota/types.js.map +1 -1
- package/lib/module/NativeBsPatch.js.map +1 -1
- package/lib/module/index.js +2 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/ota/OtaProvider.js +116 -20
- package/lib/module/ota/OtaProvider.js.map +1 -1
- package/lib/module/ota/eligibility.js +34 -0
- package/lib/module/ota/eligibility.js.map +1 -0
- package/lib/module/ota/entrySignature.js +17 -0
- package/lib/module/ota/entrySignature.js.map +1 -0
- package/lib/module/ota/installId.js +22 -0
- package/lib/module/ota/installId.js.map +1 -0
- package/lib/module/ota/otaCore.js +96 -21
- package/lib/module/ota/otaCore.js.map +1 -1
- package/lib/module/ota/storage/{GitHubRawStorage.js → R2Storage.js} +18 -14
- package/lib/module/ota/storage/R2Storage.js.map +1 -0
- package/lib/module/ota/types.js.map +1 -1
- package/lib/typescript/NativeBsPatch.d.ts +28 -2
- package/lib/typescript/NativeBsPatch.d.ts.map +1 -1
- package/lib/typescript/index.d.ts +3 -1
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/ota/OtaProvider.d.ts.map +1 -1
- package/lib/typescript/ota/eligibility.d.ts +19 -0
- package/lib/typescript/ota/eligibility.d.ts.map +1 -0
- package/lib/typescript/ota/entrySignature.d.ts +9 -0
- package/lib/typescript/ota/entrySignature.d.ts.map +1 -0
- package/lib/typescript/ota/installId.d.ts +3 -0
- package/lib/typescript/ota/installId.d.ts.map +1 -0
- package/lib/typescript/ota/otaCore.d.ts.map +1 -1
- package/lib/typescript/ota/storage/{GitHubRawStorage.d.ts → R2Storage.d.ts} +4 -3
- package/lib/typescript/ota/storage/R2Storage.d.ts.map +1 -0
- package/lib/typescript/ota/types.d.ts +52 -5
- package/lib/typescript/ota/types.d.ts.map +1 -1
- package/package.json +3 -2
- package/react-native-bspatch.podspec +3 -1
- package/src/NativeBsPatch.ts +34 -2
- package/src/index.ts +5 -1
- package/src/ota/OtaProvider.tsx +115 -24
- package/src/ota/eligibility.ts +48 -0
- package/src/ota/entrySignature.ts +31 -0
- package/src/ota/installId.ts +18 -0
- package/src/ota/otaCore.ts +102 -21
- package/src/ota/storage/{GitHubRawStorage.ts → R2Storage.ts} +17 -13
- package/src/ota/types.ts +49 -5
- package/tools/build_ota.sh +343 -116
- package/tools/doctor.js +130 -0
- package/tools/ensure_signing_key.js +216 -0
- package/tools/fingerprint.js +111 -0
- package/tools/ios_fingerprint.sh +9 -0
- package/tools/ota_admin.js +131 -0
- package/tools/setup_project.js +41 -15
- package/tools/update_registry.js +111 -11
- package/tools/upload_ota.sh +85 -184
- package/tools/verify_publish.js +81 -0
- package/lib/commonjs/ota/storage/GitHubRawStorage.js.map +0 -1
- package/lib/module/ota/storage/GitHubRawStorage.js.map +0 -1
- package/lib/typescript/ota/storage/GitHubRawStorage.d.ts.map +0 -1
package/src/ota/otaCore.ts
CHANGED
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
* - Structured telemetry logging
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { Platform } from 'react-native';
|
|
14
15
|
import NativeBsPatch from '../NativeBsPatch';
|
|
16
|
+
import { entryPayload, parsePublicKeys } from './entrySignature';
|
|
15
17
|
import { createOtaLogger, OtaEvent } from './otaLogger';
|
|
16
18
|
import {
|
|
17
19
|
ASSET_LIST_FILE,
|
|
@@ -23,7 +25,7 @@ import {
|
|
|
23
25
|
registerNewVersion,
|
|
24
26
|
updateMetadata,
|
|
25
27
|
} from './otaMetadata';
|
|
26
|
-
import {
|
|
28
|
+
import { normalizeChannel, R2StorageProvider } from './storage/R2Storage';
|
|
27
29
|
import {
|
|
28
30
|
OtaStatus,
|
|
29
31
|
type OtaAsset,
|
|
@@ -35,9 +37,9 @@ import {
|
|
|
35
37
|
|
|
36
38
|
// ─── Storage Provider Factory ──────────────────────────────────────────
|
|
37
39
|
|
|
38
|
-
const DEFAULT_STORAGE = new
|
|
40
|
+
const DEFAULT_STORAGE = new R2StorageProvider();
|
|
39
41
|
|
|
40
|
-
/** Explicit `config.storage` or the default
|
|
42
|
+
/** Explicit `config.storage` or the default provider (R2 public URL). */
|
|
41
43
|
const getStorage = (config: OtaConfig): OtaStorageProvider =>
|
|
42
44
|
config.storage ?? DEFAULT_STORAGE;
|
|
43
45
|
|
|
@@ -104,8 +106,9 @@ export const verifyHash = async (
|
|
|
104
106
|
};
|
|
105
107
|
|
|
106
108
|
/**
|
|
107
|
-
* When
|
|
108
|
-
*
|
|
109
|
+
* When there is a public key (config.publicKey, else the embedded ones), an RSA/ECDSA
|
|
110
|
+
* SHA-256 signature is mandatory and must verify against one of them (fail closed):
|
|
111
|
+
* the backup key takes over without a store build if the primary is lost.
|
|
109
112
|
*/
|
|
110
113
|
const assertSigned = async (
|
|
111
114
|
filePath: string,
|
|
@@ -113,19 +116,55 @@ const assertSigned = async (
|
|
|
113
116
|
config: OtaConfig,
|
|
114
117
|
what: string,
|
|
115
118
|
): Promise<void> => {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
119
|
+
const keys = parsePublicKeys(config.publicKey);
|
|
120
|
+
if (keys.length === 0) return;
|
|
121
|
+
let valid = false;
|
|
122
|
+
for (const key of signature ? keys : []) {
|
|
123
|
+
if (await NativeBsPatch.verifySignature(filePath, signature!, key).catch(() => false)) {
|
|
124
|
+
valid = true;
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
120
128
|
if (!valid) {
|
|
121
129
|
throw new Error(`${what} signature missing or invalid`);
|
|
122
130
|
}
|
|
123
131
|
};
|
|
124
132
|
|
|
133
|
+
/**
|
|
134
|
+
* With config.publicKey the registry entry itself must be signed and meant for this app,
|
|
135
|
+
* so a registry served by someone else (e.g. a lapsed domain) can't hand over another
|
|
136
|
+
* flavor's, platform's or release's signed bundle, or swap the asset manifest.
|
|
137
|
+
*/
|
|
138
|
+
const assertEntrySigned = async (patch: OtaPatch, config: OtaConfig): Promise<void> => {
|
|
139
|
+
if (parsePublicKeys(config.publicKey).length === 0) return;
|
|
140
|
+
const channel = config.customServer?.channel;
|
|
141
|
+
const mismatch =
|
|
142
|
+
patch.appVersion !== config.appVersion
|
|
143
|
+
? 'appVersion'
|
|
144
|
+
: patch.platform !== Platform.OS
|
|
145
|
+
? 'platform'
|
|
146
|
+
: channel && patch.flavor !== normalizeChannel(channel)
|
|
147
|
+
? 'flavor'
|
|
148
|
+
: patch.assetManifestUrl && !patch.assetManifestHash
|
|
149
|
+
? 'assetManifestHash'
|
|
150
|
+
: null;
|
|
151
|
+
if (mismatch) {
|
|
152
|
+
throw new Error(`Registry entry rejected: ${mismatch} doesn't match this app`);
|
|
153
|
+
}
|
|
154
|
+
const payloadPath = `${OTA_DIR}/entry-payload.txt`;
|
|
155
|
+
await NativeBsPatch.writeFile(payloadPath, entryPayload(patch));
|
|
156
|
+
try {
|
|
157
|
+
await assertSigned(payloadPath, patch.entrySignature, config, 'Registry entry');
|
|
158
|
+
} finally {
|
|
159
|
+
await NativeBsPatch.removePath(payloadPath).catch(() => {});
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
125
163
|
/**
|
|
126
164
|
* Download a file with retry logic using exponential backoff.
|
|
127
165
|
* Native streams to `${destPath}.part` and renames only on a 2xx response, so a
|
|
128
|
-
* cut-off download or an error page never sits at destPath looking complete
|
|
166
|
+
* cut-off download or an error page never sits at destPath looking complete;
|
|
167
|
+
* each retry resumes where the last attempt stopped.
|
|
129
168
|
* @returns Size of the downloaded file in bytes
|
|
130
169
|
*/
|
|
131
170
|
const downloadWithRetry = async (
|
|
@@ -136,6 +175,7 @@ const downloadWithRetry = async (
|
|
|
136
175
|
): Promise<number> => {
|
|
137
176
|
const retryLimit = Math.max(1, config.network?.retry ?? 3);
|
|
138
177
|
const timeout = config.network?.timeoutMs ?? 30000;
|
|
178
|
+
const headers = JSON.stringify(config.customServer?.headers ?? {});
|
|
139
179
|
const subscription = NativeBsPatch.onDownloadProgress(e => {
|
|
140
180
|
// total is -1 without a Content-Length
|
|
141
181
|
if (e.path === destPath && e.total > 0) {
|
|
@@ -146,7 +186,7 @@ const downloadWithRetry = async (
|
|
|
146
186
|
try {
|
|
147
187
|
for (let i = 0; ; i++) {
|
|
148
188
|
try {
|
|
149
|
-
return await NativeBsPatch.downloadFile(url, destPath, timeout);
|
|
189
|
+
return await NativeBsPatch.downloadFile(url, destPath, timeout, headers);
|
|
150
190
|
} catch (e) {
|
|
151
191
|
if (i >= retryLimit - 1) throw e;
|
|
152
192
|
const delay = Math.pow(2, i) * 1000; // Exponential backoff: 1s, 2s, 4s...
|
|
@@ -194,6 +234,7 @@ const isSafeRelativePath = (p: string): boolean =>
|
|
|
194
234
|
*/
|
|
195
235
|
const downloadAndSyncAssets = async (
|
|
196
236
|
assetManifestUrl: string,
|
|
237
|
+
assetManifestHash: string | undefined,
|
|
197
238
|
version: number,
|
|
198
239
|
config: OtaConfig,
|
|
199
240
|
log: ReturnType<typeof createOtaLogger>,
|
|
@@ -210,7 +251,14 @@ const downloadAndSyncAssets = async (
|
|
|
210
251
|
|
|
211
252
|
let assets: OtaAsset[] = [];
|
|
212
253
|
try {
|
|
213
|
-
|
|
254
|
+
// Through a file: its hash is in the (signed) registry entry
|
|
255
|
+
const manifestPath = `${versionDir}/asset-manifest.json`;
|
|
256
|
+
await downloadWithRetry(assetManifestUrl, manifestPath, config, () => {});
|
|
257
|
+
if (!(await verifyHash(manifestPath, assetManifestHash))) {
|
|
258
|
+
throw new Error('hash mismatch');
|
|
259
|
+
}
|
|
260
|
+
const manifest = JSON.parse(await fs.readFile(manifestPath));
|
|
261
|
+
await fs.removePath(manifestPath).catch(() => {});
|
|
214
262
|
// Hash/ext/path become file names: reject anything unexpected
|
|
215
263
|
assets = (manifest.assets || []).filter(
|
|
216
264
|
(a: any) =>
|
|
@@ -342,6 +390,7 @@ export const selectUpdate = (
|
|
|
342
390
|
* Full path (store build, no patch, or patch path failed):
|
|
343
391
|
* download full bundle → hash → signature
|
|
344
392
|
* Both then: sync assets → atomic commit → metadata → mark healthy → reload.
|
|
393
|
+
* Only a patch-path failure falls back to the full bundle, never a failed commit.
|
|
345
394
|
*
|
|
346
395
|
* The store build's bundle is never used as a patch source: it can't be
|
|
347
396
|
* reproduced byte-for-byte at build time, so the first OTA is always a full download.
|
|
@@ -382,12 +431,21 @@ const runUpdatePipeline = async (
|
|
|
382
431
|
|
|
383
432
|
const log = createOtaLogger(config.onEvent);
|
|
384
433
|
await ensureOtaDir();
|
|
434
|
+
try {
|
|
435
|
+
await assertEntrySigned(patch, config);
|
|
436
|
+
} catch (error) {
|
|
437
|
+
log.emit(OtaEvent.SIGNATURE_INVALID, { version: patch.toVersion, error: (error as Error).message });
|
|
438
|
+
onStatus(OtaStatus.ERROR, `Error: ${(error as Error).message}`);
|
|
439
|
+
throw error;
|
|
440
|
+
}
|
|
385
441
|
const totalStart = Date.now();
|
|
386
442
|
const fs = NativeBsPatch;
|
|
387
443
|
|
|
388
444
|
const finalBundlePath =
|
|
389
445
|
getBundlePath(patch.toVersion, config.baseAppVersion) || '';
|
|
390
446
|
const tempBundle = `${finalBundlePath}.tmp`;
|
|
447
|
+
/** Compressed full bundle, before decompressing into tempBundle */
|
|
448
|
+
const tempDownload = `${finalBundlePath}.download`;
|
|
391
449
|
const versionDir = `${OTA_DIR}/v${patch.toVersion}`;
|
|
392
450
|
const localPatchPath = getLocalPatchPath(patch, DOCUMENT_DIR);
|
|
393
451
|
|
|
@@ -414,6 +472,14 @@ const runUpdatePipeline = async (
|
|
|
414
472
|
}
|
|
415
473
|
}
|
|
416
474
|
|
|
475
|
+
// Room for the download, the decompressed bundle and its assets; a full disk mid-write
|
|
476
|
+
// would fail later and leave the user's storage fuller
|
|
477
|
+
const needBytes = (patch.bundleSize ?? 0) * 3 + 20 * 1024 * 1024;
|
|
478
|
+
const freeBytes = await fs.getFreeDiskSpace().catch(() => Number.MAX_SAFE_INTEGER);
|
|
479
|
+
if (freeBytes < needBytes) {
|
|
480
|
+
throw new Error(`Not enough free storage: ${Math.ceil(needBytes / 1048576)} MB needed`);
|
|
481
|
+
}
|
|
482
|
+
|
|
417
483
|
await fs.mkdir(versionDir);
|
|
418
484
|
|
|
419
485
|
const download = async (remoteName: string, dest: string) => {
|
|
@@ -450,8 +516,10 @@ const runUpdatePipeline = async (
|
|
|
450
516
|
throw new Error(`${what} hash mismatch`);
|
|
451
517
|
};
|
|
452
518
|
|
|
519
|
+
type BundleMetrics = Omit<OtaMetrics, 'totalMs' | 'fromCache'>;
|
|
520
|
+
|
|
453
521
|
/** tempBundle is verified: sync assets, swap it in, record the version, reload. */
|
|
454
|
-
const commit = async (metrics:
|
|
522
|
+
const commit = async (metrics: BundleMetrics) => {
|
|
455
523
|
if (patch.assetManifestUrl) {
|
|
456
524
|
const manifestUrl = await getStorage(config).getUpdateFileUrl(
|
|
457
525
|
patch.assetManifestUrl,
|
|
@@ -459,6 +527,7 @@ const runUpdatePipeline = async (
|
|
|
459
527
|
);
|
|
460
528
|
await downloadAndSyncAssets(
|
|
461
529
|
manifestUrl,
|
|
530
|
+
patch.assetManifestHash,
|
|
462
531
|
patch.toVersion,
|
|
463
532
|
config,
|
|
464
533
|
log,
|
|
@@ -498,7 +567,8 @@ const runUpdatePipeline = async (
|
|
|
498
567
|
}
|
|
499
568
|
};
|
|
500
569
|
|
|
501
|
-
|
|
570
|
+
/** Leaves the verified result at tempBundle. */
|
|
571
|
+
const applyPatchFile = async (sourcePath: string): Promise<BundleMetrics> => {
|
|
502
572
|
onStatus(OtaStatus.DOWNLOADING, `Downloading V${patch.toVersion}...`);
|
|
503
573
|
const { downloadMs, sizeBytes } = await download(patch.filename!, localPatchPath);
|
|
504
574
|
|
|
@@ -528,24 +598,33 @@ const runUpdatePipeline = async (
|
|
|
528
598
|
|
|
529
599
|
await assertHash(tempBundle, patch.bundleHash, 'Result bundle');
|
|
530
600
|
await assertSigned(tempBundle, patch.bundleSignature, config, 'Bundle');
|
|
531
|
-
|
|
601
|
+
return { patchSizeKB: sizeBytes / 1024, downloadMs, applyMs };
|
|
532
602
|
};
|
|
533
603
|
|
|
534
|
-
|
|
604
|
+
/** Leaves the verified full bundle at tempBundle. */
|
|
605
|
+
const installFullBundle = async (): Promise<BundleMetrics> => {
|
|
535
606
|
if (!patch.fullBundleUrl) {
|
|
536
607
|
throw new Error(`No full bundle for V${patch.toVersion}`);
|
|
537
608
|
}
|
|
538
609
|
log.emit(OtaEvent.FALLBACK_FULL_BUNDLE, { version: patch.toVersion });
|
|
539
610
|
onStatus(OtaStatus.DOWNLOADING, `Downloading V${patch.toVersion} (full bundle)...`);
|
|
540
|
-
const
|
|
611
|
+
const compression = patch.fullBundleCompression;
|
|
612
|
+
const { downloadMs, sizeBytes } = await download(patch.fullBundleUrl, compression ? tempDownload : tempBundle);
|
|
613
|
+
if (compression) {
|
|
614
|
+
onStatus(OtaStatus.APPLYING, 'Decompressing...');
|
|
615
|
+
await fs.decompressFile(tempDownload, tempBundle, compression);
|
|
616
|
+
await fs.removePath(tempDownload).catch(() => {});
|
|
617
|
+
}
|
|
541
618
|
await assertHash(tempBundle, patch.bundleHash, 'Full bundle');
|
|
542
619
|
await assertSigned(tempBundle, patch.bundleSignature, config, 'Full bundle');
|
|
543
|
-
|
|
620
|
+
return { patchSizeKB: sizeBytes / 1024, downloadMs, applyMs: 0 };
|
|
544
621
|
};
|
|
545
622
|
|
|
546
623
|
const cleanup = async () => {
|
|
547
624
|
await fs.removePath(localPatchPath).catch(() => {});
|
|
548
625
|
await fs.removePath(tempBundle).catch(() => {});
|
|
626
|
+
// tempDownload's .part stays: the next attempt resumes it
|
|
627
|
+
await fs.removePath(tempDownload).catch(() => {});
|
|
549
628
|
};
|
|
550
629
|
|
|
551
630
|
// Patch only from an OTA bundle we installed ourselves (byte-identical to the build output)
|
|
@@ -557,10 +636,12 @@ const runUpdatePipeline = async (
|
|
|
557
636
|
!!sourcePath &&
|
|
558
637
|
(await fs.exists(sourcePath));
|
|
559
638
|
|
|
639
|
+
// Only a failed patch falls back to the full bundle; a failed commit (asset sync)
|
|
640
|
+
// would fail the same way after re-downloading the whole bundle
|
|
641
|
+
let metrics: BundleMetrics | undefined;
|
|
560
642
|
if (canPatch) {
|
|
561
643
|
try {
|
|
562
|
-
await applyPatchFile(sourcePath!);
|
|
563
|
-
return;
|
|
644
|
+
metrics = await applyPatchFile(sourcePath!);
|
|
564
645
|
} catch (error) {
|
|
565
646
|
await cleanup();
|
|
566
647
|
if (__DEV__) {
|
|
@@ -574,7 +655,7 @@ const runUpdatePipeline = async (
|
|
|
574
655
|
}
|
|
575
656
|
|
|
576
657
|
try {
|
|
577
|
-
await installFullBundle();
|
|
658
|
+
await commit(metrics ?? (await installFullBundle()));
|
|
578
659
|
} catch (error) {
|
|
579
660
|
await cleanup();
|
|
580
661
|
onStatus(OtaStatus.ERROR, `Error: ${(error as Error).message}`);
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { OtaConfig, OtaPatch, OtaStorageProvider } from '../types';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Storage Provider for
|
|
5
|
-
*
|
|
4
|
+
* Storage Provider for the public URL of a Cloudflare R2 bucket (r2.dev or a custom
|
|
5
|
+
* domain): plain HTTP GETs, so any static host serving the same layout also works.
|
|
6
6
|
*
|
|
7
7
|
* Direct URL mapping:
|
|
8
8
|
* - Manifest: `${baseUrl}/${baseFolder}/ota_registry.json`
|
|
@@ -10,19 +10,23 @@ import type { OtaConfig, OtaPatch, OtaStorageProvider } from '../types';
|
|
|
10
10
|
* - Assets: `${baseUrl}/${baseFolder}/assets/${hash}.${ext}`
|
|
11
11
|
*
|
|
12
12
|
* NOTE: Only the registry is mutable: it is fetched with Cache-Control: no-cache
|
|
13
|
-
* and a timestamp query param (
|
|
13
|
+
* and a timestamp query param (the CDN in front of the bucket may cache it). Bundles,
|
|
14
14
|
* patches, manifests and assets are never overwritten, so they stay cacheable.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
/** Headers to bypass
|
|
17
|
+
/** Headers to bypass the CDN cache on mutable files (registry, manifests) */
|
|
18
18
|
const NO_CACHE_HEADERS = { 'Cache-Control': 'no-cache' };
|
|
19
19
|
|
|
20
20
|
/** GET + JSON.parse with a timeout (fetch has none of its own); rejects on non-2xx. */
|
|
21
|
-
export const fetchJson = async (
|
|
21
|
+
export const fetchJson = async (
|
|
22
|
+
url: string,
|
|
23
|
+
timeoutMs: number,
|
|
24
|
+
headers?: Record<string, string>,
|
|
25
|
+
): Promise<any> => {
|
|
22
26
|
const controller = new AbortController();
|
|
23
27
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
24
28
|
try {
|
|
25
|
-
const res = await fetch(url, { headers: NO_CACHE_HEADERS, signal: controller.signal });
|
|
29
|
+
const res = await fetch(url, { headers: { ...NO_CACHE_HEADERS, ...headers }, signal: controller.signal });
|
|
26
30
|
if (!res.ok) {
|
|
27
31
|
throw new Error(`HTTP ${res.status} for ${url}`);
|
|
28
32
|
}
|
|
@@ -36,20 +40,20 @@ export const fetchJson = async (url: string, timeoutMs: number): Promise<any> =>
|
|
|
36
40
|
const bustCache = (url: string): string =>
|
|
37
41
|
`${url}?cb=${Date.now()}`;
|
|
38
42
|
|
|
39
|
-
const normalizeChannel = (channel: string): string => {
|
|
43
|
+
export const normalizeChannel = (channel: string): string => {
|
|
40
44
|
const lower = channel.toLowerCase();
|
|
41
45
|
if (lower === 'dev') return 'development';
|
|
42
46
|
if (lower === 'prod') return 'production';
|
|
43
47
|
return lower;
|
|
44
48
|
};
|
|
45
49
|
|
|
46
|
-
export class
|
|
50
|
+
export class R2StorageProvider implements OtaStorageProvider {
|
|
47
51
|
async fetchManifest(config: OtaConfig): Promise<OtaPatch[]> {
|
|
48
52
|
if (!config.customServer) {
|
|
49
|
-
throw new Error('[
|
|
53
|
+
throw new Error('[R2Storage] customServer configuration missing');
|
|
50
54
|
}
|
|
51
55
|
|
|
52
|
-
const { baseUrl, channel, baseFolder = 'ota' } = config.customServer;
|
|
56
|
+
const { baseUrl, channel, baseFolder = 'ota', headers } = config.customServer;
|
|
53
57
|
const channelName = normalizeChannel(channel);
|
|
54
58
|
const normalizedBaseUrl = baseUrl.endsWith('/')
|
|
55
59
|
? baseUrl.slice(0, -1)
|
|
@@ -60,11 +64,11 @@ export class GitHubRawStorageProvider implements OtaStorageProvider {
|
|
|
60
64
|
);
|
|
61
65
|
|
|
62
66
|
try {
|
|
63
|
-
const registry = await fetchJson(registryUrl, 15000);
|
|
67
|
+
const registry = await fetchJson(registryUrl, 15000, headers);
|
|
64
68
|
|
|
65
69
|
if (!config.appVersion) {
|
|
66
70
|
throw new Error(
|
|
67
|
-
'[
|
|
71
|
+
'[R2Storage] config.appVersion is required to query OTA registry',
|
|
68
72
|
);
|
|
69
73
|
}
|
|
70
74
|
const appGroup = registry?.apps?.[config.appVersion];
|
|
@@ -73,7 +77,7 @@ export class GitHubRawStorageProvider implements OtaStorageProvider {
|
|
|
73
77
|
return flavorGroup?.patches ?? [];
|
|
74
78
|
} catch (error) {
|
|
75
79
|
// Surface it: "no patches" would report a broken registry as up to date
|
|
76
|
-
throw new Error(`[
|
|
80
|
+
throw new Error(`[R2Storage] Manifest fetch error: ${(error as Error).message}`);
|
|
77
81
|
}
|
|
78
82
|
}
|
|
79
83
|
|
package/src/ota/types.ts
CHANGED
|
@@ -49,16 +49,40 @@ export type OtaPatch = {
|
|
|
49
49
|
minAppVersion?: string;
|
|
50
50
|
/** Full bundle file: used when there is no OTA bundle to patch from, or the patch fails */
|
|
51
51
|
fullBundleUrl?: string;
|
|
52
|
+
/** Size of the full bundle in bytes: download progress when it arrives Content-Encoded */
|
|
53
|
+
bundleSize?: number;
|
|
52
54
|
/** RSA/ECDSA SHA-256 signature (base64) of the patch file */
|
|
53
55
|
signature?: string;
|
|
54
56
|
/** RSA/ECDSA SHA-256 signature (base64) of the resulting bundle */
|
|
55
57
|
bundleSignature?: string;
|
|
56
58
|
/** URL to the asset manifest JSON for this version */
|
|
57
59
|
assetManifestUrl?: string;
|
|
60
|
+
/** SHA-256 of the asset manifest file (covered by entrySignature) */
|
|
61
|
+
assetManifestHash?: string;
|
|
62
|
+
/** Signature (base64) of entryPayload(entry): required when a public key is set */
|
|
63
|
+
entrySignature?: string;
|
|
58
64
|
/** Target platform for this patch */
|
|
59
65
|
platform?: string;
|
|
60
66
|
/** Flavor/channel for this patch */
|
|
61
67
|
flavor?: string;
|
|
68
|
+
/** How fullBundleUrl is compressed ('gzip' | 'bz2'); absent = raw .hbc */
|
|
69
|
+
fullBundleCompression?: string;
|
|
70
|
+
/** Native runtime fingerprint the bundle was built against: installs only on a matching binary */
|
|
71
|
+
runtimeFingerprint?: string;
|
|
72
|
+
/** Hermes bytecode version of the bundle: installs only on a runtime that reads it */
|
|
73
|
+
hermesBytecodeVersion?: number;
|
|
74
|
+
/** Client features this entry needs; a client missing one skips the entry */
|
|
75
|
+
requires?: string[];
|
|
76
|
+
|
|
77
|
+
// ── Release controls (not signed: they can only hold an update back or release a signed one) ──
|
|
78
|
+
/** Kill switch: no client installs this version */
|
|
79
|
+
disabled?: boolean;
|
|
80
|
+
/** Staged rollout: share of installs (0-100) that get this version, by a stable per-install bucket */
|
|
81
|
+
rolloutPercent?: number;
|
|
82
|
+
/** QA: only these install IDs (useOta().installId) get this version */
|
|
83
|
+
allowInstallIds?: string[];
|
|
84
|
+
/** Apply as soon as it's ready instead of on the next launch (BACKGROUND_DOWNLOAD) */
|
|
85
|
+
mandatory?: boolean;
|
|
62
86
|
};
|
|
63
87
|
|
|
64
88
|
/**
|
|
@@ -99,6 +123,8 @@ export type OtaMetrics = {
|
|
|
99
123
|
export type OtaNetworkStrategy = {
|
|
100
124
|
retry?: number;
|
|
101
125
|
timeoutMs?: number;
|
|
126
|
+
/** Automatic downloads only on Wi-Fi/Ethernet (manual applyUpdate always downloads) */
|
|
127
|
+
wifiOnly?: boolean;
|
|
102
128
|
};
|
|
103
129
|
|
|
104
130
|
export type OtaConfig = {
|
|
@@ -119,8 +145,19 @@ export type OtaConfig = {
|
|
|
119
145
|
/** Telemetry: structured event callback for analytics */
|
|
120
146
|
onEvent?: OtaEventCallback;
|
|
121
147
|
/**
|
|
122
|
-
*
|
|
123
|
-
*
|
|
148
|
+
* POSTs every OTA event as JSON to `url` (fire-and-forget), with installId, platform,
|
|
149
|
+
* appVersion, appBuild, runtimeFingerprint, flavor and the running OTA version attached.
|
|
150
|
+
*/
|
|
151
|
+
telemetry?: { url: string; headers?: Record<string, string> };
|
|
152
|
+
/**
|
|
153
|
+
* Minimum time between automatic update checks (app start / foreground / checkUpdate()).
|
|
154
|
+
* checkUpdate({ force: true }) ignores it. Default 10 minutes.
|
|
155
|
+
*/
|
|
156
|
+
minCheckIntervalMs?: number;
|
|
157
|
+
/**
|
|
158
|
+
* SPKI public key(s), PEM or base64 (comma-separated for several), for RSA/ECDSA
|
|
159
|
+
* signature verification. Defaults to the keys `patch` embedded in the app.
|
|
160
|
+
* When there is a key, unsigned or wrongly signed updates are rejected.
|
|
124
161
|
*/
|
|
125
162
|
publicKey?: string;
|
|
126
163
|
|
|
@@ -130,13 +167,15 @@ export type OtaConfig = {
|
|
|
130
167
|
* Read-only and public: never put upload credentials here, they ship inside the app.
|
|
131
168
|
*/
|
|
132
169
|
customServer?: {
|
|
133
|
-
/** e.g. https://
|
|
170
|
+
/** R2 public URL, e.g. https://pub-<id>.r2.dev or a custom domain */
|
|
134
171
|
baseUrl: string;
|
|
135
172
|
channel: string;
|
|
136
173
|
baseFolder?: string;
|
|
174
|
+
/** Extra headers on every request (registry, bundles, assets), e.g. auth for a private server */
|
|
175
|
+
headers?: Record<string, string>;
|
|
137
176
|
};
|
|
138
177
|
|
|
139
|
-
/** Storage provider instance (optional, defaults to
|
|
178
|
+
/** Storage provider instance (optional, defaults to R2StorageProvider) */
|
|
140
179
|
storage?: OtaStorageProvider;
|
|
141
180
|
};
|
|
142
181
|
|
|
@@ -163,12 +202,17 @@ export type OtaState = {
|
|
|
163
202
|
metrics: OtaMetrics | null;
|
|
164
203
|
/** True if a background update has been downloaded and prepared silently */
|
|
165
204
|
isUpdateReady: boolean;
|
|
205
|
+
/** The ready update is marked mandatory: it reloads as soon as the app is in the foreground */
|
|
206
|
+
isMandatory: boolean;
|
|
207
|
+
/** Stable random ID of this install (rollout bucket, QA allow-lists, telemetry) */
|
|
208
|
+
installId: string;
|
|
166
209
|
applyUpdate: () => Promise<void>;
|
|
167
210
|
/** Installs and reloads `version` from the last checked manifest (full bundle, or a patch from the current version) */
|
|
168
211
|
applyVersion: (version: number) => Promise<void>;
|
|
169
212
|
resetToFactory: () => Promise<void>;
|
|
170
213
|
markAsHealthy: () => Promise<void>;
|
|
171
|
-
|
|
214
|
+
/** Checks now unless the last check was under minCheckIntervalMs ago (force: always) */
|
|
215
|
+
checkUpdate: (options?: { force?: boolean }) => Promise<void>;
|
|
172
216
|
/** Reload the React Native JS bundle immediately to apply downloaded update */
|
|
173
217
|
reloadBundle: () => void;
|
|
174
218
|
};
|