react-native-bspatch 1.1.2 → 1.1.4

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 (59) hide show
  1. package/README.md +145 -42
  2. package/android/src/main/java/vn/reactnativebspatch/BsPatchModule.kt +117 -17
  3. package/cpp/CMakeLists.txt +9 -1
  4. package/cpp/bsdiff/bspatch.c +14 -3
  5. package/cpp/bsdiff/bspatch_wrapper.c +124 -40
  6. package/ios/RNBsPatch.mm +142 -1
  7. package/lib/commonjs/NativeBsPatch.js.map +1 -1
  8. package/lib/commonjs/ota/OtaProvider.js +61 -12
  9. package/lib/commonjs/ota/OtaProvider.js.map +1 -1
  10. package/lib/commonjs/ota/otaCore.js +43 -20
  11. package/lib/commonjs/ota/otaCore.js.map +1 -1
  12. package/lib/commonjs/ota/otaMetadata.js +41 -5
  13. package/lib/commonjs/ota/otaMetadata.js.map +1 -1
  14. package/lib/commonjs/ota/storage/CustomStorage.js +23 -3
  15. package/lib/commonjs/ota/storage/CustomStorage.js.map +1 -1
  16. package/lib/commonjs/ota/storage/GitHubRawStorage.js +3 -0
  17. package/lib/commonjs/ota/storage/GitHubRawStorage.js.map +1 -1
  18. package/lib/commonjs/ota/storage/GitHubReleaseStorage.js +3 -0
  19. package/lib/commonjs/ota/storage/GitHubReleaseStorage.js.map +1 -1
  20. package/lib/module/NativeBsPatch.js.map +1 -1
  21. package/lib/module/ota/OtaProvider.js +61 -12
  22. package/lib/module/ota/OtaProvider.js.map +1 -1
  23. package/lib/module/ota/otaCore.js +43 -20
  24. package/lib/module/ota/otaCore.js.map +1 -1
  25. package/lib/module/ota/otaMetadata.js +38 -3
  26. package/lib/module/ota/otaMetadata.js.map +1 -1
  27. package/lib/module/ota/storage/CustomStorage.js +23 -3
  28. package/lib/module/ota/storage/CustomStorage.js.map +1 -1
  29. package/lib/module/ota/storage/GitHubRawStorage.js +3 -0
  30. package/lib/module/ota/storage/GitHubRawStorage.js.map +1 -1
  31. package/lib/module/ota/storage/GitHubReleaseStorage.js +3 -0
  32. package/lib/module/ota/storage/GitHubReleaseStorage.js.map +1 -1
  33. package/lib/typescript/NativeBsPatch.d.ts +11 -0
  34. package/lib/typescript/NativeBsPatch.d.ts.map +1 -1
  35. package/lib/typescript/ota/OtaProvider.d.ts.map +1 -1
  36. package/lib/typescript/ota/otaCore.d.ts +3 -1
  37. package/lib/typescript/ota/otaCore.d.ts.map +1 -1
  38. package/lib/typescript/ota/otaMetadata.d.ts +6 -0
  39. package/lib/typescript/ota/otaMetadata.d.ts.map +1 -1
  40. package/lib/typescript/ota/storage/CustomStorage.d.ts.map +1 -1
  41. package/lib/typescript/ota/storage/GitHubRawStorage.d.ts.map +1 -1
  42. package/lib/typescript/ota/storage/GitHubReleaseStorage.d.ts.map +1 -1
  43. package/lib/typescript/ota/types.d.ts +6 -2
  44. package/lib/typescript/ota/types.d.ts.map +1 -1
  45. package/package.json +3 -1
  46. package/react-native-bspatch.podspec +6 -1
  47. package/src/NativeBsPatch.ts +17 -0
  48. package/src/ota/OtaProvider.tsx +80 -23
  49. package/src/ota/otaCore.ts +54 -24
  50. package/src/ota/otaMetadata.ts +46 -4
  51. package/src/ota/storage/CustomStorage.ts +26 -3
  52. package/src/ota/storage/GitHubRawStorage.ts +5 -0
  53. package/src/ota/storage/GitHubReleaseStorage.ts +5 -0
  54. package/src/ota/types.ts +6 -2
  55. package/tools/build_ota.sh +366 -18
  56. package/tools/bump_version.js +133 -0
  57. package/tools/generate_keys.js +75 -0
  58. package/tools/setup_project.js +69 -4
  59. package/tools/update_registry.js +51 -1
@@ -106,9 +106,51 @@ export const registerNewVersion = (
106
106
  };
107
107
  };
108
108
 
