react-native-bspatch 1.1.1 → 1.1.3

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 (58) hide show
  1. package/README.md +120 -22
  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 +12 -2
  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 +12 -2
  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 +15 -4
  53. package/src/ota/storage/GitHubReleaseStorage.ts +5 -0
  54. package/src/ota/types.ts +6 -2
  55. package/tools/build_ota.sh +103 -7
  56. package/tools/bump_version.js +133 -0
  57. package/tools/generate_keys.js +75 -0
  58. package/tools/update_registry.js +51 -1
@@ -129,18 +129,21 @@ export const verifySignature = async (
129
129
  ): Promise<boolean> => {
130
130
  if (!signature || !publicKey) return true; // Skip if not configured
131
131
  try {
132
- // Read file content
133
- const fileData = await ReactNativeBlobUtil.fs.readFile(filePath, 'base64');
132
+ // Fast-path: use Native streaming HMAC-SHA256 (avoids Base64 conversion and JS thread blocking)
133
+ if (typeof NativeBsPatch.verifySignature === 'function') {
134
+ const isValid = await NativeBsPatch.verifySignature(filePath, signature, publicKey);
135
+ if (!isValid && __DEV__) {
136
+ console.warn('[otaCore] Signature mismatch');
137
+ }
138
+ return isValid;
139
+ }
134
140
 
135
- // Convert base64 to WordArray for CryptoJS
141
+ // Fallback to JS CryptoJS if native module is running an older binary
142
+ const fileData = await ReactNativeBlobUtil.fs.readFile(filePath, 'base64');
136
143
  const fileWords = CryptoJS.enc.Base64.parse(fileData);
137
144
  const keyWords = CryptoJS.enc.Base64.parse(publicKey);
138
145
  const signatureWords = CryptoJS.enc.Base64.parse(signature);
139
-
140
- // Compute HMAC
141
146
  const hmac = CryptoJS.HmacSHA256(fileWords, keyWords);
142
-
143
- // Compare computed HMAC with provided signature
144
147
  const isValid = hmac.toString(CryptoJS.enc.Base64) === signatureWords.toString(CryptoJS.enc.Base64);
145
148
 
146
149
  if (!isValid && __DEV__) {
@@ -507,6 +510,7 @@ export const applyOtaPatchInternal = async (
507
510
  metrics?: OtaMetrics,
508
511
  ) => void,
509
512
  onProgress: (percent: number) => void,
513
+ options: { autoReload?: boolean } = { autoReload: true },
510
514
  ) => {
511
515
  if (__DEV__ && config.allowDebugUpdates !== true) {
512
516
  onStatus(
@@ -531,13 +535,26 @@ export const applyOtaPatchInternal = async (
531
535
  try {
532
536
  // ── Step 1: Version Compatibility ──
533
537
  if (patch.minAppVersion) {
534
- if (!semverGte(config.appVersion, patch.minAppVersion)) {
538
+ let currentAppVer = config.appVersion;
539
+ if (!currentAppVer && typeof NativeBsPatch.getAppVersion === 'function') {
540
+ try {
541
+ currentAppVer = await NativeBsPatch.getAppVersion();
542
+ } catch {
543
+ currentAppVer = undefined;
544
+ }
545
+ }
546
+ if (!currentAppVer) {
547
+ throw new Error(
548
+ `[otaCore] Cannot verify minAppVersion: Native App Version could not be determined.`,
549
+ );
550
+ }
551
+ if (!semverGte(currentAppVer, patch.minAppVersion)) {
535
552
  log.emit(OtaEvent.VERSION_INCOMPATIBLE, {
536
- message: `App ${config.appVersion} < required ${patch.minAppVersion}`,
553
+ message: `App ${currentAppVer} < required ${patch.minAppVersion}`,
537
554
  version: patch.toVersion,
538
555
  });
539
556
  throw new Error(
540
- `Native app ${config.appVersion} is too old. Update to ${patch.minAppVersion}+ first.`,
557
+ `Native app ${currentAppVer} is too old. Update to ${patch.minAppVersion}+ first.`,
541
558
  );
542
559
  }
543
560
  }
@@ -848,17 +865,23 @@ export const applyOtaPatchInternal = async (
848
865
  });
849
866
 
850
867
  // ── Step 11: Reload ──
851
- // CRITICAL: Mark healthy BEFORE reload — reloadBundle() calls Runtime.exit(0)
852
- // and JS will NOT run again before restart. Native OtaBundleResolver sets
853
- // lastLaunchSuccessful=false before loading the new bundle. Without this call,
854
- // the next cold boot sees false → rollback to previous version.
868
+ // Mark healthy BEFORE reload — reloadBundle() calls Runtime.exit(0)
855
869
  NativeBsPatch.markAsHealthy();
856
- if (__DEV__) {
857
- console.log(
858
- `[otaCore] V${patch.toVersion} marked healthy — reloading.`,
859
- );
870
+
871
+ if (options.autoReload !== false) {
872
+ if (__DEV__) {
873
+ console.log(
874
+ `[otaCore] V${patch.toVersion} marked healthy — reloading.`,
875
+ );
876
+ }
877
+ NativeBsPatch.reloadBundle();
878
+ } else {
879
+ if (__DEV__) {
880
+ console.log(
881
+ `[otaCore] V${patch.toVersion} prepared silently in background — will apply on next launch.`,
882
+ );
883
+ }
860
884
  }
861
- NativeBsPatch.reloadBundle();
862
885
  } catch (error) {
863
886
  if (__DEV__) {
864
887
  console.error('[otaCore] Pipeline failed:', error);
@@ -952,11 +975,18 @@ export const applyOtaPatchInternal = async (
952
975
  );
953
976
  }
954
977
 
955
- onStatus(
956
- OtaStatus.SUCCESS,
957
- `Fallback successful! Reloading...`,
958
- );
959
- NativeBsPatch.reloadBundle();
978
+ if (options.autoReload !== false) {
979
+ onStatus(
980
+ OtaStatus.SUCCESS,
981
+ `Fallback successful! Reloading...`,
982
+ );
983
+ NativeBsPatch.reloadBundle();
984
+ } else {
985
+ onStatus(
986
+ OtaStatus.SUCCESS,
987
+ `Fallback bundle prepared silently in background.`,
988
+ );
989
+ }
960
990
  return;
961
991
  } catch (fallbackError) {
962
992
  if (__DEV__) {
@@ -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
 
@@ -23,11 +23,19 @@ const NO_CACHE_HEADERS = { 'Cache-Control': 'no-cache' };
23
23
  const bustCache = (url: string): string =>
24
24
  `${url}?cb=${Date.now()}`;
25
25
 
26
+ const normalizeChannel = (channel: string): string => {
27
+ const lower = channel.toLowerCase();
28
+ if (lower === 'dev') return 'development';
29
+ if (lower === 'prod') return 'production';
30
+ return lower;
31
+ };
32
+
26
33
  export class GitHubRawStorageProvider implements OtaStorageProvider {
27
34
  async fetchManifest(config: OtaConfig): Promise<OtaPatch[]> {
28
35
  if (!config.customServer) return config.bundledPatches || [];
29
36
 
30
37
  const { baseUrl, channel, baseFolder = 'ota' } = config.customServer;
38
+ const channelName = normalizeChannel(channel);
31
39
  const normalizedBaseUrl = baseUrl.endsWith('/')
32
40
  ? baseUrl.slice(0, -1)
33
41
  : baseUrl;
@@ -53,10 +61,13 @@ export class GitHubRawStorageProvider implements OtaStorageProvider {
53
61
  ? JSON.parse(res.data)
54
62
  : res.data;
55
63
 
64
+ if (!config.appVersion) {
65
+ throw new Error(
66
+ '[GitHubRawStorage] config.appVersion is required to query OTA registry',
67
+ );
68
+ }
56
69
  const appGroup = registry?.apps?.[config.appVersion];
57
- const flavorGroup =
58
- appGroup?.flavors?.[channel.toLowerCase()] ||
59
- appGroup?.flavors?.development;
70
+ const flavorGroup = appGroup?.flavors?.[channelName];
60
71
 
61
72
  if (!flavorGroup?.patches?.length) {
62
73
  return config.bundledPatches || [];
@@ -88,7 +99,7 @@ export class GitHubRawStorageProvider implements OtaStorageProvider {
88
99
  const normalizedBaseUrl = baseUrl.endsWith('/')
89
100
  ? baseUrl.slice(0, -1)
90
101
  : baseUrl;
91
- const channelName = channel.toLowerCase();
102
+ const channelName = normalizeChannel(channel);
92
103
 
93
104
  // Cache-bust: patch files are versioned by name but GitHub CDN may
94
105
  // serve a stale 404 response for newly uploaded files within 5 min.
@@ -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,6 +42,8 @@ fi
42
42
  FLAVOR="dev"
43
43
  ACTION="ota"
44
44
  PLATFORM_ARG=""
45
+ APP_VERSION_ARG=""
46
+ NON_INTERACTIVE=false
45
47
 
46
48
  PUSH_REMOTE=false
47
49
  # Arg parsing
@@ -49,6 +51,8 @@ while [[ "$#" -gt 0 ]]; do
49
51
  case $1 in
50
52
  --platform) PLATFORM_ARG="$2"; shift ;;
51
53
  --flavor) FLAVOR="$2"; shift ;;
54
+ --app-version) APP_VERSION_ARG="$2"; shift ;;
55
+ -y|--yes|--non-interactive) NON_INTERACTIVE=true ;;
52
56
  --push) PUSH_REMOTE=true ;;
53
57
  reset|base) ACTION="$1" ;;
54
58
  setup) ACTION="$1" ;;
@@ -239,19 +243,111 @@ sha256() {
239
243
  shasum -a 256 "$1" | awk '{print $1}'
240
244
  }
241
245
 
242
- # ── Extract APP_VERSION from otaConfig.ts ─────────────────────────────────────
243
- APP_VERSION=$(node -e "
246
+ # ── Auto-Detect APP_VERSION ──────────────────────────────────────────────────
247
+ APP_VERSION_INFO=$(node -e "
244
248
  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');
249
+ const path = require('path');
250
+ const root = '$ROOT';
251
+ const cliOverride = '$APP_VERSION_ARG';
252
+
253
+ if (cliOverride && cliOverride !== '') {
254
+ console.log(JSON.stringify({ version: cliOverride, source: 'CLI --app-version' }));
255
+ process.exit(0);
256
+ }
257
+
258
+ // 1. Try android/app/build.gradle (most reliable for Android release)
259
+ try {
260
+ const gradlePath = path.join(root, 'android/app/build.gradle');
261
+ if (fs.existsSync(gradlePath)) {
262
+ const content = fs.readFileSync(gradlePath, 'utf8');
263
+ const match = content.match(/versionName\\s+[\"']([^\"']+)[\"']/);
264
+ if (match && match[1] !== '0.0.0' && match[1] !== '0.0.1') {
265
+ console.log(JSON.stringify({ version: match[1], source: 'android/app/build.gradle' }));
266
+ process.exit(0);
267
+ }
268
+ }
269
+ } catch (e) {}
270
+
271
+ // 2. Try ios project.pbxproj (most reliable for iOS release)
272
+ try {
273
+ const iosDir = path.join(root, 'ios');
274
+ if (fs.existsSync(iosDir)) {
275
+ const files = fs.readdirSync(iosDir);
276
+ for (const f of files) {
277
+ if (f.endsWith('.xcodeproj')) {
278
+ const pbxPath = path.join(iosDir, f, 'project.pbxproj');
279
+ if (fs.existsSync(pbxPath)) {
280
+ const content = fs.readFileSync(pbxPath, 'utf8');
281
+ const match = content.match(/MARKETING_VERSION\\s*=\\s*([^;]+);/);
282
+ if (match && match[1].trim() !== '0.0.0' && match[1].trim() !== '0.0.1') {
283
+ console.log(JSON.stringify({ version: match[1].trim(), source: 'ios/' + f }));
284
+ process.exit(0);
285
+ }
286
+ }
287
+ }
288
+ }
289
+ }
290
+ } catch (e) {}
291
+
292
+ // 3. Try package.json
293
+ try {
294
+ const pkgPath = path.join(root, 'package.json');
295
+ if (fs.existsSync(pkgPath)) {
296
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
297
+ if (pkg.version && pkg.version !== '0.0.0' && pkg.version !== '0.0.1') {
298
+ console.log(JSON.stringify({ version: pkg.version, source: 'package.json' }));
299
+ process.exit(0);
300
+ }
301
+ }
302
+ } catch (e) {}
303
+
304
+ // 4. Fallback: src/ota/otaConfig.ts
305
+ try {
306
+ const otaConfigPath = path.join(root, 'src/ota/otaConfig.ts');
307
+ if (fs.existsSync(otaConfigPath)) {
308
+ const content = fs.readFileSync(otaConfigPath, 'utf8');
309
+ const match = content.match(/export const APP_VERSION = ['\"](.+?)['\"]/);
310
+ if (match) {
311
+ console.log(JSON.stringify({ version: match[1], source: 'src/ota/otaConfig.ts' }));
312
+ process.exit(0);
313
+ }
314
+ }
315
+ } catch (e) {}
316
+
317
+ console.log(JSON.stringify({ version: 'unknown', source: 'none' }));
248
318
  ")
249
319
 
250
- if [ "$APP_VERSION" == "unknown" ]; then
251
- echo "ERROR: Could not find APP_VERSION in otaConfig.ts"
320
+ APP_VERSION=$(node -e "try { console.log(JSON.parse('$APP_VERSION_INFO').version); } catch(e) { console.log('unknown'); }")
321
+ APP_VERSION_SOURCE=$(node -e "try { console.log(JSON.parse('$APP_VERSION_INFO').source); } catch(e) { console.log('none'); }")
322
+
323
+ # Interactive confirmation / prompt:
324
+ # Prompt if running in an interactive terminal, not in reset mode, and no explicit flags passed
325
+ if [ -t 0 ] && [ -z "$APP_VERSION_ARG" ] && [ "$NON_INTERACTIVE" = false ] && [ "$ACTION" != "reset" ]; then
326
+ echo ""
327
+ echo "────────────────────────────────────────────────────────"
328
+ if [ "$APP_VERSION" != "unknown" ]; then
329
+ echo " Detected Native App Version: $APP_VERSION ($APP_VERSION_SOURCE)"
330
+ read -p " Enter App Version to build [Press Enter for '$APP_VERSION']: " USER_INPUT_VER
331
+ else
332
+ read -p " Enter Native App Version to build (e.g. 1.0.0): " USER_INPUT_VER
333
+ fi
334
+ echo "────────────────────────────────────────────────────────"
335
+ echo ""
336
+
337
+ if [ -n "$USER_INPUT_VER" ]; then
338
+ APP_VERSION=$(echo "$USER_INPUT_VER" | tr -d ' ')
339
+ APP_VERSION_SOURCE="User Input"
340
+ fi
341
+ fi
342
+
343
+ if [ "$APP_VERSION" == "unknown" ] || [ -z "$APP_VERSION" ]; then
344
+ echo "ERROR: Native App Version is required to build OTA updates."
345
+ echo " Please enter a valid version or use: ./build_ota.sh --app-version <version>"
252
346
  exit 1
253
347
  fi
254
348
 
349
+ echo " ✓ Building OTA for APP_VERSION=$APP_VERSION (Source: $APP_VERSION_SOURCE)"
350
+
255
351
  # ── Reset ─────────────────────────────────────────────────────────────────────
256
352
  if [ "$ACTION" = "reset" ]; then
257
353
  echo "Resetting OTA registry and output for flavor '$FLAVOR_LOWER'..."
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+ // ─────────────────────────────────────────────────────────────────────────────
3
+ // bump_version.js — Synchronize App Version Across Platforms
4
+ //
5
+ // Updates:
6
+ // - package.json ("version")
7
+ // - android/app/build.gradle (versionName & increments versionCode)
8
+ // - ios/*.xcodeproj/project.pbxproj (MARKETING_VERSION & increments CURRENT_PROJECT_VERSION)
9
+ // - src/ota/otaConfig.ts (APP_VERSION, if it exists)
10
+ //
11
+ // Usage:
12
+ // node bump_version.js <new_version> [--root <path>]
13
+ // e.g. node bump_version.js 1.0.2
14
+ // ─────────────────────────────────────────────────────────────────────────────
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+
19
+ const args = process.argv.slice(2);
20
+ let newVersion = '';
21
+ let rootDir = process.cwd();
22
+
23
+ for (let i = 0; i < args.length; i++) {
24
+ if (args[i] === '--root' && args[i + 1]) {
25
+ rootDir = path.resolve(args[i + 1]);
26
+ i++;
27
+ } else if (!args[i].startsWith('--')) {
28
+ newVersion = args[i].trim();
29
+ }
30
+ }
31
+
32
+ if (!newVersion) {
33
+ console.error('Usage: node bump_version.js <new_version> [--root <path>]');
34
+ console.error('Example: node bump_version.js 1.0.2');
35
+ process.exit(1);
36
+ }
37
+
38
+ // Validate semver format loosely (e.g. 1.0.2 or 1.0.2-beta)
39
+ if (!/^\d+\.\d+\.\d+.*$/.test(newVersion)) {
40
+ console.error(`Invalid version format: "${newVersion}". Expected semver (e.g. 1.0.2)`);
41
+ process.exit(1);
42
+ }
43
+
44
+ console.log(`\n=== Bumping App Version to ${newVersion} ===\n`);
45
+ let changedCount = 0;
46
+
47
+ // 1. package.json
48
+ const pkgPath = path.join(rootDir, 'package.json');
49
+ if (fs.existsSync(pkgPath)) {
50
+ try {
51
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
52
+ const oldVer = pkg.version;
53
+ pkg.version = newVersion;
54
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
55
+ console.log(` ✓ package.json: ${oldVer} → ${newVersion}`);
56
+ changedCount++;
57
+ } catch (e) {
58
+ console.warn(` ⚠ Failed to update package.json: ${e.message}`);
59
+ }
60
+ }
61
+
62
+ // 2. android/app/build.gradle
63
+ const gradlePath = path.join(rootDir, 'android/app/build.gradle');
64
+ if (fs.existsSync(gradlePath)) {
65
+ try {
66
+ let content = fs.readFileSync(gradlePath, 'utf8');
67
+ let oldName = '';
68
+ let newCode = 1;
69
+
70
+ // Bump versionName
71
+ content = content.replace(/versionName\s+["']([^"']+)["']/, (_, old) => {
72
+ oldName = old;
73
+ return `versionName "${newVersion}"`;
74
+ });
75
+
76
+ // Increment versionCode
77
+ content = content.replace(/versionCode\s+(\d+)/, (_, code) => {
78
+ newCode = parseInt(code, 10) + 1;
79
+ return `versionCode ${newCode}`;
80
+ });
81
+
82
+ fs.writeFileSync(gradlePath, content, 'utf8');
83
+ console.log(` ✓ android/app/build.gradle: versionName "${oldName}" → "${newVersion}", versionCode → ${newCode}`);
84
+ changedCount++;
85
+ } catch (e) {
86
+ console.warn(` ⚠ Failed to update build.gradle: ${e.message}`);
87
+ }
88
+ }
89
+
90
+ // 3. iOS Xcode project.pbxproj
91
+ const iosDir = path.join(rootDir, 'ios');
92
+ if (fs.existsSync(iosDir)) {
93
+ try {
94
+ const files = fs.readdirSync(iosDir);
95
+ for (const f of files) {
96
+ if (f.endsWith('.xcodeproj')) {
97
+ const pbxPath = path.join(iosDir, f, 'project.pbxproj');
98
+ if (fs.existsSync(pbxPath)) {
99
+ let content = fs.readFileSync(pbxPath, 'utf8');
100
+
101
+ // Replace MARKETING_VERSION
102
+ content = content.replace(/MARKETING_VERSION\s*=\s*[^;]+;/g, `MARKETING_VERSION = ${newVersion};`);
103
+
104
+ // Increment CURRENT_PROJECT_VERSION if numeric
105
+ content = content.replace(/CURRENT_PROJECT_VERSION\s*=\s*(\d+);/g, (_, v) => {
106
+ const nextV = parseInt(v, 10) + 1;
107
+ return `CURRENT_PROJECT_VERSION = ${nextV};`;
108
+ });
109
+
110
+ fs.writeFileSync(pbxPath, content, 'utf8');
111
+ console.log(` ✓ ios/${f}/project.pbxproj: MARKETING_VERSION → ${newVersion}`);
112
+ changedCount++;
113
+ }
114
+ }
115
+ }
116
+ } catch (e) {
117
+ console.warn(` ⚠ Failed to update iOS project: ${e.message}`);
118
+ }
119
+ }
120
+
121
+ // 4. src/ota/otaConfig.ts (if exists)
122
+ const otaConfigPath = path.join(rootDir, 'src/ota/otaConfig.ts');
123
+ if (fs.existsSync(otaConfigPath)) {
124
+ try {
125
+ let content = fs.readFileSync(otaConfigPath, 'utf8');
126
+ content = content.replace(/export const APP_VERSION = ['"][^'"]+['"];/, `export const APP_VERSION = '${newVersion}';`);
127
+ fs.writeFileSync(otaConfigPath, content, 'utf8');
128
+ console.log(` ✓ src/ota/otaConfig.ts: APP_VERSION → '${newVersion}'`);
129
+ changedCount++;
130
+ } catch (e) {}
131
+ }
132
+
133
+ console.log(`\n✓ Done! Updated ${changedCount} file(s) to version ${newVersion}.\n`);