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,36 +3,34 @@
3
3
  # ─────────────────────────────────────────────────────────────────────────────
4
4
  # OTA Build Script — incremental, one version per run
5
5
  #
6
- # Supports single-step updates (v1 -> v3 direct) and version grouping.
7
- # Generates SHA-256 hashes for integrity verification.
6
+ # v1 is the store build. Every OTA version ships a full bundle; patches are built
7
+ # only from earlier OTA versions (v2+), whose bundles are byte-identical on device.
8
+ # Clients on the store build download the full bundle once, then patch.
8
9
  #
9
- # Usage:
10
- # ./build_ota.sh → build next OTA version for current App.tsx
11
- # ./build_ota.sh base → first build: create base APK/IPA + save base bundle
12
- # ./build_ota.sh reset → wipe registry and start fresh
10
+ # Usage (npx react-native-bspatch ...):
11
+ # patch [android|ios] [--flavor staging] [--release-version 1.0.4] [-y]
12
+ # → build + publish the next OTA (both platforms if omitted)
13
+ # reset [--flavor ...] [--push] → wipe this release's OTAs
14
+ # setup → wire OtaBundleResolver into AppDelegate / MainApplication
15
+ # version <x.y.z> → set the native app version (package.json, Gradle, Xcode)
16
+ # The release version is read from the native project and confirmed by a prompt (Enter keeps it).
13
17
  # ─────────────────────────────────────────────────────────────────────────────
14
18
 
15
19
  set -e
