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/tools/setup_project.js
CHANGED
|
@@ -31,19 +31,25 @@ function findFile(baseDir, predicate, maxDepth = 10) {
|
|
|
31
31
|
return results;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// --quiet (run by every `patch`): print only what changed, one line each
|
|
35
|
+
const QUIET = process.argv.includes('--quiet');
|
|
36
|
+
const log = (...args) => {
|
|
37
|
+
if (!QUIET) console.log(...args);
|
|
38
|
+
};
|
|
39
|
+
|
|
34
40
|
function patchFile(filePath, label, patchFn) {
|
|
35
41
|
if (!fs.existsSync(filePath)) {
|
|
36
|
-
|
|
42
|
+
log(` ⚠ ${label}: file not found at ${filePath}`);
|
|
37
43
|
return false;
|
|
38
44
|
}
|
|
39
45
|
const original = fs.readFileSync(filePath, 'utf8');
|
|
40
46
|
const patched = patchFn(original);
|
|
41
47
|
if (patched === original) {
|
|
42
|
-
|
|
48
|
+
log(` ✓ ${label}: already set up (skipped)`);
|
|
43
49
|
return false;
|
|
44
50
|
}
|
|
45
51
|
fs.writeFileSync(filePath, patched, 'utf8');
|
|
46
|
-
console.log(` ✓ ${label}: patched`);
|
|
52
|
+
console.log(QUIET ? label : ` ✓ ${label}: patched`);
|
|
47
53
|
return true;
|
|
48
54
|
}
|
|
49
55
|
|
|
@@ -103,6 +109,25 @@ function removePbxprojSnippet(content) {
|
|
|
103
109
|
return content.slice(0, idx) + content.slice(content.indexOf('";', idx));
|
|
104
110
|
}
|
|
105
111
|
|
|
112
|
+
// Runs tools/ios_fingerprint.sh at the end of RN's "Bundle React Native code and images"
|
|
113
|
+
// phase (after Copy Resources, with NODE_BINARY from .xcode.env): stamps the native
|
|
114
|
+
// runtime fingerprint into the .app so OTAs only install on a matching binary.
|
|
115
|
+
const FINGERPRINT_MARKER = '# [react-native-bspatch] runtime fingerprint';
|
|
116
|
+
const FINGERPRINT_STEP =
|
|
117
|
+
`\\n${FINGERPRINT_MARKER}\\n` +
|
|
118
|
+
'/bin/sh \\"$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh\\" ' +
|
|
119
|
+
'\\"$REACT_NATIVE_PATH/../react-native-bspatch/tools/ios_fingerprint.sh\\"\\n';
|
|
120
|
+
|
|
121
|
+
function addPbxprojFingerprint(content) {
|
|
122
|
+
if (content.includes(FINGERPRINT_MARKER)) return content;
|
|
123
|
+
const phase = content.indexOf('/* Bundle React Native code and images */ = {');
|
|
124
|
+
if (phase === -1) return content;
|
|
125
|
+
const script = content.indexOf('shellScript = "', phase);
|
|
126
|
+
const end = content.indexOf('";', script);
|
|
127
|
+
if (script === -1 || end === -1) return content;
|
|
128
|
+
return content.slice(0, end) + FINGERPRINT_STEP + content.slice(end);
|
|
129
|
+
}
|
|
130
|
+
|
|
106
131
|
// ── iOS: AppDelegate.swift ────────────────────────────────────────────────────
|
|
107
132
|
|
|
108
133
|
function patchAppDelegate(content) {
|
|
@@ -209,28 +234,29 @@ function main() {
|
|
|
209
234
|
const rootIdx = args.indexOf('--root');
|
|
210
235
|
const ROOT = rootIdx !== -1 ? path.resolve(args[rootIdx + 1]) : process.cwd();
|
|
211
236
|
|
|
212
|
-
|
|
213
|
-
|
|
237
|
+
log('\n=== react-native-bspatch Setup ===');
|
|
238
|
+
log(` Project root: ${ROOT}`);
|
|
214
239
|
|
|
215
240
|
const IOS_DIR = path.join(ROOT, 'ios');
|
|
216
241
|
const ANDROID_DIR = path.join(ROOT, 'android');
|
|
217
242
|
|
|
218
243
|
// ── iOS ──
|
|
219
|
-
|
|
244
|
+
log('\n[iOS]');
|
|
220
245
|
|
|
221
246
|
// project.pbxproj (ignore CocoaPods project file)
|
|
222
247
|
const pbxprojFiles = findFile(IOS_DIR, (name, fullPath) => name === 'project.pbxproj' && !fullPath.includes('Pods'));
|
|
223
248
|
if (pbxprojFiles.length === 0) {
|
|
224
|
-
|
|
249
|
+
log(' ⚠ No project.pbxproj found. Is this an iOS project?');
|
|
225
250
|
} else {
|
|
226
251
|
const pbxproj = pbxprojFiles[0];
|
|
227
252
|
patchFile(pbxproj, 'project.pbxproj (remove legacy bundle capture)', removePbxprojSnippet);
|
|
253
|
+
patchFile(pbxproj, 'project.pbxproj (runtime fingerprint build step)', addPbxprojFingerprint);
|
|
228
254
|
}
|
|
229
255
|
|
|
230
256
|
// AppDelegate.swift
|
|
231
257
|
const appDelegateFiles = findFile(IOS_DIR, (name) => name === 'AppDelegate.swift');
|
|
232
258
|
if (appDelegateFiles.length === 0) {
|
|
233
|
-
|
|
259
|
+
log(' ⚠ No AppDelegate.swift found.');
|
|
234
260
|
} else {
|
|
235
261
|
patchFile(appDelegateFiles[0], 'AppDelegate.swift', patchAppDelegate);
|
|
236
262
|
}
|
|
@@ -239,21 +265,21 @@ function main() {
|
|
|
239
265
|
const outputDir = path.join(IOS_DIR, 'output');
|
|
240
266
|
if (!fs.existsSync(outputDir)) {
|
|
241
267
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
242
|
-
|
|
268
|
+
log(' ✓ Created ios/output/ directory');
|
|
243
269
|
}
|
|
244
270
|
|
|
245
271
|
// ── Android ──
|
|
246
|
-
|
|
272
|
+
log('\n[Android]');
|
|
247
273
|
const mainAppFiles = findFile(ANDROID_DIR, (name) => name === 'MainApplication.kt');
|
|
248
274
|
if (mainAppFiles.length === 0) {
|
|
249
|
-
|
|
275
|
+
log(' ⚠ No MainApplication.kt found. Is this an Android project?');
|
|
250
276
|
} else {
|
|
251
277
|
patchFile(mainAppFiles[0], 'MainApplication.kt', patchMainApplication);
|
|
252
278
|
}
|
|
253
279
|
|
|
254
280
|
const appGradleFiles = findFile(path.join(ANDROID_DIR, 'app'), (name) => name === 'build.gradle');
|
|
255
281
|
if (appGradleFiles.length === 0) {
|
|
256
|
-
|
|
282
|
+
log(' ⚠ No android/app/build.gradle found.');
|
|
257
283
|
} else {
|
|
258
284
|
patchFile(appGradleFiles[0], 'android/app/build.gradle (remove legacy bundle capture)', removeAppBuildGradleSnippet);
|
|
259
285
|
}
|
|
@@ -262,11 +288,11 @@ function main() {
|
|
|
262
288
|
const androidOutputDir = path.join(ANDROID_DIR, 'output');
|
|
263
289
|
if (!fs.existsSync(androidOutputDir)) {
|
|
264
290
|
fs.mkdirSync(androidOutputDir, { recursive: true });
|
|
265
|
-
|
|
291
|
+
log(' ✓ Created android/output/ directory');
|
|
266
292
|
}
|
|
267
293
|
|
|
268
|
-
|
|
269
|
-
|
|
294
|
+
log('\n=== Setup Complete ===');
|
|
295
|
+
log('Next: release the app to the store (v1), then publish OTA updates with: npx react-native-bspatch\n');
|
|
270
296
|
}
|
|
271
297
|
|
|
272
298
|
main();
|
package/tools/update_registry.js
CHANGED
|
@@ -7,11 +7,38 @@
|
|
|
7
7
|
* 2. Scopes to : appVersion (e.g. "1.0.0")
|
|
8
8
|
* 3. Scopes to : flavor (e.g. "dev", "staging", "production")
|
|
9
9
|
* 4. Writes the full-bundle entry + patch entries and latestOtaVersion for that app+flavor.
|
|
10
|
+
* 5. Writes next to the bundle: its gzip copy (uploaded Content-Encoded) and
|
|
11
|
+
* release_<app>_v<N>_<platform>_<flavor>.json, the version's details for a dashboard.
|
|
10
12
|
*/
|
|
11
13
|
|
|
12
14
|
const fs = require('fs');
|
|
13
15
|
const path = require('path');
|
|
14
16
|
const crypto = require('crypto');
|
|
17
|
+
const zlib = require('zlib');
|
|
18
|
+
const { execFileSync } = require('child_process');
|
|
19
|
+
|
|
20
|
+
/** What entrySignature signs. Must stay byte-for-byte equal to src/ota/entrySignature.ts */
|
|
21
|
+
const entryPayload = e =>
|
|
22
|
+
[
|
|
23
|
+
'bspatch-entry-v2',
|
|
24
|
+
e.appVersion,
|
|
25
|
+
e.flavor ?? '',
|
|
26
|
+
e.platform ?? '',
|
|
27
|
+
e.fromVersion,
|
|
28
|
+
e.toVersion,
|
|
29
|
+
e.bundleHash ?? '',
|
|
30
|
+
e.assetManifestHash ?? '',
|
|
31
|
+
e.minAppVersion ?? '',
|
|
32
|
+
e.runtimeFingerprint ?? '',
|
|
33
|
+
e.hermesBytecodeVersion ?? '',
|
|
34
|
+
].join('\n');
|
|
35
|
+
|
|
36
|
+
/** Adds KEY=value lines to build_ota.sh's report file (sourced for the summary) */
|
|
37
|
+
const report = (key, value) => {
|
|
38
|
+
if (process.env.BSPATCH_REPORT) {
|
|
39
|
+
fs.appendFileSync(process.env.BSPATCH_REPORT, `${key}='${String(value).replace(/'/g, `'\\''`)}'\n`);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
15
42
|
|
|
16
43
|
const [
|
|
17
44
|
projectRoot,
|
|
@@ -86,6 +113,8 @@ if (!signingKey) {
|
|
|
86
113
|
const fullBundleName = `bundle_${appVersion}_v${toOtaVersion}_${platform}_${flavor}.hbc`;
|
|
87
114
|
const manifestName = path.basename(_assetManifestFile);
|
|
88
115
|
|
|
116
|
+
const signBytes = (bytes) =>
|
|
117
|
+
signingKey ? crypto.createSign('SHA256').update(bytes).sign(signingKey, 'base64') : undefined;
|
|
89
118
|
const sign = (file) => {
|
|
90
119
|
if (!signingKey) return undefined;
|
|
91
120
|
const filePath = path.join(outputDir, file);
|
|
@@ -93,21 +122,33 @@ if (!signingKey) {
|
|
|
93
122
|
console.error(` ❌ Cannot sign ${file}: not found at ${filePath}`);
|
|
94
123
|
process.exit(1);
|
|
95
124
|
}
|
|
96
|
-
|
|
97
|
-
signer.update(fs.readFileSync(filePath));
|
|
98
|
-
return signer.sign(signingKey, 'base64');
|
|
125
|
+
return signBytes(fs.readFileSync(filePath));
|
|
99
126
|
};
|
|
127
|
+
const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex');
|
|
100
128
|
|
|
101
129
|
// Signature of the resulting bundle: verified by the client on BOTH the patch
|
|
102
130
|
// path and the full-bundle fallback, so a tampered registry can't swap bundles.
|
|
103
131
|
const bundleSignature = sign(fullBundleName);
|
|
104
132
|
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
133
|
+
const bundleBytes = fs.readFileSync(path.join(outputDir, fullBundleName));
|
|
134
|
+
const bundleHash = sha256(bundleBytes);
|
|
135
|
+
const manifestBytes = fs.readFileSync(_assetManifestFile);
|
|
136
|
+
const assetManifestHash = sha256(manifestBytes);
|
|
137
|
+
// Hermes bytecode header: magic (8 bytes) then the bytecode version (uint32 LE)
|
|
138
|
+
const hermesBytecodeVersion = bundleBytes.length >= 12 ? bundleBytes.readUInt32LE(8) : undefined;
|
|
139
|
+
// Same inputs the Xcode/Gradle build hook stamps into the store binary; promote/rollback
|
|
140
|
+
// pass the source version's own fingerprint (that bundle was built from another commit)
|
|
141
|
+
const runtimeFingerprint =
|
|
142
|
+
process.env.BSPATCH_FINGERPRINT ||
|
|
143
|
+
execFileSync('node', [path.join(__dirname, 'fingerprint.js'), process.cwd(), platform]).toString().trim();
|
|
144
|
+
const previous = flavorGroup.patches.find(p => p.platform === platform);
|
|
145
|
+
if (previous?.runtimeFingerprint && previous.runtimeFingerprint !== runtimeFingerprint) {
|
|
146
|
+
// Not an error: a new store build of this release takes it. Installed binaries won't.
|
|
147
|
+
report('NATIVE_CHANGED', `v${previous.toVersion}`);
|
|
148
|
+
}
|
|
109
149
|
|
|
110
|
-
|
|
150
|
+
// Signed last: entryPayload reads the final fields
|
|
151
|
+
const entry = (from, extra) => signEntry({
|
|
111
152
|
id: `v${from}-to-v${toOtaVersion}-${flavor}-${platform}`,
|
|
112
153
|
label: `V${from} → V${toOtaVersion} (${flavor} ${platform})`,
|
|
113
154
|
appVersion,
|
|
@@ -115,13 +156,22 @@ if (!signingKey) {
|
|
|
115
156
|
toVersion: toOtaVersion,
|
|
116
157
|
platform,
|
|
117
158
|
bundleHash,
|
|
118
|
-
|
|
159
|
+
// Uploaded as a plain .gz (no Content-Encoding): resumable, with an exact progress total
|
|
160
|
+
fullBundleUrl: `${fullBundleName}.gz`,
|
|
161
|
+
fullBundleCompression: 'gzip',
|
|
162
|
+
requires: ['gzip'],
|
|
163
|
+
bundleSize: bundleBytes.length,
|
|
164
|
+
runtimeFingerprint,
|
|
165
|
+
hermesBytecodeVersion,
|
|
119
166
|
assetManifestUrl: manifestName,
|
|
167
|
+
assetManifestHash,
|
|
120
168
|
flavor,
|
|
121
169
|
bundleSignature,
|
|
122
170
|
...extra,
|
|
123
171
|
});
|
|
124
172
|
|
|
173
|
+
const signEntry = (e) => ({ ...e, entrySignature: signBytes(entryPayload(e)) });
|
|
174
|
+
|
|
125
175
|
// fromVersion 1 = the store build, whose clients always take the full bundle
|
|
126
176
|
const fullEntry = entry(1, {
|
|
127
177
|
id: `full-to-v${toOtaVersion}-${flavor}-${platform}`,
|
|
@@ -132,14 +182,64 @@ if (!signingKey) {
|
|
|
132
182
|
);
|
|
133
183
|
|
|
134
184
|
flavorGroup.patches = [...otherPlatformPatches, fullEntry, ...patchEntries];
|
|
185
|
+
|
|
186
|
+
const gzipBytes = zlib.gzipSync(bundleBytes, { level: 9 });
|
|
187
|
+
fs.writeFileSync(path.join(outputDir, `${fullBundleName}.gz`), gzipBytes);
|
|
188
|
+
|
|
189
|
+
// ── Release details (public, next to the bundle; clients never read it) ──
|
|
190
|
+
const git = (...args) => {
|
|
191
|
+
try {
|
|
192
|
+
return execFileSync('git', args, { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
193
|
+
} catch {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
const commit = git('rev-parse', 'HEAD');
|
|
198
|
+
const release = {
|
|
199
|
+
schema: 1,
|
|
200
|
+
appVersion,
|
|
201
|
+
flavor,
|
|
202
|
+
platform,
|
|
203
|
+
version: toOtaVersion,
|
|
204
|
+
publishedAt: new Date().toISOString(),
|
|
205
|
+
publishedBy: git('config', 'user.name') || process.env.USER || null,
|
|
206
|
+
notes: process.env.BSPATCH_NOTES || null,
|
|
207
|
+
git: commit && {
|
|
208
|
+
commit,
|
|
209
|
+
branch: git('rev-parse', '--abbrev-ref', 'HEAD'),
|
|
210
|
+
// Tracked files only: build output is often untracked
|
|
211
|
+
dirty: !!git('status', '--porcelain', '--untracked-files=no'),
|
|
212
|
+
},
|
|
213
|
+
cliVersion: require('../package.json').version,
|
|
214
|
+
bundle: { file: fullBundleName, hash: bundleHash, size: bundleBytes.length, gzipSize: gzipBytes.length },
|
|
215
|
+
runtimeFingerprint,
|
|
216
|
+
hermesBytecodeVersion,
|
|
217
|
+
patches: newPatches.map(p => ({
|
|
218
|
+
from: p.from,
|
|
219
|
+
file: p.file,
|
|
220
|
+
size: fs.statSync(path.join(outputDir, p.file)).size,
|
|
221
|
+
})),
|
|
222
|
+
assets: {
|
|
223
|
+
manifest: manifestName,
|
|
224
|
+
hash: assetManifestHash,
|
|
225
|
+
count: (JSON.parse(manifestBytes).assets || []).length,
|
|
226
|
+
},
|
|
227
|
+
signing: signingKey && {
|
|
228
|
+
keyId: sha256(crypto.createPublicKey(signingKey).export({ type: 'spki', format: 'der' })).slice(0, 16),
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
fs.writeFileSync(
|
|
232
|
+
path.join(outputDir, `release_${appVersion}_v${toOtaVersion}_${platform}_${flavor}.json`),
|
|
233
|
+
JSON.stringify(release, null, 2) + '\n',
|
|
234
|
+
);
|
|
135
235
|
}
|
|
136
236
|
|
|
137
237
|
// ── Write updated registry ───────────────────────────────────────────────────
|
|
138
238
|
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
|
|
139
239
|
fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2) + '\n');
|
|
140
|
-
//
|
|
240
|
+
// "<entries> · <signing>": build_ota.sh shows it as the Registry step's result
|
|
141
241
|
const patchCount = newPatches.length;
|
|
142
242
|
console.log(
|
|
143
|
-
`
|
|
243
|
+
`full bundle${patchCount ? ` + ${patchCount} patch${patchCount > 1 ? 'es' : ''}` : ''}` +
|
|
144
244
|
(signingKey ? ' · signed' : ' · unsigned (no ota_private_key.pem)'),
|
|
145
245
|
);
|
package/tools/upload_ota.sh
CHANGED
|
@@ -1,23 +1,27 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
|
|
3
3
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
4
|
-
# OTA Upload Script — Cloudflare R2
|
|
5
|
-
# Uploads: 1)
|
|
6
|
-
# 2)
|
|
4
|
+
# OTA Upload Script — Cloudflare R2
|
|
5
|
+
# Uploads: 1) This version's .hbc.gz, .patch, .json → {baseFolder}/{appVersion}/{channel}/
|
|
6
|
+
# 2) New content-addressed assets → {baseFolder}/assets/
|
|
7
|
+
# 3) ota_registry.json, last → {baseFolder}/ota_registry.json
|
|
7
8
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
8
9
|
|
|
9
|
-
#
|
|
10
|
+
# Called by build_ota.sh inside its Upload/Remote step: env (.env.<flavor> + .env.ota)
|
|
11
|
+
# and the step_progress/step_fail/run_logged helpers are exported from there
|
|
10
12
|
ROOT_DIR=$(pwd)
|
|
11
13
|
|
|
12
|
-
BASE_URL="${OTA_BASE_URL:-}"
|
|
13
14
|
BASE_FOLDER="${OTA_FOLDER:-ota}"
|
|
14
15
|
APP_VERSION="$1"
|
|
15
16
|
CHANNEL="$2"
|
|
16
17
|
OTA_VERSION="$3"
|
|
17
|
-
|
|
18
|
+
# $4 (bundle path) is unused: this version's files are picked up from the output dir
|
|
18
19
|
PLATFORM="$5"
|
|
19
20
|
|
|
20
21
|
REGISTRY_FILE="$ROOT_DIR/ota_registry.json"
|
|
22
|
+
WORK=$(mktemp -d); trap 'rm -rf "$WORK"' EXIT
|
|
23
|
+
# Result for build_ota.sh's step line and summary (sourced there)
|
|
24
|
+
report() { printf '%s=%q\n' "$1" "$2" >> "${BSPATCH_REPORT:-/dev/null}"; }
|
|
21
25
|
OUTPUT_DIR="$ROOT_DIR/android/output"
|
|
22
26
|
if [[ "$PLATFORM" == "ios" ]]; then
|
|
23
27
|
OUTPUT_DIR="$ROOT_DIR/ios/output"
|
|
@@ -49,187 +53,84 @@ is_release_file() {
|
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
# ── Cloudflare R2 (S3 API, signed by curl --aws-sigv4; no CLI needed) ────────
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if [ -t 1 ]; then printf "\r\033[K ${DIM}%-9s${RESET} deleting… %d" "remote" "$DELETED"; fi
|
|
87
|
-
done
|
|
88
|
-
if [ -t 1 ]; then printf "\r\033[K"; fi
|
|
89
|
-
info remote "$DELETED object(s) deleted ${DIM}($BUNDLES bundles · $PATCHES patches · $MANIFESTS manifests)${RESET}"
|
|
90
|
-
else
|
|
91
|
-
# Only this version's files: earlier bundles/patches are already uploaded and never change
|
|
92
|
-
RELEASE_FILES=()
|
|
93
|
-
for FILE in "$OUTPUT_DIR"/*_v"${OTA_VERSION}"_*.{patch,hbc,json}; do
|
|
94
|
-
[[ -f "$FILE" ]] && is_release_file "$(basename "$FILE")" && RELEASE_FILES+=("$FILE")
|
|
95
|
-
done
|
|
96
|
-
for FILE in "${RELEASE_FILES[@]}"; do
|
|
97
|
-
FNAME=$(basename "$FILE")
|
|
98
|
-
r2_put "$FILE" "$RELEASE_PREFIX/$FNAME" "$IMMUTABLE" "$(pretty "$FNAME")"
|
|
99
|
-
done
|
|
100
|
-
# Content-addressed assets: upload only hashes the bucket doesn't have yet
|
|
101
|
-
ASSETS=("$OUTPUT_DIR"/asset-store/*)
|
|
102
|
-
[[ -f "${ASSETS[0]}" ]] || ASSETS=()
|
|
103
|
-
N=0; NEW=0; NEW_BYTES=0
|
|
104
|
-
for FILE in "${ASSETS[@]}"; do
|
|
105
|
-
N=$((N + 1))
|
|
106
|
-
KEY="$BASE_FOLDER/assets/$(basename "$FILE")"
|
|
107
|
-
if ! r2 -f -I "$R2_URL/$KEY" -o /dev/null 2>/dev/null; then
|
|
108
|
-
r2 -f -X PUT -T "$FILE" -H "Cache-Control: $IMMUTABLE" "$R2_URL/$KEY" -o /dev/null || { echo " ❌ Upload failed: $KEY"; exit 1; }
|
|
109
|
-
NEW=$((NEW + 1)); NEW_BYTES=$((NEW_BYTES + $(wc -c < "$FILE")))
|
|
110
|
-
fi
|
|
111
|
-
if [ -t 1 ]; then printf "\r\033[K ${DIM}assets %d/%d checked${RESET}" "$N" "${#ASSETS[@]}"; fi
|
|
112
|
-
done
|
|
113
|
-
if [ -t 1 ] && [ ${#ASSETS[@]} -gt 0 ]; then printf "\r\033[K"; fi
|
|
114
|
-
printf " ${GREEN}✓${RESET} %s %s\n" "$(padr assets 16)" "${#ASSETS[@]} · $NEW new ($(human_size "$NEW_BYTES"))"
|
|
115
|
-
fi
|
|
56
|
+
: "${OTA_R2_ACCOUNT_ID:?set OTA_R2_ACCOUNT_ID}" "${OTA_R2_ACCESS_KEY_ID:?set OTA_R2_ACCESS_KEY_ID}" "${OTA_R2_SECRET_ACCESS_KEY:?set OTA_R2_SECRET_ACCESS_KEY}"
|
|
57
|
+
R2_URL="https://${OTA_R2_ACCOUNT_ID}.r2.cloudflarestorage.com/${OTA_R2_BUCKET}"
|
|
58
|
+
r2() { curl -sS --aws-sigv4 "aws:amz:auto:s3" --user "$OTA_R2_ACCESS_KEY_ID:$OTA_R2_SECRET_ACCESS_KEY" "$@"; }
|
|
59
|
+
# "patch_1.0.4_v4_to_v7_…" → "patch v4 → v7", "bundle_…_v7_…" → "bundle v7"
|
|
60
|
+
pretty() {
|
|
61
|
+
case "$1" in
|
|
62
|
+
patch_*) echo "$1" | sed -E 's/.*_v([0-9]+)_to_v([0-9]+)_.*/patch v\1 → v\2/' ;;
|
|
63
|
+
bundle_*) echo "$1" | sed -E 's/.*_v([0-9]+)_.*/bundle v\1/' ;;
|
|
64
|
+
asset-manifest_*) echo "asset manifest" ;;
|
|
65
|
+
release_*) echo "release info" ;;
|
|
66
|
+
*) echo "$1" ;;
|
|
67
|
+
esac
|
|
68
|
+
}
|
|
69
|
+
TOTAL_BYTES=0
|
|
70
|
+
r2_put() { # <file> <key> <cache-control> <label>
|
|
71
|
+
local SIZE; SIZE=$(file_size "$1")
|
|
72
|
+
RUN_NOTE="↑ $4 · $(human_size "$SIZE")" run_logged "$WORK/curl.log" \
|
|
73
|
+
r2 -f -X PUT -T "$1" -H "Cache-Control: $3" "$R2_URL/$2" -o /dev/null \
|
|
74
|
+
|| { step_fail "upload failed: $2"; cat "$WORK/curl.log"; exit 1; }
|
|
75
|
+
TOTAL_BYTES=$((TOTAL_BYTES + SIZE))
|
|
76
|
+
}
|
|
77
|
+
RELEASE_PREFIX="$BASE_FOLDER/$APP_VERSION/$CHANNEL"
|
|
78
|
+
IMMUTABLE="public, max-age=31536000, immutable"
|
|
79
|
+
|
|
80
|
+
if [ "$CLEAN_REMOTE" = true ]; then
|
|
81
|
+
# ponytail: one ListObjectsV2 page (1000 keys); a release+channel holds far fewer
|
|
82
|
+
LISTING=$(r2 -f "$R2_URL?list-type=2&prefix=$RELEASE_PREFIX/") || { step_fail "listing $RELEASE_PREFIX/ failed"; exit 1; }
|
|
83
|
+
DELETED=0; BUNDLES=0; PATCHES=0; MANIFESTS=0
|
|
84
|
+
for KEY in $(echo "$LISTING" | grep -o '<Key>[^<]*</Key>' | sed 's/<[^>]*>//g'); do
|
|
85
|
+
r2 -f -X DELETE "$R2_URL/$KEY" -o /dev/null || { step_fail "delete failed: $KEY"; exit 1; }
|
|
86
|
+
DELETED=$((DELETED + 1))
|
|
87
|
+
case "$KEY" in *.hbc) BUNDLES=$((BUNDLES + 1)) ;; *.patch) PATCHES=$((PATCHES + 1)) ;; *.json) MANIFESTS=$((MANIFESTS + 1)) ;; esac
|
|
88
|
+
step_progress "deleting… $DELETED"
|
|
89
|
+
done
|
|
116
90
|
# Registry last: clients never see a version whose files aren't uploaded yet
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
else
|
|
121
|
-
r2_put "$REGISTRY_FILE" "$BASE_FOLDER/ota_registry.json" "no-cache" "registry"
|
|
122
|
-
fi
|
|
91
|
+
r2 -f -X PUT -T "$REGISTRY_FILE" -H "Cache-Control: no-cache" "$R2_URL/$BASE_FOLDER/ota_registry.json" -o /dev/null || { step_fail "upload failed: ota_registry.json"; exit 1; }
|
|
92
|
+
report UP_SIZE "$DELETED deleted"
|
|
93
|
+
report UP_NOTE "$BUNDLES bundles · $PATCHES patches · $MANIFESTS manifests · registry updated"
|
|
123
94
|
exit 0
|
|
124
95
|
fi
|
|
125
96
|
|
|
126
|
-
#
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
REPO=$(echo "$CLEAN_URL" | cut -d'/' -f2)
|
|
137
|
-
BRANCH=$(echo "$CLEAN_URL" | cut -d'/' -f3)
|
|
138
|
-
if [[ -z "$BRANCH" ]]; then BRANCH="main"; fi
|
|
139
|
-
|
|
140
|
-
# Check for gh token or env tokens
|
|
141
|
-
GH_CLI_TOKEN=$(gh auth token 2>/dev/null || true)
|
|
142
|
-
TOKEN="${GH_CLI_TOKEN:-${GH_TOKEN:-${GITHUB_TOKEN:-}}}"
|
|
143
|
-
if [[ "$TOKEN" == "baseform" ]]; then TOKEN=""; fi
|
|
144
|
-
|
|
145
|
-
# Token goes in a per-command header, never into the clone's .git/config
|
|
146
|
-
GIT_AUTH=()
|
|
147
|
-
if [[ -n "$TOKEN" ]]; then
|
|
148
|
-
GIT_REMOTE="https://github.com/${OWNER}/${REPO}.git"
|
|
149
|
-
GIT_AUTH=(-c "http.https://github.com/.extraheader=AUTHORIZATION: basic $(printf 'x-access-token:%s' "$TOKEN" | base64 | tr -d '\n')")
|
|
150
|
-
echo " Using authenticated gh CLI token for ${OWNER}/${REPO}"
|
|
151
|
-
else
|
|
152
|
-
GIT_REMOTE="git@github.com:${OWNER}/${REPO}.git"
|
|
153
|
-
fi
|
|
154
|
-
|
|
155
|
-
GH_REPO_DIR="/tmp/ota_git_repo_${REPO}"
|
|
156
|
-
|
|
157
|
-
echo ""
|
|
158
|
-
echo "=== Auto Syncing OTA Release to GitHub Storage ==="
|
|
159
|
-
echo " Target Repo : ${OWNER}/${REPO}"
|
|
160
|
-
echo " Branch : ${BRANCH}"
|
|
161
|
-
|
|
162
|
-
# 1. Clone or Pull target storage repo
|
|
163
|
-
if [ -d "$GH_REPO_DIR/.git" ]; then
|
|
164
|
-
echo " Syncing local storage clone with remote..."
|
|
165
|
-
git -C "$GH_REPO_DIR" remote set-url origin "$GIT_REMOTE" 2>/dev/null || true
|
|
166
|
-
git "${GIT_AUTH[@]}" -C "$GH_REPO_DIR" fetch origin "$BRANCH" 2>/dev/null || true
|
|
167
|
-
git -C "$GH_REPO_DIR" checkout "$BRANCH" 2>/dev/null || true
|
|
168
|
-
git -C "$GH_REPO_DIR" reset --hard "origin/$BRANCH" 2>/dev/null || true
|
|
169
|
-
else
|
|
170
|
-
echo " Cloning storage repository..."
|
|
171
|
-
rm -rf "$GH_REPO_DIR"
|
|
172
|
-
git "${GIT_AUTH[@]}" clone --branch "$BRANCH" --depth 1 "$GIT_REMOTE" "$GH_REPO_DIR" 2>/dev/null || git "${GIT_AUTH[@]}" clone "$GIT_REMOTE" "$GH_REPO_DIR" || exit 1
|
|
173
|
-
chmod 700 "$GH_REPO_DIR"
|
|
174
|
-
fi
|
|
175
|
-
|
|
176
|
-
# 2. Copy generated files into storage repo (or reset remote)
|
|
177
|
-
if [ "$CLEAN_REMOTE" = true ]; then
|
|
178
|
-
echo " Resetting remote storage state for channel '$CHANNEL' ..."
|
|
179
|
-
rm -rf "$GH_REPO_DIR/$BASE_FOLDER/$APP_VERSION/${CHANNEL:?}"
|
|
180
|
-
mkdir -p "$GH_REPO_DIR/$BASE_FOLDER"
|
|
181
|
-
cp "$REGISTRY_FILE" "$GH_REPO_DIR/$BASE_FOLDER/ota_registry.json"
|
|
182
|
-
cp "$REGISTRY_FILE" "$GH_REPO_DIR/ota_registry.json" 2>/dev/null || true
|
|
183
|
-
else
|
|
184
|
-
TARGET_RELEASE_DIR="$GH_REPO_DIR/$BASE_FOLDER/$APP_VERSION/$CHANNEL"
|
|
185
|
-
mkdir -p "$TARGET_RELEASE_DIR"
|
|
186
|
-
|
|
187
|
-
echo " Copying release files to storage repo for channel '$CHANNEL'..."
|
|
188
|
-
for FILE in "$OUTPUT_DIR"/*.{patch,hbc,json}; do
|
|
189
|
-
[[ -f "$FILE" ]] || continue
|
|
190
|
-
FNAME=$(basename "$FILE")
|
|
191
|
-
is_release_file "$FNAME" || continue
|
|
192
|
-
cp "$FILE" "$TARGET_RELEASE_DIR/$FNAME"
|
|
193
|
-
done
|
|
194
|
-
|
|
195
|
-
# Copy asset store if present
|
|
196
|
-
if [[ -d "$OUTPUT_DIR/asset-store" ]]; then
|
|
197
|
-
TARGET_ASSET_DIR="$GH_REPO_DIR/$BASE_FOLDER/assets"
|
|
198
|
-
mkdir -p "$TARGET_ASSET_DIR"
|
|
199
|
-
cp -R "$OUTPUT_DIR/asset-store/"* "$TARGET_ASSET_DIR/" 2>/dev/null || true
|
|
200
|
-
fi
|
|
97
|
+
# Only this version's files: earlier bundles/patches are already uploaded and never change.
|
|
98
|
+
# The bundle ships as its .gz only (written by update_registry.js): clients and later
|
|
99
|
+
# `patch` runs decompress it, so the raw .hbc never needs to be on storage.
|
|
100
|
+
for FILE in "$OUTPUT_DIR"/*_v"${OTA_VERSION}"_*.{patch,gz,json}; do
|
|
101
|
+
[[ -f "$FILE" ]] || continue
|
|
102
|
+
FNAME=$(basename "$FILE")
|
|
103
|
+
is_release_file "$FNAME" || continue
|
|
104
|
+
r2_put "$FILE" "$RELEASE_PREFIX/$FNAME" "$IMMUTABLE" "$(pretty "$FNAME")"
|
|
105
|
+
if [[ "$FNAME" == *.hbc.gz ]]; then report UP_GZIP_BYTES "$(file_size "$FILE")"; fi
|
|
106
|
+
done
|
|
201
107
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
108
|
+
# Content-addressed assets: upload only hashes the bucket doesn't have yet.
|
|
109
|
+
# One listing (1000 keys a page) instead of a HEAD request per asset.
|
|
110
|
+
ASSETS=("$OUTPUT_DIR"/asset-store/*)
|
|
111
|
+
[[ -f "${ASSETS[0]}" ]] || ASSETS=()
|
|
112
|
+
if [ ${#ASSETS[@]} -gt 0 ]; then
|
|
113
|
+
step_progress "checking ${#ASSETS[@]} assets…"
|
|
114
|
+
: > "$WORK/remote-assets"
|
|
115
|
+
AFTER=""
|
|
116
|
+
while :; do
|
|
117
|
+
PAGE=$(r2 -f "$R2_URL?list-type=2&prefix=$BASE_FOLDER/assets/${AFTER:+&start-after=$AFTER}") || { step_fail "listing $BASE_FOLDER/assets/ failed"; exit 1; }
|
|
118
|
+
echo "$PAGE" | grep -o '<Key>[^<]*</Key>' | sed 's/<[^>]*>//g' >> "$WORK/remote-assets"
|
|
119
|
+
[[ "$PAGE" == *"<IsTruncated>true</IsTruncated>"* ]] || break
|
|
120
|
+
AFTER=$(tail -n 1 "$WORK/remote-assets")
|
|
121
|
+
done
|
|
122
|
+
NEW=0; NEW_BYTES=0
|
|
123
|
+
for FILE in "${ASSETS[@]}"; do
|
|
124
|
+
KEY="$BASE_FOLDER/assets/$(basename "$FILE")"
|
|
125
|
+
grep -qxF "$KEY" "$WORK/remote-assets" && continue
|
|
126
|
+
step_progress "↑ asset $((NEW + 1)) · $(basename "$FILE")"
|
|
127
|
+
r2 -f -X PUT -T "$FILE" -H "Cache-Control: $IMMUTABLE" "$R2_URL/$KEY" -o /dev/null || { step_fail "upload failed: $KEY"; exit 1; }
|
|
128
|
+
NEW=$((NEW + 1)); NEW_BYTES=$((NEW_BYTES + $(file_size "$FILE")))
|
|
129
|
+
done
|
|
130
|
+
report UP_ASSETS "$NEW new of ${#ASSETS[@]} · $(human_size "$NEW_BYTES")"; TOTAL_BYTES=$((TOTAL_BYTES + NEW_BYTES))
|
|
131
|
+
fi
|
|
207
132
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
git config user.email >/dev/null || git config user.email "react-native-bspatch@users.noreply.github.com"
|
|
213
|
-
git add .
|
|
214
|
-
if git diff --staged --quiet; then
|
|
215
|
-
echo " ✓ Storage repository is up to date (no changes)."
|
|
216
|
-
else
|
|
217
|
-
COMMIT_MSG="auto(ota): publish $PLATFORM $APP_VERSION v$OTA_VERSION ($CHANNEL)"
|
|
218
|
-
if [ "$CLEAN_REMOTE" = true ]; then
|
|
219
|
-
COMMIT_MSG="auto(ota): reset remote OTA registry and releases"
|
|
220
|
-
fi
|
|
221
|
-
echo " Committing OTA changes ($COMMIT_MSG)..."
|
|
222
|
-
git commit -m "$COMMIT_MSG"
|
|
223
|
-
echo " Pushing to GitHub (${BRANCH})..."
|
|
224
|
-
|
|
225
|
-
if git "${GIT_AUTH[@]}" push origin "$BRANCH"; then
|
|
226
|
-
echo " ✓ Successfully pushed OTA release to GitHub!"
|
|
227
|
-
else
|
|
228
|
-
# Usually someone else pushed first: re-run so the registry is re-synced, never force-push
|
|
229
|
-
echo " ❌ Git push failed (remote changed or no write access to ${OWNER}/${REPO}). Re-run the build."
|
|
230
|
-
exit 1
|
|
231
|
-
fi
|
|
232
|
-
fi
|
|
233
|
-
cd "$ROOT_DIR"
|
|
234
|
-
echo ""
|
|
235
|
-
echo "=== GitHub Sync Complete ==="
|
|
133
|
+
# Registry last: clients never see a version whose files aren't uploaded yet
|
|
134
|
+
r2_put "$REGISTRY_FILE" "$BASE_FOLDER/ota_registry.json" "no-cache" "registry"
|
|
135
|
+
report UP_SIZE "$(human_size "$TOTAL_BYTES")"
|
|
136
|
+
report UP_DEST "R2 $OTA_R2_BUCKET"
|