react-native-bspatch 2.0.0 → 3.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 (52) hide show
  1. package/README.md +126 -151
  2. package/android/src/main/java/vn/reactnativebspatch/BsPatchModule.kt +0 -25
  3. package/android/src/main/java/vn/reactnativebspatch/OtaBundleResolver.kt +14 -6
  4. package/ios/OtaBundleResolver.swift +14 -6
  5. package/ios/RNBsPatch.mm +1 -28
  6. package/lib/commonjs/NativeBsPatch.js.map +1 -1
  7. package/lib/commonjs/ota/OtaProvider.js +29 -16
  8. package/lib/commonjs/ota/OtaProvider.js.map +1 -1
  9. package/lib/commonjs/ota/otaAssetResolver.js +0 -20
  10. package/lib/commonjs/ota/otaAssetResolver.js.map +1 -1
  11. package/lib/commonjs/ota/otaCore.js +200 -330
  12. package/lib/commonjs/ota/otaCore.js.map +1 -1
  13. package/lib/commonjs/ota/otaMetadata.js +21 -21
  14. package/lib/commonjs/ota/otaMetadata.js.map +1 -1
  15. package/lib/commonjs/ota/storage/GitHubRawStorage.js +13 -15
  16. package/lib/commonjs/ota/storage/GitHubRawStorage.js.map +1 -1
  17. package/lib/commonjs/ota/types.js.map +1 -1
  18. package/lib/module/NativeBsPatch.js.map +1 -1
  19. package/lib/module/ota/OtaProvider.js +30 -17
  20. package/lib/module/ota/OtaProvider.js.map +1 -1
  21. package/lib/module/ota/otaAssetResolver.js +1 -21
  22. package/lib/module/ota/otaAssetResolver.js.map +1 -1
  23. package/lib/module/ota/otaCore.js +197 -329
  24. package/lib/module/ota/otaCore.js.map +1 -1
  25. package/lib/module/ota/otaMetadata.js +19 -19
  26. package/lib/module/ota/otaMetadata.js.map +1 -1
  27. package/lib/module/ota/storage/GitHubRawStorage.js +13 -15
  28. package/lib/module/ota/storage/GitHubRawStorage.js.map +1 -1
  29. package/lib/module/ota/types.js.map +1 -1
  30. package/lib/typescript/NativeBsPatch.d.ts +0 -1
  31. package/lib/typescript/NativeBsPatch.d.ts.map +1 -1
  32. package/lib/typescript/ota/OtaProvider.d.ts.map +1 -1
  33. package/lib/typescript/ota/otaAssetResolver.d.ts.map +1 -1
  34. package/lib/typescript/ota/otaCore.d.ts +13 -3
  35. package/lib/typescript/ota/otaCore.d.ts.map +1 -1
  36. package/lib/typescript/ota/otaMetadata.d.ts +9 -7
  37. package/lib/typescript/ota/otaMetadata.d.ts.map +1 -1
  38. package/lib/typescript/ota/storage/GitHubRawStorage.d.ts.map +1 -1
  39. package/lib/typescript/ota/types.d.ts +5 -19
  40. package/lib/typescript/ota/types.d.ts.map +1 -1
  41. package/package.json +1 -1
  42. package/src/NativeBsPatch.ts +0 -5
  43. package/src/ota/OtaProvider.tsx +30 -23
  44. package/src/ota/otaAssetResolver.ts +1 -17
  45. package/src/ota/otaCore.ts +203 -457
  46. package/src/ota/otaMetadata.ts +30 -20
  47. package/src/ota/storage/GitHubRawStorage.ts +13 -21
  48. package/src/ota/types.ts +5 -20
  49. package/tools/build_ota.sh +222 -525
  50. package/tools/setup_project.js +17 -115
  51. package/tools/update_registry.js +31 -32
  52. package/tools/upload_ota.sh +61 -14
@@ -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 project.pbxproj (Xcode build phase bundle sync)
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
- // Plain shell; escaped below for the pbxproj shellScript string literal
54
- const XCODE_BUNDLE_SYNC_SNIPPET = [
55
- '',
56
- '# [react-native-bspatch] Sync compiled bundle to output/ for OTA builds',
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
- // Replace an older snippet in place: from the marker to the afterEvaluate block's closing brace
128
- const end = findClosingParen(content, content.indexOf('{', content.indexOf('afterEvaluate', markerIdx)), '{', '}');
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, markerIdx) + ANDROID_GRADLE_SNIPPET.trim() + content.slice(end + 1);
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 patchPbxproj(content) {
169
- if (content.includes(XCODE_BUNDLE_SYNC_SNIPPET)) return content;
170
-
171
- // Older snippet present → replace it in place (it lacked the base bundle auto-capture)
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 (Xcode build phase)', patchPbxproj);
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 (auto-capture base bundle)', patchAppBuildGradle);
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 steps:');
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();
@@ -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. Updates patches and latestOtaVersion for that specific app+flavor combination.
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 patches ──────────────────────────────────────────────────────────
84
- if (newPatches.length > 0) {
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 mappedNewPatches = newPatches.map(p => {
111
- const signature = sign(p.file);
112
-
113
- const patchObj = {
114
- id: `v${p.from}-to-v${p.to}-${flavor}-${platform}`,
115
- label: `V${p.from} → V${p.to} (${flavor} ${platform})`,
116
- appVersion,
117
- filename: p.file,
118
- fromVersion: p.from,
119
- toVersion: p.to,
120
- platform,
121
- baseHash: p.baseHash || '',
122
- bundleHash: p.bundleHash || '',
123
- fullBundleUrl: fullBundleName,
124
- assetManifestUrl: manifestName,
125
- flavor: flavor,
126
- };
127
-
128
- if (signature) {
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
- return patchObj;
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, ...mappedNewPatches];
135
+ flavorGroup.patches = [...otherPlatformPatches, fullEntry, ...patchEntries];
137
136
  }
138
137
 
139
138
  // ── Write updated registry ───────────────────────────────────────────────────
@@ -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
- # Load environment variables if .env exists
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' (base bundle v1 + base-assets kept)..."
112
- find "$GH_REPO_DIR/$BASE_FOLDER/$APP_VERSION/$CHANNEL" -type f \
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