109
+ /**
110
+ * Scans the OTA directory and removes old version directories and leftover temporary files
111
+ * that are no longer referenced in the active metadata.
112
+ */
113
+ export const cleanupOldVersions = async (
114
+ metadata: OtaMetadata,
115
+ baseVersion: number,
116
+ ): Promise<void> => {
117
+ try {
118
+ if (!(await ReactNativeBlobUtil.fs.exists(OTA_DIR))) return;
119
+ const items = await ReactNativeBlobUtil.fs.ls(OTA_DIR);
120
+
121
+ // Retain current active version and rollback versions
122
+ const retainedVersions = new Set<number>([
123
+ metadata.currentVersion,
124
+ ...(metadata.previousVersions || []),
125
+ ]);
126
+
127
+ for (const item of items) {
128
+ // Check if directory matches "v<number>"
129
+ const versionMatch = item.match(/^v(\d+)$/);
130
+ if (versionMatch) {
131
+ const ver = parseInt(versionMatch[1], 10);
132
+ if (!retainedVersions.has(ver) && ver !== baseVersion) {
133
+ const dirPath = `${OTA_DIR}/${item}`;
134
+ await ReactNativeBlobUtil.fs.unlink(dirPath).catch(() => {});
135
+ if (__DEV__) {
136
+ console.log(`[otaMetadata] Cleaned up obsolete OTA version: ${item}`);
137
+ }
138
+ }
139
+ }
140
+ // Clean up leftover temporary bundle files (.tmp)
141
+ if (item.endsWith('.tmp')) {
142
+ await ReactNativeBlobUtil.fs.unlink(`${OTA_DIR}/${item}`).catch(() => {});
143
+ }
144
+ }
145
+ } catch (e) {
146
+ console.warn('[otaMetadata] Cleanup old versions failed:', e);
147
+ }
148
+ };
149
+
109
150
  /**
110
151
  * Marks the current session as healthy, indicating a successful boot.
111
152
  * This prevents the native bridge from rolling back the version on the next launch.
153
+ * Also cleans up obsolete version directories from disk.
112
154
  *
113
155
  * @param defaultVersion - Base version for metadata lookup fallback.
114
156
  * @returns The updated metadata.
@@ -117,16 +159,16 @@ export const markHealthy = async (
117
159
  defaultVersion: number,
118
160
  ): Promise<OtaMetadata> => {
119
161
  const metadata = await loadMetadata(defaultVersion);
120
- if (metadata.lastLaunchSuccessful && metadata.rollbackCount === 0) {
121
- return metadata;
122
- }
123
-
124
162
  const updated: OtaMetadata = {
125
163
  ...metadata,
126
164
  lastLaunchSuccessful: true,
127
165
  rollbackCount: 0,
128
166
  };
129
167
  await saveMetadata(updated);
168
+
169
+ // Garbage collect older versions on disk to free storage
170
+ await cleanupOldVersions(updated, defaultVersion);
171
+
130
172
  return updated;
131
173
  };
132
174
 
@@ -7,8 +7,15 @@ interface TokenCache {
7
7
  expiresAt: number;
8
8
  }
9
9
 
10
+ interface RegistryCache {
11
+ etag?: string;
12
+ patches: OtaPatch[];
13
+ }
14
+
10
15
  /** Cache tokens per server endpoint so switching configs invalidates stale tokens. */
11
16
  const tokenCache = new Map<string, TokenCache>();
17
+ /** Cache registry ETag and parsed patches to avoid redownloading unmodified manifests. */
18
+ const registryCache = new Map<string, RegistryCache>();
12
19
 