16
- SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
17
- MODULE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
20
+ # Resolve symlinks: npx runs us via node_modules/.bin/react-native-bspatch
21
+ SOURCE="$0"
22
+ while [ -L "$SOURCE" ]; do
23
+ LINK_DIR="$(cd "$(dirname "$SOURCE")" && pwd)"
24
+ SOURCE="$(readlink "$SOURCE")"
25
+ case "$SOURCE" in /*) ;; *) SOURCE="$LINK_DIR/$SOURCE" ;; esac
26
+ done
27
+ SCRIPT_DIR="$(cd "$(dirname "$SOURCE")" && pwd)"
18
28
  ROOT="$PWD"
19
- BUNDLE_XCODE_IOS="$ROOT/ios/output/main.jsbundle"
20
29
  BSDIFF="$SCRIPT_DIR/bsdiff"
21
30
 
22
- # ── Load .env file if exists ─────────────────────────────────────────────────
23
- # Look in project root first, then module directory (fallback)
24
- ENV_FILE=""
25
- if [ -f "$ROOT/.env" ]; then
26
- ENV_FILE="$ROOT/.env"
27
- elif [ -f "$MODULE_DIR/.env" ]; then
28
- ENV_FILE="$MODULE_DIR/.env"
29
- fi
30
-
31
- if [ -n "$ENV_FILE" ]; then
32
- echo "Loading environment variables from $(basename "$ENV_FILE")..."
33
- set -a; . "$ENV_FILE"; set +a
34
- else
35
- echo " No .env file found in $ROOT or $MODULE_DIR"
31
+ if [ "$1" = "version" ]; then
32
+ shift
33
+ exec node "$SCRIPT_DIR/bump_version.js" "$@" --root "$ROOT"
36
34
  fi
37
35
 
38
36
  # ── Parameters ─────────────────────────────────────────────────────────────
@@ -40,43 +38,25 @@ FLAVOR="dev"
40
38
  ACTION="ota"
41
39
  PLATFORM_ARG=""
42
40
  APP_VERSION_ARG=""
43
- NON_INTERACTIVE=false
44
-
45
- SEED_APK=""
46
- SEED_IPA=""
47
- SEED_BUNDLE=""
48
- FORCE_BASE=false
41
+ CONFIRM_VERSION=true
49
42
 
50
43
  # Arg parsing
51
44
  while [[ "$#" -gt 0 ]]; do
52
45
  case $1 in
53
46
  --platform) PLATFORM_ARG="$2"; shift ;;
54
47
  --flavor) FLAVOR="$2"; shift ;;
55
- --app-version) APP_VERSION_ARG="$2"; shift ;;
56
- --apk) SEED_APK="$2"; ACTION="seed"; shift ;;
57
- --ipa) SEED_IPA="$2"; ACTION="seed"; shift ;;
58
- --bundle) SEED_BUNDLE="$2"; ACTION="seed"; shift ;;
59
- --force) FORCE_BASE=true ;;
60
- -y|--yes|--non-interactive) NON_INTERACTIVE=true ;;
48
+ --release-version|--app-version) APP_VERSION_ARG="$2"; shift ;;
49
+ -y|--yes|--non-interactive) CONFIRM_VERSION=false ;;
61
50
  --push) PUSH_REMOTE=true ;;
62
- reset|base) ACTION="$1" ;;
63
- setup) ACTION="$1" ;;
64
- seed) ACTION="seed" ;;
65
- apk|ipa) ACTION="base" ;; # Compatibility for legacy actions
51
+ patch) ACTION="ota" ;;
52
+ android|ios) PLATFORM_ARG="$1" ;;
53
+ reset|setup) ACTION="$1" ;;
54
+ base|seed|apk|ipa|--apk|--ipa|*.apk|*.aab|*.ipa)
55
+ # Removed in 3.0: falling through would silently build and publish an OTA
56
+ echo "❌ '$1' was removed: the first OTA after a store release is a full bundle, no base/seed needed."
57
+ exit 1 ;;
66
58
  dev|staging|production) FLAVOR="$1" ;; # Support positional flavor
67
- [0-9]*) ;; # Ignore version if passed as $1 (pos 1 usually)
68
- *)
69
- if [[ "$1" == *.apk ]] || [[ "$1" == *.aab ]]; then
70
- SEED_APK="$1"
71
- ACTION="seed"
72
- elif [[ "$1" == *.ipa ]]; then
73
- SEED_IPA="$1"
74
- ACTION="seed"
75
- elif [[ "$1" == *.hbc ]] || [[ "$1" == *.bundle ]] || [[ "$1" == *.jsbundle ]]; then
76
- SEED_BUNDLE="$1"
77
- ACTION="seed"
78
- fi
79
- ;;
59
+ *) echo "❌ Unknown argument: $1"; exit 1 ;;
80
60
  esac
81
61
  shift
82
62
  done
@@ -85,7 +65,39 @@ FLAVOR_LOWER=$(echo "$FLAVOR" | tr '[:upper:]' '[:lower:]')
85
65
  if [ "$FLAVOR_LOWER" = "dev" ]; then FLAVOR_LOWER="development"; fi
86
66
  if [ "$FLAVOR_LOWER" = "prod" ]; then FLAVOR_LOWER="production"; fi
87
67
 
88
- # ── Setup ──────────────────────────────────────────────────────────────────
68
+ # ── Environment ──────────────────────────────────────────────────────────────
69
+ # .env.<flavor>: OTA_BASE_URL / OTA_FOLDER, the same values react-native-config bakes into the app.
70
+ # .env.ota: upload secrets (OTA_R2_*). Never put them in .env.<flavor>: react-native-config
71
+ # ships every key of that file inside the APK/IPA.
72
+ # Parsed like react-native-config (KEY=VALUE, optional quotes), never executed:
73
+ # `APP_NAME=Baseform Dev` is valid dotenv but breaks `source`.
74
+ load_env() {
75
+ echo "Loading $(basename "$1")..."
76
+ local line k v
77
+ while IFS= read -r line || [ -n "$line" ]; do
78
+ [[ "$line" =~ ^[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*=(.*)$ ]] || continue
79
+ k="${BASH_REMATCH[1]}"; v="${BASH_REMATCH[2]%$'\r'}"; v="${v#"${v%%[![:space:]]*}"}"
80
+ if [[ "$v" =~ ^\"(.*)\"$ ]] || [[ "$v" =~ ^\'(.*)\'$ ]]; then v="${BASH_REMATCH[1]}"; fi
81
+ export "$k=$v"
82
+ done < "$1"
83
+ }
84
+ if [ "$ACTION" != "setup" ]; then
85
+ if [ ! -f "$ROOT/.env.$FLAVOR_LOWER" ]; then
86
+ echo "❌ $ROOT/.env.$FLAVOR_LOWER not found (OTA_BASE_URL / OTA_FOLDER for flavor '$FLAVOR_LOWER')"
87
+ exit 1
88
+ fi
89
+ load_env "$ROOT/.env.$FLAVOR_LOWER"
90
+ if [ -f "$ROOT/.env.ota" ]; then load_env "$ROOT/.env.ota"; fi
91
+ fi
92
+
93
+ # ── Setup (project wiring only, no build env needed) ─────────────────────────
94
+ if [ "$ACTION" = "setup" ]; then
95
+ echo "Setting up react-native-bspatch in this project..."
96
+ node "$SCRIPT_DIR/setup_project.js" --root "$ROOT"
97
+ exit 0
98
+ fi
99
+
100
+ # ── Build environment ──────────────────────────────────────────────────────
89
101
  echo "Preparing build environment..."
90
102
  # Kill any existing Metro processes to prevent file locks
91
103
  pkill -f "react-native/local-cli/cli.js" || true
@@ -114,40 +126,47 @@ if [ "$ACTION" != "setup" ]; then
114
126
  echo " Using URL: $BASE_URL"
115
127
  echo " Using Folder: $BASE_FOLDER"
116
128
 
117
- # GitHub is the only storage backend for now
118
- if [[ "$BASE_URL" != "https://raw.githubusercontent.com/"* ]]; then
119
- echo " ❌ OTA_BASE_URL must be https://raw.githubusercontent.com/<owner>/<repo>/<branch>"
120
- exit 1
129
+ if [[ -n "${OTA_R2_BUCKET:-}" ]]; then
130
+ R2_URL="https://${OTA_R2_ACCOUNT_ID:?set OTA_R2_ACCOUNT_ID}.r2.cloudflarestorage.com/${OTA_R2_BUCKET}"
131
+ r2() { curl -sS --aws-sigv4 "aws:amz:auto:s3" --user "${OTA_R2_ACCESS_KEY_ID:?}:${OTA_R2_SECRET_ACCESS_KEY:?}" "$@"; }
132
+ # S3 API, not the public URL: always the latest object, no CDN cache
133
+ API_HTTP=$(r2 -o "$REGISTRY_SHARED.tmp" -w "%{http_code}" "$R2_URL/$BASE_FOLDER/ota_registry.json" || echo 0)
134
+ else
135
+ if [[ "$BASE_URL" != "https://raw.githubusercontent.com/"* ]]; then
136
+ echo " ❌ Set OTA_R2_* (Cloudflare R2) or OTA_BASE_URL=https://raw.githubusercontent.com/<owner>/<repo>/<branch>"
137
+ exit 1
138
+ fi
139
+ CLEAN_URL="${BASE_URL#https://raw.githubusercontent.com/}"
140
+ GH_OWNER=$(echo "$CLEAN_URL" | cut -d'/' -f1)
141
+ GH_REPO=$(echo "$CLEAN_URL" | cut -d'/' -f2)
142
+ GH_BRANCH=$(echo "$CLEAN_URL" | cut -d'/' -f3)
143
+ if [[ -z "$GH_BRANCH" ]]; then GH_BRANCH="main"; fi
144
+
145
+ # Contents API (not the raw CDN) always returns the latest commit; the raw
146
+ # media type has no 1 MB limit and needs no base64 decoding.
147
+ GH_API_TOKEN="$(gh auth token 2>/dev/null || true)"
148
+ GH_API_TOKEN="${GH_API_TOKEN:-${GH_TOKEN:-${GITHUB_TOKEN:-}}}"
149
+ GH_API_AUTH=()
150
+ if [[ -n "$GH_API_TOKEN" ]]; then GH_API_AUTH=(-H "Authorization: Bearer $GH_API_TOKEN"); fi
151
+
152
+ API_HTTP=$(curl -s -o "$REGISTRY_SHARED.tmp" -w "%{http_code}" "${GH_API_AUTH[@]}" \
153
+ -H "Accept: application/vnd.github.raw" \
154
+ "https://api.github.com/repos/${GH_OWNER}/${GH_REPO}/contents/${BASE_FOLDER}/ota_registry.json?ref=${GH_BRANCH}" || echo 0)
121
155
  fi
122
- echo " Downloading remote registry from GitHub..."
123
-
124
- CLEAN_URL="${BASE_URL#https://raw.githubusercontent.com/}"
125
- GH_OWNER=$(echo "$CLEAN_URL" | cut -d'/' -f1)
126
- GH_REPO=$(echo "$CLEAN_URL" | cut -d'/' -f2)
127
- GH_BRANCH=$(echo "$CLEAN_URL" | cut -d'/' -f3)
128
- if [[ -z "$GH_BRANCH" ]]; then GH_BRANCH="main"; fi
129
-
130
- # Contents API (not the raw CDN) always returns the latest commit; the raw
131
- # media type has no 1 MB limit and needs no base64 decoding.
132
- GH_API_TOKEN="$(gh auth token 2>/dev/null || true)"
133
- GH_API_TOKEN="${GH_API_TOKEN:-${GH_TOKEN:-${GITHUB_TOKEN:-}}}"
134
- GH_API_AUTH=()
135
- if [[ -n "$GH_API_TOKEN" ]]; then GH_API_AUTH=(-H "Authorization: Bearer $GH_API_TOKEN"); fi
136
-
137
- API_HTTP=$(curl -s -o "$REGISTRY_SHARED.tmp" -w "%{http_code}" "${GH_API_AUTH[@]}" \
138
- -H "Accept: application/vnd.github.raw" \
139
- "https://api.github.com/repos/${GH_OWNER}/${GH_REPO}/contents/${BASE_FOLDER}/ota_registry.json?ref=${GH_BRANCH}" || echo 0)
140
156
 
141
157
  if [[ "$API_HTTP" == "200" ]] && node -e "JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'))" "$REGISTRY_SHARED.tmp" 2>/dev/null; then
142
158
  mv "$REGISTRY_SHARED.tmp" "$REGISTRY_SHARED"
143
- echo " ✓ Remote registry synced (via GitHub API)"
159
+ echo " ✓ Remote registry synced"
144
160
  elif [[ "$API_HTTP" == "404" ]]; then
145
161
  rm -f "$REGISTRY_SHARED.tmp"
146
- echo " Notice: No remote registry yet (HTTP 404) — starting fresh with local state."
162
+ # Remote is the source of truth: a local registry from another storage would publish
163
+ # entries for files that don't exist here
164
+ echo '{"apps":{}}' > "$REGISTRY_SHARED"
165
+ echo " Notice: No remote registry yet (HTTP 404) — starting a new one."
147
166
  else
148
167
  # Continuing would later push a stale local registry over the remote one
149
168
  rm -f "$REGISTRY_SHARED.tmp"
150
- echo " ❌ Registry sync failed (HTTP $API_HTTP). Check network / gh auth and retry."
169
+ echo " ❌ Registry sync failed (HTTP $API_HTTP). Check network / credentials and retry."
151
170
  exit 1
152
171
  fi
153
172
  fi
@@ -156,8 +175,14 @@ fi
156
175
  download_remote() {
157
176
  local REMOTE_PATH="$1"
158
177
  local DEST="$2"
159
- # no-cache + timestamp query bypasses the GitHub CDN 5-min cache
160
- if curl -s -f -H "Cache-Control: no-cache" -o "$DEST.tmp" "$BASE_URL/$REMOTE_PATH?cb=$(date +%s)"; then
178
+ local OK=0
179
+ if [[ -n "${OTA_R2_BUCKET:-}" ]]; then
180
+ r2 -f -o "$DEST.tmp" "$R2_URL/$REMOTE_PATH" 2>/dev/null || OK=1
181
+ else
182
+ # no-cache + timestamp query bypasses the GitHub CDN 5-min cache
183
+ curl -s -f -H "Cache-Control: no-cache" -o "$DEST.tmp" "$BASE_URL/$REMOTE_PATH?cb=$(date +%s)" || OK=1
184
+ fi
185
+ if [ $OK -eq 0 ]; then
161
186
  mv "$DEST.tmp" "$DEST"
162
187
  else
163
188
  rm -f "$DEST.tmp"
@@ -170,110 +195,55 @@ sha256() {
170
195
  shasum -a 256 "$1" | awk '{print $1}'
171
196
  }
172
197
 
173
- # ── Auto-Detect APP_VERSION ──────────────────────────────────────────────────
174
- APP_VERSION_INFO=$(node -e "
175
- const fs = require('fs');
176
- const path = require('path');
177
- const root = '$ROOT';
178
- const cliOverride = '$APP_VERSION_ARG';
179
-
180
- if (cliOverride && cliOverride !== '') {
181
- console.log(JSON.stringify({ version: cliOverride, source: 'CLI --app-version' }));
182
- process.exit(0);
183
- }
184
-
185
- // 1. Try android/app/build.gradle (most reliable for Android release)
186
- try {
187
- const gradlePath = path.join(root, 'android/app/build.gradle');
188
- if (fs.existsSync(gradlePath)) {
189
- const content = fs.readFileSync(gradlePath, 'utf8');
190
- const match = content.match(/versionName\\s+[\"']([^\"']+)[\"']/);
191
- if (match && match[1] !== '0.0.0' && match[1] !== '0.0.1') {
192
- console.log(JSON.stringify({ version: match[1], source: 'android/app/build.gradle' }));
193
- process.exit(0);
194
- }
195
- }
196
- } catch (e) {}
197
-
198
- // 2. Try ios project.pbxproj (most reliable for iOS release)
199
- try {
200
- const iosDir = path.join(root, 'ios');
201
- if (fs.existsSync(iosDir)) {
202
- const files = fs.readdirSync(iosDir);
203
- for (const f of files) {
204
- if (f.endsWith('.xcodeproj')) {
205
- const pbxPath = path.join(iosDir, f, 'project.pbxproj');
206
- if (fs.existsSync(pbxPath)) {
207
- const content = fs.readFileSync(pbxPath, 'utf8');
208
- const match = content.match(/MARKETING_VERSION\\s*=\\s*([^;]+);/);
209
- if (match && match[1].trim() !== '0.0.0' && match[1].trim() !== '0.0.1') {
210
- console.log(JSON.stringify({ version: match[1].trim(), source: 'ios/' + f }));
211
- process.exit(0);
212
- }
213
- }
214
- }
215
- }
216
- }
217
- } catch (e) {}
218
-
219
- // 3. Try package.json
220
- try {
221
- const pkgPath = path.join(root, 'package.json');
222
- if (fs.existsSync(pkgPath)) {
223
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
224
- if (pkg.version && pkg.version !== '0.0.0' && pkg.version !== '0.0.1') {
225
- console.log(JSON.stringify({ version: pkg.version, source: 'package.json' }));
226
- process.exit(0);
227
- }
228
- }
229
- } catch (e) {}
230
-
231
- // 4. Fallback: src/ota/otaConfig.ts
232
- try {
233
- const otaConfigPath = path.join(root, 'src/ota/otaConfig.ts');
234
- if (fs.existsSync(otaConfigPath)) {
235
- const content = fs.readFileSync(otaConfigPath, 'utf8');
236
- const match = content.match(/export const APP_VERSION = ['\"](.+?)['\"]/);
237
- if (match) {
238
- console.log(JSON.stringify({ version: match[1], source: 'src/ota/otaConfig.ts' }));
239
- process.exit(0);
240
- }
241
- }
242
- } catch (e) {}
243
-
244
- console.log(JSON.stringify({ version: 'unknown', source: 'none' }));
245
- ")
246
-
247
- APP_VERSION=$(node -e "try { console.log(JSON.parse('$APP_VERSION_INFO').version); } catch(e) { console.log('unknown'); }")
248
- APP_VERSION_SOURCE=$(node -e "try { console.log(JSON.parse('$APP_VERSION_INFO').source); } catch(e) { console.log('none'); }")
249
-
250
- # Interactive confirmation / prompt:
251
- # Prompt if running in an interactive terminal, not in reset mode, and no explicit flags passed
252
- if [ -t 0 ] && [ -z "$APP_VERSION_ARG" ] && [ "$NON_INTERACTIVE" = false ] && [ "$ACTION" != "reset" ]; then
253
- echo ""
254
- echo "────────────────────────────────────────────────────────"
255
- if [ "$APP_VERSION" != "unknown" ]; then
256
- echo " Detected Native App Version: $APP_VERSION ($APP_VERSION_SOURCE)"
257
- read -p " Enter App Version to build [Press Enter for '$APP_VERSION']: " USER_INPUT_VER
258
- else
259
- read -p " Enter Native App Version to build (e.g. 1.0.0): " USER_INPUT_VER
260
- fi
261
- echo "────────────────────────────────────────────────────────"
262
- echo ""
198
+ # ── Progress helpers (exported: upload_ota.sh uses them too) ──────────────────
199
+ human_size() {
200
+ awk -v b="$1" 'BEGIN { split("B KB MB GB", u); i = 1; while (b >= 1024 && i < 4) { b /= 1024; i++ }
201
+ printf (i == 1 ? "%d %s" : "%.1f %s"), b, u[i] }'
202
+ }
203
+ file_size() { wc -c < "$1" | tr -d ' '; }
204
+ STEP=0
205
+ STEP_TOTAL=5
206
+ step() { STEP=$((STEP + 1)); STEP_START=$SECONDS; echo ""; echo " [$STEP/$STEP_TOTAL] $*"; }
207
+ step_done() { echo " ✓ $* ($((SECONDS - STEP_START))s)"; }
208
+ export -f human_size file_size
209
+
210
+ # ── Release version (native app version the OTA targets) ─────────────────────
211
+ android_version() {
212
+ sed -nE "s/^[[:space:]]*versionName[[:space:]]+[\"']([^\"']+)[\"'].*/\1/p" "$ROOT/android/app/build.gradle" 2>/dev/null | head -n 1
213
+ }
214
+ ios_version() {
215
+ grep -ho 'MARKETING_VERSION = [^;]*' "$ROOT"/ios/*.xcodeproj/project.pbxproj 2>/dev/null | head -n 1 | sed 's/.*= //'
216
+ }
263
217
 
264
- if [ -n "$USER_INPUT_VER" ]; then
265
- APP_VERSION=$(echo "$USER_INPUT_VER" | tr -d ' ')
266
- APP_VERSION_SOURCE="User Input"
218
+ if [ -n "$APP_VERSION_ARG" ]; then
219
+ APP_VERSION="$APP_VERSION_ARG"
220
+ elif [ "$PLATFORM_ARG" = "android" ]; then
221
+ APP_VERSION=$(android_version)
222
+ elif [ "$PLATFORM_ARG" = "ios" ]; then
223
+ APP_VERSION=$(ios_version)
224
+ else
225
+ AV=$(android_version); IV=$(ios_version)
226
+ if [ -n "$AV" ] && [ -n "$IV" ] && [ "$AV" != "$IV" ]; then
227
+ # One registry key per release version: publishing both under one would strand one platform
228
+ echo "❌ Android is $AV but iOS is $IV. Run 'patch android' and 'patch ios' separately."
229
+ exit 1
267
230
  fi
231
+ APP_VERSION="${AV:-$IV}"
268
232
  fi
269
233
 
270
- if [ "$APP_VERSION" == "unknown" ] || [ -z "$APP_VERSION" ]; then
271
- echo "ERROR: Native App Version is required to build OTA updates."
272
- echo " Please enter a valid version or use: ./build_ota.sh --app-version <version>"
273
- exit 1
234
+ # Confirm in a terminal (Enter keeps the detected version); CI / -y / --release-version skip it
235
+ if [ -t 0 ] && [ "$CONFIRM_VERSION" = true ] && [ -z "$APP_VERSION_ARG" ]; then
236
+ echo ""
237
+ read -r -p " Release version for ${PLATFORM_ARG:-android + ios} [${APP_VERSION:-none detected}]: " INPUT_VERSION
238
+ INPUT_VERSION="${INPUT_VERSION// /}"
239
+ APP_VERSION="${INPUT_VERSION:-$APP_VERSION}"
274
240
  fi
275
241
 
276
- echo " ✓ Building OTA for APP_VERSION=$APP_VERSION (Source: $APP_VERSION_SOURCE)"
242
+ if [ -z "$APP_VERSION" ]; then
243
+ echo "❌ Could not read the native app version. Pass --release-version <x.y.z>."
244
+ exit 1
245
+ fi
246
+ echo " ✓ Release version: $APP_VERSION"
277
247
 
278
248
  # ── Reset ─────────────────────────────────────────────────────────────────────
279
249
  if [ "$ACTION" = "reset" ]; then
@@ -285,8 +255,11 @@ if [ "$ACTION" = "reset" ]; then
285
255
  try {
286
256
  const fs = require('fs');
287
257
  const reg = JSON.parse(fs.readFileSync('$REGISTRY_SHARED', 'utf8'));
288
- if (reg.apps && reg.apps['$APP_VERSION'] && reg.apps['$APP_VERSION'].flavors) {
289
- delete reg.apps['$APP_VERSION'].flavors['$FLAVOR_LOWER'];
258
+ const flavors = reg.apps?.['$APP_VERSION']?.flavors;
259
+ if (flavors?.['$FLAVOR_LOWER']) {
260
+ // Keep the counter: reused numbers would make clients on an old vN skip the new vN,
261
+ // and reused file names would serve stale cached bundles
262
+ flavors['$FLAVOR_LOWER'] = { latestOtaVersion: flavors['$FLAVOR_LOWER'].latestOtaVersion || 1, patches: [] };
290
263
  }
291
264
  fs.writeFileSync('$REGISTRY_SHARED', JSON.stringify(reg, null, 4));
292
265
  } catch(e) {}
@@ -295,15 +268,13 @@ if [ "$ACTION" = "reset" ]; then
295
268
  echo '{"apps":{}}' > "$REGISTRY_SHARED"
296
269
  fi
297
270
 
298
- # Delete only this app version + flavor. Base bundles (v1 = store binary) are kept:
299
- # the next build re-registers them automatically. Delete them by hand to re-seed.
271
+ # Delete only this app version + flavor
300
272
  for D in "$ROOT/android/output" "$ROOT/ios/output"; do
301
- find "$D" -maxdepth 1 -type f -name "*_${APP_VERSION}_*" -name "*_${FLAVOR_LOWER}[._]*" \
302
- -not -name "bundle_${APP_VERSION}_v1_*" -delete 2>/dev/null || true
273
+ find "$D" -maxdepth 1 -type f -name "*_${APP_VERSION}_*" -name "*_${FLAVOR_LOWER}[._]*" -delete 2>/dev/null || true
303
274
  find "$D" -maxdepth 1 -type f -name "asset-manifest-v*_${FLAVOR_LOWER}.json" -delete 2>/dev/null || true # legacy names
304
275
  done
305
276
 
306
- echo " ✓ Registry and output cleared for $APP_VERSION/$FLAVOR_LOWER (base bundle v1 + base-assets kept)"
277
+ echo " ✓ Registry and output cleared for $APP_VERSION/$FLAVOR_LOWER "
307
278
 
308
279
  if [ "$PUSH_REMOTE" = true ]; then
309
280
  echo " Syncing reset state for flavor '$FLAVOR_LOWER' to remote storage..."
@@ -312,13 +283,6 @@ if [ "$ACTION" = "reset" ]; then
312
283
  exit 0
313
284
  fi
314
285
 
315
- # ── Setup ─────────────────────────────────────────────────────────────────────
316
- if [ "$ACTION" = "setup" ]; then
317
- echo "Setting up react-native-bspatch in this project..."
318
- node "$SCRIPT_DIR/setup_project.js" --root "$ROOT"
319
- exit 0
320
- fi
321
-
322
286
  # ── Ensure bsdiff compiled ───────────────────────────────────────────────────
323
287
  if [ ! -f "$BSDIFF" ]; then
324
288
  echo "Compiling bsdiff..."
@@ -358,12 +322,8 @@ build_platform() {
358
322
 
359
323
  echo ""
360
324
  echo ">>> Building $PLATFORM | Version: $APP_VERSION | OTA: v$CURRENT → v$NEXT"
361
- if [ "$PLATFORM" == "ios" ]; then
362
- local IOS_VER=$(grep -ho 'MARKETING_VERSION = [^;]*' "$ROOT"/ios/*.xcodeproj/project.pbxproj 2>/dev/null | head -n 1 | sed 's/.*= //')
363
- if [ -n "$IOS_VER" ] && [ "$IOS_VER" != "$APP_VERSION" ]; then
364
- echo " ⚠ iOS MARKETING_VERSION is $IOS_VER but building for $APP_VERSION — iOS clients on $IOS_VER won't see this OTA"
365
- fi
366
- fi
325
+ STEP=0
326
+ local PLATFORM_START=$SECONDS
367
327
 
368
328
  # ── Bundle ──
369
329
  # Workspace for building
@@ -371,37 +331,23 @@ build_platform() {
371
331
  mkdir -p "$WORKSPACE"
372
332
 
373
333
  # Determine source bundle
334
+ local BUILD_LOG="$WORKSPACE/bundle.log"
374
335
  if [ "$PLATFORM" == "android" ]; then
375
- cd "$ROOT/android"
376
- ./gradlew ":app:createBundle${FLAVOR_CAP}ReleaseJsAndAssets" -Pkotlin.incremental=false --quiet
377
- cd "$ROOT"
336
+ step "Bundling JS (Gradle createBundle${FLAVOR_CAP}ReleaseJsAndAssets)..."
337
+ # Gradle's config noise goes to a log; shown only on failure
338
+ if ! (cd "$ROOT/android" && ./gradlew ":app:createBundle${FLAVOR_CAP}ReleaseJsAndAssets" -Pkotlin.incremental=false --quiet) > "$BUILD_LOG" 2>&1; then
339
+ tail -n 40 "$BUILD_LOG"; echo " ❌ Gradle bundle failed (full log: $BUILD_LOG)"; exit 1
340
+ fi
378
341
  BUNDLE_SOURCE="$ROOT/android/app/build/generated/assets/react/${FLAVOR_LOWER}Release/index.android.bundle"
379
342
  else
380
- local XCODE_STALE=""
381
- if [ -f "$BUNDLE_XCODE_IOS" ]; then
382
- # ponytail: mtime check, any source edit/checkout after the last Xcode build marks it stale
383
- XCODE_STALE=$(find "$ROOT/src" "$ROOT/App.tsx" "$ROOT/index.js" "$ROOT/package.json" -type f -newer "$BUNDLE_XCODE_IOS" -print -quit 2>/dev/null)
384
- if [ -n "$XCODE_STALE" ] && [ "$ACTION" = "base" ]; then
385
- echo " ⚠ Xcode bundle ($(stat -f '%Sm' "$BUNDLE_XCODE_IOS")) is older than $XCODE_STALE — ignoring it"
386
- fi
387
- fi
388
- if [ -f "$BUNDLE_XCODE_IOS" ] && [ -z "$XCODE_STALE" ] && [ "$ACTION" = "base" ] && [ $CURRENT -eq 0 ]; then
389
- # Base action: use Xcode-compiled bundle (already Hermes-compiled, matches IPA exactly)
390
- echo " Using Xcode bundle as base source..."
391
- cp "$BUNDLE_XCODE_IOS" "$WORKSPACE/source.bundle"
392
- # Also discover assets for base manifest
393
- echo " Discovering assets from project (shadow bundle)..."
394
- npx react-native bundle --platform ios --dev false --entry-file index.js \
395
- --bundle-output "$WORKSPACE/shadow.bundle.tmp" --assets-dest "$OUTPUT/assets"
396
- rm -f "$WORKSPACE/shadow.bundle.tmp"
397
- else
398
- # OTA update: always generate fresh bundle from current code + discover assets
399
- echo " Generating fresh iOS bundle from current code..."
400
- npx react-native bundle --platform ios --dev false --entry-file index.js \
401
- --bundle-output "$WORKSPACE/source.bundle" --assets-dest "$OUTPUT/assets"
343
+ step "Bundling JS (react-native bundle)..."
344
+ if ! npx react-native bundle --platform ios --dev false --entry-file index.js \
345
+ --bundle-output "$WORKSPACE/source.bundle" --assets-dest "$OUTPUT/assets" > "$BUILD_LOG" 2>&1; then
346
+ tail -n 40 "$BUILD_LOG"; echo " ❌ Bundle failed (full log: $BUILD_LOG)"; exit 1
402
347
  fi
403
348
  BUNDLE_SOURCE="$WORKSPACE/source.bundle"
404
349
  fi
350
+ step_done "$(human_size "$(file_size "$BUNDLE_SOURCE")")"
405
351
 
406
352
  # Ensure Hermes bytecode format (for both iOS and Android OTA bundles)
407
353
  find_hermesc
@@ -412,14 +358,14 @@ build_platform() {
412
358
  if [ "$FIRST_BYTE" == "c6" ]; then IS_HBC=true; fi
413
359
  fi
414
360
 
361
+ step "Hermes bytecode..."
415
362
  if [ "$IS_HBC" = true ]; then
416
- echo " Bundle is already in Hermes bytecode format."
363
+ echo " already Hermes bytecode"
417
364
  else
418
- echo " Compiling to Hermes bytecode..."
419
365
  if [ -z "$HERMESC_PATH" ] || [ ! -f "$HERMESC_PATH" ]; then
420
366
  echo " ❌ ERROR: hermesc not found"; exit 1
421
367
  fi
422
- # -O matches what Xcode/Gradle use for release, keeping patches against the store bundle small
368
+ # -O matches what Xcode/Gradle use for release
423
369
  "$HERMESC_PATH" -emit-binary -O -out "$WORKSPACE/source.hbc" "$BUNDLE_SOURCE"
424
370
  mv "$WORKSPACE/source.hbc" "$BUNDLE_SOURCE"
425
371
  fi
@@ -427,47 +373,20 @@ build_platform() {
427
373
  local BUNDLE_NAME="bundle_${APP_VERSION}_v${NEXT}_${PLATFORM}_${FLAVOR_LOWER}.hbc"
428
374
  local NEW_BUNDLE_PATH="$OUTPUT/$BUNDLE_NAME"
429
375
 
430
- if [ $CURRENT -eq 0 ] && [ "$ACTION" = "base" ] && [ "$PLATFORM" == "android" ]; then
431
- echo "Building base Release APK for $FLAVOR_CAP..."
432
- cd "$ROOT/android"
433
- ./gradlew "assemble${FLAVOR_CAP}Release" -Pkotlin.incremental=false --quiet
434
- cd "$ROOT"
435
- # Locate the APK dynamically as flavor names can vary in path
436
- APK_PATH=$(find "$ROOT/android/app/build/outputs/apk" -name "app-${FLAVOR_LOWER}-release.apk" | head -n 1)
437
- if [ -f "$APK_PATH" ]; then
438
- unzip -p "$APK_PATH" assets/index.android.bundle > "$NEW_BUNDLE_PATH"
439
- else
440
- echo " ⚠ Warning: Could not find base APK at $APK_PATH. Using generated bundle directly."
441
- cp "$BUNDLE_SOURCE" "$NEW_BUNDLE_PATH"
442
- fi
443
- else
444
- cp "$BUNDLE_SOURCE" "$NEW_BUNDLE_PATH"
445
- fi
376
+ cp "$BUNDLE_SOURCE" "$NEW_BUNDLE_PATH"
377
+ step_done "$BUNDLE_NAME, $(human_size "$(file_size "$NEW_BUNDLE_PATH")")"
446
378
 
447
379
  # ── Assets ──
448
380
  local ASSET_MANIFEST_FILE="$OUTPUT/asset-manifest_${APP_VERSION}_v${NEXT}_${PLATFORM}_${FLAVOR_LOWER}.json"
449
- local BASE_ASSET_MANIFEST="$OUTPUT/base-assets_${PLATFORM}_${FLAVOR_LOWER}.json"
450
381
  local ASSET_STORE_DIR="$OUTPUT/asset-store"
451
382
  mkdir -p "$ASSET_STORE_DIR"
452
383
  local ASSETS_SRC_DIR="$ROOT/android/app/build/generated/res/react/${FLAVOR_LOWER}Release"
453
384
  if [ "$PLATFORM" == "ios" ]; then ASSETS_SRC_DIR="$OUTPUT/assets"; fi
454
385
 
455
- # Load base assets — try server if missing locally (e.g. after ota:clean)
456
- if [ ! -f "$BASE_ASSET_MANIFEST" ]; then
457
- local REMOTE_BASE_ASSET_PATH="$BASE_FOLDER/$APP_VERSION/$FLAVOR_LOWER/$(basename "$BASE_ASSET_MANIFEST")"
458
- echo " Base asset manifest missing locally, downloading from server..."
459
- download_remote "$REMOTE_BASE_ASSET_PATH" "$BASE_ASSET_MANIFEST" || true
460
- if [ -f "$BASE_ASSET_MANIFEST" ]; then
461
- echo " ✓ base-assets manifest restored from server"
462
- else
463
- echo " ⚠ base-assets manifest not found on server — all assets will be treated as new"
464
- fi
465
- fi
466
-
467
386
  local ASSET_JSON_LIST="["
468
387
  local FIRST=true
469
- local NEW_BASE_ASSETS="{"
470
-
388
+ local ASSET_COUNT=0
389
+ step "Hashing assets..."
471
390
  if [ -d "$ASSETS_SRC_DIR" ]; then
472
391
  while IFS= read -r -d '' ASSET_FILE; do
473
392
  local HASH=$(sha256 "$ASSET_FILE")
@@ -475,200 +394,68 @@ build_platform() {
475
394
  local EX="${FNAME##*.}"
476
395
  # Capture relative path for resolver matching
477
396
  local REL_PATH="${ASSET_FILE#$ASSETS_SRC_DIR/}"
478
-
479
- # Track for the new base manifest
480
- if [ "$NEW_BASE_ASSETS" != "{" ]; then NEW_BASE_ASSETS+=", "; fi
481
- NEW_BASE_ASSETS+="\"$REL_PATH\": \"$HASH\""
482
-
483
- # Optimization: Skip if hash matches the base APK version (client already has it)
484
- local IS_IN_BASE=false
485
- if [ -f "$BASE_ASSET_MANIFEST" ] && grep -qF "\"$REL_PATH\": \"$HASH\"" "$BASE_ASSET_MANIFEST"; then
486
- IS_IN_BASE=true
487
- fi
488
- # Always add ALL assets to the manifest so the resolver can find them at runtime.
489
- # Only skip the file copy if the asset is unchanged from base (avoids redundant uploads).
490
- local ASSET_IS_BASE="false"
491
- if [ "$IS_IN_BASE" != "true" ] || [ "$ACTION" = "base" ]; then
492
- cp "$ASSET_FILE" "$ASSET_STORE_DIR/${HASH}.${EX}"
493
- else
494
- ASSET_IS_BASE="true"
495
- fi
397
+ # ponytail: every asset ships via the content-addressed store (clients download each hash once);
398
+ # skipping assets already in the store binary needs a manifest of that binary
399
+ cp "$ASSET_FILE" "$ASSET_STORE_DIR/${HASH}.${EX}"
496
400
 
497
401
  if [ "$FIRST" = true ]; then FIRST=false; else ASSET_JSON_LIST+=", "; fi
498
- ASSET_JSON_LIST+="{\"hash\": \"$HASH\", \"ext\": \"$EX\", \"originalPath\": \"$REL_PATH\", \"isBase\": $ASSET_IS_BASE}"
402
+ ASSET_JSON_LIST+="{\"hash\": \"$HASH\", \"ext\": \"$EX\", \"originalPath\": \"$REL_PATH\"}"
403
+ ASSET_COUNT=$((ASSET_COUNT + 1))
404
+ if [ -t 1 ]; then printf "\r %d assets" "$ASSET_COUNT"; fi
499
405
  done < <(find "$ASSETS_SRC_DIR" -type f -print0 2>/dev/null)
406
+ if [ -t 1 ]; then printf "\r\033[K"; fi
500
407
  fi
501
408
  ASSET_JSON_LIST+="]"
502
- NEW_BASE_ASSETS+="}"
503
-
504
- # If base action, save the manifest to local disk for future comparisons
505
- if [ "$ACTION" = "base" ]; then
506
- echo "$NEW_BASE_ASSETS" > "$BASE_ASSET_MANIFEST"
507
- echo " ✓ Base asset manifest saved locally"
508
- fi
409
+ step_done "$ASSET_COUNT assets"
509
410
 
510
411
  cat > "$ASSET_MANIFEST_FILE" << EOF
511
412
  { "version": $NEXT, "appVersion": "$APP_VERSION", "platform": "$PLATFORM", "flavor": "$FLAVOR_LOWER", "assets": $ASSET_JSON_LIST }
512
413
  EOF
513
414
 
514
415
  # ── Patches ──
515
- local PATCHES_JSON="[]"
416
+ # From the last 5 OTA versions only (never v1 = store build). Clients on the
417
+ # store build or further behind download the full bundle.
516
418
  local NEW_HASH=$(sha256 "$NEW_BUNDLE_PATH")
517
- if [ $CURRENT -gt 0 ]; then
518
- PATCHES_JSON="["
519
- # Patches from v1 (fresh store installs) + the last 5 versions. Older clients
520
- # fall back to the full bundle instead of bsdiff-ing every version each build.
521
- for (( i=1; i<$NEXT; i++ )); do
522
- if [ $i -gt 1 ] && [ $i -lt $((NEXT - 5)) ]; then continue; fi
523
- local OLD_B="bundle_${APP_VERSION}_v${i}_${PLATFORM}_${FLAVOR_LOWER}.hbc"
524
- local OLD_P="$OUTPUT/$OLD_B"
525
- if [ ! -f "$OLD_P" ]; then
526
- echo " Missing local bundle for v$i. Attempting to download from server..."
527
- local REMOTE_BUNDLE_PATH="$BASE_FOLDER/$APP_VERSION/$FLAVOR_LOWER/$OLD_B"
528
- download_remote "$REMOTE_BUNDLE_PATH" "$OLD_P" || true
529
- fi
530
- if [ ! -f "$OLD_P" ]; then
531
- echo " ⚠ Warning: Could not find or download bundle v$i. Skipping patch generation from v$i."
532
- continue
533
- fi
534
- local PNAME="patch_${APP_VERSION}_v${i}_to_v${NEXT}_${FLAVOR_LOWER}_${PLATFORM}.patch"
535
- "$BSDIFF" "$OLD_P" "$NEW_BUNDLE_PATH" "$OUTPUT/$PNAME"
536
- if [ "$PATCHES_JSON" != "[" ]; then PATCHES_JSON+=", "; fi
537
- PATCHES_JSON+="{\"from\": $i, \"to\": $NEXT, \"file\": \"$PNAME\", \"baseHash\": \"$(sha256 "$OLD_P")\", \"bundleHash\": \"$NEW_HASH\"}"
538
- done
539
- PATCHES_JSON+="]"
419
+ local PATCHES_JSON="["
420
+ local FIRST_FROM=$((NEXT - 5)); if [ $FIRST_FROM -lt 2 ]; then FIRST_FROM=2; fi
421
+ local PATCH_TOTAL=$((NEXT - FIRST_FROM)) PATCH_N=0 PATCH_MADE=0
422
+ if [ $PATCH_TOTAL -gt 0 ]; then
423
+ step "Creating $PATCH_TOTAL patch(es) from v$FIRST_FROM..v$((NEXT - 1))..."
424
+ else
425
+ step "Patches: none (first OTA of this release — clients take the full bundle)"
540
426
  fi
427
+ for (( i=FIRST_FROM; i<NEXT; i++ )); do
428
+ PATCH_N=$((PATCH_N + 1))
429
+ local OLD_B="bundle_${APP_VERSION}_v${i}_${PLATFORM}_${FLAVOR_LOWER}.hbc"
430
+ local OLD_P="$OUTPUT/$OLD_B"
431
+ if [ ! -f "$OLD_P" ]; then
432
+ echo " ($PATCH_N/$PATCH_TOTAL) v$i: downloading base bundle..."
433
+ local REMOTE_BUNDLE_PATH="$BASE_FOLDER/$APP_VERSION/$FLAVOR_LOWER/$OLD_B"
434
+ download_remote "$REMOTE_BUNDLE_PATH" "$OLD_P" || true
435
+ fi
436
+ if [ ! -f "$OLD_P" ]; then
437
+ echo " ($PATCH_N/$PATCH_TOTAL) v$i: ⚠ bundle not found, skipped (v$i clients take the full bundle)"
438
+ continue
439
+ fi
440
+ local PNAME="patch_${APP_VERSION}_v${i}_to_v${NEXT}_${FLAVOR_LOWER}_${PLATFORM}.patch"
441
+ local PATCH_START=$SECONDS
442
+ if [ -t 1 ]; then printf " (%d/%d) v%d → v%d ..." "$PATCH_N" "$PATCH_TOTAL" "$i" "$NEXT"; fi
443
+ "$BSDIFF" "$OLD_P" "$NEW_BUNDLE_PATH" "$OUTPUT/$PNAME"
444
+ if [ -t 1 ]; then printf "\r\033[K"; fi
445
+ echo " ($PATCH_N/$PATCH_TOTAL) v$i → v$NEXT: $(human_size "$(file_size "$OUTPUT/$PNAME")") ($((SECONDS - PATCH_START))s)"
446
+ PATCH_MADE=$((PATCH_MADE + 1))
447
+ if [ "$PATCHES_JSON" != "[" ]; then PATCHES_JSON+=", "; fi
448
+ PATCHES_JSON+="{\"from\": $i, \"to\": $NEXT, \"file\": \"$PNAME\", \"baseHash\": \"$(sha256 "$OLD_P")\", \"bundleHash\": \"$NEW_HASH\"}"
449
+ done
450
+ PATCHES_JSON+="]"
451
+ if [ $PATCH_TOTAL -gt 0 ]; then step_done "$PATCH_MADE patch(es)"; fi
541
452
 
542
453
  # ── Finalize ──
454
+ step "Registry + upload..."
543
455
  node "$SCRIPT_DIR/update_registry.js" "$REGISTRY_SHARED" "$APP_VERSION" "$NEXT" "$PATCHES_JSON" "$PLATFORM" "$ASSET_MANIFEST_FILE" "$FLAVOR_LOWER"
544
456
  bash "$SCRIPT_DIR/upload_ota.sh" "$APP_VERSION" "$FLAVOR_LOWER" "$NEXT" "$NEW_BUNDLE_PATH" "$PLATFORM"
545
- }
546
-
547
- seed_platform() {
548
- local PLATFORM="$1"
549
- local FLAVOR="$2"
550
- local BINARY_PATH="$3"
551
- local FLAVOR_LOWER=$(echo "$FLAVOR" | tr '[:upper:]' '[:lower:]')
552
- if [ "$FLAVOR_LOWER" = "dev" ]; then FLAVOR_LOWER="development"; fi
553
- if [ "$FLAVOR_LOWER" = "prod" ]; then FLAVOR_LOWER="production"; fi
554
-
555
- local OUTPUT="$ROOT/android/output"
556
- if [ "$PLATFORM" == "ios" ]; then OUTPUT="$ROOT/ios/output"; fi
557
- mkdir -p "$OUTPUT"
558
-
559
457
  echo ""
560
- echo ">>> Seeding Base Bundle (v1) | Platform: $PLATFORM | App: $APP_VERSION | Flavor: $FLAVOR_LOWER"
561
- echo " Source binary: $BINARY_PATH"
562
-
563
- if [ ! -f "$BINARY_PATH" ]; then
564
- echo " ❌ ERROR: Source binary file not found: $BINARY_PATH"
565
- exit 1
566
- fi
567
-
568
- local TARGET_BUNDLE="$OUTPUT/bundle_${APP_VERSION}_v1_${PLATFORM}_${FLAVOR_LOWER}.hbc"
569
- local BASE_ASSET_MANIFEST="$OUTPUT/base-assets_${PLATFORM}_${FLAVOR_LOWER}.json"
570
- local ASSET_MANIFEST_FILE="$OUTPUT/asset-manifest_${APP_VERSION}_v1_${PLATFORM}_${FLAVOR_LOWER}.json"
571
- local TMP_DIR=$(mktemp -d)
572
-
573
- if [ "$PLATFORM" == "android" ]; then
574
- echo " Extracting index.android.bundle from Android binary..."
575
- if unzip -l "$BINARY_PATH" 2>/dev/null | grep -q "assets/index.android.bundle"; then
576
- unzip -p "$BINARY_PATH" assets/index.android.bundle > "$TMP_DIR/extracted.bundle"
577
- elif unzip -l "$BINARY_PATH" 2>/dev/null | grep -q "base/assets/index.android.bundle"; then
578
- unzip -p "$BINARY_PATH" base/assets/index.android.bundle > "$TMP_DIR/extracted.bundle"
579
- elif [[ "$BINARY_PATH" == *.bundle ]] || [[ "$BINARY_PATH" == *.hbc ]]; then
580
- cp "$BINARY_PATH" "$TMP_DIR/extracted.bundle"
581
- else
582
- echo " ❌ ERROR: Could not find 'assets/index.android.bundle' inside $BINARY_PATH"
583
- rm -rf "$TMP_DIR"
584
- exit 1
585
- fi
586
- else
587
- echo " Extracting main.jsbundle from iOS binary..."
588
- local IPA_ENTRY=$(unzip -l "$BINARY_PATH" 2>/dev/null | grep -o 'Payload/[^/]*\.app/main\.jsbundle' | head -n 1)
589
- if [ -n "$IPA_ENTRY" ]; then
590
- unzip -p "$BINARY_PATH" "$IPA_ENTRY" > "$TMP_DIR/extracted.bundle"
591
- elif unzip -l "$BINARY_PATH" 2>/dev/null | grep -q "main.jsbundle"; then
592
- local ANY_BUNDLE=$(unzip -l "$BINARY_PATH" 2>/dev/null | grep -o '[^ ]*main\.jsbundle' | head -n 1)
593
- unzip -p "$BINARY_PATH" "$ANY_BUNDLE" > "$TMP_DIR/extracted.bundle"
594
- elif [[ "$BINARY_PATH" == *.bundle ]] || [[ "$BINARY_PATH" == *.hbc ]] || [[ "$BINARY_PATH" == *.jsbundle ]]; then
595
- cp "$BINARY_PATH" "$TMP_DIR/extracted.bundle"
596
- else
597
- echo " ❌ ERROR: Could not find 'main.jsbundle' inside $BINARY_PATH"
598
- rm -rf "$TMP_DIR"
599
- exit 1
600
- fi
601
- fi
602
-
603
- # Check Hermes bytecode format (0xc6 magic number)
604
- FIRST_BYTE=$(od -An -N1 -t x1 "$TMP_DIR/extracted.bundle" | tr -d ' ' || echo "")
605
- if [ "$FIRST_BYTE" == "c6" ]; then
606
- echo " ✓ Bundle is verified Hermes bytecode format."
607
- cp "$TMP_DIR/extracted.bundle" "$TARGET_BUNDLE"
608
- else
609
- echo " Bundle is JavaScript text. Compiling to Hermes bytecode..."
610
- find_hermesc
611
- if [ -z "$HERMESC_PATH" ] || [ ! -f "$HERMESC_PATH" ]; then
612
- echo " ❌ ERROR: hermesc compiler not found. Cannot compile bundle to bytecode."
613
- rm -rf "$TMP_DIR"
614
- exit 1
615
- fi
616
- "$HERMESC_PATH" -emit-binary -O -out "$TARGET_BUNDLE" "$TMP_DIR/extracted.bundle"
617
- fi
618
-
619
- # Extract base assets manifest
620
- echo " Scanning assets from binary for base manifest..."
621
- local NEW_BASE_ASSETS="{"
622
- local ASSET_JSON_LIST="["
623
- local FIRST=true
624
-
625
- if [ "$PLATFORM" == "android" ]; then
626
- unzip -q -o "$BINARY_PATH" "res/drawable*/*" "res/raw/*" "base/res/drawable*/*" "base/res/raw/*" -d "$TMP_DIR/assets_unzipped" 2>/dev/null || true
627
- else
628
- unzip -q -o "$BINARY_PATH" "Payload/*.app/assets/*" -d "$TMP_DIR/assets_unzipped" 2>/dev/null || true
629
- fi
630
-
631
- if [ -d "$TMP_DIR/assets_unzipped" ]; then
632
- while IFS= read -r -d '' ASSET_FILE; do
633
- local HASH=$(sha256 "$ASSET_FILE")
634
- local FNAME=$(basename "$ASSET_FILE")
635
- local EX="${FNAME##*.}"
636
- local REL_PATH="${ASSET_FILE#$TMP_DIR/assets_unzipped/}"
637
- # Same keys as build_platform: iOS "assets/...", Android "drawable-*/..." / "raw/..."
638
- REL_PATH=$(echo "$REL_PATH" | sed -E 's#^Payload/[^/]+\.app/##; s#^(base/)?res/##')
639
-
640
- if [ "$NEW_BASE_ASSETS" != "{" ]; then NEW_BASE_ASSETS+=", "; fi
641
- NEW_BASE_ASSETS+="\"$REL_PATH\": \"$HASH\""
642
-
643
- if [ "$FIRST" = true ]; then FIRST=false; else ASSET_JSON_LIST+=", "; fi
644
- ASSET_JSON_LIST+="{\"hash\": \"$HASH\", \"ext\": \"$EX\", \"originalPath\": \"$REL_PATH\", \"isBase\": true}"
645
- done < <(find "$TMP_DIR/assets_unzipped" -type f -print0 2>/dev/null)
646
- fi
647
-
648
- NEW_BASE_ASSETS+="}"
649
- ASSET_JSON_LIST+="]"
650
-
651
- echo "$NEW_BASE_ASSETS" > "$BASE_ASSET_MANIFEST"
652
- cat > "$ASSET_MANIFEST_FILE" << EOF
653
- { "version": 1, "appVersion": "$APP_VERSION", "platform": "$PLATFORM", "flavor": "$FLAVOR_LOWER", "assets": $ASSET_JSON_LIST }
654
- EOF
655
-
656
- rm -rf "$TMP_DIR"
657
-
658
- local BUNDLE_HASH=$(sha256 "$TARGET_BUNDLE")
659
- echo " ✓ Base bundle v1 successfully created: $(basename "$TARGET_BUNDLE")"
660
- echo " SHA-256: $BUNDLE_HASH"
661
-
662
- # Update registry for v1
663
- node "$SCRIPT_DIR/update_registry.js" "$REGISTRY_SHARED" "$APP_VERSION" "1" "[]" "$PLATFORM" "$ASSET_MANIFEST_FILE" "$FLAVOR_LOWER"
664
-
665
- # Push if requested
666
- if [ "$PUSH_REMOTE" = true ]; then
667
- echo " Pushing seeded base bundle to remote..."
668
- bash "$SCRIPT_DIR/upload_ota.sh" "$APP_VERSION" "$FLAVOR_LOWER" "1" "$TARGET_BUNDLE" "$PLATFORM"
669
- fi
670
-
671
- echo " ✓ Seeding complete for $PLATFORM!"
458
+ echo " ✓ $PLATFORM v$NEXT published in $((SECONDS - PLATFORM_START))s"
672
459
  }
