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
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * doctor.js — checks the whole OTA setup of a project for one release + flavor, read-only.
4
+ * Run by `npx react-native-bspatch doctor` after the registry was synced from R2 (so the
5
+ * R2 credentials already work when this runs).
6
+ *
7
+ * Usage: node doctor.js <projectRoot> <registryPath> <appVersion> <flavor> <baseUrl> <baseFolder>
8
+ * Exit 1 when a check fails.
9
+ */
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const { spawnSync } = require('child_process');
14
+
15
+ const [root, registryPath, appVersion, flavor, baseUrl, folder] = process.argv.slice(2);
16
+ const tty = process.stdout.isTTY;
17
+ const c = (code, text) => (tty ? `\x1b[${code}m${text}\x1b[0m` : text);
18
+ let failed = 0;
19
+ const line = (mark, label, text) => console.log(` ${mark} ${c('36', label.padEnd(10))}${text}`);
20
+ const ok = (label, text) => line(c('32', '✓'), label, text);
21
+ const warn = (label, text) => line(c('33', '⚠'), label, text);
22
+ const bad = (label, text) => {
23
+ failed++;
24
+ line(c('31', '✗'), label, text);
25
+ };
26
+ const read = (rel) => {
27
+ try {
28
+ return fs.readFileSync(path.join(root, rel), 'utf8');
29
+ } catch {
30
+ return '';
31
+ }
32
+ };
33
+ const find = (dir, name) => {
34
+ const out = [];
35
+ const walk = (d, depth) => {
36
+ if (depth > 10) return;
37
+ let entries = [];
38
+ try {
39
+ entries = fs.readdirSync(d, { withFileTypes: true });
40
+ } catch {}
41
+ for (const e of entries) {
42
+ if (['Pods', 'build', 'node_modules', '.gradle'].includes(e.name)) continue;
43
+ const full = path.join(d, e.name);
44
+ if (e.isDirectory()) walk(full, depth + 1);
45
+ else if (e.name === name) out.push(full);
46
+ }
47
+ };
48
+ walk(path.join(root, dir), 0);
49
+ return out;
50
+ };
51
+
52
+ (async () => {
53
+ console.log(`\n ${c('1', 'doctor')} ${c('2', `· ${appVersion} · ${flavor}`)}\n`);
54
+ const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
55
+ const group = registry.apps?.[appVersion]?.flavors?.[flavor];
56
+
57
+ // ── Storage: the URL the app reads serves what was uploaded to the bucket ──
58
+ if (/\.r2\.dev\b/.test(baseUrl)) warn('storage', `${baseUrl} is R2's rate-limited dev URL: connect a custom domain for production`);
59
+ try {
60
+ const res = await fetch(`${baseUrl.replace(/\/$/, '')}/${folder}/ota_registry.json?cb=${Date.now()}`, {
61
+ headers: { 'Cache-Control': 'no-cache' },
62
+ signal: AbortSignal.timeout(15000),
63
+ });
64
+ if (res.status === 404 && !Object.keys(registry.apps || {}).length) {
65
+ warn('storage', `${new URL(baseUrl).host} works; nothing published to this bucket yet (the first patch creates the registry)`);
66
+ throw null;
67
+ }
68
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
69
+ const served = await res.json();
70
+ if (JSON.stringify(served) === JSON.stringify(registry)) ok('storage', `${new URL(baseUrl).host} serves the bucket's registry`);
71
+ else bad('storage', `${new URL(baseUrl).host} serves a different registry than the bucket: OTA_BASE_URL in .env.${flavor} points at another bucket (or a stale cache)`);
72
+ } catch (e) {
73
+ if (e) bad('storage', `OTA_BASE_URL (${baseUrl}) doesn't serve ${folder}/ota_registry.json: ${e.message}`);
74
+ }
75
+
76
+ // ── Signing ──
77
+ const key = spawnSync('node', [path.join(__dirname, 'ensure_signing_key.js'), root, registryPath, '--check'], { encoding: 'utf8' });
78
+ const keyText = (key.stdout.trim() || key.stderr.trim().replace(/^\s*❌\s*/, '')).split('\n')[0];
79
+ if (key.status === 0) ok('signing', keyText);
80
+ else if (key.status === 2) warn('signing', keyText);
81
+ else bad('signing', keyText);
82
+ if (fs.existsSync(path.join(root, 'ota_private_key.backup.pem'))) {
83
+ warn('backup', 'ota_private_key.backup.pem is on this machine: keep it offline (password manager) and delete it here');
84
+ }
85
+
86
+ // ── Native wiring ──
87
+ const appDelegate = find('ios', 'AppDelegate.swift')[0];
88
+ if (!appDelegate) warn('iOS', 'no AppDelegate.swift found');
89
+ else if (fs.readFileSync(appDelegate, 'utf8').includes('OtaBundleResolver.resolve')) ok('iOS', 'AppDelegate loads the OTA bundle');
90
+ else bad('iOS', 'AppDelegate doesn\'t load the OTA bundle: run `npx react-native-bspatch setup`');
91
+ const pbxproj = find('ios', 'project.pbxproj')[0];
92
+ if (pbxproj && !fs.readFileSync(pbxproj, 'utf8').includes('[react-native-bspatch] runtime fingerprint')) {
93
+ bad('iOS', 'no runtime fingerprint build step: run `npx react-native-bspatch setup`, then rebuild');
94
+ }
95
+ const mainApp = find('android', 'MainApplication.kt')[0];
96
+ if (!mainApp) warn('Android', 'no MainApplication.kt found');
97
+ else if (fs.readFileSync(mainApp, 'utf8').includes('OtaBundleResolver.resolve')) ok('Android', 'MainApplication loads the OTA bundle');
98
+ else bad('Android', 'MainApplication doesn\'t load the OTA bundle: run `npx react-native-bspatch setup`');
99
+
100
+ let installed;
101
+ try {
102
+ installed = JSON.parse(read('node_modules/react-native-bspatch/package.json')).version;
103
+ } catch {}
104
+ const podVersion = read('ios/Podfile.lock').match(/- react-native-bspatch \(([^)]+)\)/)?.[1];
105
+ if (installed && podVersion && podVersion !== installed) {
106
+ bad('pods', `Podfile.lock has react-native-bspatch ${podVersion}, node_modules ${installed}: run pod install`);
107
+ }
108
+
109
+ // ── Native fingerprint vs what's live ──
110
+ for (const platform of ['ios', 'android']) {
111
+ const fp = spawnSync('node', [path.join(__dirname, 'fingerprint.js'), root, platform], { encoding: 'utf8' }).stdout.trim();
112
+ const live = (group?.patches || []).find(e => e.platform === platform);
113
+ if (!live) ok(platform, `fingerprint ${fp} ${c('2', '(no OTA live yet)')}`);
114
+ else if (!live.runtimeFingerprint) warn(platform, `fingerprint ${fp}; live v${live.toVersion} has none (published by an older CLI)`);
115
+ else if (live.runtimeFingerprint === fp) ok(platform, `fingerprint ${fp} = live v${live.toVersion}`);
116
+ else warn(platform, `fingerprint ${fp} ≠ live v${live.toVersion} (${live.runtimeFingerprint}): native code changed, the next OTA only reaches a new store build`);
117
+ }
118
+
119
+ // ── Tools ──
120
+ const major = Number(process.versions.node.split('.')[0]);
121
+ if (major < 18) bad('node', `Node ${process.versions.node}: 18+ needed`);
122
+ const hermesc = ['node_modules/hermes-compiler/hermesc', 'node_modules/react-native/sdks/hermesc']
123
+ .flatMap(d => ['osx-bin', 'linux64-bin'].map(b => path.join(root, d, b, 'hermesc')))
124
+ .find(p => fs.existsSync(p));
125
+ if (hermesc) ok('hermesc', path.relative(root, hermesc));
126
+ else bad('hermesc', 'not found in node_modules: is react-native installed?');
127
+
128
+ console.log(failed ? `\n ${c('31', `${failed} problem(s)`)}\n` : `\n ${c('32', 'all good')}\n`);
129
+ process.exit(failed ? 1 : 0);
130
+ })();
@@ -0,0 +1,216 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ensure_signing_key.js — run by `patch` before building, so signing needs no setup step.
4
+ *
5
+ * The app embeds two public keys: the signing key and an offline backup
6
+ * (ota_private_key.backup.pem), so a lost or leaked signing key is replaced by promoting
7
+ * the backup, without a store build.
8
+ *
9
+ * Key present → every place the app has keys (Info.plist, Android resource, `publicKey:`
10
+ * in JS) must include it; it gets embedded natively, with a backup, if the app has none.
11
+ * Key missing → a new pair is created and embedded, but only for an app that has never
12
+ * been signed: a new key for an app whose installs verify the old one would make every
13
+ * update fail, so that case (and CI) stops with what to do instead.
14
+ *
15
+ * Usage: node ensure_signing_key.js <projectRoot> <registryPath> [--interactive | --check]
16
+ * --check (doctor): never writes; exit 2 when `patch` would create or embed something.
17
+ * stdout: one status line. Errors and guidance go to stderr; exit 1 on error.
18
+ */
19
+
20
+ const crypto = require('crypto');
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+ const { execFileSync } = require('child_process');
24
+
25
+ const [root, registryPath, ...flags] = process.argv.slice(2);
26
+ const interactive = flags.includes('--interactive');
27
+ const checkOnly = flags.includes('--check');
28
+ const KEY_FILE = path.join(root, 'ota_private_key.pem');
29
+ const BACKUP_FILE = path.join(root, 'ota_private_key.backup.pem');
30
+ const ANDROID_RES = path.join(root, 'android/app/src/main/res/values/bspatch_public_key.xml');
31
+
32
+ const fail = (message) => {
33
+ console.error(message);
34
+ process.exit(1);
35
+ };
36
+ const rel = (p) => path.relative(root, p);
37
+ const spki = (key) => crypto.createPublicKey(key).export({ type: 'spki', format: 'der' });
38
+ const keyId = (der) => crypto.createHash('sha256').update(der).digest('hex').slice(0, 16);
39
+ const split = (keys) => keys.split(/[\s,]+/).filter(Boolean);
40
+ const newPrivateKey = () =>
41
+ crypto.generateKeyPairSync('rsa', {
42
+ modulusLength: 2048,
43
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
44
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
45
+ }).privateKey;
46
+ const ignore = (file) => {
47
+ const gitignore = path.join(root, '.gitignore');
48
+ const name = path.basename(file);
49
+ const content = fs.existsSync(gitignore) ? fs.readFileSync(gitignore, 'utf8') : '';
50
+ if (!new RegExp(`^/?${name.replace(/\./g, '\\.')}$`, 'm').test(content)) {
51
+ fs.appendFileSync(gitignore, `${content.endsWith('\n') || !content ? '' : '\n'}${name}\n`);
52
+ }
53
+ };
54
+ /** The backup's public key, creating the backup (gitignored) if there is none */
55
+ const backupPublicKey = () => {
56
+ if (!fs.existsSync(BACKUP_FILE)) {
57
+ fs.writeFileSync(BACKUP_FILE, newPrivateKey(), { mode: 0o600 });
58
+ ignore(BACKUP_FILE);
59
+ console.error(
60
+ ` ✓ Created the backup signing key ${rel(BACKUP_FILE)}: move it OFF this machine (password manager).\n` +
61
+ ' If the signing key is ever lost or leaked, put it back as ota_private_key.pem: installed apps already trust it.',
62
+ );
63
+ }
64
+ return spki(fs.readFileSync(BACKUP_FILE, 'utf8')).toString('base64');
65
+ };
66
+
67
+ /** The app target's Info.plist files (one per target/configuration), test targets excluded */
68
+ const infoPlists = () => {
69
+ const iosDir = path.join(root, 'ios');
70
+ let projects = [];
71
+ try {
72
+ projects = fs.readdirSync(iosDir).filter(f => f.endsWith('.xcodeproj'));
73
+ } catch {
74
+ return [];
75
+ }
76
+ const plists = new Set();
77
+ for (const project of projects) {
78
+ const pbx = fs.readFileSync(path.join(iosDir, project, 'project.pbxproj'), 'utf8');
79
+ for (const [, value] of pbx.matchAll(/INFOPLIST_FILE = "?([^";]+)"?;/g)) {
80
+ if (/Tests/.test(value)) continue;
81
+ const file = path.join(iosDir, value.replace(/^\$\(SRCROOT\)\/?/, ''));
82
+ if (fs.existsSync(file)) plists.add(file);
83
+ }
84
+ }
85
+ return [...plists];
86
+ };
87
+
88
+ /** Public keys the app already ships: [{ where, keys, native }] */
89
+ const appKeys = () => {
90
+ const keys = [];
91
+ for (const plist of infoPlists()) {
92
+ try {
93
+ const key = execFileSync('plutil', ['-extract', 'BsPatchPublicKey', 'raw', '-o', '-', plist], {
94
+ stdio: ['ignore', 'pipe', 'ignore'],
95
+ }).toString().trim();
96
+ if (key) keys.push({ where: rel(plist), keys: split(key), native: true });
97
+ } catch {} // no such key (or no plutil off macOS)
98
+ }
99
+ const res = fs.existsSync(ANDROID_RES) && fs.readFileSync(ANDROID_RES, 'utf8').match(/<string[^>]*>([^<]+)</);
100
+ if (res) keys.push({ where: rel(ANDROID_RES), keys: split(res[1]), native: true });
101
+ // `publicKey: '…'` passed to OtaProvider
102
+ const walk = (dir) => {
103
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
104
+ const full = path.join(dir, e.name);
105
+ if (e.isDirectory()) {
106
+ if (!['node_modules', 'ios', 'android', '.git'].includes(e.name) && !e.name.startsWith('.')) walk(full);
107
+ } else if (/\.(tsx?|jsx?)$/.test(e.name)) {
108
+ const m = fs.readFileSync(full, 'utf8').match(/publicKey\s*:\s*['"`]([A-Za-z0-9+/=\s-]{100,}?)['"`]/);
109
+ if (m) keys.push({ where: rel(full), keys: split(m[1]), native: false });
110
+ }
111
+ }
112
+ };
113
+ walk(root);
114
+ return keys;
115
+ };
116
+
117
+ /** Embeds the public keys natively (comma-separated); returns where they went */
118
+ const embed = (publicKeys) => {
119
+ const publicKeyBase64 = publicKeys.join(',');
120
+ const written = [];
121
+ if (fs.existsSync(path.dirname(path.dirname(ANDROID_RES)))) {
122
+ fs.mkdirSync(path.dirname(ANDROID_RES), { recursive: true });
123
+ fs.writeFileSync(
124
+ ANDROID_RES,
125
+ `<?xml version="1.0" encoding="utf-8"?>
126
+ <!-- react-native-bspatch: public key OTA updates are verified with (not a secret) -->
127
+ <resources>
128
+ <string name="bspatch_public_key" translatable="false">${publicKeyBase64}</string>
129
+ </resources>
130
+ `,
131
+ );
132
+ written.push(rel(ANDROID_RES));
133
+ }
134
+ for (const plist of infoPlists()) {
135
+ execFileSync('plutil', ['-replace', 'BsPatchPublicKey', '-string', publicKeyBase64, plist]);
136
+ written.push(rel(plist));
137
+ }
138
+ return written;
139
+ };
140
+
141
+ const registrySigned = () => {
142
+ try {
143
+ const { apps = {} } = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
144
+ return Object.values(apps).some(app =>
145
+ Object.values(app.flavors || {}).some(f =>
146
+ (f.patches || []).some(e => e.entrySignature || e.bundleSignature || e.signature),
147
+ ),
148
+ );
149
+ } catch {
150
+ return false;
151
+ }
152
+ };
153
+
154
+ const known = appKeys();
155
+ const privateKey = process.env.OTA_PRIVATE_KEY || (fs.existsSync(KEY_FILE) && fs.readFileSync(KEY_FILE, 'utf8'));
156
+
157
+ if (privateKey) {
158
+ let der;
159
+ try {
160
+ der = spki(privateKey);
161
+ } catch (e) {
162
+ fail(` ❌ The OTA signing key can't be read (${process.env.OTA_PRIVATE_KEY ? 'OTA_PRIVATE_KEY' : rel(KEY_FILE)}): ${e.message}`);
163
+ }
164
+ const publicKeyBase64 = der.toString('base64');
165
+ const wrong = known.filter(k => !k.keys.includes(publicKeyBase64));
166
+ if (wrong.length) {
167
+ fail(
168
+ ` ❌ The OTA signing key isn't one of the public keys in ${wrong.map(k => k.where).join(', ')}.\n` +
169
+ ' Installed apps would reject every update signed with it: use the original ota_private_key.pem\n' +
170
+ ' (or the backup key, if the app embeds it).',
171
+ );
172
+ }
173
+ if (checkOnly && !known.some(k => k.native)) {
174
+ console.log(`key ${keyId(der)} · not embedded in the app yet (the next patch embeds it)`);
175
+ process.exit(2);
176
+ }
177
+ if (known.some(k => k.native)) {
178
+ console.log(`key ${keyId(der)}`);
179
+ } else {
180
+ const where = embed([publicKeyBase64, backupPublicKey()]);
181
+ console.error(` ✓ Embedded the signing + backup public keys in ${where.join(', ')}: commit them; store builds from now on verify updates.`);
182
+ console.log(`key ${keyId(der)} · embedded in app`);
183
+ }
184
+ } else {
185
+ if (known.length || registrySigned()) {
186
+ fail(
187
+ ` ❌ ota_private_key.pem not found, but this app already ${
188
+ known.length ? `verifies updates with the keys in ${known.map(k => k.where).join(', ')}` : 'has signed releases'
189
+ }.\n` +
190
+ ` Copy the original key to ${KEY_FILE} (CI: set OTA_PRIVATE_KEY), or put the backup key there.\n` +
191
+ " Don't create a new one: installed apps would reject every update signed with it.",
192
+ );
193
+ }
194
+ if (checkOnly) {
195
+ console.log('no signing key yet (the next patch in a terminal creates one)');
196
+ process.exit(2);
197
+ }
198
+ if (!interactive) {
199
+ fail(
200
+ ' ❌ No OTA signing key. Run `npx react-native-bspatch patch` once in a terminal: it creates one.\n' +
201
+ ' Then add the key to CI as OTA_PRIVATE_KEY.',
202
+ );
203
+ }
204
+ const pem = newPrivateKey();
205
+ fs.writeFileSync(KEY_FILE, pem, { mode: 0o600 });
206
+ ignore(KEY_FILE);
207
+ const der = spki(pem);
208
+ const where = embed([der.toString('base64'), backupPublicKey()]);
209
+ console.error(
210
+ ` ✓ Created the OTA signing key ${rel(KEY_FILE)} (gitignored) and embedded its public key, with the backup's, in:\n` +
211
+ where.map(w => ` ${w}\n`).join('') +
212
+ ' • Back the signing key up too (password manager, CI secret OTA_PRIVATE_KEY).\n' +
213
+ ' • Commit the files above: store builds from now on verify every update.',
214
+ );
215
+ console.log(`key ${keyId(der)} · created`);
216
+ }
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * fingerprint.js — native runtime fingerprint: what an OTA's JS needs from the binary.
4
+ *
5
+ * Stamped into the store build (Xcode build phase → bspatch_fingerprint.txt in the .app,
6
+ * Gradle → resValue bspatch_runtime_fingerprint) and recorded in every registry entry by
7
+ * `patch`. A client only installs an OTA whose fingerprint matches its binary, so JS that
8
+ * calls a native module the binary doesn't have never reaches it.
9
+ *
10
+ * Inputs (per platform): the react-native version, every direct dependency with native code
11
+ * for that platform (name@version), and the native dependency lock: ios/Podfile.lock on iOS,
12
+ * the dependency lines of the Gradle build files on Android. The app's own native sources
13
+ * are not included: changing AppDelegate/MainApplication needs a new store build anyway.
14
+ *
15
+ * Usage: node fingerprint.js <projectRoot> <ios|android> [--explain]
16
+ */
17
+
18
+ const crypto = require('crypto');
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+
22
+ const [root, platform, flag] = process.argv.slice(2);
23
+ if (!root || !['ios', 'android'].includes(platform)) {
24
+ console.error('Usage: node fingerprint.js <projectRoot> <ios|android> [--explain]');
25
+ process.exit(1);
26
+ }
27
+
28
+ const read = (file) => {
29
+ try {
30
+ return fs.readFileSync(path.join(root, file), 'utf8');
31
+ } catch {
32
+ return '';
33
+ }
34
+ };
35
+ const pkg = JSON.parse(read('package.json') || '{}');
36
+
37
+ const hasNative = (dir) => {
38
+ const has = (p) => fs.existsSync(path.join(dir, p));
39
+ if (platform === 'android') return has('android/build.gradle') || has('android/build.gradle.kts');
40
+ try {
41
+ return fs.readdirSync(dir).some(f => f.endsWith('.podspec')) || has('ios');
42
+ } catch {
43
+ return false;
44
+ }
45
+ };
46
+
47
+ /** Hash of a package's native sources (ios/, android/src, cpp/, *.podspec, android/build.gradle) */
48
+ const nativeHash = (dir) => {
49
+ const hash = crypto.createHash('sha256');
50
+ const walk = (rel) => {
51
+ const full = path.join(dir, rel);
52
+ let stat;
53
+ try {
54
+ stat = fs.statSync(full);
55
+ } catch {
56
+ return;
57
+ }
58
+ if (stat.isDirectory()) {
59
+ for (const name of fs.readdirSync(full).sort()) {
60
+ if (!['build', '.cxx', '.gradle'].includes(name)) walk(path.join(rel, name));
61
+ }
62
+ } else {
63
+ hash.update(rel).update(fs.readFileSync(full));
64
+ }
65
+ };
66
+ for (const rel of ['ios', 'android/src', 'android/build.gradle', 'cpp']) walk(rel);
67
+ for (const f of fs.readdirSync(dir).filter(f => f.endsWith('.podspec')).sort()) walk(f);
68
+ return hash.digest('hex').slice(0, 16);
69
+ };
70
+
71
+ const inputs = [];
72
+ for (const name of Object.keys(pkg.dependencies || {}).sort()) {
73
+ const dir = path.join(root, 'node_modules', name);
74
+ let version;
75
+ try {
76
+ version = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).version;
77
+ } catch {
78
+ continue; // not installed: can't be linked either
79
+ }
80
+ if (name === 'react-native-bspatch') {
81
+ // Its own native code, not its version: a JS-only release of this library ships by OTA
82
+ inputs.push(`dep ${name}#${nativeHash(dir)}`);
83
+ } else if (name === 'react-native' || hasNative(dir)) {
84
+ inputs.push(`dep ${name}@${version}`);
85
+ }
86
+ }
87
+
88
+ if (platform === 'ios') {
89
+ // Skipped: COCOAPODS: (the CocoaPods version that wrote the file) and this library's own
90
+ // lines (its version and podspec checksum change with JS-only releases; nativeHash covers it)
91
+ for (const line of read('ios/Podfile.lock').split('\n')) {
92
+ if (line.trim() && !line.startsWith('COCOAPODS:') && !line.includes('react-native-bspatch')) {
93
+ inputs.push(`pod ${line.trimEnd()}`);
94
+ }
95
+ }
96
+ } else {
97
+ for (const file of ['android/build.gradle', 'android/app/build.gradle', 'android/settings.gradle']) {
98
+ for (const line of read(file).split('\n')) {
99
+ if (/^\s*(implementation|api|classpath|compileOnly|runtimeOnly|include)\b/.test(line)) {
100
+ inputs.push(`gradle ${file}: ${line.trim()}`);
101
+ }
102
+ }
103
+ }
104
+ for (const line of read('android/gradle.properties').split('\n')) {
105
+ if (/^\s*(newArchEnabled|hermesEnabled)\s*=/.test(line)) inputs.push(`prop ${line.trim()}`);
106
+ }
107
+ }
108
+
109
+ const fingerprint = crypto.createHash('sha256').update(inputs.join('\n')).digest('hex').slice(0, 16);
110
+ if (flag === '--explain') console.error(inputs.join('\n'));
111
+ console.log(fingerprint);
@@ -0,0 +1,9 @@
1
+ #!/bin/sh
2
+ # Xcode build phase step (wired by `react-native-bspatch setup`, run through RN's
3
+ # with-environment.sh so NODE_BINARY is set): stamps the native runtime fingerprint
4
+ # into the .app. An OTA only installs on a binary with the same fingerprint.
5
+ set -e
6
+ DIR=$(cd "$(dirname "$0")" && pwd)
7
+ FP=$("$NODE_BINARY" "$DIR/fingerprint.js" "$SRCROOT/.." ios)
8
+ echo "$FP" > "$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/bspatch_fingerprint.txt"
9
+ echo "react-native-bspatch: runtime fingerprint $FP"
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ota_admin.js — release controls and status for one release + flavor (run by build_ota.sh
4
+ * after it synced the registry).
5
+ *
6
+ * status what's live per platform (+ its release_*.json details)
7
+ * disable N | enable N kill switch: no client installs vN
8
+ * rollout N <percent> staged rollout: that share of installs gets vN (100 = everyone)
9
+ * mandatory N [--off] apply vN as soon as it's ready instead of on the next launch
10
+ * qa N --ids a,b | --off only these install IDs (useOta().installId) get vN
11
+ *
12
+ * Controls live on the registry entries of the live version and aren't signed: they can
13
+ * only hold an update back or release one that is already signed.
14
+ *
15
+ * Usage: node ota_admin.js <command> <registry> <appVersion> <flavor> <releasesDir>
16
+ * [--platform p] [--ids a,b] [--off] [--json] [N] [percent]
17
+ * Exit 3 when the registry changed (build_ota.sh uploads it), 1 on error.
18
+ */
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+
23
+ const argv = process.argv.slice(2);
24
+ const [command, registryPath, appVersion, flavor, releasesDir] = argv;
25
+ const flag = (name) => {
26
+ const i = argv.indexOf(name);
27
+ return i === -1 ? undefined : argv[i + 1];
28
+ };
29
+ const platform = flag('--platform');
30
+ const off = argv.includes('--off');
31
+ const json = argv.includes('--json');
32
+ const numbers = argv.slice(5).filter((a, i, all) => /^\d+$/.test(a) && !['--platform', '--ids'].includes(all[i - 1]));
33
+ const [version, percent] = numbers.map(Number);
34
+
35
+ const tty = process.stdout.isTTY;
36
+ const c = (code, text) => (tty ? `\x1b[${code}m${text}\x1b[0m` : text);
37
+ const fail = (message) => {
38
+ console.error(` ❌ ${message}`);
39
+ process.exit(1);
40
+ };
41
+
42
+ const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
43
+ const group = registry.apps?.[appVersion]?.flavors?.[flavor];
44
+ if (!group?.patches?.length) fail(`No OTA published for ${appVersion} / ${flavor} yet`);
45
+ const entries = group.patches.filter(e => !platform || e.platform === platform);
46
+
47
+ const controls = (e) =>
48
+ [
49
+ e.disabled && c('31', 'disabled'),
50
+ e.rolloutPercent !== undefined && c('33', `rollout ${e.rolloutPercent}%`),
51
+ e.allowInstallIds?.length && c('35', `QA ${e.allowInstallIds.length} install(s)`),
52
+ e.mandatory && c('36', 'mandatory'),
53
+ ].filter(Boolean);
54
+
55
+ if (command === 'status') {
56
+ const platforms = [...new Set(entries.map(e => e.platform))].sort();
57
+ const report = platforms.map(p => {
58
+ const live = entries.filter(e => e.platform === p);
59
+ const full = live.find(e => !e.filename) || live[0];
60
+ const file = path.join(releasesDir, `release_${appVersion}_v${full.toVersion}_${p}_${flavor}.json`);
61
+ const release = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : null;
62
+ return { platform: p, version: full.toVersion, patchesFrom: live.filter(e => e.filename).map(e => e.fromVersion), entry: full, release };
63
+ });
64
+ if (json) {
65
+ console.log(JSON.stringify({ appVersion, flavor, latestOtaVersion: group.latestOtaVersion, platforms: report }));
66
+ process.exit(0);
67
+ }
68
+ console.log(` ${c('1', `${appVersion} · ${flavor}`)} ${c('2', `latest v${group.latestOtaVersion}`)}\n`);
69
+ for (const { platform: p, version: v, patchesFrom, entry, release } of report) {
70
+ const state = controls(entry);
71
+ console.log(` ${c('1', p.padEnd(8))} v${v} ${state.length ? state.join(' · ') : c('32', 'live for everyone')}`);
72
+ const row = (k, text) => console.log(` ${c('36', k.padEnd(10))}${text}`);
73
+ if (release) {
74
+ row('published', `${release.publishedAt.replace('T', ' ').slice(0, 16)} UTC by ${release.publishedBy || '?'}`);
75
+ if (release.notes) row('notes', release.notes);
76
+ if (release.git) row('commit', `${release.git.commit.slice(0, 7)} ${c('2', release.git.branch)}${release.git.dirty ? c('33', ' + uncommitted changes') : ''}`);
77
+ row('bundle', `${(release.bundle.gzipSize / 1048576).toFixed(1)} MB gzip · ${(release.bundle.size / 1048576).toFixed(1)} MB raw`);
78
+ }
79
+ row('patches', patchesFrom.length ? patchesFrom.map(f => `v${f}`).join(', ') : c('2', 'none (full bundle only)'));
80
+ row('native', `${entry.runtimeFingerprint || '?'} ${c('2', `· hermes bytecode ${entry.hermesBytecodeVersion ?? '?'}`)}`);
81
+ row('signed', entry.entrySignature ? c('32', '✓') : c('33', 'no'));
82
+ if (entry.allowInstallIds?.length) row('QA ids', entry.allowInstallIds.join(', '));
83
+ console.log('');
84
+ }
85
+ process.exit(0);
86
+ }
87
+
88
+ // ── Controls: on the live version only (older versions' entries are gone from the registry) ──
89
+ if (!Number.isInteger(version)) fail(`${command} needs a version, e.g. ${command} ${group.latestOtaVersion}`);
90
+ const targets = entries.filter(e => e.toVersion === version);
91
+ if (!targets.length) {
92
+ fail(`v${version} isn't live for ${appVersion} / ${flavor}${platform ? ` / ${platform}` : ''} (latest is v${group.latestOtaVersion}): controls apply to the live version`);
93
+ }
94
+ const apply = (fn) => targets.forEach(fn);
95
+ let summary;
96
+ switch (command) {
97
+ case 'disable':
98
+ apply(e => (e.disabled = true));
99
+ summary = c('31', `v${version} disabled`) + ': no client installs it; clients already on it stay';
100
+ break;
101
+ case 'enable':
102
+ apply(e => delete e.disabled);
103
+ summary = c('32', `v${version} enabled`);
104
+ break;
105
+ case 'rollout':
106
+ if (!Number.isInteger(percent) || percent < 0 || percent > 100) fail('rollout needs a percent 0-100, e.g. rollout 7 20');
107
+ apply(e => (percent === 100 ? delete e.rolloutPercent : (e.rolloutPercent = percent)));
108
+ summary = percent === 100 ? c('32', `v${version} rolled out to everyone`) : c('33', `v${version} → ${percent}% of installs`);
109
+ break;
110
+ case 'mandatory':
111
+ apply(e => (off ? delete e.mandatory : (e.mandatory = true)));
112
+ summary = off ? `v${version} no longer mandatory` : c('36', `v${version} mandatory`) + ': applies as soon as it is downloaded';
113
+ break;
114
+ case 'qa': {
115
+ if (off) {
116
+ apply(e => delete e.allowInstallIds);
117
+ summary = `v${version} QA list removed`;
118
+ break;
119
+ }
120
+ const ids = (flag('--ids') || '').split(',').map(s => s.trim()).filter(Boolean);
121
+ if (!ids.length) fail('qa needs --ids <installId,…> (useOta().installId) or --off');
122
+ apply(e => (e.allowInstallIds = ids));
123
+ summary = c('35', `v${version} → QA only`) + ` (${ids.length} install${ids.length > 1 ? 's' : ''})`;
124
+ break;
125
+ }
126
+ default:
127
+ fail(`Unknown command ${command}`);
128
+ }
129
+ fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2) + '\n');
130
+ console.log(` ${summary} ${c('2', `· ${[...new Set(targets.map(e => e.platform))].join(' + ')}`)}`);
131
+ process.exit(3);