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.
Files changed (81) hide show
  1. package/README.md +83 -27
  2. package/android/build.gradle +25 -0
  3. package/android/src/main/AndroidManifest.xml +2 -0
  4. package/android/src/main/java/vn/reactnativebspatch/BsPatchModule.kt +86 -6
  5. package/android/src/main/res/values/bspatch_public_key.xml +5 -0
  6. package/cpp/bsdiff/bspatch_wrapper.c +34 -0
  7. package/cpp/bsdiff/bspatch_wrapper.h +2 -0
  8. package/cpp/bspatch_jni.cpp +18 -0
  9. package/ios/RNBsPatch.mm +142 -1
  10. package/lib/commonjs/NativeBsPatch.js.map +1 -1
  11. package/lib/commonjs/index.js +9 -2
  12. package/lib/commonjs/index.js.map +1 -1
  13. package/lib/commonjs/ota/OtaProvider.js +116 -20
  14. package/lib/commonjs/ota/OtaProvider.js.map +1 -1
  15. package/lib/commonjs/ota/eligibility.js +40 -0
  16. package/lib/commonjs/ota/eligibility.js.map +1 -0
  17. package/lib/commonjs/ota/entrySignature.js +23 -0
  18. package/lib/commonjs/ota/entrySignature.js.map +1 -0
  19. package/lib/commonjs/ota/installId.js +27 -0
  20. package/lib/commonjs/ota/installId.js.map +1 -0
  21. package/lib/commonjs/ota/otaCore.js +96 -21
  22. package/lib/commonjs/ota/otaCore.js.map +1 -1
  23. package/lib/commonjs/ota/storage/{GitHubRawStorage.js → R2Storage.js} +20 -15
  24. package/lib/commonjs/ota/storage/R2Storage.js.map +1 -0
  25. package/lib/commonjs/ota/types.js.map +1 -1
  26. package/lib/module/NativeBsPatch.js.map +1 -1
  27. package/lib/module/index.js +2 -1
  28. package/lib/module/index.js.map +1 -1
  29. package/lib/module/ota/OtaProvider.js +116 -20
  30. package/lib/module/ota/OtaProvider.js.map +1 -1
  31. package/lib/module/ota/eligibility.js +34 -0
  32. package/lib/module/ota/eligibility.js.map +1 -0
  33. package/lib/module/ota/entrySignature.js +17 -0
  34. package/lib/module/ota/entrySignature.js.map +1 -0
  35. package/lib/module/ota/installId.js +22 -0
  36. package/lib/module/ota/installId.js.map +1 -0
  37. package/lib/module/ota/otaCore.js +96 -21
  38. package/lib/module/ota/otaCore.js.map +1 -1
  39. package/lib/module/ota/storage/{GitHubRawStorage.js → R2Storage.js} +18 -14
  40. package/lib/module/ota/storage/R2Storage.js.map +1 -0
  41. package/lib/module/ota/types.js.map +1 -1
  42. package/lib/typescript/NativeBsPatch.d.ts +28 -2
  43. package/lib/typescript/NativeBsPatch.d.ts.map +1 -1
  44. package/lib/typescript/index.d.ts +3 -1
  45. package/lib/typescript/index.d.ts.map +1 -1
  46. package/lib/typescript/ota/OtaProvider.d.ts.map +1 -1
  47. package/lib/typescript/ota/eligibility.d.ts +19 -0
  48. package/lib/typescript/ota/eligibility.d.ts.map +1 -0
  49. package/lib/typescript/ota/entrySignature.d.ts +9 -0
  50. package/lib/typescript/ota/entrySignature.d.ts.map +1 -0
  51. package/lib/typescript/ota/installId.d.ts +3 -0
  52. package/lib/typescript/ota/installId.d.ts.map +1 -0
  53. package/lib/typescript/ota/otaCore.d.ts.map +1 -1
  54. package/lib/typescript/ota/storage/{GitHubRawStorage.d.ts → R2Storage.d.ts} +4 -3
  55. package/lib/typescript/ota/storage/R2Storage.d.ts.map +1 -0
  56. package/lib/typescript/ota/types.d.ts +52 -5
  57. package/lib/typescript/ota/types.d.ts.map +1 -1
  58. package/package.json +3 -2
  59. package/react-native-bspatch.podspec +3 -1
  60. package/src/NativeBsPatch.ts +34 -2
  61. package/src/index.ts +5 -1
  62. package/src/ota/OtaProvider.tsx +115 -24
  63. package/src/ota/eligibility.ts +48 -0
  64. package/src/ota/entrySignature.ts +31 -0
  65. package/src/ota/installId.ts +18 -0
  66. package/src/ota/otaCore.ts +102 -21
  67. package/src/ota/storage/{GitHubRawStorage.ts → R2Storage.ts} +17 -13
  68. package/src/ota/types.ts +49 -5
  69. package/tools/build_ota.sh +343 -116
  70. package/tools/doctor.js +130 -0
  71. package/tools/ensure_signing_key.js +216 -0
  72. package/tools/fingerprint.js +111 -0
  73. package/tools/ios_fingerprint.sh +9 -0
  74. package/tools/ota_admin.js +131 -0
  75. package/tools/setup_project.js +41 -15
  76. package/tools/update_registry.js +111 -11
  77. package/tools/upload_ota.sh +85 -184
  78. package/tools/verify_publish.js +81 -0
  79. package/lib/commonjs/ota/storage/GitHubRawStorage.js.map +0 -1
  80. package/lib/module/ota/storage/GitHubRawStorage.js.map +0 -1
  81. package/lib/typescript/ota/storage/GitHubRawStorage.d.ts.map +0 -1
