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.
- 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 +12 -2
- 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 +12 -2
- 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 +15 -4
- 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/otaCore.ts
CHANGED
|
@@ -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
|
-
//
|
|
133
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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 ${
|
|
553
|
+
message: `App ${currentAppVer} < required ${patch.minAppVersion}`,
|
|
537
554
|
version: patch.toVersion,
|
|
538
555
|
});
|
|
539
556
|
throw new Error(
|
|
540
|
-
`Native app ${
|
|
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
|
-
//
|
|
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
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
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__) {
|
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
|
|
|
@@ -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
|
|
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
|
|
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`);
|