react-native-bspatch 2.0.1 → 3.0.1
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 +127 -151
- package/android/src/main/java/vn/reactnativebspatch/BsPatchModule.kt +0 -25
- package/android/src/main/java/vn/reactnativebspatch/OtaBundleResolver.kt +14 -6
- package/ios/OtaBundleResolver.swift +14 -6
- package/ios/RNBsPatch.mm +1 -28
- package/lib/commonjs/NativeBsPatch.js.map +1 -1
- package/lib/commonjs/ota/OtaProvider.js +29 -16
- package/lib/commonjs/ota/OtaProvider.js.map +1 -1
- package/lib/commonjs/ota/otaAssetResolver.js +0 -20
- package/lib/commonjs/ota/otaAssetResolver.js.map +1 -1
- package/lib/commonjs/ota/otaCore.js +200 -330
- package/lib/commonjs/ota/otaCore.js.map +1 -1
- package/lib/commonjs/ota/otaMetadata.js +21 -21
- package/lib/commonjs/ota/otaMetadata.js.map +1 -1
- package/lib/commonjs/ota/storage/GitHubRawStorage.js +13 -15
- package/lib/commonjs/ota/storage/GitHubRawStorage.js.map +1 -1
- package/lib/commonjs/ota/types.js.map +1 -1
- package/lib/module/NativeBsPatch.js.map +1 -1
- package/lib/module/ota/OtaProvider.js +30 -17
- package/lib/module/ota/OtaProvider.js.map +1 -1
- package/lib/module/ota/otaAssetResolver.js +1 -21
- package/lib/module/ota/otaAssetResolver.js.map +1 -1
- package/lib/module/ota/otaCore.js +197 -329
- package/lib/module/ota/otaCore.js.map +1 -1
- package/lib/module/ota/otaMetadata.js +19 -19
- package/lib/module/ota/otaMetadata.js.map +1 -1
- package/lib/module/ota/storage/GitHubRawStorage.js +13 -15
- package/lib/module/ota/storage/GitHubRawStorage.js.map +1 -1
- package/lib/module/ota/types.js.map +1 -1
- package/lib/typescript/NativeBsPatch.d.ts +0 -1
- package/lib/typescript/NativeBsPatch.d.ts.map +1 -1
- package/lib/typescript/ota/OtaProvider.d.ts.map +1 -1
- package/lib/typescript/ota/otaAssetResolver.d.ts.map +1 -1
- package/lib/typescript/ota/otaCore.d.ts +13 -3
- package/lib/typescript/ota/otaCore.d.ts.map +1 -1
- package/lib/typescript/ota/otaMetadata.d.ts +9 -7
- package/lib/typescript/ota/otaMetadata.d.ts.map +1 -1
- package/lib/typescript/ota/storage/GitHubRawStorage.d.ts.map +1 -1
- package/lib/typescript/ota/types.d.ts +5 -19
- package/lib/typescript/ota/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/NativeBsPatch.ts +0 -5
- package/src/ota/OtaProvider.tsx +30 -23
- package/src/ota/otaAssetResolver.ts +1 -17
- package/src/ota/otaCore.ts +203 -457
- package/src/ota/otaMetadata.ts +30 -20
- package/src/ota/storage/GitHubRawStorage.ts +13 -21
- package/src/ota/types.ts +5 -20
- package/tools/build_ota.sh +206 -516
- package/tools/cli.js +9 -0
- package/tools/setup_project.js +17 -115
- package/tools/update_registry.js +31 -32
- package/tools/upload_ota.sh +61 -14
package/tools/cli.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Yarn Berry runs package bins with node regardless of the shebang, so the bash CLI needs a JS entry
|
|
3
|
+
const { spawnSync } = require('child_process');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const result = spawnSync('bash', [path.join(__dirname, 'build_ota.sh'), ...process.argv.slice(2)], {
|
|
7
|
+
stdio: 'inherit',
|
|
8
|
+
});
|
|
9
|
+
process.exit(result.status ?? 1);
|
package/tools/setup_project.js
CHANGED
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
// react-native-bspatch Setup Script
|
|
4
4
|
//
|
|
5
5
|
// Automatically injects OTA integration into a React Native project:
|
|
6
|
-
// - iOS: Patches
|
|
7
|
-
// Patches AppDelegate.swift (import + bundleURL override)
|
|
6
|
+
// - iOS: Patches AppDelegate.swift (import + bundleURL override)
|
|
8
7
|
// - Android: Patches MainApplication.kt (import + jsBundleFilePath)
|
|
9
8
|
//
|
|
10
9
|
// Idempotent: safe to run multiple times; skips already-patched files.
|
|
@@ -50,84 +49,17 @@ function patchFile(filePath, label, patchFn) {
|
|
|
50
49
|
|
|
51
50
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
52
51
|
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
'DEST_FILE="$PROJECT_DIR/output/main.jsbundle"',
|
|
58
|
-
'BUNDLE_RESULT="$CONFIGURATION_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/main.jsbundle"',
|
|
59
|
-
'',
|
|
60
|
-
'if [ -f "$BUNDLE_RESULT" ]; then',
|
|
61
|
-
' mkdir -p "$(dirname "$DEST_FILE")"',
|
|
62
|
-
' cp "$BUNDLE_RESULT" "$DEST_FILE"',
|
|
63
|
-
' echo "Bundle synced to $DEST_FILE"',
|
|
64
|
-
' if [ "$CONFIGURATION" = "Release" ]; then',
|
|
65
|
-
' APP_VER="${MARKETING_VERSION:-1.0.0}"',
|
|
66
|
-
' # v1 = the store build; never overwrite it (delete the file to re-capture)',
|
|
67
|
-
' for FLAVOR in development staging production; do',
|
|
68
|
-
' DEST_V1="$PROJECT_DIR/output/bundle_${APP_VER}_v1_ios_${FLAVOR}.hbc"',
|
|
69
|
-
' if [ ! -f "$DEST_V1" ]; then',
|
|
70
|
-
' cp "$BUNDLE_RESULT" "$DEST_V1"',
|
|
71
|
-
' echo "[react-native-bspatch] ✓ Auto-captured iOS base bundle v1 ($FLAVOR)"',
|
|
72
|
-
' fi',
|
|
73
|
-
' done',
|
|
74
|
-
' fi',
|
|
75
|
-
'else',
|
|
76
|
-
' echo "Bundle not found at $BUNDLE_RESULT (skipping sync)"',
|
|
77
|
-
'fi',
|
|
78
|
-
].join('\n').replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
|
|
79
|
-
|
|
80
|
-
// The escaped version as it appears in pbxproj shellScript value
|
|
81
|
-
const XCODE_BUNDLE_SYNC_MARKER = 'react-native-bspatch] Sync compiled bundle to output/';
|
|
82
|
-
|
|
83
|
-
const ANDROID_GRADLE_MARKER = '[react-native-bspatch] Auto-capture Base Bundle';
|
|
84
|
-
const ANDROID_GRADLE_SNIPPET = `
|
|
85
|
-
// [react-native-bspatch] Auto-capture Base Bundle on Release builds
|
|
86
|
-
afterEvaluate {
|
|
87
|
-
android.applicationVariants.all { variant ->
|
|
88
|
-
if (variant.buildType.name == 'release') {
|
|
89
|
-
def flavorName = (variant.flavorName ?: 'development').toLowerCase()
|
|
90
|
-
if (flavorName == 'dev') flavorName = 'development'
|
|
91
|
-
if (flavorName == 'prod') flavorName = 'production'
|
|
92
|
-
def versionName = variant.versionName ?: '1.0.0'
|
|
93
|
-
def capVariant = variant.name.capitalize()
|
|
94
|
-
def bundleTask = tasks.findByName("createBundle\${capVariant}JsAndAssets")
|
|
95
|
-
if (bundleTask != null) {
|
|
96
|
-
def saveTask = tasks.register("saveOtaBaseBundle\${capVariant}") {
|
|
97
|
-
doLast {
|
|
98
|
-
def bundleDir = file("\$buildDir/generated/assets/react/\${variant.name}")
|
|
99
|
-
def bundleFile = file("\$bundleDir/index.android.bundle")
|
|
100
|
-
def outputDir = file("\$rootDir/output")
|
|
101
|
-
// v1 = the store build; never overwrite it (delete the file to re-capture).
|
|
102
|
-
// build_ota.sh runs this same bundle task for every OTA build.
|
|
103
|
-
def destFile = file("\$outputDir/bundle_\${versionName}_v1_android_\${flavorName}.hbc")
|
|
104
|
-
if (bundleFile.exists() && !destFile.exists()) {
|
|
105
|
-
outputDir.mkdirs()
|
|
106
|
-
def destName = "bundle_\${versionName}_v1_android_\${flavorName}.hbc"
|
|
107
|
-
copy {
|
|
108
|
-
from bundleFile
|
|
109
|
-
into outputDir
|
|
110
|
-
rename { destName }
|
|
111
|
-
}
|
|
112
|
-
println "[react-native-bspatch] ✓ Auto-captured base bundle: android/output/\${destName}"
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
bundleTask.finalizedBy(saveTask)
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
`;
|
|
122
|
-
|
|
123
|
-
function patchAppBuildGradle(content) {
|
|
124
|
-
const markerIdx = content.indexOf('// ' + ANDROID_GRADLE_MARKER);
|
|
125
|
-
if (markerIdx === -1) return content.trimEnd() + '\n' + ANDROID_GRADLE_SNIPPET;
|
|
52
|
+
// Older versions injected these to capture the store bundle (v1) as a patch source.
|
|
53
|
+
// OTA no longer patches from the store bundle, so setup removes them.
|
|
54
|
+
const LEGACY_XCODE_MARKER = '\\n# [react-native-bspatch] Sync compiled bundle to output/';
|
|
55
|
+
const LEGACY_GRADLE_MARKER = '// [react-native-bspatch] Auto-capture Base Bundle';
|
|
126
56
|
|
|
127
|
-
|
|
128
|
-
const
|
|
57
|
+
function removeAppBuildGradleSnippet(content) {
|
|
58
|
+
const idx = content.indexOf(LEGACY_GRADLE_MARKER);
|
|
59
|
+
if (idx === -1) return content;
|
|
60
|
+
const end = findClosingParen(content, content.indexOf('{', content.indexOf('afterEvaluate', idx)), '{', '}');
|
|
129
61
|
if (end === -1) return content;
|
|
130
|
-
return content.slice(0,
|
|
62
|
+
return content.slice(0, idx).trimEnd() + '\n' + content.slice(end + 1).trimStart();
|
|
131
63
|
}
|
|
132
64
|
|
|
133
65
|
const IOS_IMPORT = 'import react_native_bspatch';
|
|
@@ -165,34 +97,10 @@ function findClosingParen(source, openParenIdx, open = '(', close = ')') {
|
|
|
165
97
|
|
|
166
98
|
// ── iOS: project.pbxproj ─────────────────────────────────────────────────────
|
|
167
99
|
|
|
168
|
-
function
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const oldIdx = content.indexOf('\\n# [' + XCODE_BUNDLE_SYNC_MARKER);
|
|
173
|
-
if (oldIdx !== -1) {
|
|
174
|
-
const endIdx = content.indexOf('";', oldIdx);
|
|
175
|
-
return content.slice(0, oldIdx) + XCODE_BUNDLE_SYNC_SNIPPET + content.slice(endIdx);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// Search by phase name
|
|
179
|
-
const nameIdx = content.indexOf('name = "Bundle React Native code and images";');
|
|
180
|
-
if (nameIdx !== -1) {
|
|
181
|
-
const shellScriptIdx = content.indexOf('shellScript = "', nameIdx);
|
|
182
|
-
if (shellScriptIdx !== -1) {
|
|
183
|
-
const endIdx = content.indexOf('";', shellScriptIdx + 15);
|
|
184
|
-
if (endIdx !== -1) {
|
|
185
|
-
return (
|
|
186
|
-
content.slice(0, endIdx) +
|
|
187
|
-
XCODE_BUNDLE_SYNC_SNIPPET +
|
|
188
|
-
content.slice(endIdx)
|
|
189
|
-
);
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
console.log(' ⚠ Could not find "Bundle React Native code and images" build phase in project.pbxproj.');
|
|
195
|
-
return content;
|
|
100
|
+
function removePbxprojSnippet(content) {
|
|
101
|
+
const idx = content.indexOf(LEGACY_XCODE_MARKER);
|
|
102
|
+
if (idx === -1) return content;
|
|
103
|
+
return content.slice(0, idx) + content.slice(content.indexOf('";', idx));
|
|
196
104
|
}
|
|
197
105
|
|
|
198
106
|
// ── iOS: AppDelegate.swift ────────────────────────────────────────────────────
|
|
@@ -316,7 +224,7 @@ function main() {
|
|
|
316
224
|
console.log(' ⚠ No project.pbxproj found. Is this an iOS project?');
|
|
317
225
|
} else {
|
|
318
226
|
const pbxproj = pbxprojFiles[0];
|
|
319
|
-
patchFile(pbxproj, 'project.pbxproj (
|
|
227
|
+
patchFile(pbxproj, 'project.pbxproj (remove legacy bundle capture)', removePbxprojSnippet);
|
|
320
228
|
}
|
|
321
229
|
|
|
322
230
|
// AppDelegate.swift
|
|
@@ -347,7 +255,7 @@ function main() {
|
|
|
347
255
|
if (appGradleFiles.length === 0) {
|
|
348
256
|
console.log(' ⚠ No android/app/build.gradle found.');
|
|
349
257
|
} else {
|
|
350
|
-
patchFile(appGradleFiles[0], 'android/app/build.gradle (
|
|
258
|
+
patchFile(appGradleFiles[0], 'android/app/build.gradle (remove legacy bundle capture)', removeAppBuildGradleSnippet);
|
|
351
259
|
}
|
|
352
260
|
|
|
353
261
|
// Ensure android/output/ exists
|
|
@@ -358,13 +266,7 @@ function main() {
|
|
|
358
266
|
}
|
|
359
267
|
|
|
360
268
|
console.log('\n=== Setup Complete ===');
|
|
361
|
-
console.log('Next
|
|
362
|
-
console.log(' 1. Base bundles will now automatically be captured on Release builds (Gradle & Xcode).');
|
|
363
|
-
console.log(' 2. Or seed directly from an existing Store binary:');
|
|
364
|
-
console.log(' yarn react-native-bspatch seed --apk path/to/release.apk');
|
|
365
|
-
console.log(' yarn react-native-bspatch seed --ipa path/to/release.ipa');
|
|
366
|
-
console.log(' 3. Or create base release manually: yarn ota:init');
|
|
367
|
-
console.log(' 4. Then publish OTA updates safely with: yarn ota:build\n');
|
|
269
|
+
console.log('Next: release the app to the store (v1), then publish OTA updates with: npx react-native-bspatch\n');
|
|
368
270
|
}
|
|
369
271
|
|
|
370
272
|
main();
|
package/tools/update_registry.js
CHANGED
|
@@ -6,8 +6,7 @@
|
|
|
6
6
|
* 1. Reads : android/output/ota_registry.json (or ios/output/...)
|
|
7
7
|
* 2. Scopes to : appVersion (e.g. "1.0.0")
|
|
8
8
|
* 3. Scopes to : flavor (e.g. "dev", "staging", "production")
|
|
9
|
-
* 4.
|
|
10
|
-
* 5. Writes : otaPatches.generated.ts (flattened across all app versions/flavors)
|
|
9
|
+
* 4. Writes the full-bundle entry + patch entries and latestOtaVersion for that app+flavor.
|
|
11
10
|
*/
|
|
12
11
|
|
|
13
12
|
const fs = require('fs');
|
|
@@ -33,10 +32,6 @@ const registryPath = projectRoot.endsWith('.json')
|
|
|
33
32
|
? projectRoot
|
|
34
33
|
: path.join(projectRoot, 'ota_registry.json');
|
|
35
34
|
|
|
36
|
-
const generatedPath = projectRoot.endsWith('.json')
|
|
37
|
-
? path.join(path.dirname(projectRoot), 'src/ota/otaPatches.generated.ts')
|
|
38
|
-
: path.join(projectRoot, 'src/ota/otaPatches.generated.ts');
|
|
39
|
-
|
|
40
35
|
// ── Load or init registry ───────────────────────────────────────────────────
|
|
41
36
|
let registry = { apps: {} };
|
|
42
37
|
if (fs.existsSync(registryPath)) {
|
|
@@ -80,8 +75,9 @@ if (!signingKey) {
|
|
|
80
75
|
}
|
|
81
76
|
}
|
|
82
77
|
|
|
83
|
-
// ── Update
|
|
84
|
-
|
|
78
|
+
// ── Update entries ──────────────────────────────────────────────────────────
|
|
79
|
+
// One full-bundle entry (no `filename`) + one entry per patch from an earlier OTA version
|
|
80
|
+
{
|
|
85
81
|
// We merge patches by platform to avoid overwriting between Android and iOS
|
|
86
82
|
const otherPlatformPatches = flavorGroup.patches.filter(p => p.platform !== platform);
|
|
87
83
|
|
|
@@ -107,33 +103,36 @@ if (newPatches.length > 0) {
|
|
|
107
103
|
// path and the full-bundle fallback, so a tampered registry can't swap bundles.
|
|
108
104
|
const bundleSignature = sign(fullBundleName);
|
|
109
105
|
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
patchObj.signature = signature;
|
|
130
|
-
patchObj.bundleSignature = bundleSignature;
|
|
131
|
-
}
|
|
106
|
+
const bundleHash = crypto
|
|
107
|
+
.createHash('sha256')
|
|
108
|
+
.update(fs.readFileSync(path.join(outputDir, fullBundleName)))
|
|
109
|
+
.digest('hex');
|
|
110
|
+
|
|
111
|
+
const entry = (from, extra) => ({
|
|
112
|
+
id: `v${from}-to-v${toOtaVersion}-${flavor}-${platform}`,
|
|
113
|
+
label: `V${from} → V${toOtaVersion} (${flavor} ${platform})`,
|
|
114
|
+
appVersion,
|
|
115
|
+
fromVersion: from,
|
|
116
|
+
toVersion: toOtaVersion,
|
|
117
|
+
platform,
|
|
118
|
+
bundleHash,
|
|
119
|
+
fullBundleUrl: fullBundleName,
|
|
120
|
+
assetManifestUrl: manifestName,
|
|
121
|
+
flavor,
|
|
122
|
+
bundleSignature,
|
|
123
|
+
...extra,
|
|
124
|
+
});
|
|
132
125
|
|
|
133
|
-
|
|
126
|
+
// fromVersion 1 = the store build, whose clients always take the full bundle
|
|
127
|
+
const fullEntry = entry(1, {
|
|
128
|
+
id: `full-to-v${toOtaVersion}-${flavor}-${platform}`,
|
|
129
|
+
label: `Full V${toOtaVersion} (${flavor} ${platform})`,
|
|
134
130
|
});
|
|
131
|
+
const patchEntries = newPatches.map(p =>
|
|
132
|
+
entry(p.from, { filename: p.file, baseHash: p.baseHash, signature: sign(p.file) }),
|
|
133
|
+
);
|
|
135
134
|
|
|
136
|
-
flavorGroup.patches = [...otherPlatformPatches, ...
|
|
135
|
+
flavorGroup.patches = [...otherPlatformPatches, fullEntry, ...patchEntries];
|
|
137
136
|
}
|
|
138
137
|
|
|
139
138
|
// ── Write updated registry ───────────────────────────────────────────────────
|
package/tools/upload_ota.sh
CHANGED
|
@@ -1,16 +1,13 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
|
|
3
3
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
4
|
-
# OTA Upload Script — GitHub storage repository
|
|
4
|
+
# OTA Upload Script — Cloudflare R2 (when OTA_R2_BUCKET is set) or a GitHub storage repository
|
|
5
5
|
# Uploads: 1) All .patch & .hbc files → {baseFolder}/{appVersion}/{channel}/
|
|
6
6
|
# 2) ota_registry.json → {baseFolder}/ota_registry.json
|
|
7
7
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
8
8
|
|
|
9
|
-
#
|
|
9
|
+
# Env (.env.<flavor> + .env.ota) is loaded and exported by build_ota.sh
|
|
10
10
|
ROOT_DIR=$(pwd)
|
|
11
|
-
if [ -f "$ROOT_DIR/.env" ]; then
|
|
12
|
-
set -a; . "$ROOT_DIR/.env"; set +a
|
|
13
|
-
fi
|
|
14
11
|
|
|
15
12
|
BASE_URL="${OTA_BASE_URL:-}"
|
|
16
13
|
BASE_FOLDER="${OTA_FOLDER:-${6:-ota}}"
|
|
@@ -26,9 +23,6 @@ if [[ "$PLATFORM" == "ios" ]]; then
|
|
|
26
23
|
OUTPUT_DIR="$ROOT_DIR/ios/output"
|
|
27
24
|
fi
|
|
28
25
|
|
|
29
|
-
echo ""
|
|
30
|
-
echo "=== OTA Upload | App: $APP_VERSION | Channel: $CHANNEL | V$OTA_VERSION | Platform: $PLATFORM ==="
|
|
31
|
-
echo " Output dir: $OUTPUT_DIR"
|
|
32
26
|
|
|
33
27
|
# Validate
|
|
34
28
|
if [[ -z "$APP_VERSION" || -z "$OTA_VERSION" ]]; then
|
|
@@ -48,14 +42,68 @@ for ARG in "$@"; do
|
|
|
48
42
|
fi
|
|
49
43
|
done
|
|
50
44
|
|
|
51
|
-
# Files of this app version + channel only (bundle_/patch_/asset-manifest_ carry both
|
|
52
|
-
# base-assets_<platform>_<channel>.json carries just the channel)
|
|
45
|
+
# Files of this app version + channel only (bundle_/patch_/asset-manifest_ carry both)
|
|
53
46
|
is_release_file() {
|
|
54
47
|
local FNAME="$1"
|
|
55
|
-
if [[ "$FNAME" == "base-assets_"*"_${CHANNEL}.json" ]]; then return 0; fi
|
|
56
48
|
[[ "$FNAME" == *"_${APP_VERSION}_"* ]] && [[ "$FNAME" == *"_${CHANNEL}."* || "$FNAME" == *"_${CHANNEL}_"* ]]
|
|
57
49
|
}
|
|
58
50
|
|
|
51
|
+
# ── Cloudflare R2 (S3 API, signed by curl --aws-sigv4; no CLI needed) ────────
|
|
52
|
+
if [[ -n "${OTA_R2_BUCKET:-}" ]]; then
|
|
53
|
+
: "${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}"
|
|
54
|
+
R2_URL="https://${OTA_R2_ACCOUNT_ID}.r2.cloudflarestorage.com/${OTA_R2_BUCKET}"
|
|
55
|
+
r2() { curl -sS --aws-sigv4 "aws:amz:auto:s3" --user "$OTA_R2_ACCESS_KEY_ID:$OTA_R2_SECRET_ACCESS_KEY" "$@"; }
|
|
56
|
+
r2_put() { # <file> <key> <cache-control> [label]
|
|
57
|
+
local SIZE PROGRESS=(-s)
|
|
58
|
+
SIZE=$(wc -c < "$1" | tr -d ' ')
|
|
59
|
+
# Progress bar for big files (bundles) in a terminal
|
|
60
|
+
if [ -t 2 ] && [ "$SIZE" -gt 1048576 ]; then echo " ↑ ${4:-$2}"; PROGRESS=(-#); fi
|
|
61
|
+
curl -S "${PROGRESS[@]}" -f --aws-sigv4 "aws:amz:auto:s3" --user "$OTA_R2_ACCESS_KEY_ID:$OTA_R2_SECRET_ACCESS_KEY" \
|
|
62
|
+
-X PUT -T "$1" -H "Cache-Control: $3" "$R2_URL/$2" -o /dev/null || { echo " ❌ Upload failed: $2"; exit 1; }
|
|
63
|
+
echo " ✓ ${4:-$2} ($(human_size "$SIZE"))"
|
|
64
|
+
}
|
|
65
|
+
RELEASE_PREFIX="$BASE_FOLDER/$APP_VERSION/$CHANNEL"
|
|
66
|
+
IMMUTABLE="public, max-age=31536000, immutable"
|
|
67
|
+
|
|
68
|
+
echo " → Cloudflare R2: $OTA_R2_BUCKET/$RELEASE_PREFIX"
|
|
69
|
+
if [ "$CLEAN_REMOTE" = true ]; then
|
|
70
|
+
echo " Deleting $RELEASE_PREFIX/ ..."
|
|
71
|
+
# ponytail: one ListObjectsV2 page (1000 keys); a release+channel holds far fewer
|
|
72
|
+
for KEY in $(r2 -f "$R2_URL?list-type=2&prefix=$RELEASE_PREFIX/" | grep -o '<Key>[^<]*</Key>' | sed 's/<[^>]*>//g'); do
|
|
73
|
+
r2 -f -X DELETE "$R2_URL/$KEY" -o /dev/null && echo " ✗ $KEY"
|
|
74
|
+
done
|
|
75
|
+
else
|
|
76
|
+
# Only this version's files: earlier bundles/patches are already uploaded and never change
|
|
77
|
+
RELEASE_FILES=()
|
|
78
|
+
for FILE in "$OUTPUT_DIR"/*_v"${OTA_VERSION}"_*.{patch,hbc,json}; do
|
|
79
|
+
[[ -f "$FILE" ]] && is_release_file "$(basename "$FILE")" && RELEASE_FILES+=("$FILE")
|
|
80
|
+
done
|
|
81
|
+
N=0
|
|
82
|
+
for FILE in "${RELEASE_FILES[@]}"; do
|
|
83
|
+
N=$((N + 1)); FNAME=$(basename "$FILE")
|
|
84
|
+
r2_put "$FILE" "$RELEASE_PREFIX/$FNAME" "$IMMUTABLE" "($N/${#RELEASE_FILES[@]}) $FNAME"
|
|
85
|
+
done
|
|
86
|
+
# Content-addressed assets: upload only hashes the bucket doesn't have yet
|
|
87
|
+
ASSETS=("$OUTPUT_DIR"/asset-store/*)
|
|
88
|
+
[[ -f "${ASSETS[0]}" ]] || ASSETS=()
|
|
89
|
+
N=0; NEW=0; NEW_BYTES=0
|
|
90
|
+
for FILE in "${ASSETS[@]}"; do
|
|
91
|
+
N=$((N + 1))
|
|
92
|
+
KEY="$BASE_FOLDER/assets/$(basename "$FILE")"
|
|
93
|
+
if ! r2 -f -I "$R2_URL/$KEY" -o /dev/null 2>/dev/null; then
|
|
94
|
+
r2 -f -X PUT -T "$FILE" -H "Cache-Control: $IMMUTABLE" "$R2_URL/$KEY" -o /dev/null || { echo " ❌ Upload failed: $KEY"; exit 1; }
|
|
95
|
+
NEW=$((NEW + 1)); NEW_BYTES=$((NEW_BYTES + $(wc -c < "$FILE")))
|
|
96
|
+
fi
|
|
97
|
+
if [ -t 1 ]; then printf "\r assets %d/%d checked, %d uploaded" "$N" "${#ASSETS[@]}" "$NEW"; fi
|
|
98
|
+
done
|
|
99
|
+
if [ -t 1 ] && [ ${#ASSETS[@]} -gt 0 ]; then printf "\r\033[K"; fi
|
|
100
|
+
echo " ✓ assets: ${#ASSETS[@]} total, $NEW uploaded ($(human_size "$NEW_BYTES")), $((${#ASSETS[@]} - NEW)) already on R2"
|
|
101
|
+
fi
|
|
102
|
+
# Registry last: clients never see a version whose files aren't uploaded yet
|
|
103
|
+
r2_put "$REGISTRY_FILE" "$BASE_FOLDER/ota_registry.json" "no-cache" "ota_registry.json"
|
|
104
|
+
exit 0
|
|
105
|
+
fi
|
|
106
|
+
|
|
59
107
|
# ── GitHub Auto-Push Integration ──────────────────────────────────────────────
|
|
60
108
|
if [[ "$BASE_URL" != "https://raw.githubusercontent.com/"* ]]; then
|
|
61
109
|
echo "ERROR: OTA_BASE_URL must be https://raw.githubusercontent.com/<owner>/<repo>/<branch>"
|
|
@@ -108,9 +156,8 @@ fi
|
|
|
108
156
|
|
|
109
157
|
# 2. Copy generated files into storage repo (or reset remote)
|
|
110
158
|
if [ "$CLEAN_REMOTE" = true ]; then
|
|
111
|
-
echo " Resetting remote storage state for channel '$CHANNEL'
|
|
112
|
-
|
|
113
|
-
-not -name "bundle_${APP_VERSION}_v1_*" -not -name 'base-assets_*' -delete 2>/dev/null || true
|
|
159
|
+
echo " Resetting remote storage state for channel '$CHANNEL' ..."
|
|
160
|
+
rm -rf "$GH_REPO_DIR/$BASE_FOLDER/$APP_VERSION/${CHANNEL:?}"
|
|
114
161
|
mkdir -p "$GH_REPO_DIR/$BASE_FOLDER"
|
|
115
162
|
cp "$REGISTRY_FILE" "$GH_REPO_DIR/$BASE_FOLDER/ota_registry.json"
|
|
116
163
|
cp "$REGISTRY_FILE" "$GH_REPO_DIR/ota_registry.json" 2>/dev/null || true
|