react-native-custom-ota 0.0.0-stage → 1.0.0
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 +214 -2
- package/RNCustomOTA.podspec +20 -0
- package/android/build.gradle +21 -0
- package/android/src/main/java/com/customota/CustomOTABundleResolver.kt +85 -0
- package/android/src/main/java/com/customota/OTAUpdaterModule.kt +152 -0
- package/android/src/main/java/com/customota/OTAUpdaterPackage.kt +22 -0
- package/bin/custom-ota.js +217 -0
- package/ios/CustomOTABundleResolver.swift +79 -0
- package/ios/OTAUpdater.m +19 -0
- package/ios/OTAUpdater.swift +105 -0
- package/lib/commonjs/hoc.js +64 -0
- package/lib/commonjs/index.js +32 -0
- package/lib/commonjs/manager.js +262 -0
- package/lib/commonjs/native.js +9 -0
- package/lib/commonjs/storage.js +48 -0
- package/lib/commonjs/types.js +23 -0
- package/lib/module/hoc.js +28 -0
- package/lib/module/index.js +16 -0
- package/lib/module/manager.js +255 -0
- package/lib/module/native.js +6 -0
- package/lib/module/storage.js +39 -0
- package/lib/module/types.js +20 -0
- package/lib/typescript/hoc.d.ts +3 -0
- package/lib/typescript/hoc.js +64 -0
- package/lib/typescript/index.d.ts +16 -0
- package/lib/typescript/index.js +32 -0
- package/lib/typescript/manager.d.ts +35 -0
- package/lib/typescript/manager.js +262 -0
- package/lib/typescript/native.d.ts +8 -0
- package/lib/typescript/native.js +9 -0
- package/lib/typescript/src/hoc.d.ts +3 -0
- package/lib/typescript/src/index.d.ts +15 -0
- package/lib/typescript/src/manager.d.ts +33 -0
- package/lib/typescript/src/native.d.ts +8 -0
- package/lib/typescript/src/storage.d.ts +8 -0
- package/lib/typescript/src/types.d.ts +54 -0
- package/lib/typescript/storage.d.ts +8 -0
- package/lib/typescript/storage.js +48 -0
- package/lib/typescript/types.d.ts +60 -0
- package/lib/typescript/types.js +23 -0
- package/package.json +49 -4
- package/react-native.config.js +11 -0
- package/src/hoc.tsx +34 -0
- package/src/index.ts +19 -0
- package/src/manager.ts +328 -0
- package/src/native.ts +17 -0
- package/src/storage.ts +45 -0
- package/src/types.ts +66 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.TEMP_DIR = exports.BACKUP_DIR = exports.CURRENT_DIR = exports.OTA_ROOT = void 0;
|
|
7
|
+
exports.ensureOTADirs = ensureOTADirs;
|
|
8
|
+
exports.readState = readState;
|
|
9
|
+
exports.writeState = writeState;
|
|
10
|
+
const react_native_fs_1 = __importDefault(require("react-native-fs"));
|
|
11
|
+
exports.OTA_ROOT = `${react_native_fs_1.default.DocumentDirectoryPath}/ota`;
|
|
12
|
+
exports.CURRENT_DIR = `${exports.OTA_ROOT}/current`;
|
|
13
|
+
exports.BACKUP_DIR = `${exports.OTA_ROOT}/backup`;
|
|
14
|
+
exports.TEMP_DIR = `${exports.OTA_ROOT}/temp`;
|
|
15
|
+
const STATE_FILE = `${exports.OTA_ROOT}/state.json`;
|
|
16
|
+
async function ensureOTADirs() {
|
|
17
|
+
await react_native_fs_1.default.mkdir(exports.OTA_ROOT).catch(() => { });
|
|
18
|
+
await react_native_fs_1.default.mkdir(exports.TEMP_DIR).catch(() => { });
|
|
19
|
+
}
|
|
20
|
+
async function readState() {
|
|
21
|
+
await ensureOTADirs();
|
|
22
|
+
if (!(await react_native_fs_1.default.exists(STATE_FILE))) {
|
|
23
|
+
return {
|
|
24
|
+
currentVersion: "0.0.0",
|
|
25
|
+
previousVersion: null,
|
|
26
|
+
pending: false,
|
|
27
|
+
ready: true,
|
|
28
|
+
failedVersions: []
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const content = await react_native_fs_1.default.readFile(STATE_FILE, "utf8");
|
|
33
|
+
return JSON.parse(content);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return {
|
|
37
|
+
currentVersion: "0.0.0",
|
|
38
|
+
previousVersion: null,
|
|
39
|
+
pending: false,
|
|
40
|
+
ready: true,
|
|
41
|
+
failedVersions: []
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function writeState(state) {
|
|
46
|
+
await ensureOTADirs();
|
|
47
|
+
await react_native_fs_1.default.writeFile(STATE_FILE, JSON.stringify(state, null, 2), "utf8");
|
|
48
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export declare enum SyncStatus {
|
|
2
|
+
UP_TO_DATE = 0,
|
|
3
|
+
UPDATE_AVAILABLE = 1,
|
|
4
|
+
DOWNLOADING_PACKAGE = 2,
|
|
5
|
+
INSTALLING_UPDATE = 3,
|
|
6
|
+
UPDATE_INSTALLED = 4,
|
|
7
|
+
ERROR = -1
|
|
8
|
+
}
|
|
9
|
+
export declare enum CheckFrequency {
|
|
10
|
+
ON_APP_START = "ON_APP_START",
|
|
11
|
+
ON_APP_RESUME = "ON_APP_RESUME",
|
|
12
|
+
MANUAL = "MANUAL"
|
|
13
|
+
}
|
|
14
|
+
export declare enum InstallMode {
|
|
15
|
+
IMMEDIATE = "IMMEDIATE",
|
|
16
|
+
ON_NEXT_RESTART = "ON_NEXT_RESTART"
|
|
17
|
+
}
|
|
18
|
+
export interface UpdateDialogOptions {
|
|
19
|
+
title?: string;
|
|
20
|
+
optionalUpdateMessage?: string;
|
|
21
|
+
mandatoryUpdateMessage?: string;
|
|
22
|
+
optionalInstallButtonLabel?: string;
|
|
23
|
+
optionalIgnoreButtonLabel?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface SyncOptions {
|
|
26
|
+
serverUrl: string;
|
|
27
|
+
appId?: string;
|
|
28
|
+
apiKey?: string;
|
|
29
|
+
nativeVersion?: string;
|
|
30
|
+
channel?: string;
|
|
31
|
+
installMode?: InstallMode;
|
|
32
|
+
mandatoryInstallMode?: InstallMode;
|
|
33
|
+
updateDialog?: boolean | UpdateDialogOptions;
|
|
34
|
+
checkFrequency?: CheckFrequency;
|
|
35
|
+
}
|
|
36
|
+
export interface RemotePackage {
|
|
37
|
+
updateAvailable: boolean;
|
|
38
|
+
appId?: string;
|
|
39
|
+
platform?: string;
|
|
40
|
+
otaVersion?: string;
|
|
41
|
+
version?: string;
|
|
42
|
+
bundle?: string;
|
|
43
|
+
sha256?: string;
|
|
44
|
+
channel?: string;
|
|
45
|
+
minNativeVersion?: string;
|
|
46
|
+
mandatory?: boolean;
|
|
47
|
+
isMandatory?: boolean;
|
|
48
|
+
releaseNotes?: string;
|
|
49
|
+
downloadUrl?: string;
|
|
50
|
+
size?: number;
|
|
51
|
+
reason?: string;
|
|
52
|
+
}
|
|
53
|
+
export interface LocalPackage {
|
|
54
|
+
currentVersion: string;
|
|
55
|
+
previousVersion: string | null;
|
|
56
|
+
pending: boolean;
|
|
57
|
+
trial?: boolean;
|
|
58
|
+
ready: boolean;
|
|
59
|
+
failedVersions: string[];
|
|
60
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.InstallMode = exports.CheckFrequency = exports.SyncStatus = void 0;
|
|
4
|
+
var SyncStatus;
|
|
5
|
+
(function (SyncStatus) {
|
|
6
|
+
SyncStatus[SyncStatus["UP_TO_DATE"] = 0] = "UP_TO_DATE";
|
|
7
|
+
SyncStatus[SyncStatus["UPDATE_AVAILABLE"] = 1] = "UPDATE_AVAILABLE";
|
|
8
|
+
SyncStatus[SyncStatus["DOWNLOADING_PACKAGE"] = 2] = "DOWNLOADING_PACKAGE";
|
|
9
|
+
SyncStatus[SyncStatus["INSTALLING_UPDATE"] = 3] = "INSTALLING_UPDATE";
|
|
10
|
+
SyncStatus[SyncStatus["UPDATE_INSTALLED"] = 4] = "UPDATE_INSTALLED";
|
|
11
|
+
SyncStatus[SyncStatus["ERROR"] = -1] = "ERROR";
|
|
12
|
+
})(SyncStatus || (exports.SyncStatus = SyncStatus = {}));
|
|
13
|
+
var CheckFrequency;
|
|
14
|
+
(function (CheckFrequency) {
|
|
15
|
+
CheckFrequency["ON_APP_START"] = "ON_APP_START";
|
|
16
|
+
CheckFrequency["ON_APP_RESUME"] = "ON_APP_RESUME";
|
|
17
|
+
CheckFrequency["MANUAL"] = "MANUAL";
|
|
18
|
+
})(CheckFrequency || (exports.CheckFrequency = CheckFrequency = {}));
|
|
19
|
+
var InstallMode;
|
|
20
|
+
(function (InstallMode) {
|
|
21
|
+
InstallMode["IMMEDIATE"] = "IMMEDIATE";
|
|
22
|
+
InstallMode["ON_NEXT_RESTART"] = "ON_NEXT_RESTART";
|
|
23
|
+
})(InstallMode || (exports.InstallMode = InstallMode = {}));
|
package/package.json
CHANGED
|
@@ -1,6 +1,51 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-custom-ota",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Self-hosted OTA (Over-The-Air) update library for React Native with automatic rollback, similar to react-native-code-push",
|
|
5
|
+
"main": "lib/commonjs/index.js",
|
|
6
|
+
"module": "lib/module/index.js",
|
|
7
|
+
"types": "lib/typescript/index.d.ts",
|
|
8
|
+
"react-native": "src/index.ts",
|
|
9
|
+
"source": "src/index.ts",
|
|
10
|
+
"bin": {
|
|
11
|
+
"custom-ota": "bin/custom-ota.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src",
|
|
15
|
+
"lib",
|
|
16
|
+
"android/src",
|
|
17
|
+
"android/build.gradle",
|
|
18
|
+
"ios",
|
|
19
|
+
"bin",
|
|
20
|
+
"react-native.config.js",
|
|
21
|
+
"RNCustomOTA.podspec",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "npx tsc --declaration --outDir lib/typescript && npx tsc --module commonjs --outDir lib/commonjs && npx tsc --module esnext --outDir lib/module",
|
|
26
|
+
"prepack": "npm run build"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"react-native",
|
|
30
|
+
"ota",
|
|
31
|
+
"code-push",
|
|
32
|
+
"hot-update",
|
|
33
|
+
"live-update",
|
|
34
|
+
"self-hosted",
|
|
35
|
+
"rollback"
|
|
36
|
+
],
|
|
37
|
+
"author": "Custom OTA Team",
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"react": "*",
|
|
41
|
+
"react-native": "*"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"react-native-fs": "^2.20.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/react": "^18.2.0 || ^19.0.0",
|
|
48
|
+
"@types/react-native": "^0.72.0",
|
|
49
|
+
"typescript": "^5.0.0"
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/hoc.tsx
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import React, { ComponentType, useEffect } from "react";
|
|
2
|
+
import { AppState, AppStateStatus } from "react-native";
|
|
3
|
+
import { CustomOTAManager } from "./manager";
|
|
4
|
+
import { CheckFrequency, SyncOptions } from "./types";
|
|
5
|
+
|
|
6
|
+
export function withCustomOTA(options: SyncOptions) {
|
|
7
|
+
return function <P extends object>(WrappedComponent: ComponentType<P>): ComponentType<P> {
|
|
8
|
+
return function CustomOTAWrapper(props: P) {
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
// Tự động kiểm tra cập nhật khi app khởi động
|
|
11
|
+
if (options.checkFrequency !== CheckFrequency.MANUAL) {
|
|
12
|
+
CustomOTAManager.sync(options);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Nếu bật kiểm tra khi App Resume từ background
|
|
16
|
+
if (options.checkFrequency === CheckFrequency.ON_APP_RESUME) {
|
|
17
|
+
const subscription = AppState.addEventListener(
|
|
18
|
+
"change",
|
|
19
|
+
(nextAppState: AppStateStatus) => {
|
|
20
|
+
if (nextAppState === "active") {
|
|
21
|
+
CustomOTAManager.sync(options);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
);
|
|
25
|
+
return () => {
|
|
26
|
+
subscription.remove();
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
}, []);
|
|
30
|
+
|
|
31
|
+
return <WrappedComponent {...props} />;
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { CustomOTAManager } from "./manager";
|
|
2
|
+
import { withCustomOTA } from "./hoc";
|
|
3
|
+
|
|
4
|
+
export * from "./types";
|
|
5
|
+
export * from "./manager";
|
|
6
|
+
export * from "./hoc";
|
|
7
|
+
export * from "./storage";
|
|
8
|
+
|
|
9
|
+
const CustomOTA = {
|
|
10
|
+
sync: CustomOTAManager.sync.bind(CustomOTAManager),
|
|
11
|
+
checkForUpdate: CustomOTAManager.checkForUpdate.bind(CustomOTAManager),
|
|
12
|
+
downloadAndInstall: CustomOTAManager.downloadAndInstall.bind(CustomOTAManager),
|
|
13
|
+
restartApp: CustomOTAManager.restartApp.bind(CustomOTAManager),
|
|
14
|
+
getUpdateMetadata: CustomOTAManager.getUpdateMetadata.bind(CustomOTAManager),
|
|
15
|
+
markReady: CustomOTAManager.markReady.bind(CustomOTAManager),
|
|
16
|
+
wrap: withCustomOTA
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export default CustomOTA;
|
package/src/manager.ts
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { Alert, Platform } from "react-native";
|
|
2
|
+
import RNFS from "react-native-fs";
|
|
3
|
+
import { OTAUpdater } from "./native";
|
|
4
|
+
import { BACKUP_DIR, CURRENT_DIR, TEMP_DIR, ensureOTADirs, readState, writeState } from "./storage";
|
|
5
|
+
import {
|
|
6
|
+
InstallMode,
|
|
7
|
+
LocalPackage,
|
|
8
|
+
RemotePackage,
|
|
9
|
+
SyncOptions,
|
|
10
|
+
SyncStatus,
|
|
11
|
+
UpdateDialogOptions
|
|
12
|
+
} from "./types";
|
|
13
|
+
|
|
14
|
+
export class CustomOTAManager {
|
|
15
|
+
private static defaultDialog: UpdateDialogOptions = {
|
|
16
|
+
title: "Có bản cập nhật mới",
|
|
17
|
+
optionalUpdateMessage: "Đã có bản cập nhật ứng dụng mới. Bạn có muốn cài đặt ngay bây giờ?",
|
|
18
|
+
mandatoryUpdateMessage: "Đã có bản cập nhật bắt buộc. Vui lòng cài đặt để tiếp tục sử dụng.",
|
|
19
|
+
optionalInstallButtonLabel: "Cập nhật",
|
|
20
|
+
optionalIgnoreButtonLabel: "Để sau"
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Đánh dấu bản build OTA hiện tại đã khởi động thành công.
|
|
25
|
+
* Xóa bản backup trước đó và tắt cờ pending.
|
|
26
|
+
*/
|
|
27
|
+
static async markReady(): Promise<void> {
|
|
28
|
+
try {
|
|
29
|
+
const state = await readState();
|
|
30
|
+
state.pending = false;
|
|
31
|
+
state.ready = true;
|
|
32
|
+
state.trial = false;
|
|
33
|
+
await writeState(state);
|
|
34
|
+
} catch (e) {
|
|
35
|
+
console.warn("[CustomOTA] Error updating state in markReady:", e);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (OTAUpdater?.markReady) {
|
|
39
|
+
try {
|
|
40
|
+
await OTAUpdater.markReady();
|
|
41
|
+
} catch (e) {
|
|
42
|
+
console.warn("[CustomOTA] Error calling native markReady:", e);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Khởi động lại ứng dụng để nạp ngay bản OTA mới.
|
|
49
|
+
*/
|
|
50
|
+
static async restartApp(): Promise<void> {
|
|
51
|
+
if (OTAUpdater?.restart) {
|
|
52
|
+
await OTAUpdater.restart();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Lấy thông tin phiên bản OTA hiện hành trên máy.
|
|
58
|
+
*/
|
|
59
|
+
static async getUpdateMetadata(): Promise<LocalPackage> {
|
|
60
|
+
return readState();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Kiểm tra xem server có bản cập nhật mới hay không.
|
|
65
|
+
*/
|
|
66
|
+
static async checkForUpdate(options: {
|
|
67
|
+
serverUrl: string;
|
|
68
|
+
appId?: string;
|
|
69
|
+
apiKey?: string;
|
|
70
|
+
nativeVersion?: string;
|
|
71
|
+
channel?: string;
|
|
72
|
+
}): Promise<RemotePackage | null> {
|
|
73
|
+
await ensureOTADirs();
|
|
74
|
+
|
|
75
|
+
const state = await readState();
|
|
76
|
+
const platform = Platform.OS === "ios" ? "ios" : "android";
|
|
77
|
+
const nativeVersion = options.nativeVersion || "1.0.0";
|
|
78
|
+
const channel = options.channel || "production";
|
|
79
|
+
|
|
80
|
+
const params = new URLSearchParams({
|
|
81
|
+
platform,
|
|
82
|
+
nativeVersion,
|
|
83
|
+
otaVersion: state.currentVersion,
|
|
84
|
+
channel
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
if (options.appId) {
|
|
88
|
+
params.append("appId", options.appId);
|
|
89
|
+
}
|
|
90
|
+
if (options.apiKey) {
|
|
91
|
+
params.append("apiKey", options.apiKey);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const headers: Record<string, string> = {};
|
|
95
|
+
if (options.apiKey) {
|
|
96
|
+
headers["X-OTA-API-KEY"] = options.apiKey;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const response = await fetch(`${options.serverUrl.replace(/\/$/, "")}/ota/check?${params}`, {
|
|
100
|
+
headers
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
if (!response.ok) {
|
|
104
|
+
throw new Error(`OTA Check failed: HTTP ${response.status}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const rawData = await response.json();
|
|
108
|
+
if (!rawData.updateAvailable) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const targetVersion = rawData.otaVersion || rawData.version;
|
|
113
|
+
|
|
114
|
+
// Không báo cập nhật nếu phiên bản đã được cài đặt trên máy
|
|
115
|
+
if (targetVersion && state.currentVersion === targetVersion) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Không báo cập nhật nếu phiên bản này từng bị crash và tự rollback
|
|
120
|
+
if (targetVersion && state.failedVersions?.includes(targetVersion)) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const serverUrlClean = options.serverUrl.replace(/\/$/, "");
|
|
125
|
+
const rawUrl = rawData.downloadUrl || "";
|
|
126
|
+
const fullDownloadUrl = rawUrl.startsWith("http")
|
|
127
|
+
? rawUrl
|
|
128
|
+
: `${serverUrlClean}${rawUrl.startsWith("/") ? "" : "/"}${rawUrl}`;
|
|
129
|
+
|
|
130
|
+
const data: RemotePackage = {
|
|
131
|
+
...rawData,
|
|
132
|
+
updateAvailable: true,
|
|
133
|
+
version: targetVersion,
|
|
134
|
+
otaVersion: targetVersion,
|
|
135
|
+
downloadUrl: fullDownloadUrl,
|
|
136
|
+
mandatory: Boolean(rawData.mandatory || rawData.isMandatory),
|
|
137
|
+
isMandatory: Boolean(rawData.mandatory || rawData.isMandatory)
|
|
138
|
+
};
|
|
139
|
+
return data;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Tải và cài đặt bản cập nhật.
|
|
144
|
+
*/
|
|
145
|
+
static async downloadAndInstall(
|
|
146
|
+
update: RemotePackage,
|
|
147
|
+
onProgress?: (progress: number) => void,
|
|
148
|
+
installMode?: InstallMode
|
|
149
|
+
): Promise<boolean> {
|
|
150
|
+
const version = update.otaVersion || update.version;
|
|
151
|
+
if (!update.downloadUrl || !version || !update.sha256) {
|
|
152
|
+
const missingFields = [
|
|
153
|
+
!update.downloadUrl && "downloadUrl",
|
|
154
|
+
!version && "version",
|
|
155
|
+
!update.sha256 && "sha256"
|
|
156
|
+
].filter(Boolean).join(", ");
|
|
157
|
+
throw new Error(`Invalid update package metadata: missing ${missingFields}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const zipPath = `${TEMP_DIR}/update-${version}.zip`;
|
|
161
|
+
await RNFS.unlink(zipPath).catch(() => {});
|
|
162
|
+
|
|
163
|
+
// Download zip file
|
|
164
|
+
const downloadRes = RNFS.downloadFile({
|
|
165
|
+
fromUrl: update.downloadUrl.startsWith("http") ? update.downloadUrl : `${update.downloadUrl}`,
|
|
166
|
+
toFile: zipPath,
|
|
167
|
+
progressDivider: 1,
|
|
168
|
+
progress: ({ bytesWritten, contentLength }) => {
|
|
169
|
+
if (contentLength > 0 && onProgress) {
|
|
170
|
+
onProgress(bytesWritten / contentLength);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const res = await downloadRes.promise;
|
|
176
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
177
|
+
throw new Error(`Download failed with status ${res.statusCode}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// SHA-256 verification
|
|
181
|
+
const actualHash = await RNFS.hash(zipPath, "sha256");
|
|
182
|
+
if (actualHash.toLowerCase() !== update.sha256.toLowerCase()) {
|
|
183
|
+
await RNFS.unlink(zipPath).catch(() => {});
|
|
184
|
+
throw new Error(`SHA-256 Checksum mismatch! Expected ${update.sha256}, got ${actualHash}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Unpack temp directory
|
|
188
|
+
const tempDir = `${TEMP_DIR}/${version}`;
|
|
189
|
+
await RNFS.unlink(tempDir).catch(() => {});
|
|
190
|
+
await RNFS.mkdir(tempDir);
|
|
191
|
+
|
|
192
|
+
await OTAUpdater.unzip(zipPath, tempDir);
|
|
193
|
+
|
|
194
|
+
const bundleName = Platform.OS === "ios" ? "main.jsbundle" : "index.android.bundle";
|
|
195
|
+
const bundleFile = `${tempDir}/${bundleName}`;
|
|
196
|
+
|
|
197
|
+
if (!(await RNFS.exists(bundleFile))) {
|
|
198
|
+
throw new Error(`Bundle missing after unzip: ${bundleName}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Atomic move & backup
|
|
202
|
+
await RNFS.unlink(BACKUP_DIR).catch(() => {});
|
|
203
|
+
if (await RNFS.exists(CURRENT_DIR)) {
|
|
204
|
+
await RNFS.moveFile(CURRENT_DIR, BACKUP_DIR);
|
|
205
|
+
}
|
|
206
|
+
await RNFS.moveFile(tempDir, CURRENT_DIR);
|
|
207
|
+
|
|
208
|
+
// Save state
|
|
209
|
+
const oldState = await readState();
|
|
210
|
+
await writeState({
|
|
211
|
+
currentVersion: version,
|
|
212
|
+
previousVersion: oldState.currentVersion !== "0.0.0" ? oldState.currentVersion : null,
|
|
213
|
+
pending: true,
|
|
214
|
+
ready: false,
|
|
215
|
+
trial: false,
|
|
216
|
+
failedVersions: oldState.failedVersions || []
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
await RNFS.unlink(zipPath).catch(() => {});
|
|
220
|
+
|
|
221
|
+
if (installMode === InstallMode.IMMEDIATE) {
|
|
222
|
+
await this.restartApp();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Phương thức đồng bộ tự động như CodePush.sync()
|
|
230
|
+
*/
|
|
231
|
+
static async sync(
|
|
232
|
+
options: SyncOptions,
|
|
233
|
+
syncStatusCallback?: (status: SyncStatus) => void,
|
|
234
|
+
downloadProgressCallback?: (progress: number) => void
|
|
235
|
+
): Promise<SyncStatus> {
|
|
236
|
+
try {
|
|
237
|
+
// 1. Tự động markReady khi khởi động
|
|
238
|
+
await this.markReady();
|
|
239
|
+
|
|
240
|
+
// 2. Kiểm tra update
|
|
241
|
+
const update = await this.checkForUpdate({
|
|
242
|
+
serverUrl: options.serverUrl,
|
|
243
|
+
appId: options.appId,
|
|
244
|
+
apiKey: options.apiKey,
|
|
245
|
+
nativeVersion: options.nativeVersion,
|
|
246
|
+
channel: options.channel
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
if (!update || !update.updateAvailable) {
|
|
250
|
+
syncStatusCallback?.(SyncStatus.UP_TO_DATE);
|
|
251
|
+
return SyncStatus.UP_TO_DATE;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
syncStatusCallback?.(SyncStatus.UPDATE_AVAILABLE);
|
|
255
|
+
|
|
256
|
+
const proceedWithInstall = async () => {
|
|
257
|
+
syncStatusCallback?.(SyncStatus.DOWNLOADING_PACKAGE);
|
|
258
|
+
|
|
259
|
+
const serverUrlClean = options.serverUrl.replace(/\/$/, "");
|
|
260
|
+
const fullDownloadUrl = update.downloadUrl?.startsWith("http")
|
|
261
|
+
? update.downloadUrl
|
|
262
|
+
: `${serverUrlClean}${update.downloadUrl}`;
|
|
263
|
+
|
|
264
|
+
await this.downloadAndInstall(
|
|
265
|
+
{ ...update, downloadUrl: fullDownloadUrl },
|
|
266
|
+
downloadProgressCallback
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
syncStatusCallback?.(SyncStatus.INSTALLING_UPDATE);
|
|
270
|
+
syncStatusCallback?.(SyncStatus.UPDATE_INSTALLED);
|
|
271
|
+
|
|
272
|
+
const effectiveInstallMode = (update.mandatory && options.mandatoryInstallMode)
|
|
273
|
+
? options.mandatoryInstallMode
|
|
274
|
+
: (options.installMode || InstallMode.ON_NEXT_RESTART);
|
|
275
|
+
|
|
276
|
+
if (effectiveInstallMode === InstallMode.ON_NEXT_RESTART) {
|
|
277
|
+
return SyncStatus.UPDATE_INSTALLED;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Restart immediate
|
|
281
|
+
await this.restartApp();
|
|
282
|
+
return SyncStatus.UPDATE_INSTALLED;
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
// 3. Nếu cấu hình hiển thị Update Dialog
|
|
286
|
+
if (options.updateDialog) {
|
|
287
|
+
const dialogOpts: UpdateDialogOptions =
|
|
288
|
+
typeof options.updateDialog === "object"
|
|
289
|
+
? { ...this.defaultDialog, ...options.updateDialog }
|
|
290
|
+
: this.defaultDialog;
|
|
291
|
+
|
|
292
|
+
const isMandatory = Boolean(update.mandatory);
|
|
293
|
+
const message = isMandatory
|
|
294
|
+
? `${dialogOpts.mandatoryUpdateMessage}\n\nPhiên bản: ${update.otaVersion}\n${update.releaseNotes || ""}`
|
|
295
|
+
: `${dialogOpts.optionalUpdateMessage}\n\nPhiên bản: ${update.otaVersion}\n${update.releaseNotes || ""}`;
|
|
296
|
+
|
|
297
|
+
const buttons: any[] = [];
|
|
298
|
+
|
|
299
|
+
if (!isMandatory) {
|
|
300
|
+
buttons.push({
|
|
301
|
+
text: dialogOpts.optionalIgnoreButtonLabel,
|
|
302
|
+
style: "cancel"
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
buttons.push({
|
|
307
|
+
text: dialogOpts.optionalInstallButtonLabel,
|
|
308
|
+
onPress: () => {
|
|
309
|
+
proceedWithInstall();
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
Alert.alert(dialogOpts.title || "Cập nhật ứng dụng", message, buttons, {
|
|
314
|
+
cancelable: !isMandatory
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
return SyncStatus.UPDATE_AVAILABLE;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Tự động tải ngầm không hiện dialog
|
|
321
|
+
return await proceedWithInstall();
|
|
322
|
+
} catch (error) {
|
|
323
|
+
console.error("[react-native-custom-ota] Sync error:", error);
|
|
324
|
+
syncStatusCallback?.(SyncStatus.ERROR);
|
|
325
|
+
return SyncStatus.ERROR;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
package/src/native.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { NativeModules } from "react-native";
|
|
2
|
+
|
|
3
|
+
interface OTAUpdaterInterface {
|
|
4
|
+
unzip(zipPath: string, targetDir: string): Promise<void>;
|
|
5
|
+
markReady(): Promise<void>;
|
|
6
|
+
getCurrentVersion(): Promise<string>;
|
|
7
|
+
restart(): Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const OTAUpdater = NativeModules.OTAUpdater as OTAUpdaterInterface;
|
|
11
|
+
|
|
12
|
+
if (!OTAUpdater) {
|
|
13
|
+
console.warn(
|
|
14
|
+
"[react-native-custom-ota] NativeModule.OTAUpdater is null! " +
|
|
15
|
+
"Make sure you have linked the library and rebuilt the native binary."
|
|
16
|
+
);
|
|
17
|
+
}
|
package/src/storage.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import RNFS from "react-native-fs";
|
|
2
|
+
import { LocalPackage } from "./types";
|
|
3
|
+
|
|
4
|
+
export const OTA_ROOT = `${RNFS.DocumentDirectoryPath}/ota`;
|
|
5
|
+
export const CURRENT_DIR = `${OTA_ROOT}/current`;
|
|
6
|
+
export const BACKUP_DIR = `${OTA_ROOT}/backup`;
|
|
7
|
+
export const TEMP_DIR = `${OTA_ROOT}/temp`;
|
|
8
|
+
const STATE_FILE = `${OTA_ROOT}/state.json`;
|
|
9
|
+
|
|
10
|
+
export async function ensureOTADirs(): Promise<void> {
|
|
11
|
+
await RNFS.mkdir(OTA_ROOT).catch(() => {});
|
|
12
|
+
await RNFS.mkdir(TEMP_DIR).catch(() => {});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function readState(): Promise<LocalPackage> {
|
|
16
|
+
await ensureOTADirs();
|
|
17
|
+
|
|
18
|
+
if (!(await RNFS.exists(STATE_FILE))) {
|
|
19
|
+
return {
|
|
20
|
+
currentVersion: "0.0.0",
|
|
21
|
+
previousVersion: null,
|
|
22
|
+
pending: false,
|
|
23
|
+
ready: true,
|
|
24
|
+
failedVersions: []
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const content = await RNFS.readFile(STATE_FILE, "utf8");
|
|
30
|
+
return JSON.parse(content);
|
|
31
|
+
} catch {
|
|
32
|
+
return {
|
|
33
|
+
currentVersion: "0.0.0",
|
|
34
|
+
previousVersion: null,
|
|
35
|
+
pending: false,
|
|
36
|
+
ready: true,
|
|
37
|
+
failedVersions: []
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function writeState(state: LocalPackage): Promise<void> {
|
|
43
|
+
await ensureOTADirs();
|
|
44
|
+
await RNFS.writeFile(STATE_FILE, JSON.stringify(state, null, 2), "utf8");
|
|
45
|
+
}
|