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.
- package/README.md +120 -22
- package/android/src/main/java/vn/reactnativebspatch/BsPatchModule.kt +117 -17
- package/cpp/CMakeLists.txt +9 -1
- package/cpp/bsdiff/bspatch.c +14 -3
- package/cpp/bsdiff/bspatch_wrapper.c +124 -40
- package/ios/RNBsPatch.mm +142 -1
- package/lib/commonjs/NativeBsPatch.js.map +1 -1
- package/lib/commonjs/ota/OtaProvider.js +61 -12
- package/lib/commonjs/ota/OtaProvider.js.map +1 -1
- package/lib/commonjs/ota/otaCore.js +43 -20
- package/lib/commonjs/ota/otaCore.js.map +1 -1
- package/lib/commonjs/ota/otaMetadata.js +41 -5
- package/lib/commonjs/ota/otaMetadata.js.map +1 -1
- package/lib/commonjs/ota/storage/CustomStorage.js +23 -3
- package/lib/commonjs/ota/storage/CustomStorage.js.map +1 -1
- package/lib/commonjs/ota/storage/GitHubRawStorage.js +3 -0
- package/lib/commonjs/ota/storage/GitHubRawStorage.js.map +1 -1
- package/lib/commonjs/ota/storage/GitHubReleaseStorage.js +3 -0
- package/lib/commonjs/ota/storage/GitHubReleaseStorage.js.map +1 -1
- package/lib/module/NativeBsPatch.js.map +1 -1
- package/lib/module/ota/OtaProvider.js +61 -12
- package/lib/module/ota/OtaProvider.js.map +1 -1
- package/lib/module/ota/otaCore.js +43 -20
- package/lib/module/ota/otaCore.js.map +1 -1
- package/lib/module/ota/otaMetadata.js +38 -3
- package/lib/module/ota/otaMetadata.js.map +1 -1
- package/lib/module/ota/storage/CustomStorage.js +23 -3
- package/lib/module/ota/storage/CustomStorage.js.map +1 -1
- package/lib/module/ota/storage/GitHubRawStorage.js +3 -0
- package/lib/module/ota/storage/GitHubRawStorage.js.map +1 -1
- package/lib/module/ota/storage/GitHubReleaseStorage.js +3 -0
- package/lib/module/ota/storage/GitHubReleaseStorage.js.map +1 -1
- package/lib/typescript/NativeBsPatch.d.ts +11 -0
- package/lib/typescript/NativeBsPatch.d.ts.map +1 -1
- package/lib/typescript/ota/OtaProvider.d.ts.map +1 -1
- package/lib/typescript/ota/otaCore.d.ts +3 -1
- package/lib/typescript/ota/otaCore.d.ts.map +1 -1
- package/lib/typescript/ota/otaMetadata.d.ts +6 -0
- package/lib/typescript/ota/otaMetadata.d.ts.map +1 -1
- package/lib/typescript/ota/storage/CustomStorage.d.ts.map +1 -1
- package/lib/typescript/ota/storage/GitHubRawStorage.d.ts.map +1 -1
- package/lib/typescript/ota/storage/GitHubReleaseStorage.d.ts.map +1 -1
- package/lib/typescript/ota/types.d.ts +6 -2
- package/lib/typescript/ota/types.d.ts.map +1 -1
- package/package.json +3 -1
- package/react-native-bspatch.podspec +6 -1
- package/src/NativeBsPatch.ts +17 -0
- package/src/ota/OtaProvider.tsx +80 -23
- package/src/ota/otaCore.ts +54 -24
- package/src/ota/otaMetadata.ts +46 -4
- package/src/ota/storage/CustomStorage.ts +26 -3
- package/src/ota/storage/GitHubRawStorage.ts +5 -0
- package/src/ota/storage/GitHubReleaseStorage.ts +5 -0
- package/src/ota/types.ts +6 -2
- package/tools/build_ota.sh +103 -7
- package/tools/bump_version.js +133 -0
- package/tools/generate_keys.js +75 -0
- package/tools/update_registry.js +51 -1
package/src/ota/otaMetadata.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
};
|
package/tools/build_ota.sh
CHANGED
|
@@ -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
|
-
# ──
|
|
243
|
-
|
|
246
|
+
# ── Auto-Detect APP_VERSION ──────────────────────────────────────────────────
|
|
247
|
+
APP_VERSION_INFO=$(node -e "
|
|
244
248
|
const fs = require('fs');
|
|
245
|
-
const
|
|
246
|
-
const
|
|
247
|
-
|
|
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
|
-
|
|
251
|
-
|
|
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');
|
package/tools/update_registry.js
CHANGED
|
@@ -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
|
-
|
|
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];
|