673
460
 
674
461
  # ── Main ───────────────────────────────────────────────────────────────────
@@ -678,26 +465,6 @@ FLAVOR_LOWER=$(echo "$FLAVOR" | tr '[:upper:]' '[:lower:]')
678
465
  if [ "$FLAVOR_LOWER" = "dev" ]; then FLAVOR_LOWER="development"; fi
679
466
  if [ "$FLAVOR_LOWER" = "prod" ]; then FLAVOR_LOWER="production"; fi
680
467
 
681
- # ── Seed Action ────────────────────────────────────────────────────────────
682
- if [ "$ACTION" = "seed" ]; then
683
- if [ -n "$SEED_APK" ]; then
684
- seed_platform "android" "$FLAVOR_LOWER" "$SEED_APK"
685
- elif [ -n "$SEED_IPA" ]; then
686
- seed_platform "ios" "$FLAVOR_LOWER" "$SEED_IPA"
687
- elif [ -n "$SEED_BUNDLE" ]; then
688
- PLATFORM="${PLATFORM_ARG:-android}"
689
- seed_platform "$PLATFORM" "$FLAVOR_LOWER" "$SEED_BUNDLE"
690
- else
691
- echo "❌ Please provide a binary file to seed:"
692
- echo " ./build_ota.sh seed --apk <path-to-apk>"
693
- echo " ./build_ota.sh seed --ipa <path-to-ipa>"
694
- exit 1
695
- fi
696
- echo ""
697
- echo "=== Base Seeding Complete ==="
698
- exit 0
699
- fi
700
-
701
468
  # Read current version once to coordinate across platforms
