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