react-native-bspatch 3.1.2 → 4.0.1
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 +3 -3
- package/android/build.gradle +18 -40
- package/android/src/main/java/vn/reactnativebspatch/BsPatchModule.kt +148 -13
- package/android/src/main/java/vn/reactnativebspatch/OtaBundleResolver.kt +21 -3
- package/cpp/CMakeLists.txt +0 -3
- package/cpp/bsdiff/bspatch_wrapper.c +3 -2
- package/ios/OtaBundleResolver.swift +22 -5
- package/ios/RNBsPatch.mm +220 -32
- package/lib/commonjs/NativeBsPatch.js.map +1 -1
- package/lib/commonjs/index.js +1 -13
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/ota/OtaProvider.js +21 -21
- package/lib/commonjs/ota/OtaProvider.js.map +1 -1
- package/lib/commonjs/ota/otaCore.js +50 -88
- package/lib/commonjs/ota/otaCore.js.map +1 -1
- package/lib/commonjs/ota/otaMetadata.js +46 -23
- package/lib/commonjs/ota/otaMetadata.js.map +1 -1
- package/lib/commonjs/ota/storage/GitHubRawStorage.js +22 -12
- package/lib/commonjs/ota/storage/GitHubRawStorage.js.map +1 -1
- package/lib/module/NativeBsPatch.js.map +1 -1
- package/lib/module/index.js +0 -8
- package/lib/module/index.js.map +1 -1
- package/lib/module/ota/OtaProvider.js +22 -22
- package/lib/module/ota/OtaProvider.js.map +1 -1
- package/lib/module/ota/otaCore.js +50 -87
- package/lib/module/ota/otaCore.js.map +1 -1
- package/lib/module/ota/otaMetadata.js +44 -21
- package/lib/module/ota/otaMetadata.js.map +1 -1
- package/lib/module/ota/storage/GitHubRawStorage.js +20 -10
- package/lib/module/ota/storage/GitHubRawStorage.js.map +1 -1
- package/lib/typescript/NativeBsPatch.d.ts +36 -1
- package/lib/typescript/NativeBsPatch.d.ts.map +1 -1
- package/lib/typescript/index.d.ts +0 -7
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/ota/OtaProvider.d.ts.map +1 -1
- package/lib/typescript/ota/otaCore.d.ts +2 -1
- package/lib/typescript/ota/otaCore.d.ts.map +1 -1
- package/lib/typescript/ota/otaMetadata.d.ts +5 -0
- package/lib/typescript/ota/otaMetadata.d.ts.map +1 -1
- package/lib/typescript/ota/storage/GitHubRawStorage.d.ts +2 -0
- package/lib/typescript/ota/storage/GitHubRawStorage.d.ts.map +1 -1
- package/package.json +2 -3
- package/react-native-bspatch.podspec +3 -25
- package/src/NativeBsPatch.ts +44 -1
- package/src/index.ts +0 -7
- package/src/ota/OtaProvider.tsx +37 -23
- package/src/ota/otaCore.ts +55 -94
- package/src/ota/otaMetadata.ts +44 -25
- package/src/ota/storage/GitHubRawStorage.ts +17 -22
- package/tools/build_ota.sh +18 -4
- package/tools/upload_ota.sh +3 -2
- package/ios/RNBsPatch.h +0 -8
package/src/index.ts
CHANGED
|
@@ -20,13 +20,6 @@ export * from './ota/otaLogger';
|
|
|
20
20
|
export * from './ota/OtaProvider';
|
|
21
21
|
export * from './ota/types';
|
|
22
22
|
|
|
23
|
-
/**
|
|
24
|
-
* @deprecated No-ops since 3.1: OTA assets are placed next to the bundle, where React
|
|
25
|
-
* Native resolves them by default, so no source transformer is needed.
|
|
26
|
-
*/
|
|
27
|
-
export const initOtaAssetResolver = async (_version: number): Promise<void> => {};
|
|
28
|
-
/** @deprecated No-op since 3.1, see initOtaAssetResolver. */
|
|
29
|
-
export const resetOtaAssetResolver = (): void => {};
|
|
30
23
|
export { GitHubRawStorageProvider } from './ota/storage/GitHubRawStorage';
|
|
31
24
|
|
|
32
25
|
export default {
|
package/src/ota/OtaProvider.tsx
CHANGED
|
@@ -16,11 +16,10 @@ import {
|
|
|
16
16
|
Text,
|
|
17
17
|
View,
|
|
18
18
|
} from 'react-native';
|
|
19
|
-
import ReactNativeBlobUtil from 'react-native-blob-util';
|
|
20
19
|
import NativeBsPatch from '../NativeBsPatch';
|
|
21
20
|
import { applyOtaPatchInternal, fetchOtaManifest, selectUpdate, validateOtaConfig } from './otaCore';
|
|
22
21
|
import { createOtaLogger, OtaEvent } from './otaLogger';
|
|
23
|
-
import { loadMetadata, markHealthy } from './otaMetadata';
|
|
22
|
+
import { DOCUMENT_DIR, loadMetadata, markHealthy, OTA_DIR } from './otaMetadata';
|
|
24
23
|
import {
|
|
25
24
|
OtaConfig,
|
|
26
25
|
OtaMetrics,
|
|
@@ -109,11 +108,12 @@ export function OtaProvider({
|
|
|
109
108
|
*/
|
|
110
109
|
const executeApplyUpdate = useCallback(
|
|
111
110
|
async (patchList: OtaPatch[], currentVer: number, targetVer: number) => {
|
|
112
|
-
console.log('[OtaProvider] executeApplyUpdate | targetV:', targetVer, 'currentV:', currentVer);
|
|
113
111
|
const directPatch = selectUpdate(patchList, currentVer, targetVer);
|
|
114
112
|
|
|
115
113
|
if (!directPatch) {
|
|
116
|
-
|
|
114
|
+
if (__DEV__) {
|
|
115
|
+
console.warn('[OtaProvider] No update entry found for', currentVer, '->', targetVer);
|
|
116
|
+
}
|
|
117
117
|
setStatus(OtaStatus.ERROR);
|
|
118
118
|
setMessage('No upgrade path found.');
|
|
119
119
|
return;
|
|
@@ -137,7 +137,9 @@ export function OtaProvider({
|
|
|
137
137
|
},
|
|
138
138
|
);
|
|
139
139
|
} catch (error) {
|
|
140
|
-
|
|
140
|
+
if (__DEV__) {
|
|
141
|
+
console.error('[OtaProvider] Update error:', error);
|
|
142
|
+
}
|
|
141
143
|
setStatus(OtaStatus.ERROR);
|
|
142
144
|
setMessage(`Update failed: ${(error as Error).message}`);
|
|
143
145
|
}
|
|
@@ -223,21 +225,20 @@ export function OtaProvider({
|
|
|
223
225
|
const remotePatches = await fetchOtaManifest(effectiveConfig);
|
|
224
226
|
const metadata = await loadMetadata(config.baseAppVersion);
|
|
225
227
|
const localV = metadata.currentVersion;
|
|
226
|
-
console.log('[OtaProvider] checkUpdates | localV:', localV, 'remotePatches count:', remotePatches.length);
|
|
227
228
|
|
|
228
|
-
//
|
|
229
|
+
// The one platform filter (storage providers return every platform).
|
|
230
|
+
// Also skips versions that crashed on boot and were rolled back natively.
|
|
229
231
|
const failed = metadata.failedVersions ?? [];
|
|
230
232
|
const platformPatches = remotePatches.filter(
|
|
231
|
-
p =>
|
|
233
|
+
p =>
|
|
234
|
+
(!p.platform || p.platform.toLowerCase() === Platform.OS) &&
|
|
235
|
+
!failed.includes(p.toVersion),
|
|
232
236
|
);
|
|
233
|
-
console.log('[OtaProvider] platformPatches count:', platformPatches.length);
|
|
234
237
|
|
|
235
238
|
const maxV =
|
|
236
239
|
platformPatches.length > 0
|
|
237
240
|
? Math.max(...platformPatches.map(p => p.toVersion))
|
|
238
241
|
: localV;
|
|
239
|
-
|
|
240
|
-
console.log('[OtaProvider] latestV determined:', maxV);
|
|
241
242
|
|
|
242
243
|
setPatches(platformPatches);
|
|
243
244
|
setCurrentVersion(localV);
|
|
@@ -286,10 +287,10 @@ export function OtaProvider({
|
|
|
286
287
|
useEffect(() => {
|
|
287
288
|
const bootstrap = async () => {
|
|
288
289
|
// Clean up any pending trash directories from previous resets (non-blocking)
|
|
289
|
-
|
|
290
|
+
NativeBsPatch.readDir(DOCUMENT_DIR).then(dirs => {
|
|
290
291
|
for (const dir of dirs) {
|
|
291
292
|
if (dir.startsWith('ota_trash_')) {
|
|
292
|
-
|
|
293
|
+
NativeBsPatch.removePath(`${DOCUMENT_DIR}/${dir}`).catch(() => {});
|
|
293
294
|
}
|
|
294
295
|
}
|
|
295
296
|
}).catch(() => {});
|
|
@@ -367,24 +368,22 @@ export function OtaProvider({
|
|
|
367
368
|
};
|
|
368
369
|
}, [config.updateMode, checkUpdates]);
|
|
369
370
|
|
|
370
|
-
const markAsHealthy = async () => {
|
|
371
|
+
const markAsHealthy = useCallback(async () => {
|
|
371
372
|
const log = createOtaLogger(config.onEvent);
|
|
372
373
|
log.emit(OtaEvent.MARK_HEALTHY);
|
|
373
374
|
const appVersion = await NativeBsPatch.getAppVersion().catch(() => config.appVersion);
|
|
374
375
|
await markHealthy(config.baseAppVersion, runningVersionRef.current, appVersion);
|
|
375
|
-
};
|
|
376
|
+
}, [config]);
|
|
376
377
|
|
|
377
|
-
const resetToFactory = async () => {
|
|
378
|
+
const resetToFactory = useCallback(async () => {
|
|
378
379
|
const log = createOtaLogger(config.onEvent);
|
|
379
380
|
try {
|
|
380
381
|
setStatus(OtaStatus.APPLYING);
|
|
381
382
|
setMessage('Executing system wipe...');
|
|
382
383
|
log.emit(OtaEvent.RESET_FACTORY);
|
|
383
|
-
|
|
384
|
-
if (await ReactNativeBlobUtil.fs.exists(otaDir)) {
|
|
384
|
+
if (await NativeBsPatch.exists(OTA_DIR)) {
|
|
385
385
|
// Fast atomic rename to prevent blocking the JS thread during deletion of potentially 1000s of files
|
|
386
|
-
|
|
387
|
-
await ReactNativeBlobUtil.fs.mv(otaDir, trashDir);
|
|
386
|
+
await NativeBsPatch.moveFile(OTA_DIR, `${DOCUMENT_DIR}/ota_trash_${Date.now()}`);
|
|
388
387
|
// Deletion is deferred to the next app bootstrap to prevent blocking the bridge before reload
|
|
389
388
|
}
|
|
390
389
|
setMessage('Reset complete. Core reload initiated...');
|
|
@@ -393,9 +392,10 @@ export function OtaProvider({
|
|
|
393
392
|
setStatus(OtaStatus.ERROR);
|
|
394
393
|
setMessage(`Reset failed: ${(error as Error).message}`);
|
|
395
394
|
}
|
|
396
|
-
};
|
|
395
|
+
}, [config]);
|
|
397
396
|
|
|
398
|
-
|
|
397
|
+
// Memoized: consumers re-render only when something they can see changes
|
|
398
|
+
const value = useMemo<OtaState>(() => ({
|
|
399
399
|
status,
|
|
400
400
|
message,
|
|
401
401
|
progress,
|
|
@@ -410,7 +410,21 @@ export function OtaProvider({
|
|
|
410
410
|
markAsHealthy,
|
|
411
411
|
checkUpdate: checkUpdates,
|
|
412
412
|
reloadBundle: () => NativeBsPatch.reloadBundle(),
|
|
413
|
-
}
|
|
413
|
+
}), [
|
|
414
|
+
status,
|
|
415
|
+
message,
|
|
416
|
+
progress,
|
|
417
|
+
currentVersion,
|
|
418
|
+
latestVersion,
|
|
419
|
+
pendingCount,
|
|
420
|
+
isChecking,
|
|
421
|
+
metrics,
|
|
422
|
+
isUpdateReady,
|
|
423
|
+
applyUpdate,
|
|
424
|
+
resetToFactory,
|
|
425
|
+
markAsHealthy,
|
|
426
|
+
checkUpdates,
|
|
427
|
+
]);
|
|
414
428
|
|
|
415
429
|
// Logic to determine if we should block the UI
|
|
416
430
|
const isBlocking = useMemo(() => {
|
package/src/ota/otaCore.ts
CHANGED
|
@@ -11,18 +11,19 @@
|
|
|
11
11
|
* - Structured telemetry logging
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { Platform } from 'react-native';
|
|
15
|
-
import ReactNativeBlobUtil from 'react-native-blob-util';
|
|
16
14
|
import NativeBsPatch from '../NativeBsPatch';
|
|
17
15
|
import { createOtaLogger, OtaEvent } from './otaLogger';
|
|
18
16
|
import {
|
|
17
|
+
ASSET_LIST_FILE,
|
|
18
|
+
DOCUMENT_DIR,
|
|
19
19
|
ensureOtaDir,
|
|
20
|
+
OTA_DIR,
|
|
20
21
|
getBundlePath,
|
|
21
22
|
loadMetadata,
|
|
22
23
|
registerNewVersion,
|
|
23
24
|
updateMetadata,
|
|
24
25
|
} from './otaMetadata';
|
|
25
|
-
import { GitHubRawStorageProvider } from './storage/GitHubRawStorage';
|
|
26
|
+
import { fetchJson, GitHubRawStorageProvider } from './storage/GitHubRawStorage';
|
|
26
27
|
import {
|
|
27
28
|
OtaStatus,
|
|
28
29
|
type OtaAsset,
|
|
@@ -32,8 +33,6 @@ import {
|
|
|
32
33
|
type OtaStorageProvider,
|
|
33
34
|
} from './types';
|
|
34
35
|
|
|
35
|
-
const OTA_DIR = `${ReactNativeBlobUtil.fs.dirs.DocumentDir}/ota`;
|
|
36
|
-
|
|
37
36
|
// ─── Storage Provider Factory ──────────────────────────────────────────
|
|
38
37
|
|
|
39
38
|
const DEFAULT_STORAGE = new GitHubRawStorageProvider();
|
|
@@ -97,7 +96,7 @@ export const verifyHash = async (
|
|
|
97
96
|
): Promise<boolean> => {
|
|
98
97
|
if (!expectedHash) return true;
|
|
99
98
|
try {
|
|
100
|
-
const hash = await
|
|
99
|
+
const hash = await NativeBsPatch.hashFile(path);
|
|
101
100
|
return hash.toLowerCase() === expectedHash.toLowerCase();
|
|
102
101
|
} catch {
|
|
103
102
|
return false;
|
|
@@ -125,66 +124,40 @@ const assertSigned = async (
|
|
|
125
124
|
|
|
126
125
|
/**
|
|
127
126
|
* Download a file with retry logic using exponential backoff.
|
|
128
|
-
*
|
|
127
|
+
* Native streams to `${destPath}.part` and renames only on a 2xx response, so a
|
|
128
|
+
* cut-off download or an error page never sits at destPath looking complete.
|
|
129
|
+
* @returns Size of the downloaded file in bytes
|
|
129
130
|
*/
|
|
130
131
|
const downloadWithRetry = async (
|
|
131
132
|
url: string,
|
|
132
133
|
destPath: string,
|
|
133
134
|
config: OtaConfig,
|
|
134
135
|
onProgress: (percent: number) => void,
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const retryLimit = config.network?.retry ?? 3;
|
|
136
|
+
): Promise<number> => {
|
|
137
|
+
const retryLimit = Math.max(1, config.network?.retry ?? 3);
|
|
138
138
|
const timeout = config.network?.timeoutMs ?? 30000;
|
|
139
|
+
const subscription = NativeBsPatch.onDownloadProgress(e => {
|
|
140
|
+
// total is -1 without a Content-Length
|
|
141
|
+
if (e.path === destPath && e.total > 0) {
|
|
142
|
+
onProgress(Math.min(100, Math.floor((e.received / e.total) * 100)));
|
|
143
|
+
}
|
|
144
|
+
});
|
|
139
145
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
})
|
|
152
|
-
.fetch('GET', url, {
|
|
153
|
-
'Cache-Control': 'no-cache',
|
|
154
|
-
...headers,
|
|
155
|
-
})
|
|
156
|
-
.progress((received: any, total: any) => {
|
|
157
|
-
const percent = Math.floor(
|
|
158
|
-
(Number(received) / Number(total)) * 100,
|
|
159
|
-
);
|
|
160
|
-
onProgress(percent);
|
|
161
|
-
});
|
|
162
|
-
taskRef = { cancel: () => task.cancel() };
|
|
163
|
-
const resp = await task;
|
|
164
|
-
const status = resp.respInfo.status;
|
|
165
|
-
if (status < 200 || status >= 300) {
|
|
166
|
-
throw new Error(`HTTP ${status} for ${url}`);
|
|
167
|
-
}
|
|
168
|
-
if (await ReactNativeBlobUtil.fs.exists(destPath)) {
|
|
169
|
-
await ReactNativeBlobUtil.fs.unlink(destPath);
|
|
170
|
-
}
|
|
171
|
-
await ReactNativeBlobUtil.fs.mv(partPath, destPath);
|
|
172
|
-
return;
|
|
173
|
-
} catch (e) {
|
|
174
|
-
// Cancel any lingering request before retrying
|
|
175
|
-
taskRef?.cancel();
|
|
176
|
-
await ReactNativeBlobUtil.fs.unlink(partPath).catch(() => {});
|
|
177
|
-
if (i === retryLimit - 1) throw e;
|
|
178
|
-
const delay = Math.pow(2, i) * 1000; // Exponential backoff: 1s, 2s, 4s...
|
|
179
|
-
if (__DEV__) {
|
|
180
|
-
console.warn(
|
|
181
|
-
`[otaCore] Download attempt ${
|
|
182
|
-
i + 1
|
|
183
|
-
} failed. Retrying in ${delay}ms...`,
|
|
184
|
-
);
|
|
146
|
+
try {
|
|
147
|
+
for (let i = 0; ; i++) {
|
|
148
|
+
try {
|
|
149
|
+
return await NativeBsPatch.downloadFile(url, destPath, timeout);
|
|
150
|
+
} catch (e) {
|
|
151
|
+
if (i >= retryLimit - 1) throw e;
|
|
152
|
+
const delay = Math.pow(2, i) * 1000; // Exponential backoff: 1s, 2s, 4s...
|
|
153
|
+
if (__DEV__) {
|
|
154
|
+
console.warn(`[otaCore] Download attempt ${i + 1} failed. Retrying in ${delay}ms...`);
|
|
155
|
+
}
|
|
156
|
+
await new Promise<void>(resolve => setTimeout(resolve, delay));
|
|
185
157
|
}
|
|
186
|
-
await new Promise<void>(resolve => setTimeout(resolve, delay));
|
|
187
158
|
}
|
|
159
|
+
} finally {
|
|
160
|
+
subscription.remove();
|
|
188
161
|
}
|
|
189
162
|
};
|
|
190
163
|
|
|
@@ -204,7 +177,7 @@ export const fetchOtaManifest = (config: OtaConfig): Promise<OtaPatch[]> =>
|
|
|
204
177
|
|
|
205
178
|
// ─── Hashed Asset Store ───────────────────────────────────────────────
|
|
206
179
|
|
|
207
|
-
const ASSET_STORE_DIR = `${
|
|
180
|
+
const ASSET_STORE_DIR = `${OTA_DIR}/assets`;
|
|
208
181
|
|
|
209
182
|
/** Relative path inside the version dir: no absolute paths, no `..` segments. */
|
|
210
183
|
const isSafeRelativePath = (p: string): boolean =>
|
|
@@ -231,20 +204,13 @@ const downloadAndSyncAssets = async (
|
|
|
231
204
|
) => void,
|
|
232
205
|
onProgress: (percent: number) => void,
|
|
233
206
|
): Promise<void> => {
|
|
234
|
-
const fs =
|
|
207
|
+
const fs = NativeBsPatch;
|
|
235
208
|
onStatus(OtaStatus.SYNCING_ASSETS, 'Syncing assets...');
|
|
236
209
|
const versionDir = `${OTA_DIR}/v${version}`;
|
|
237
210
|
|
|
238
211
|
let assets: OtaAsset[] = [];
|
|
239
212
|
try {
|
|
240
|
-
const
|
|
241
|
-
followRedirect: true,
|
|
242
|
-
timeout: 10000,
|
|
243
|
-
}).fetch('GET', assetManifestUrl, { 'Cache-Control': 'no-cache' });
|
|
244
|
-
if (resp.respInfo.status !== 200) {
|
|
245
|
-
throw new Error(`status ${resp.respInfo.status}`);
|
|
246
|
-
}
|
|
247
|
-
const manifest = typeof resp.data === 'string' ? JSON.parse(resp.data) : resp.data;
|
|
213
|
+
const manifest = await fetchJson(assetManifestUrl, 10000);
|
|
248
214
|
// Hash/ext/path become file names: reject anything unexpected
|
|
249
215
|
assets = (manifest.assets || []).filter(
|
|
250
216
|
(a: any) =>
|
|
@@ -258,13 +224,18 @@ const downloadAndSyncAssets = async (
|
|
|
258
224
|
throw new Error(`Failed to fetch asset manifest: ${(e as Error).message}`);
|
|
259
225
|
}
|
|
260
226
|
if (assets.length === 0) return;
|
|
227
|
+
// Lets cleanupOldVersions keep the store files that retained versions use
|
|
228
|
+
await fs.writeFile(
|
|
229
|
+
`${versionDir}/${ASSET_LIST_FILE}`,
|
|
230
|
+
JSON.stringify(assets.map(a => `${a.hash}.${a.ext}`)),
|
|
231
|
+
);
|
|
261
232
|
|
|
262
233
|
onStatus(OtaStatus.SYNCING_ASSETS, `Syncing ${assets.length} asset(s)...`);
|
|
263
|
-
//
|
|
234
|
+
// Native mkdir creates parents and tolerates an existing dir; this just skips repeats
|
|
264
235
|
const dirs = new Map<string, Promise<void>>();
|
|
265
236
|
const ensureDir = (dir: string): Promise<void> => {
|
|
266
237
|
if (!dirs.has(dir)) {
|
|
267
|
-
dirs.set(dir, fs.
|
|
238
|
+
dirs.set(dir, fs.mkdir(dir));
|
|
268
239
|
}
|
|
269
240
|
return dirs.get(dir)!;
|
|
270
241
|
};
|
|
@@ -285,18 +256,14 @@ const downloadAndSyncAssets = async (
|
|
|
285
256
|
if (!url) throw new Error('no URL');
|
|
286
257
|
await downloadWithRetry(url, storePath, config, () => {});
|
|
287
258
|
if (!(await verifyHash(storePath, asset.hash))) {
|
|
288
|
-
await fs.
|
|
259
|
+
await fs.removePath(storePath).catch(() => {});
|
|
289
260
|
throw new Error('hash mismatch');
|
|
290
261
|
}
|
|
291
262
|
downloaded++;
|
|
292
263
|
}
|
|
293
264
|
const dest = `${versionDir}/${asset.originalPath}`;
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
await ensureDir(`${versionDir}/${parts.slice(0, i).join('/')}`);
|
|
297
|
-
}
|
|
298
|
-
if (await fs.exists(dest)) await fs.unlink(dest);
|
|
299
|
-
await fs.cp(storePath, dest);
|
|
265
|
+
await ensureDir(dest.slice(0, dest.lastIndexOf('/')));
|
|
266
|
+
await fs.copyFile(storePath, dest);
|
|
300
267
|
} catch (e) {
|
|
301
268
|
if (__DEV__) {
|
|
302
269
|
console.warn(`[otaCore] Asset ${asset.originalPath} failed:`, e);
|
|
@@ -342,7 +309,7 @@ export const getLocalAssetPath = (hash: string, ext: string): string =>
|
|
|
342
309
|
* Gets the local filesystem path for a downloaded patch file.
|
|
343
310
|
*
|
|
344
311
|
* @param patch - Patch metadata object
|
|
345
|
-
* @param docDir - Document directory path (
|
|
312
|
+
* @param docDir - Document directory path (`DOCUMENT_DIR`)
|
|
346
313
|
* @returns Absolute path to the patch file
|
|
347
314
|
*/
|
|
348
315
|
export const getLocalPatchPath = (patch: OtaPatch, docDir: string): string =>
|
|
@@ -351,15 +318,14 @@ export const getLocalPatchPath = (patch: OtaPatch, docDir: string): string =>
|
|
|
351
318
|
/**
|
|
352
319
|
* Picks the registry entry for `localV → targetV`: a patch from the OTA bundle
|
|
353
320
|
* already on disk, else the full-bundle entry (store build, or outside the patch window).
|
|
321
|
+
* `patchList` is already filtered to this platform (checkUpdates).
|
|
354
322
|
*/
|
|
355
323
|
export const selectUpdate = (
|
|
356
324
|
patchList: OtaPatch[],
|
|
357
325
|
localV: number,
|
|
358
326
|
targetV: number,
|
|
359
327
|
): OtaPatch | undefined => {
|
|
360
|
-
const forTarget = patchList.filter(
|
|
361
|
-
p => p.toVersion === targetV && (!p.platform || p.platform === Platform.OS),
|
|
362
|
-
);
|
|
328
|
+
const forTarget = patchList.filter(p => p.toVersion === targetV);
|
|
363
329
|
return (
|
|
364
330
|
forTarget.find(p => p.filename && p.fromVersion === localV) ??
|
|
365
331
|
forTarget.find(p => !p.filename)
|
|
@@ -417,13 +383,13 @@ const runUpdatePipeline = async (
|
|
|
417
383
|
const log = createOtaLogger(config.onEvent);
|
|
418
384
|
await ensureOtaDir();
|
|
419
385
|
const totalStart = Date.now();
|
|
420
|
-
const fs =
|
|
386
|
+
const fs = NativeBsPatch;
|
|
421
387
|
|
|
422
388
|
const finalBundlePath =
|
|
423
389
|
getBundlePath(patch.toVersion, config.baseAppVersion) || '';
|
|
424
390
|
const tempBundle = `${finalBundlePath}.tmp`;
|
|
425
391
|
const versionDir = `${OTA_DIR}/v${patch.toVersion}`;
|
|
426
|
-
const localPatchPath = getLocalPatchPath(patch,
|
|
392
|
+
const localPatchPath = getLocalPatchPath(patch, DOCUMENT_DIR);
|
|
427
393
|
|
|
428
394
|
// ── Version Compatibility ──
|
|
429
395
|
// Checked before anything else so an incompatible update can't reach the full-bundle path
|
|
@@ -448,16 +414,15 @@ const runUpdatePipeline = async (
|
|
|
448
414
|
}
|
|
449
415
|
}
|
|
450
416
|
|
|
451
|
-
|
|
452
|
-
await fs.mkdir(versionDir);
|
|
453
|
-
}
|
|
417
|
+
await fs.mkdir(versionDir);
|
|
454
418
|
|
|
455
419
|
const download = async (remoteName: string, dest: string) => {
|
|
456
420
|
log.emit(OtaEvent.DOWNLOAD_START, { version: patch.toVersion });
|
|
457
421
|
const dlStart = Date.now();
|
|
422
|
+
let sizeBytes: number;
|
|
458
423
|
try {
|
|
459
424
|
const url = await getStorage(config).getUpdateFileUrl(remoteName, config);
|
|
460
|
-
await downloadWithRetry(url, dest, config, onProgress);
|
|
425
|
+
sizeBytes = await downloadWithRetry(url, dest, config, onProgress);
|
|
461
426
|
} catch (dlError) {
|
|
462
427
|
log.emit(OtaEvent.DOWNLOAD_FAIL, {
|
|
463
428
|
error: (dlError as Error).message,
|
|
@@ -466,7 +431,6 @@ const runUpdatePipeline = async (
|
|
|
466
431
|
throw dlError;
|
|
467
432
|
}
|
|
468
433
|
const downloadMs = Date.now() - dlStart;
|
|
469
|
-
const sizeBytes = Number((await fs.stat(dest)).size);
|
|
470
434
|
log.emit(OtaEvent.DOWNLOAD_SUCCESS, {
|
|
471
435
|
version: patch.toVersion,
|
|
472
436
|
durationMs: downloadMs,
|
|
@@ -477,7 +441,7 @@ const runUpdatePipeline = async (
|
|
|
477
441
|
|
|
478
442
|
const assertHash = async (path: string, expected: string | undefined, what: string) => {
|
|
479
443
|
if (!expected || (await verifyHash(path, expected))) return;
|
|
480
|
-
const actual = await fs.
|
|
444
|
+
const actual = await fs.hashFile(path).catch(() => '');
|
|
481
445
|
log.emit(OtaEvent.HASH_MISMATCH, {
|
|
482
446
|
message: `${what} hash mismatch`,
|
|
483
447
|
version: patch.toVersion,
|
|
@@ -503,10 +467,7 @@ const runUpdatePipeline = async (
|
|
|
503
467
|
);
|
|
504
468
|
}
|
|
505
469
|
|
|
506
|
-
|
|
507
|
-
await fs.unlink(finalBundlePath);
|
|
508
|
-
}
|
|
509
|
-
await fs.mv(tempBundle, finalBundlePath);
|
|
470
|
+
await fs.moveFile(tempBundle, finalBundlePath);
|
|
510
471
|
|
|
511
472
|
// Native version, not config.appVersion: the native resolver compares against it on boot
|
|
512
473
|
const nativeAppVersion = await NativeBsPatch.getAppVersion().catch(() => config.appVersion);
|
|
@@ -556,7 +517,7 @@ const runUpdatePipeline = async (
|
|
|
556
517
|
const applyStart = Date.now();
|
|
557
518
|
const success = await NativeBsPatch.applyPatch(sourcePath, tempBundle, localPatchPath);
|
|
558
519
|
const applyMs = Date.now() - applyStart;
|
|
559
|
-
await fs.
|
|
520
|
+
await fs.removePath(localPatchPath).catch(() => {});
|
|
560
521
|
if (!success) {
|
|
561
522
|
log.emit(OtaEvent.PATCH_FAIL, {
|
|
562
523
|
message: 'BsPatch returned false',
|
|
@@ -583,8 +544,8 @@ const runUpdatePipeline = async (
|
|
|
583
544
|
};
|
|
584
545
|
|
|
585
546
|
const cleanup = async () => {
|
|
586
|
-
await fs.
|
|
587
|
-
await fs.
|
|
547
|
+
await fs.removePath(localPatchPath).catch(() => {});
|
|
548
|
+
await fs.removePath(tempBundle).catch(() => {});
|
|
588
549
|
};
|
|
589
550
|
|
|
590
551
|
// Patch only from an OTA bundle we installed ourselves (byte-identical to the build output)
|
package/src/ota/otaMetadata.ts
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { Platform } from 'react-native';
|
|
2
|
-
import
|
|
2
|
+
import NativeBsPatch from '../NativeBsPatch';
|
|
3
3
|
import { OtaMetadata } from './types';
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/** Same directory the native resolver reads `ota/` from */
|
|
6
|
+
export const DOCUMENT_DIR = NativeBsPatch.getDocumentDir();
|
|
7
|
+
export const OTA_DIR = `${DOCUMENT_DIR}/ota`;
|
|
6
8
|
const METADATA_PATH = `${OTA_DIR}/metadata.json`;
|
|
7
9
|
const METADATA_TMP_PATH = `${METADATA_PATH}.tmp`;
|
|
10
|
+
const ASSET_STORE_DIR = `${OTA_DIR}/assets`;
|
|
11
|
+
/** Per-version list of `hash.ext` store files the version uses (written by otaCore) */
|
|
12
|
+
export const ASSET_LIST_FILE = 'assets.json';
|
|
8
13
|
|
|
9
14
|
/** Serializes metadata updates (see updateMetadata). */
|
|
10
15
|
let saveLock: Promise<void> = Promise.resolve();
|
|
@@ -12,11 +17,7 @@ let saveLock: Promise<void> = Promise.resolve();
|
|
|
12
17
|
/**
|
|
13
18
|
* Ensures the OTA root directory exists.
|
|
14
19
|
*/
|
|
15
|
-
export const ensureOtaDir =
|
|
16
|
-
if (!(await ReactNativeBlobUtil.fs.exists(OTA_DIR))) {
|
|
17
|
-
await ReactNativeBlobUtil.fs.mkdir(OTA_DIR);
|
|
18
|
-
}
|
|
19
|
-
};
|
|
20
|
+
export const ensureOtaDir = (): Promise<void> => NativeBsPatch.mkdir(OTA_DIR);
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Loads the current OTA metadata from the local filesystem.
|
|
@@ -28,10 +29,10 @@ export const loadMetadata = async (
|
|
|
28
29
|
defaultVersion: number,
|
|
29
30
|
): Promise<OtaMetadata> => {
|
|
30
31
|
try {
|
|
31
|
-
//
|
|
32
|
+
// Versions before 4.0 could crash between unlink and rename, leaving only the .tmp copy
|
|
32
33
|
for (const path of [METADATA_PATH, METADATA_TMP_PATH]) {
|
|
33
|
-
if (await
|
|
34
|
-
return JSON.parse(await
|
|
34
|
+
if (await NativeBsPatch.exists(path)) {
|
|
35
|
+
return JSON.parse(await NativeBsPatch.readFile(path));
|
|
35
36
|
}
|
|
36
37
|
}
|
|
37
38
|
} catch (e) {
|
|
@@ -62,16 +63,8 @@ export const updateMetadata = async (
|
|
|
62
63
|
await prevLock;
|
|
63
64
|
const metadata = fn(await loadMetadata(defaultVersion));
|
|
64
65
|
await ensureOtaDir();
|
|
65
|
-
//
|
|
66
|
-
await
|
|
67
|
-
METADATA_TMP_PATH,
|
|
68
|
-
JSON.stringify(metadata, null, 2),
|
|
69
|
-
'utf8',
|
|
70
|
-
);
|
|
71
|
-
if (await ReactNativeBlobUtil.fs.exists(METADATA_PATH)) {
|
|
72
|
-
await ReactNativeBlobUtil.fs.unlink(METADATA_PATH);
|
|
73
|
-
}
|
|
74
|
-
await ReactNativeBlobUtil.fs.mv(METADATA_TMP_PATH, METADATA_PATH);
|
|
66
|
+
// Native writeFile is atomic: a kill mid-write never leaves truncated JSON
|
|
67
|
+
await NativeBsPatch.writeFile(METADATA_PATH, JSON.stringify(metadata, null, 2));
|
|
75
68
|
return metadata;
|
|
76
69
|
} catch (e) {
|
|
77
70
|
console.error('[otaMetadata] Failed to save metadata:', e);
|
|
@@ -125,8 +118,8 @@ export const cleanupOldVersions = async (
|
|
|
125
118
|
baseVersion: number,
|
|
126
119
|
): Promise<void> => {
|
|
127
120
|
try {
|
|
128
|
-
if (!(await
|
|
129
|
-
const items = await
|
|
121
|
+
if (!(await NativeBsPatch.exists(OTA_DIR))) return;
|
|
122
|
+
const items = await NativeBsPatch.readDir(OTA_DIR);
|
|
130
123
|
|
|
131
124
|
// Retain current active version and rollback versions
|
|
132
125
|
const retainedVersions = new Set<number>([
|
|
@@ -139,9 +132,10 @@ export const cleanupOldVersions = async (
|
|
|
139
132
|
const versionMatch = item.match(/^v(\d+)$/);
|
|
140
133
|
if (versionMatch) {
|
|
141
134
|
const ver = parseInt(versionMatch[1], 10);
|
|
142
|
-
|
|
135
|
+
// Higher than current: may be an install in progress, not yet in metadata
|
|
136
|
+
if (!retainedVersions.has(ver) && ver !== baseVersion && ver < metadata.currentVersion) {
|
|
143
137
|
const dirPath = `${OTA_DIR}/${item}`;
|
|
144
|
-
await
|
|
138
|
+
await NativeBsPatch.removePath(dirPath).catch(() => {});
|
|
145
139
|
if (__DEV__) {
|
|
146
140
|
console.log(`[otaMetadata] Cleaned up obsolete OTA version: ${item}`);
|
|
147
141
|
}
|
|
@@ -149,14 +143,39 @@ export const cleanupOldVersions = async (
|
|
|
149
143
|
}
|
|
150
144
|
// Clean up leftover temporary bundle files (.tmp)
|
|
151
145
|
if (item.endsWith('.tmp') && item !== 'metadata.json.tmp') {
|
|
152
|
-
await
|
|
146
|
+
await NativeBsPatch.removePath(`${OTA_DIR}/${item}`).catch(() => {});
|
|
153
147
|
}
|
|
154
148
|
}
|
|
149
|
+
await cleanupAssetStore();
|
|
155
150
|
} catch (e) {
|
|
156
151
|
console.warn('[otaMetadata] Cleanup old versions failed:', e);
|
|
157
152
|
}
|
|
158
153
|
};
|
|
159
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Deletes store files no remaining version lists. The store is only a download
|
|
157
|
+
* cache (each version holds its own copies), so this never breaks a version.
|
|
158
|
+
* ponytail: an install that reuses a store file between our read of its list and
|
|
159
|
+
* the unlink fails its asset sync; it is retried on the next check.
|
|
160
|
+
*/
|
|
161
|
+
const cleanupAssetStore = async (): Promise<void> => {
|
|
162
|
+
const fs = NativeBsPatch;
|
|
163
|
+
if (!(await fs.exists(ASSET_STORE_DIR))) return;
|
|
164
|
+
const keep = new Set<string>();
|
|
165
|
+
for (const item of await fs.readDir(OTA_DIR)) {
|
|
166
|
+
const list = `${OTA_DIR}/${item}/${ASSET_LIST_FILE}`;
|
|
167
|
+
if (/^v\d+$/.test(item) && (await fs.exists(list))) {
|
|
168
|
+
const files: string[] = JSON.parse(await fs.readFile(list));
|
|
169
|
+
files.forEach(f => keep.add(f));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
for (const file of await fs.readDir(ASSET_STORE_DIR)) {
|
|
173
|
+
if (!keep.has(file)) {
|
|
174
|
+
await fs.removePath(`${ASSET_STORE_DIR}/${file}`).catch(() => {});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
160
179
|
/**
|
|
161
180
|
* Marks the running version healthy: the native resolver stops treating its
|
|
162
181
|
* launches as pending, so it is never rolled back afterwards (crash on first
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { Platform } from 'react-native';
|
|
2
|
-
import ReactNativeBlobUtil from 'react-native-blob-util';
|
|
3
1
|
import type { OtaConfig, OtaPatch, OtaStorageProvider } from '../types';
|
|
4
2
|
|
|
5
3
|
/**
|
|
@@ -19,6 +17,21 @@ import type { OtaConfig, OtaPatch, OtaStorageProvider } from '../types';
|
|
|
19
17
|
/** Headers to bypass GitHub CDN cache on mutable files (registry, manifests) */
|
|
20
18
|
const NO_CACHE_HEADERS = { 'Cache-Control': 'no-cache' };
|
|
21
19
|
|
|
20
|
+
/** GET + JSON.parse with a timeout (fetch has none of its own); rejects on non-2xx. */
|
|
21
|
+
export const fetchJson = async (url: string, timeoutMs: number): Promise<any> => {
|
|
22
|
+
const controller = new AbortController();
|
|
23
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
24
|
+
try {
|
|
25
|
+
const res = await fetch(url, { headers: NO_CACHE_HEADERS, signal: controller.signal });
|
|
26
|
+
if (!res.ok) {
|
|
27
|
+
throw new Error(`HTTP ${res.status} for ${url}`);
|
|
28
|
+
}
|
|
29
|
+
return await res.json();
|
|
30
|
+
} finally {
|
|
31
|
+
clearTimeout(timer);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
22
35
|
/** Append a timestamp query param to defeat CDN caches on mutable URLs */
|
|
23
36
|
const bustCache = (url: string): string =>
|
|
24
37
|
`${url}?cb=${Date.now()}`;
|
|
@@ -47,21 +60,7 @@ export class GitHubRawStorageProvider implements OtaStorageProvider {
|
|
|
47
60
|
);
|
|
48
61
|
|
|
49
62
|
try {
|
|
50
|
-
const
|
|
51
|
-
'GET',
|
|
52
|
-
registryUrl,
|
|
53
|
-
NO_CACHE_HEADERS,
|
|
54
|
-
);
|
|
55
|
-
if (res.respInfo.status !== 200) {
|
|
56
|
-
throw new Error(
|
|
57
|
-
`Registry fetch failed with status ${res.respInfo.status}`,
|
|
58
|
-
);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const registry =
|
|
62
|
-
typeof res.data === 'string'
|
|
63
|
-
? JSON.parse(res.data)
|
|
64
|
-
: res.data;
|
|
63
|
+
const registry = await fetchJson(registryUrl, 15000);
|
|
65
64
|
|
|
66
65
|
if (!config.appVersion) {
|
|
67
66
|
throw new Error(
|
|
@@ -71,11 +70,7 @@ export class GitHubRawStorageProvider implements OtaStorageProvider {
|
|
|
71
70
|
const appGroup = registry?.apps?.[config.appVersion];
|
|
72
71
|
const flavorGroup = appGroup?.flavors?.[channelName];
|
|
73
72
|
|
|
74
|
-
return
|
|
75
|
-
(p: OtaPatch) =>
|
|
76
|
-
!p.platform ||
|
|
77
|
-
p.platform.toLowerCase() === Platform.OS.toLowerCase(),
|
|
78
|
-
);
|
|
73
|
+
return flavorGroup?.patches ?? [];
|
|
79
74
|
} catch (error) {
|
|
80
75
|
// Surface it: "no patches" would report a broken registry as up to date
|
|
81
76
|
throw new Error(`[GitHubRawStorage] Manifest fetch error: ${(error as Error).message}`);
|