13
20
  /**
14
21
  * Implementation for the custom MinIO-based storage server.
@@ -60,6 +67,8 @@ export class CustomStorageProvider implements OtaStorageProvider {
60
67
 
61
68
  const { baseUrl, channel, baseFolder = 'bspatch' } = config.customServer;
62
69
  const registryPath = `${baseFolder}/ota_registry.json`;
70
+ const regCacheKey = `${baseUrl}|${registryPath}|${config.appVersion}|${channel}`;
71
+ const cachedReg = registryCache.get(regCacheKey);
63
72
 
64
73
  try {
65
74
  const token = await this.getAccessToken(config);
@@ -74,11 +83,23 @@ export class CustomStorageProvider implements OtaStorageProvider {
74
83
  const registryUrl = linkJson.data;
75
84
  if (!registryUrl) throw new Error('Failed to generate registry download link');
76
85
 
77
- const registryRes = await ReactNativeBlobUtil.fetch('GET', registryUrl);
86
+ const reqHeaders: Record<string, string> = {};
87
+ if (cachedReg?.etag) {
88
+ reqHeaders['If-None-Match'] = cachedReg.etag;
89
+ }
90
+
91
+ const registryRes = await ReactNativeBlobUtil.fetch('GET', registryUrl, reqHeaders);
92
+ if (registryRes.respInfo.status === 304 && cachedReg) {
93
+ return cachedReg.patches;
94
+ }
78
95
  if (registryRes.respInfo.status !== 200) {
79
96
  throw new Error(`Registry fetch failed with status ${registryRes.respInfo.status}`);
80
97
  }
81
98
 
99
+ const etag = registryRes.respInfo.headers?.['etag'] || registryRes.respInfo.headers?.['ETag'];
100
+ if (!config.appVersion) {
101
+ throw new Error('[CustomStorage] config.appVersion is required to query OTA registry');
102
+ }
82
103
  const registry = JSON.parse(registryRes.data);
83
104
  const appGroup = registry?.apps?.[config.appVersion];
84
105
  const flavorGroup = appGroup?.flavors?.[channel.toLowerCase()];
@@ -87,10 +108,12 @@ export class CustomStorageProvider implements OtaStorageProvider {
87
108
  return config.bundledPatches || [];
88
109
  }
89
110
 
90
- return (flavorGroup.patches || []).filter((p: OtaPatch) => p.platform === Platform.OS);
111
+ const matchedPatches = (flavorGroup.patches || []).filter((p: OtaPatch) => p.platform === Platform.OS);
112
+ registryCache.set(regCacheKey, { etag, patches: matchedPatches });
113
+ return matchedPatches;
91
114
  } catch (error) {
92
115
  console.warn('[CustomStorage] Manifest fetch error:', (error as Error).message);
93
- return config.bundledPatches || [];
116
+ return cachedReg?.patches || config.bundledPatches || [];
94
117
  }
95
118
  }
96
119
 
@@ -61,6 +61,11 @@ export class GitHubRawStorageProvider implements OtaStorageProvider {
61
61
  ? JSON.parse(res.data)
62
62
  : res.data;
63
63
 
64
+ if (!config.appVersion) {
65
+ throw new Error(
66
+ '[GitHubRawStorage] config.appVersion is required to query OTA registry',
67
+ );
68
+ }
64
69
  const appGroup = registry?.apps?.[config.appVersion];
65
70
  const flavorGroup = appGroup?.flavors?.[channelName];
66
71
 
@@ -59,6 +59,11 @@ export class GitHubReleaseStorageProvider implements OtaStorageProvider {
59
59
  }
60
60
 
61
61
  // Structured: apps["1.0.0"].channels["production"].patches[]
62
+ if (!config.appVersion) {
63
+ throw new Error(
64
+ '[GitHubReleaseStorage] config.appVersion is required to query OTA registry',
65
+ );
66
+ }
62
67
  const appGroup = registry?.apps?.[config.appVersion];
63
68
  const channelGroup = appGroup?.channels?.[config.githubRelease.repo] // use repo as channel fallback
64
69
  ?? appGroup?.channels?.production
package/src/ota/types.ts CHANGED
@@ -117,8 +117,8 @@ export type OtaNetworkStrategy = {
117
117
  };
118
118
 
119
119
  export type OtaConfig = {
120
- /** Current app versionName (e.g. "1.0.0") */
121
- appVersion: string;
120
+ /** Current app versionName (e.g. "1.0.0"). If omitted, auto-detected from native app. */
121
+ appVersion?: string;
122
122
  /** Initial version baked into the APK (usually 1) */
123
123
  baseAppVersion: number;
124
124
  /** Default patches if manifest fetch fails */
@@ -213,8 +213,12 @@ export type OtaState = {
213
213
  pendingCount: number;
214
214
  isChecking: boolean;
215
215
  metrics: OtaMetrics | null;
216
+ /** True if a background update has been downloaded and prepared silently */
217
+ isUpdateReady: boolean;
216
218
  applyUpdate: () => Promise<void>;
217
219
  resetToFactory: () => Promise<void>;
218
220
  markAsHealthy: () => Promise<void>;
219
221
  checkUpdate: () => Promise<void>;
222
+ /** Reload the React Native JS bundle immediately to apply downloaded update */
223
+ reloadBundle: () => void;
220
224
  };
@@ -42,19 +42,44 @@ fi
42
42
  FLAVOR="dev"
43
43
  ACTION="ota"
44
44
  PLATFORM_ARG=""
45
+ APP_VERSION_ARG=""
46
+ NON_INTERACTIVE=false
47
+
48
+ SEED_APK=""
49
+ SEED_IPA=""
50
+ SEED_BUNDLE=""
51
+ FORCE_BASE=false
45
52
 
46
- PUSH_REMOTE=false
47
53
  # Arg parsing
48
54
  while [[ "$#" -gt 0 ]]; do
49
55
  case $1 in
50
56
  --platform) PLATFORM_ARG="$2"; shift ;;
51
57
  --flavor) FLAVOR="$2"; shift ;;
58
+ --app-version) APP_VERSION_ARG="$2"; shift ;;
59
+ --apk) SEED_APK="$2"; ACTION="seed"; shift ;;
60
+ --ipa) SEED_IPA="$2"; ACTION="seed"; shift ;;
61
+ --bundle) SEED_BUNDLE="$2"; ACTION="seed"; shift ;;
62
+ --force) FORCE_BASE=true ;;
63
+ -y|--yes|--non-interactive) NON_INTERACTIVE=true ;;
52
64
  --push) PUSH_REMOTE=true ;;
53
65
  reset|base) ACTION="$1" ;;
54
66
  setup) ACTION="$1" ;;
55
- apk|ipa|seed) ACTION="base" ;; # Compatibility for legacy/seed actions
67
+ seed) ACTION="seed" ;;
68
+ apk|ipa) ACTION="base" ;; # Compatibility for legacy actions
56
69
  dev|staging|production) FLAVOR="$1" ;; # Support positional flavor
57
70
  [0-9]*) ;; # Ignore version if passed as $1 (pos 1 usually)
