react-native-bspatch 1.1.2 → 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 +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 +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
@@ -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,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`);
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+ // ─────────────────────────────────────────────────────────────────────────────
3
+ // generate_keys.js — Generate Asymmetric Keypair for OTA Patch Signing
4
+ //
5
+ // Usage:
6
+ // node tools/generate_keys.js [--type rsa|ec] [--out <dir>]
7
+ // e.g. node tools/generate_keys.js
8
+ // ─────────────────────────────────────────────────────────────────────────────
9
+
10
+ const crypto = require('crypto');
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+
14
+ const args = process.argv.slice(2);
15
+ let keyType = 'rsa';
16
+ let outDir = process.cwd();
17
+
18
+ for (let i = 0; i < args.length; i++) {
19
+ if (args[i] === '--type' && args[i + 1]) {
20
+ keyType = args[i + 1].toLowerCase();
21
+ i++;
22
+ } else if (args[i] === '--out' && args[i + 1]) {
23
+ outDir = path.resolve(args[i + 1]);
24
+ i++;
25
+ }
26
+ }
27
+
28
+ console.log(`\n=== Generating ${keyType.toUpperCase()} Keypair for OTA Patch Security ===\n`);
29
+
30
+ let privateKey, publicKey;
31
+
32
+ if (keyType === 'ec') {
33
+ const keypair = crypto.generateKeyPairSync('ec', {
34
+ namedCurve: 'prime256v1',
35
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
36
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
37
+ });
38
+ privateKey = keypair.privateKey;
39
+ publicKey = keypair.publicKey;
40
+ } else {
41
+ // Default: RSA 2048
42
+ const keypair = crypto.generateKeyPairSync('rsa', {
43
+ modulusLength: 2048,
44
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
45
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
46
+ });
47
+ privateKey = keypair.privateKey;
48
+ publicKey = keypair.publicKey;
49
+ }
50
+
51
+ const privPath = path.join(outDir, 'ota_private_key.pem');
52
+ const pubPath = path.join(outDir, 'ota_public_key.pem');
53
+
54
+ fs.writeFileSync(privPath, privateKey, { mode: 0o600 });
55
+ fs.writeFileSync(pubPath, publicKey, { mode: 0o644 });
56
+
57
+ // Clean public key string for inline JS config
58
+ const pubKeyClean = publicKey
59
+ .replace(/-----BEGIN PUBLIC KEY-----/, '')
60
+ .replace(/-----END PUBLIC KEY-----/, '')
61
+ .replace(/\s+/g, '');
62
+
63
+ console.log(` ✓ Private Key saved to: ${privPath}`);
64
+ console.log(` ✓ Public Key saved to: ${pubPath}`);
65
+ console.log('\n────────────────────────────────────────────────────────');
66
+ console.log(' HOW TO USE:');
67
+ console.log(' 1. KEEP PRIVATE KEY SAFE on your build machine / CI server:');
68
+ console.log(' Add "ota_private_key.pem" to .gitignore!');
69
+ console.log('');
70
+ console.log(' 2. ADD PUBLIC KEY TO YOUR CLIENT APP CONFIG:');
71
+ console.log(' const otaConfig: OtaConfig = {');
72
+ console.log(` publicKey: '${pubKeyClean}',`);
73
+ console.log(' ...');
74
+ console.log(' };');
75
+ console.log('────────────────────────────────────────────────────────\n');
@@ -12,6 +12,7 @@
12
12
 
13
13
  const fs = require('fs');
14
14
  const path = require('path');
15
+ const crypto = require('crypto');
15
16
 
16
17
  const [
17
18
  projectRoot,
@@ -61,6 +62,24 @@ if (!registry.apps[appVersion].flavors[flavor]) {
61
62
  const flavorGroup = registry.apps[appVersion].flavors[flavor];
62
63
  flavorGroup.latestOtaVersion = toOtaVersion;
63
64
 
65
+ // ── Check for private key for asymmetric signing ───────────────────────────
66
+ let signingKey = process.env.OTA_PRIVATE_KEY || null;
67
+ if (!signingKey) {
68
+ const candidateKeyPaths = [
69
+ path.join(process.cwd(), 'ota_private_key.pem'),
70
+ path.join(path.dirname(registryPath), 'ota_private_key.pem'),
71
+ path.join(path.dirname(registryPath), '../ota_private_key.pem'),
72
+ ];
73
+ for (const kp of candidateKeyPaths) {
74
+ if (fs.existsSync(kp)) {
75
+ try {
76
+ signingKey = fs.readFileSync(kp, 'utf8');
77
+ break;
78
+ } catch (e) {}
79
+ }
80
+ }
81
+ }
82
+
64
83
  // ── Update patches ──────────────────────────────────────────────────────────
65
84
  if (newPatches.length > 0) {
66
85
  // We merge patches by platform to avoid overwriting between Android and iOS
@@ -71,7 +90,32 @@ if (newPatches.length > 0) {
71
90
  const fullBundleName = `bundle_${appVersion}_v${toOtaVersion}_${platform}_${flavor}.hbc`;
72
91
  const manifestName = path.basename(_assetManifestFile);
73
92
 
74
- return {
93
+ let signature = undefined;
94
+ if (signingKey) {
95
+ try {
96
+ const patchFileCandidates = [
97
+ path.join(path.dirname(registryPath), p.file),
98
+ path.join(process.cwd(), p.file),
99
+ ];
100
+ let patchData = null;
101
+ for (const pfc of patchFileCandidates) {
102
+ if (fs.existsSync(pfc)) {
103
+ patchData = fs.readFileSync(pfc);
104
+ break;
105
+ }
106
+ }
107
+ if (patchData) {
108
+ const signer = crypto.createSign('SHA256');
109
+ signer.update(patchData);
110
+ signature = signer.sign(signingKey, 'base64');
111
+ console.log(` ✓ Signed patch ${p.file} with asymmetric private key`);
112
+ }
113
+ } catch (signErr) {
114
+ console.warn(` ⚠ Could not sign patch ${p.file}: ${signErr.message}`);
115
+ }
116
+ }
117
+
118
+ const patchObj = {
75
119
  id: `v${p.from}-to-v${p.to}-${flavor}-${platform}`,
76
120
  label: `V${p.from} → V${p.to} (${flavor} ${platform})`,
77
121
  appVersion,
@@ -85,6 +129,12 @@ if (newPatches.length > 0) {
85
129
  assetManifestUrl: manifestName,
86
130
  flavor: flavor,
87
131
  };
132
+
133
+ if (signature) {
134
+ patchObj.signature = signature;
135
+ }
136
+
137
+ return patchObj;
88
138
  });
89
139
 
90
140
  flavorGroup.patches = [...otherPlatformPatches, ...mappedNewPatches];