@@ -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 { fetchJson, GitHubRawStorageProvider } from './storage/GitHubRawStorage';
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 GitHubRawStorageProvider();
40
+ const DEFAULT_STORAGE = new R2StorageProvider();
39
41
 
40
- /** Explicit `config.storage` or the default static-host provider (GitHub raw, R2, CDN...). */
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 `config.publicKey` is set, an RSA/ECDSA SHA-256 signature is mandatory
108
- * and must verify (fail closed). Without a public key, signing is not enforced.
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
- if (!config.publicKey) return;
117
- const valid =
118
- !!signature &&
119
- (await NativeBsPatch.verifySignature(filePath, signature, config.publicKey).catch(() => false));
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
- const manifest = await fetchJson(assetManifestUrl, 10000);
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: Omit<OtaMetrics, 'totalMs' | 'fromCache'>) => {
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
- const applyPatchFile = async (sourcePath: string) => {
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
- await commit({ patchSizeKB: sizeBytes / 1024, downloadMs, applyMs });
601
+ return { patchSizeKB: sizeBytes / 1024, downloadMs, applyMs };
532
602
  };
533
603
 
534
- const installFullBundle = async () => {
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 { downloadMs, sizeBytes } = await download(patch.fullBundleUrl, tempBundle);
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
- await commit({ patchSizeKB: sizeBytes / 1024, downloadMs, applyMs: 0 });
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 any static host with this layout: GitHub raw, a public
5
- * Cloudflare R2 bucket (r2.dev or custom domain), S3, a CDN...
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 (GitHub's CDN caches for 5 minutes). Bundles,
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 GitHub CDN cache on mutable files (registry, manifests) */
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 (url: string, timeoutMs: number): Promise<any> => {
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 GitHubRawStorageProvider implements OtaStorageProvider {
50
+ export class R2StorageProvider implements OtaStorageProvider {
47
51
  async fetchManifest(config: OtaConfig): Promise<OtaPatch[]> {
48
52
  if (!config.customServer) {
49
- throw new Error('[GitHubRawStorage] customServer configuration missing');
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
- '[GitHubRawStorage] config.appVersion is required to query OTA registry',
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(`[GitHubRawStorage] Manifest fetch error: ${(error as Error).message}`);
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
- * SPKI public key (PEM or base64) for RSA/ECDSA signature verification.
123
- * When set, patches and bundles without a valid signature are rejected.
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://raw.githubusercontent.com/<owner>/<repo>/<branch> */
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 GitHubRawStorageProvider) */
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
- checkUpdate: () => Promise<void>;
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
  };