71
+ *)
72
+ if [[ "$1" == *.apk ]] || [[ "$1" == *.aab ]]; then
73
+ SEED_APK="$1"
74
+ ACTION="seed"
75
+ elif [[ "$1" == *.ipa ]]; then
76
+ SEED_IPA="$1"
77
+ ACTION="seed"
78
+ elif [[ "$1" == *.hbc ]] || [[ "$1" == *.bundle ]]; then
79
+ SEED_BUNDLE="$1"
80
+ ACTION="seed"
81
+ fi
82
+ ;;
58
83
  esac
59
84
  shift
60
85
  done
@@ -239,19 +264,111 @@ sha256() {
239
264
  shasum -a 256 "$1" | awk '{print $1}'
240
265
  }
241
266
 
242
- # ── Extract APP_VERSION from otaConfig.ts ─────────────────────────────────────
243
- APP_VERSION=$(node -e "
267
+ # ── Auto-Detect APP_VERSION ──────────────────────────────────────────────────
268
+ APP_VERSION_INFO=$(node -e "
244
269
  const fs = require('fs');
245
- const content = fs.readFileSync('$ROOT/src/ota/otaConfig.ts', 'utf8');
246
- const match = content.match(/export const APP_VERSION = ['\"](.+?)['\"]/);
247
- console.log(match ? match[1] : 'unknown');
270
+ const path = require('path');
271
+ const root = '$ROOT';
272
+ const cliOverride = '$APP_VERSION_ARG';
273
+
274
+ if (cliOverride && cliOverride !== '') {
275
+ console.log(JSON.stringify({ version: cliOverride, source: 'CLI --app-version' }));
276
+ process.exit(0);
277
+ }
278
+
279
+ // 1. Try android/app/build.gradle (most reliable for Android release)
280
+ try {
281
+ const gradlePath = path.join(root, 'android/app/build.gradle');
282
+ if (fs.existsSync(gradlePath)) {
283
+ const content = fs.readFileSync(gradlePath, 'utf8');
284
+ const match = content.match(/versionName\\s+[\"']([^\"']+)[\"']/);
285
+ if (match && match[1] !== '0.0.0' && match[1] !== '0.0.1') {
286
+ console.log(JSON.stringify({ version: match[1], source: 'android/app/build.gradle' }));
287
+ process.exit(0);
288
+ }
289
+ }
290
+ } catch (e) {}
291
+
292
+ // 2. Try ios project.pbxproj (most reliable for iOS release)
293
+ try {
294
+ const iosDir = path.join(root, 'ios');
295
+ if (fs.existsSync(iosDir)) {
296
+ const files = fs.readdirSync(iosDir);
297
+ for (const f of files) {
298
+ if (f.endsWith('.xcodeproj')) {
299
+ const pbxPath = path.join(iosDir, f, 'project.pbxproj');
300
+ if (fs.existsSync(pbxPath)) {
301
+ const content = fs.readFileSync(pbxPath, 'utf8');
302
+ const match = content.match(/MARKETING_VERSION\\s*=\\s*([^;]+);/);
303
+ if (match && match[1].trim() !== '0.0.0' && match[1].trim() !== '0.0.1') {
304
+ console.log(JSON.stringify({ version: match[1].trim(), source: 'ios/' + f }));
305
+ process.exit(0);
306
+ }
307
+ }
308
+ }
309
+ }
310
+ }
311
+ } catch (e) {}
312
+
313
+ // 3. Try package.json
314
+ try {
315
+ const pkgPath = path.join(root, 'package.json');
316
+ if (fs.existsSync(pkgPath)) {
317
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
318
+ if (pkg.version && pkg.version !== '0.0.0' && pkg.version !== '0.0.1') {
319
+ console.log(JSON.stringify({ version: pkg.version, source: 'package.json' }));
320
+ process.exit(0);
321
+ }
322
+ }
323
+ } catch (e) {}
324
+
325
+ // 4. Fallback: src/ota/otaConfig.ts
326
+ try {
327
+ const otaConfigPath = path.join(root, 'src/ota/otaConfig.ts');
328
+ if (fs.existsSync(otaConfigPath)) {
329
+ const content = fs.readFileSync(otaConfigPath, 'utf8');
330
+ const match = content.match(/export const APP_VERSION = ['\"](.+?)['\"]/);
331
+ if (match) {
332
+ console.log(JSON.stringify({ version: match[1], source: 'src/ota/otaConfig.ts' }));
333
+ process.exit(0);
334
+ }
335
+ }
336
+ } catch (e) {}
337
+
338
+ console.log(JSON.stringify({ version: 'unknown', source: 'none' }));
248
339
  ")
249
340
 
250
- if [ "$APP_VERSION" == "unknown" ]; then
251
- echo "ERROR: Could not find APP_VERSION in otaConfig.ts"
341
+ APP_VERSION=$(node -e "try { console.log(JSON.parse('$APP_VERSION_INFO').version); } catch(e) { console.log('unknown'); }")
342
+ APP_VERSION_SOURCE=$(node -e "try { console.log(JSON.parse('$APP_VERSION_INFO').source); } catch(e) { console.log('none'); }")
343
+
344
+ # Interactive confirmation / prompt:
345
+ # Prompt if running in an interactive terminal, not in reset mode, and no explicit flags passed
346
+ if [ -t 0 ] && [ -z "$APP_VERSION_ARG" ] && [ "$NON_INTERACTIVE" = false ] && [ "$ACTION" != "reset" ]; then
347
+ echo ""
348
+ echo "────────────────────────────────────────────────────────"
349
+ if [ "$APP_VERSION" != "unknown" ]; then
350
+ echo " Detected Native App Version: $APP_VERSION ($APP_VERSION_SOURCE)"
351
+ read -p " Enter App Version to build [Press Enter for '$APP_VERSION']: " USER_INPUT_VER
352
+ else
353
+ read -p " Enter Native App Version to build (e.g. 1.0.0): " USER_INPUT_VER
354
+ fi
355
+ echo "────────────────────────────────────────────────────────"
356
+ echo ""
357
+
358
+ if [ -n "$USER_INPUT_VER" ]; then
359
+ APP_VERSION=$(echo "$USER_INPUT_VER" | tr -d ' ')
360
+ APP_VERSION_SOURCE="User Input"
361
+ fi
362
+ fi
363
+
364
+ if [ "$APP_VERSION" == "unknown" ] || [ -z "$APP_VERSION" ]; then
365
+ echo "ERROR: Native App Version is required to build OTA updates."
366
+ echo " Please enter a valid version or use: ./build_ota.sh --app-version <version>"
252
367
  exit 1
253
368
  fi
254
369
 
370
+ echo " ✓ Building OTA for APP_VERSION=$APP_VERSION (Source: $APP_VERSION_SOURCE)"
371
+
255
372
  # ── Reset ─────────────────────────────────────────────────────────────────────
256
373
  if [ "$ACTION" = "reset" ]; then
257
374
  echo "Resetting OTA registry and output for flavor '$FLAVOR_LOWER'..."
@@ -301,6 +418,22 @@ if [ ! -f "$BSDIFF" ]; then
301
418
  echo " ✓ bsdiff compiled"
302
419
  fi
303
420
 
421
+ find_hermesc() {
422
+ HERMESC_PATH="$ROOT/node_modules/hermes-compiler/hermesc/osx-bin/hermesc"
423
+ if [ ! -f "$HERMESC_PATH" ]; then
424
+ HERMESC_PATH="$ROOT/node_modules/react-native/sdks/hermesc/osx-bin/hermesc"
425
+ fi
426
+ if [ ! -f "$HERMESC_PATH" ]; then
427
+ HERMESC_PATH="$ROOT/node_modules/hermes-compiler/hermesc/linux64-bin/hermesc"
428
+ fi
429
+ if [ ! -f "$HERMESC_PATH" ]; then
430
+ HERMESC_PATH="$ROOT/node_modules/react-native/sdks/hermesc/linux64-bin/hermesc"
431
+ fi
432
+ if [ ! -f "$HERMESC_PATH" ]; then
433
+ HERMESC_PATH="$(which hermesc 2>/dev/null || echo "")"
434
+ fi
435
+ }
436
+
304
437
  build_platform() {
305
438
  local PLATFORM="$1"
306
439
  local FLAVOR="$2"
@@ -316,9 +449,6 @@ build_platform() {
316
449
  echo ""
317
450
  echo ">>> Building $PLATFORM | Version: $APP_VERSION | OTA: v$CURRENT → v$NEXT"
318
451
 
319
- # ── Bundle ──
320
- # ... (rest of the function omitted for space, but I'll make sure to replace correctly)
321
-
322
452
  # ── Bundle ──
323
453
  # Workspace for building
324
454
  local WORKSPACE="$OUTPUT/workspace"
@@ -350,10 +480,7 @@ build_platform() {
350
480
  fi
351
481
 
352
482
  # Ensure Hermes bytecode format (for both iOS and Android OTA bundles)
353
- HERMESC_PATH="$ROOT/node_modules/hermes-compiler/hermesc/osx-bin/hermesc"
354
- if [ ! -f "$HERMESC_PATH" ]; then
355
- HERMESC_PATH="$ROOT/node_modules/react-native/sdks/hermesc/osx-bin/hermesc"
356
- fi
483
+ find_hermesc
357
484
 
358
485
  IS_HBC=false
359
486
  if [ -f "$BUNDLE_SOURCE" ]; then
@@ -492,11 +619,159 @@ EOF
492
619
  bash "$SCRIPT_DIR/upload_ota_custom.sh" "$APP_VERSION" "$FLAVOR_LOWER" "$NEXT" "$NEW_BUNDLE_PATH" "$PLATFORM"
493
620
  }
494
621
 
622
+ seed_platform() {
623
+ local PLATFORM="$1"
624
+ local FLAVOR="$2"
625
+ local BINARY_PATH="$3"
626
+ local FLAVOR_LOWER=$(echo "$FLAVOR" | tr '[:upper:]' '[:lower:]')
627
+ if [ "$FLAVOR_LOWER" = "dev" ]; then FLAVOR_LOWER="development"; fi
628
+ if [ "$FLAVOR_LOWER" = "prod" ]; then FLAVOR_LOWER="production"; fi
629
+
630
+ local OUTPUT="$ROOT/android/output"
631
+ if [ "$PLATFORM" == "ios" ]; then OUTPUT="$ROOT/ios/output"; fi
632
+ mkdir -p "$OUTPUT"
633
+
634
+ echo ""
635
+ echo ">>> Seeding Base Bundle (v1) | Platform: $PLATFORM | App: $APP_VERSION | Flavor: $FLAVOR_LOWER"
636
+ echo " Source binary: $BINARY_PATH"
637
+
638
+ if [ ! -f "$BINARY_PATH" ]; then
639
+ echo " ❌ ERROR: Source binary file not found: $BINARY_PATH"
640
+ exit 1
641
+ fi
642
+
643
+ local TARGET_BUNDLE="$OUTPUT/bundle_${APP_VERSION}_v1_${PLATFORM}_${FLAVOR_LOWER}.hbc"
644
+ local BASE_ASSET_MANIFEST="$OUTPUT/base-assets_${PLATFORM}_${FLAVOR_LOWER}.json"
645
+ local ASSET_MANIFEST_FILE="$OUTPUT/asset-manifest-v1_${FLAVOR_LOWER}.json"
646
+ local TMP_DIR=$(mktemp -d)
647
+
648
+ if [ "$PLATFORM" == "android" ]; then
649
+ echo " Extracting index.android.bundle from Android binary..."
650
+ if unzip -l "$BINARY_PATH" 2>/dev/null | grep -q "assets/index.android.bundle"; then
651
+ unzip -p "$BINARY_PATH" assets/index.android.bundle > "$TMP_DIR/extracted.bundle"
652
+ elif unzip -l "$BINARY_PATH" 2>/dev/null | grep -q "base/assets/index.android.bundle"; then
653
+ unzip -p "$BINARY_PATH" base/assets/index.android.bundle > "$TMP_DIR/extracted.bundle"
654
+ elif [[ "$BINARY_PATH" == *.bundle ]] || [[ "$BINARY_PATH" == *.hbc ]]; then
655
+ cp "$BINARY_PATH" "$TMP_DIR/extracted.bundle"
656
+ else
657
+ echo " ❌ ERROR: Could not find 'assets/index.android.bundle' inside $BINARY_PATH"
658
+ rm -rf "$TMP_DIR"
659
+ exit 1
660
+ fi
661
+ else
662
+ echo " Extracting main.jsbundle from iOS binary..."
663
+ local IPA_ENTRY=$(unzip -l "$BINARY_PATH" 2>/dev/null | grep -o 'Payload/[^/]*\.app/main\.jsbundle' | head -n 1)
664
+ if [ -n "$IPA_ENTRY" ]; then
665
+ unzip -p "$BINARY_PATH" "$IPA_ENTRY" > "$TMP_DIR/extracted.bundle"
666
+ elif unzip -l "$BINARY_PATH" 2>/dev/null | grep -q "main.jsbundle"; then
667
+ local ANY_BUNDLE=$(unzip -l "$BINARY_PATH" 2>/dev/null | grep -o '[^ ]*main\.jsbundle' | head -n 1)
668
+ unzip -p "$BINARY_PATH" "$ANY_BUNDLE" > "$TMP_DIR/extracted.bundle"
669
+ elif [[ "$BINARY_PATH" == *.bundle ]] || [[ "$BINARY_PATH" == *.hbc ]]; then
670
+ cp "$BINARY_PATH" "$TMP_DIR/extracted.bundle"
671
+ else
672
+ echo " ❌ ERROR: Could not find 'main.jsbundle' inside $BINARY_PATH"
673
+ rm -rf "$TMP_DIR"
674
+ exit 1
675
+ fi
676
+ fi
677
+
678
+ # Check Hermes bytecode format (0xc6 magic number)
679
+ FIRST_BYTE=$(od -An -N1 -t x1 "$TMP_DIR/extracted.bundle" | tr -d ' ' || echo "")
680
+ if [ "$FIRST_BYTE" == "c6" ]; then
681
+ echo " ✓ Bundle is verified Hermes bytecode format."
682
+ cp "$TMP_DIR/extracted.bundle" "$TARGET_BUNDLE"
683
+ else
684
+ echo " Bundle is JavaScript text. Compiling to Hermes bytecode..."
685
+ find_hermesc
686
+ if [ -z "$HERMESC_PATH" ] || [ ! -f "$HERMESC_PATH" ]; then
687
+ echo " ❌ ERROR: hermesc compiler not found. Cannot compile bundle to bytecode."
688
+ rm -rf "$TMP_DIR"
689
+ exit 1
690
+ fi
691
+ "$HERMESC_PATH" -emit-binary -out "$TARGET_BUNDLE" "$TMP_DIR/extracted.bundle"
692
+ fi
693
+
694
+ # Extract base assets manifest
695
+ echo " Scanning assets from binary for base manifest..."
696
+ local NEW_BASE_ASSETS="{"
697
+ local ASSET_JSON_LIST="["
698
+ local FIRST=true
699
+
700
+ if [ "$PLATFORM" == "android" ]; then
701
+ unzip -q -o "$BINARY_PATH" "res/drawable*/*" "res/raw/*" -d "$TMP_DIR/assets_unzipped" 2>/dev/null || true
702
+ else
703
+ unzip -q -o "$BINARY_PATH" "Payload/*.app/assets/*" -d "$TMP_DIR/assets_unzipped" 2>/dev/null || true
704
+ fi
705
+
706
+ if [ -d "$TMP_DIR/assets_unzipped" ]; then
707
+ while IFS= read -r -d '' ASSET_FILE; do
708
+ local HASH=$(sha256 "$ASSET_FILE")
709
+ local FNAME=$(basename "$ASSET_FILE")
710
+ local EX="${FNAME##*.}"
711
+ local REL_PATH="${ASSET_FILE#$TMP_DIR/assets_unzipped/}"
712
+
713
+ if [ "$NEW_BASE_ASSETS" != "{" ]; then NEW_BASE_ASSETS+=", "; fi
714
+ NEW_BASE_ASSETS+="\"$REL_PATH\": \"$HASH\""
715
+
716
+ if [ "$FIRST" = true ]; then FIRST=false; else ASSET_JSON_LIST+=", "; fi
717
+ ASSET_JSON_LIST+="{\"hash\": \"$HASH\", \"ext\": \"$EX\", \"originalPath\": \"$REL_PATH\", \"isBase\": true}"
718
+ done < <(find "$TMP_DIR/assets_unzipped" -type f -print0 2>/dev/null)
719
+ fi
720
+
721
+ NEW_BASE_ASSETS+="}"
722
+ ASSET_JSON_LIST+="]"
723
+
724
+ echo "$NEW_BASE_ASSETS" > "$BASE_ASSET_MANIFEST"
725
+ cat > "$ASSET_MANIFEST_FILE" << EOF
726
+ { "version": 1, "appVersion": "$APP_VERSION", "platform": "$PLATFORM", "flavor": "$FLAVOR_LOWER", "assets": $ASSET_JSON_LIST }
727
+ EOF
728
+
729
+ rm -rf "$TMP_DIR"
730
+
731
+ local BUNDLE_HASH=$(sha256 "$TARGET_BUNDLE")
732
+ echo " ✓ Base bundle v1 successfully created: $(basename "$TARGET_BUNDLE")"
733
+ echo " SHA-256: $BUNDLE_HASH"
734
+
735
+ # Update registry for v1
736
+ node "$SCRIPT_DIR/update_registry.js" "$REGISTRY_SHARED" "$APP_VERSION" "1" "[]" "$PLATFORM" "$ASSET_MANIFEST_FILE" "$FLAVOR_LOWER"
737
+
738
+ # Push if requested
739
+ if [ "$PUSH_REMOTE" = true ]; then
740
+ echo " Pushing seeded base bundle to remote..."
741
+ bash "$SCRIPT_DIR/upload_ota_custom.sh" "$APP_VERSION" "$FLAVOR_LOWER" "1" "$TARGET_BUNDLE" "$PLATFORM"
742
+ fi
743
+
744
+ echo " ✓ Seeding complete for $PLATFORM!"
745
+ }
746
+
495
747
  # ── Main ───────────────────────────────────────────────────────────────────
496
- echo "=== OTA Dual Build | App: $APP_VERSION | Flavor: $FLAVOR ==="
748
+ echo "=== OTA Build Pipeline | App: $APP_VERSION | Flavor: $FLAVOR ==="
497
749
 
498
- # Read current version once to coordinate across platforms
499
750
  FLAVOR_LOWER=$(echo "$FLAVOR" | tr '[:upper:]' '[:lower:]')
751
+ if [ "$FLAVOR_LOWER" = "dev" ]; then FLAVOR_LOWER="development"; fi
752
+ if [ "$FLAVOR_LOWER" = "prod" ]; then FLAVOR_LOWER="production"; fi
753
+
754
+ # ── Seed Action ────────────────────────────────────────────────────────────
755
+ if [ "$ACTION" = "seed" ]; then
756
+ if [ -n "$SEED_APK" ]; then
757
+ seed_platform "android" "$FLAVOR_LOWER" "$SEED_APK"
758
+ elif [ -n "$SEED_IPA" ]; then
759
+ seed_platform "ios" "$FLAVOR_LOWER" "$SEED_IPA"
760
+ elif [ -n "$SEED_BUNDLE" ]; then
761
+ PLATFORM="${PLATFORM_ARG:-android}"
762
+ seed_platform "$PLATFORM" "$FLAVOR_LOWER" "$SEED_BUNDLE"
763
+ else
764
+ echo "❌ Please provide a binary file to seed:"
765
+ echo " ./build_ota.sh seed --apk <path-to-apk>"
766
+ echo " ./build_ota.sh seed --ipa <path-to-ipa>"
767
+ exit 1
768
+ fi
769
+ echo ""
770
+ echo "=== Base Seeding Complete ==="
771
+ exit 0
772
+ fi
773
+
774
+ # Read current version once to coordinate across platforms
500
775
  CURRENT_TOTAL=0
501
776
  if [ -f "$REGISTRY_SHARED" ]; then
502
777
  CURRENT_TOTAL=$(node -e "
@@ -506,6 +781,79 @@ if [ -f "$REGISTRY_SHARED" ]; then
506
781
  } catch(e) { console.log(0); }
507
782
  ")
508
783
  fi
784
+
785
+ # ── Fail-Safe Guard for OTA Updates ────────────────────────────────────────
786
+ # If building an OTA patch but no base version (v1) exists in the registry:
787
+ if [ "$ACTION" = "ota" ] && [ $CURRENT_TOTAL -eq 0 ]; then
788
+ echo "Checking for pre-existing Base Bundle v1..."
789
+
790
+ CHECK_PLATFORMS=()
791
+ if [ -n "$PLATFORM_ARG" ]; then
792
+ CHECK_PLATFORMS=("$PLATFORM_ARG")
793
+ else
794
+ CHECK_PLATFORMS=("android" "ios")
795
+ fi
796
+
797
+ ALL_FOUND=true
798
+ MISSING_PLATFORM=""
799
+ for P in "${CHECK_PLATFORMS[@]}"; do
800
+ P_OUTPUT="$ROOT/$P/output"
801
+ P_BASE_BUNDLE="$P_OUTPUT/bundle_${APP_VERSION}_v1_${P}_${FLAVOR_LOWER}.hbc"
802
+
803
+ # Check if local exists
804
+ if [ ! -f "$P_BASE_BUNDLE" ]; then
805
+ # Try downloading from remote if available
806
+ REMOTE_BASE_PATH="$BASE_FOLDER/$APP_VERSION/$FLAVOR_LOWER/$(basename "$P_BASE_BUNDLE")"
807
+ download_from_minio "$REMOTE_BASE_PATH" "$P_BASE_BUNDLE" || true
808
+ fi
809
+
810
+ if [ -f "$P_BASE_BUNDLE" ]; then
811
+ echo " ✓ Found Base Bundle v1 for $P: $(basename "$P_BASE_BUNDLE")"
812
+ # Auto-adopt base bundle in registry
813
+ node "$SCRIPT_DIR/update_registry.js" "$REGISTRY_SHARED" "$APP_VERSION" "1" "[]" "$P" "" "$FLAVOR_LOWER"
814
+ else
815
+ ALL_FOUND=false
816
+ MISSING_PLATFORM="$P"
817
+ fi
818
+ done
819
+
820
+ if [ "$ALL_FOUND" = true ]; then
821
+ echo " ✓ Base bundle v1 verified and registered. Proceeding with incremental OTA update..."
822
+ CURRENT_TOTAL=1
823
+ else
824
+ if [ "$FORCE_BASE" = true ]; then
825
+ echo " ⚠ Warning: --force flag detected. Creating base bundle v1 from current working tree..."
826
+ ACTION="base"
827
+ else
828
+ echo ""
829
+ echo "╔══════════════════════════════════════════════════════════════════════════════════════════╗"
830
+ echo "║ ❌ CANNOT BUILD OTA: MISSING BASE BUNDLE (v1) ║"
831
+ echo "╠══════════════════════════════════════════════════════════════════════════════════════════╣"
832
+ echo "║ App Version: $APP_VERSION | Flavor: $FLAVOR_LOWER | Missing: $MISSING_PLATFORM"
833
+ echo "║"
834
+ echo "║ No base bundle (v1) was found for this app version (neither locally nor on remote). ║"
835
+ echo "║ If an OTA update is built now, it would generate a base bundle from your current ║"
836
+ echo "║ working tree, which will MISMATCH the version installed on user devices and force a ║"
837
+ echo "║ 100% full-bundle download (wasting bandwidth and bypassing patch benefits). ║"
838
+ echo "║"
839
+ echo "║ How to resolve: ║"
840
+ echo "║ 1. If you already have the release APK/IPA submitted to Google Play / App Store: ║"
841
+ echo "║ Seed the exact base bundle from your release binary: ║"
842
+ echo "║ ./build_ota.sh seed --apk <path-to-apk> ║"
843
+ echo "║ ./build_ota.sh seed --ipa <path-to-ipa> ║"
844
+ echo "║"
845
+ echo "║ 2. If you are preparing a brand new Store release right now from this commit: ║"
846
+ echo "║ ./build_ota.sh base ║"
847
+ echo "║"
848
+ echo "║ 3. If you really want to force create base v1 from current code: ║"
849
+ echo "║ ./build_ota.sh --force ║"
850
+ echo "╚══════════════════════════════════════════════════════════════════════════════════════════╝"
851
+ echo ""
852
+ exit 1
853
+ fi
854
+ fi
855
+ fi
856
+
509
857
  TARGET_OTA=$((CURRENT_TOTAL + 1))
510
858
 
511
859
  if [ -n "$PLATFORM_ARG" ]; then