702
469
  CURRENT_TOTAL=0
703
470
  if [ -f "$REGISTRY_SHARED" ]; then
@@ -709,78 +476,8 @@ if [ -f "$REGISTRY_SHARED" ]; then
709
476
  ")
710
477
  fi
711
478
 
712
- # ── Fail-Safe Guard for OTA Updates ────────────────────────────────────────
713
- # If building an OTA patch but no base version (v1) exists in the registry:
714
- if [ "$ACTION" = "ota" ] && [ $CURRENT_TOTAL -eq 0 ]; then
715
- echo "Checking for pre-existing Base Bundle v1..."
716
-
717
- CHECK_PLATFORMS=()
718
- if [ -n "$PLATFORM_ARG" ]; then
719
- CHECK_PLATFORMS=("$PLATFORM_ARG")
720
- else
721
- CHECK_PLATFORMS=("android" "ios")
722
- fi
723
-
724
- ALL_FOUND=true
725
- MISSING_PLATFORM=""
726
- for P in "${CHECK_PLATFORMS[@]}"; do
727
- P_OUTPUT="$ROOT/$P/output"
728
- P_BASE_BUNDLE="$P_OUTPUT/bundle_${APP_VERSION}_v1_${P}_${FLAVOR_LOWER}.hbc"
729
-
730
- # Check if local exists
731
- if [ ! -f "$P_BASE_BUNDLE" ]; then
732
- # Try downloading from remote if available
733
- REMOTE_BASE_PATH="$BASE_FOLDER/$APP_VERSION/$FLAVOR_LOWER/$(basename "$P_BASE_BUNDLE")"
734
- download_remote "$REMOTE_BASE_PATH" "$P_BASE_BUNDLE" || true
735
- fi
736
-
737
- if [ -f "$P_BASE_BUNDLE" ]; then
738
- echo " ✓ Found Base Bundle v1 for $P: $(basename "$P_BASE_BUNDLE")"
739
- # Auto-adopt base bundle in registry
740
- node "$SCRIPT_DIR/update_registry.js" "$REGISTRY_SHARED" "$APP_VERSION" "1" "[]" "$P" "" "$FLAVOR_LOWER"
741
- else
742
- ALL_FOUND=false
743
- MISSING_PLATFORM="$P"
744
- fi
745
- done
746
-
747
- if [ "$ALL_FOUND" = true ]; then
748
- echo " ✓ Base bundle v1 verified and registered. Proceeding with incremental OTA update..."
749
- CURRENT_TOTAL=1
750
- else
751
- if [ "$FORCE_BASE" = true ]; then
752
- echo " ⚠ Warning: --force flag detected. Creating base bundle v1 from current working tree..."
753
- ACTION="base"
754
- else
755
- echo ""
756
- echo "╔══════════════════════════════════════════════════════════════════════════════════════════╗"
757
- echo "║ ❌ CANNOT BUILD OTA: MISSING BASE BUNDLE (v1) ║"
758
- echo "╠══════════════════════════════════════════════════════════════════════════════════════════╣"
759
- echo "║ App Version: $APP_VERSION | Flavor: $FLAVOR_LOWER | Missing: $MISSING_PLATFORM"
760
- echo "║"
761
- echo "║ No base bundle (v1) was found for this app version (neither locally nor on remote). ║"
762
- echo "║ If an OTA update is built now, it would generate a base bundle from your current ║"
763
- echo "║ working tree, which will MISMATCH the version installed on user devices and force a ║"
764
- echo "║ 100% full-bundle download (wasting bandwidth and bypassing patch benefits). ║"
765
- echo "║"
766
- echo "║ How to resolve: ║"
767
- echo "║ 1. If you already have the release APK/IPA submitted to Google Play / App Store: ║"
768
- echo "║ Seed the exact base bundle from your release binary: ║"
769
- echo "║ ./build_ota.sh seed --apk <path-to-apk> ║"
770
- echo "║ ./build_ota.sh seed --ipa <path-to-ipa> ║"
771
- echo "║"
772
- echo "║ 2. If you are preparing a brand new Store release right now from this commit: ║"
773
- echo "║ ./build_ota.sh base ║"
774
- echo "║"
775
- echo "║ 3. If you really want to force create base v1 from current code: ║"
776
- echo "║ ./build_ota.sh --force ║"
777
- echo "╚══════════════════════════════════════════════════════════════════════════════════════════╝"
778
- echo ""
779
- exit 1
780
- fi
781
- fi
782
- fi
783
-
479
+ # v1 is the store build: the first OTA build is v2
480
+ if [ "$CURRENT_TOTAL" -lt 1 ]; then CURRENT_TOTAL=1; fi
784
481
  TARGET_OTA=$((CURRENT_TOTAL + 1))
785
482
 
786
483
  if [ -n "$PLATFORM_ARG" ]; then