@hot-updater/react-native 0.30.6 → 0.30.8
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/lib/commonjs/index.js +41 -19
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/index.spec.js +155 -0
- package/lib/commonjs/index.spec.js.map +1 -0
- package/lib/commonjs/store.js +3 -0
- package/lib/commonjs/store.js.map +1 -1
- package/lib/commonjs/store.spec.js +59 -0
- package/lib/commonjs/store.spec.js.map +1 -0
- package/lib/commonjs/wrap.js +14 -0
- package/lib/commonjs/wrap.js.map +1 -1
- package/lib/commonjs/wrap.spec.js +76 -0
- package/lib/commonjs/wrap.spec.js.map +1 -0
- package/lib/module/index.js +42 -20
- package/lib/module/index.js.map +1 -1
- package/lib/module/index.spec.js +154 -0
- package/lib/module/index.spec.js.map +1 -0
- package/lib/module/store.js +3 -0
- package/lib/module/store.js.map +1 -1
- package/lib/module/store.spec.js +58 -0
- package/lib/module/store.spec.js.map +1 -0
- package/lib/module/wrap.js +13 -0
- package/lib/module/wrap.js.map +1 -1
- package/lib/module/wrap.spec.js +75 -0
- package/lib/module/wrap.spec.js.map +1 -0
- package/lib/typescript/commonjs/index.d.ts +10 -1
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/store.d.ts.map +1 -1
- package/lib/typescript/commonjs/wrap.d.ts +2 -0
- package/lib/typescript/commonjs/wrap.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +10 -1
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/store.d.ts.map +1 -1
- package/lib/typescript/module/wrap.d.ts +2 -0
- package/lib/typescript/module/wrap.d.ts.map +1 -1
- package/package.json +6 -6
- package/plugin/build/withHotUpdater.js +14 -9
- package/src/index.spec.ts +177 -0
- package/src/index.ts +100 -39
- package/src/store.spec.ts +57 -0
- package/src/store.ts +7 -0
- package/src/wrap.spec.ts +82 -0
- package/src/wrap.tsx +26 -0
|
@@ -5,18 +5,19 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
const promises_1 = require("node:fs/promises");
|
|
7
7
|
const path_1 = __importDefault(require("path"));
|
|
8
|
-
const cli_tools_1 = require("@hot-updater/cli-tools");
|
|
9
8
|
const config_plugins_1 = require("expo/config-plugins");
|
|
10
|
-
const hot_updater_1 = require("hot-updater");
|
|
11
9
|
const package_json_1 = __importDefault(require("../../package.json"));
|
|
12
10
|
const transformers_1 = require("./transformers");
|
|
11
|
+
const loadCliTools = () => import("@hot-updater/cli-tools");
|
|
12
|
+
const loadHotUpdater = () => import("hot-updater");
|
|
13
13
|
let fingerprintCache = null;
|
|
14
14
|
const getFingerprint = async () => {
|
|
15
15
|
if (fingerprintCache) {
|
|
16
16
|
return fingerprintCache;
|
|
17
17
|
}
|
|
18
|
-
|
|
19
|
-
await (
|
|
18
|
+
const { createFingerprintJSON, generateFingerprints } = await loadHotUpdater();
|
|
19
|
+
fingerprintCache = await generateFingerprints();
|
|
20
|
+
await createFingerprintJSON(fingerprintCache);
|
|
20
21
|
return fingerprintCache;
|
|
21
22
|
};
|
|
22
23
|
/**
|
|
@@ -36,7 +37,8 @@ const getPublicKeyFromConfig = async (signingConfig) => {
|
|
|
36
37
|
const envPrivateKey = process.env.HOT_UPDATER_PRIVATE_KEY;
|
|
37
38
|
if (envPrivateKey) {
|
|
38
39
|
try {
|
|
39
|
-
const
|
|
40
|
+
const { getPublicKeyFromPrivate } = await loadHotUpdater();
|
|
41
|
+
const publicKeyPEM = getPublicKeyFromPrivate(envPrivateKey);
|
|
40
42
|
console.log("[hot-updater] Using public key extracted from HOT_UPDATER_PRIVATE_KEY environment variable");
|
|
41
43
|
return publicKeyPEM.trim();
|
|
42
44
|
}
|
|
@@ -59,8 +61,9 @@ const getPublicKeyFromConfig = async (signingConfig) => {
|
|
|
59
61
|
const publicKeyPath = privateKeyPath.replace(/private-key\.pem$/, "public-key.pem");
|
|
60
62
|
try {
|
|
61
63
|
// Priority 2: Private key file (existing method)
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
+
const { getPublicKeyFromPrivate, loadPrivateKey } = await loadHotUpdater();
|
|
65
|
+
const privateKeyPEM = await loadPrivateKey(privateKeyPath);
|
|
66
|
+
const publicKeyPEM = getPublicKeyFromPrivate(privateKeyPEM);
|
|
64
67
|
console.log(`[hot-updater] Extracted public key from ${privateKeyPath}`);
|
|
65
68
|
return publicKeyPEM.trim();
|
|
66
69
|
}
|
|
@@ -118,7 +121,8 @@ const withHotUpdaterConfigAsync = (props) => (config) => {
|
|
|
118
121
|
// === iOS: Add channel and fingerprint to Info.plist ===
|
|
119
122
|
modifiedConfig = (0, config_plugins_1.withInfoPlist)(modifiedConfig, async (cfg) => {
|
|
120
123
|
let fingerprintHash = null;
|
|
121
|
-
const
|
|
124
|
+
const { loadConfig } = await loadCliTools();
|
|
125
|
+
const config = await loadConfig(null);
|
|
122
126
|
if (config.updateStrategy !== "appVersion") {
|
|
123
127
|
const fingerprint = await getFingerprint();
|
|
124
128
|
fingerprintHash = fingerprint.ios.hash;
|
|
@@ -137,7 +141,8 @@ const withHotUpdaterConfigAsync = (props) => (config) => {
|
|
|
137
141
|
// === Android: Add channel and fingerprint to strings.xml ===
|
|
138
142
|
modifiedConfig = (0, config_plugins_1.withStringsXml)(modifiedConfig, async (cfg) => {
|
|
139
143
|
let fingerprintHash = null;
|
|
140
|
-
const
|
|
144
|
+
const { loadConfig } = await loadCliTools();
|
|
145
|
+
const config = await loadConfig(null);
|
|
141
146
|
if (config.updateStrategy !== "appVersion") {
|
|
142
147
|
const fingerprint = await getFingerprint();
|
|
143
148
|
fingerprintHash = fingerprint.android.hash;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const mocks = vi.hoisted(() => ({
|
|
4
|
+
addListener: vi.fn(() => () => {}),
|
|
5
|
+
checkForUpdate: vi.fn(),
|
|
6
|
+
clearCrashHistory: vi.fn(() => true),
|
|
7
|
+
createDefaultResolver: vi.fn(),
|
|
8
|
+
getAppVersion: vi.fn(() => "1.0.0"),
|
|
9
|
+
getBaseURL: vi.fn(() => null),
|
|
10
|
+
getBundleId: vi.fn(() => "bundle-id"),
|
|
11
|
+
getChannel: vi.fn(() => "production"),
|
|
12
|
+
getCohort: vi.fn(() => "123"),
|
|
13
|
+
getCrashHistory: vi.fn(() => []),
|
|
14
|
+
getDefaultChannel: vi.fn(() => "production"),
|
|
15
|
+
getFingerprintHash: vi.fn(() => null),
|
|
16
|
+
getManifest: vi.fn(() => null),
|
|
17
|
+
getMinBundleId: vi.fn(() => "min-bundle-id"),
|
|
18
|
+
init: vi.fn(),
|
|
19
|
+
isChannelSwitched: vi.fn(() => false),
|
|
20
|
+
reload: vi.fn(),
|
|
21
|
+
resetChannel: vi.fn(),
|
|
22
|
+
setCohort: vi.fn(),
|
|
23
|
+
setReloadBehavior: vi.fn(),
|
|
24
|
+
updateBundle: vi.fn(),
|
|
25
|
+
wrap: vi.fn(),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
vi.mock("./DefaultResolver", () => ({
|
|
29
|
+
createDefaultResolver: mocks.createDefaultResolver,
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
vi.mock("./checkForUpdate", () => ({
|
|
33
|
+
checkForUpdate: mocks.checkForUpdate,
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
vi.mock("./native", () => ({
|
|
37
|
+
addListener: mocks.addListener,
|
|
38
|
+
clearCrashHistory: mocks.clearCrashHistory,
|
|
39
|
+
getAppVersion: mocks.getAppVersion,
|
|
40
|
+
getBaseURL: mocks.getBaseURL,
|
|
41
|
+
getBundleId: mocks.getBundleId,
|
|
42
|
+
getChannel: mocks.getChannel,
|
|
43
|
+
getCohort: mocks.getCohort,
|
|
44
|
+
getCrashHistory: mocks.getCrashHistory,
|
|
45
|
+
getDefaultChannel: mocks.getDefaultChannel,
|
|
46
|
+
getFingerprintHash: mocks.getFingerprintHash,
|
|
47
|
+
getManifest: mocks.getManifest,
|
|
48
|
+
getMinBundleId: mocks.getMinBundleId,
|
|
49
|
+
isChannelSwitched: mocks.isChannelSwitched,
|
|
50
|
+
reload: mocks.reload,
|
|
51
|
+
resetChannel: mocks.resetChannel,
|
|
52
|
+
setCohort: mocks.setCohort,
|
|
53
|
+
setReloadBehavior: mocks.setReloadBehavior,
|
|
54
|
+
updateBundle: mocks.updateBundle,
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
vi.mock("./wrap", () => ({
|
|
58
|
+
init: mocks.init,
|
|
59
|
+
wrap: mocks.wrap,
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
const importHotUpdater = async () => {
|
|
63
|
+
const { HotUpdater } = await import("./index");
|
|
64
|
+
return HotUpdater;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
describe("HotUpdater client initialization", () => {
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
vi.resetModules();
|
|
70
|
+
|
|
71
|
+
for (const mock of Object.values(mocks)) {
|
|
72
|
+
mock.mockReset();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
mocks.addListener.mockReturnValue(() => {});
|
|
76
|
+
mocks.checkForUpdate.mockResolvedValue(null);
|
|
77
|
+
mocks.createDefaultResolver.mockImplementation((baseURL: string) => ({
|
|
78
|
+
baseURL,
|
|
79
|
+
checkUpdate: vi.fn(),
|
|
80
|
+
notifyAppReady: vi.fn(),
|
|
81
|
+
}));
|
|
82
|
+
mocks.getBaseURL.mockReturnValue(null);
|
|
83
|
+
mocks.init.mockReturnValue(undefined);
|
|
84
|
+
mocks.wrap.mockReturnValue((Component: unknown) => Component);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("initializes manual update flows without wrapping a component", async () => {
|
|
88
|
+
const resolver = {
|
|
89
|
+
checkUpdate: vi.fn(),
|
|
90
|
+
notifyAppReady: vi.fn(),
|
|
91
|
+
};
|
|
92
|
+
mocks.createDefaultResolver.mockReturnValue(resolver);
|
|
93
|
+
|
|
94
|
+
const HotUpdater = await importHotUpdater();
|
|
95
|
+
|
|
96
|
+
const result = HotUpdater.init({
|
|
97
|
+
baseURL: "https://updates.example.com",
|
|
98
|
+
requestHeaders: {
|
|
99
|
+
Authorization: "Bearer token",
|
|
100
|
+
},
|
|
101
|
+
requestTimeout: 1000,
|
|
102
|
+
updateMode: "manual",
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(result).toBeUndefined();
|
|
106
|
+
expect(mocks.createDefaultResolver).toHaveBeenCalledWith(
|
|
107
|
+
"https://updates.example.com",
|
|
108
|
+
);
|
|
109
|
+
expect(mocks.init).toHaveBeenCalledWith({
|
|
110
|
+
requestHeaders: {
|
|
111
|
+
Authorization: "Bearer token",
|
|
112
|
+
},
|
|
113
|
+
requestTimeout: 1000,
|
|
114
|
+
resolver,
|
|
115
|
+
updateMode: "manual",
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("uses init configuration for later manual update checks", async () => {
|
|
120
|
+
const resolver = {
|
|
121
|
+
checkUpdate: vi.fn(),
|
|
122
|
+
notifyAppReady: vi.fn(),
|
|
123
|
+
};
|
|
124
|
+
mocks.createDefaultResolver.mockReturnValue(resolver);
|
|
125
|
+
|
|
126
|
+
const HotUpdater = await importHotUpdater();
|
|
127
|
+
|
|
128
|
+
HotUpdater.init({
|
|
129
|
+
baseURL: "https://updates.example.com",
|
|
130
|
+
requestHeaders: {
|
|
131
|
+
Authorization: "Bearer token",
|
|
132
|
+
},
|
|
133
|
+
requestTimeout: 1000,
|
|
134
|
+
updateMode: "manual",
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await HotUpdater.checkForUpdate({
|
|
138
|
+
requestHeaders: {
|
|
139
|
+
"X-Runtime": "secondary",
|
|
140
|
+
},
|
|
141
|
+
updateStrategy: "appVersion",
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
expect(mocks.checkForUpdate).toHaveBeenCalledWith({
|
|
145
|
+
requestHeaders: {
|
|
146
|
+
Authorization: "Bearer token",
|
|
147
|
+
"X-Runtime": "secondary",
|
|
148
|
+
},
|
|
149
|
+
requestTimeout: 1000,
|
|
150
|
+
resolver,
|
|
151
|
+
updateStrategy: "appVersion",
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("points users to wrap or init when methods are called too early", async () => {
|
|
156
|
+
const HotUpdater = await importHotUpdater();
|
|
157
|
+
|
|
158
|
+
expect(() =>
|
|
159
|
+
HotUpdater.checkForUpdate({
|
|
160
|
+
updateStrategy: "appVersion",
|
|
161
|
+
}),
|
|
162
|
+
).toThrow("requires HotUpdater.wrap() or HotUpdater.init() to be used");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("rejects automatic update mode for init at runtime", async () => {
|
|
166
|
+
const HotUpdater = await importHotUpdater();
|
|
167
|
+
|
|
168
|
+
expect(() =>
|
|
169
|
+
HotUpdater.init({
|
|
170
|
+
baseURL: "https://updates.example.com",
|
|
171
|
+
updateMode: "auto",
|
|
172
|
+
updateStrategy: "appVersion",
|
|
173
|
+
} as never),
|
|
174
|
+
).toThrow('HotUpdater.init() only supports updateMode: "manual"');
|
|
175
|
+
expect(mocks.init).not.toHaveBeenCalled();
|
|
176
|
+
});
|
|
177
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -27,7 +27,13 @@ import {
|
|
|
27
27
|
} from "./native";
|
|
28
28
|
import { hotUpdaterStore } from "./store";
|
|
29
29
|
import type { HotUpdaterResolver } from "./types";
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
type HotUpdaterOptions,
|
|
32
|
+
init,
|
|
33
|
+
type InternalWrapOptions,
|
|
34
|
+
type ManualUpdateOptions,
|
|
35
|
+
wrap,
|
|
36
|
+
} from "./wrap";
|
|
31
37
|
|
|
32
38
|
export type {
|
|
33
39
|
CustomReloadHandler,
|
|
@@ -48,6 +54,7 @@ export {
|
|
|
48
54
|
type SignatureVerificationFailure,
|
|
49
55
|
} from "./types";
|
|
50
56
|
export type { HotUpdaterOptions, RunUpdateProcessResponse } from "./wrap";
|
|
57
|
+
export type { ManualUpdateOptions as HotUpdaterInitOptions } from "./wrap";
|
|
51
58
|
|
|
52
59
|
/**
|
|
53
60
|
* Register getBaseURL to global objects for use without imports.
|
|
@@ -81,23 +88,88 @@ function createHotUpdaterClient() {
|
|
|
81
88
|
resolver: null,
|
|
82
89
|
};
|
|
83
90
|
|
|
91
|
+
const normalizeOptions = (
|
|
92
|
+
options: HotUpdaterOptions,
|
|
93
|
+
apiName: "init" | "wrap",
|
|
94
|
+
): InternalWrapOptions => {
|
|
95
|
+
if ("baseURL" in options && options.baseURL) {
|
|
96
|
+
const { baseURL, ...rest } = options;
|
|
97
|
+
return {
|
|
98
|
+
...rest,
|
|
99
|
+
resolver: createDefaultResolver(baseURL),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if ("resolver" in options && options.resolver) {
|
|
104
|
+
return options;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const baseURLExample =
|
|
108
|
+
apiName === "wrap"
|
|
109
|
+
? ` export default HotUpdater.wrap({\n` +
|
|
110
|
+
` baseURL: "<your-update-server-url>",\n` +
|
|
111
|
+
` updateStrategy: "appVersion",\n` +
|
|
112
|
+
` updateMode: "auto"\n` +
|
|
113
|
+
` })(App);\n\n`
|
|
114
|
+
: ` HotUpdater.init({\n` +
|
|
115
|
+
` baseURL: "<your-update-server-url>",\n` +
|
|
116
|
+
` updateMode: "manual"\n` +
|
|
117
|
+
` });\n\n`;
|
|
118
|
+
|
|
119
|
+
const resolverExample =
|
|
120
|
+
apiName === "wrap"
|
|
121
|
+
? ` export default HotUpdater.wrap({\n` +
|
|
122
|
+
` resolver: {\n` +
|
|
123
|
+
` checkUpdate: async (params) => { /* custom logic */ },\n` +
|
|
124
|
+
` notifyAppReady: async (params) => { /* custom logic */ }\n` +
|
|
125
|
+
` },\n` +
|
|
126
|
+
` updateMode: "manual"\n` +
|
|
127
|
+
` })(App);\n\n`
|
|
128
|
+
: ` HotUpdater.init({\n` +
|
|
129
|
+
` resolver: {\n` +
|
|
130
|
+
` checkUpdate: async (params) => { /* custom logic */ },\n` +
|
|
131
|
+
` notifyAppReady: async (params) => { /* custom logic */ }\n` +
|
|
132
|
+
` },\n` +
|
|
133
|
+
` updateMode: "manual"\n` +
|
|
134
|
+
` });\n\n`;
|
|
135
|
+
|
|
136
|
+
throw new Error(
|
|
137
|
+
`[HotUpdater] Either baseURL or resolver must be provided.\n\n` +
|
|
138
|
+
`Option 1: Using baseURL (recommended for most cases)\n` +
|
|
139
|
+
baseURLExample +
|
|
140
|
+
`Option 2: Using custom resolver (advanced)\n` +
|
|
141
|
+
resolverExample +
|
|
142
|
+
`For more information, visit: https://hot-updater.dev/docs/react-native-api/${apiName}`,
|
|
143
|
+
);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const configureGlobal = (
|
|
147
|
+
normalizedOptions: InternalWrapOptions,
|
|
148
|
+
options: HotUpdaterOptions,
|
|
149
|
+
) => {
|
|
150
|
+
globalConfig.resolver = normalizedOptions.resolver;
|
|
151
|
+
globalConfig.requestHeaders = options.requestHeaders;
|
|
152
|
+
globalConfig.requestTimeout = options.requestTimeout;
|
|
153
|
+
};
|
|
154
|
+
|
|
84
155
|
const ensureGlobalResolver = (methodName: string) => {
|
|
85
156
|
if (!globalConfig.resolver) {
|
|
86
157
|
throw new Error(
|
|
87
|
-
`[HotUpdater] ${methodName} requires HotUpdater.wrap() to be used.\n\n` +
|
|
88
|
-
`To fix this issue,
|
|
89
|
-
`Option 1: With
|
|
158
|
+
`[HotUpdater] ${methodName} requires HotUpdater.wrap() or HotUpdater.init() to be used.\n\n` +
|
|
159
|
+
`To fix this issue, configure HotUpdater before calling ${methodName}:\n\n` +
|
|
160
|
+
`Option 1: With HotUpdater.wrap()\n` +
|
|
90
161
|
` export default HotUpdater.wrap({\n` +
|
|
91
162
|
` baseURL: "<your-update-server-url>",\n` +
|
|
92
163
|
` updateStrategy: "appVersion",\n` +
|
|
93
164
|
` updateMode: "auto"\n` +
|
|
94
165
|
` })(App);\n\n` +
|
|
95
|
-
`Option 2:
|
|
96
|
-
`
|
|
166
|
+
`Option 2: With HotUpdater.init() for custom runtimes\n` +
|
|
167
|
+
` HotUpdater.init({\n` +
|
|
97
168
|
` baseURL: "<your-update-server-url>",\n` +
|
|
98
169
|
` updateMode: "manual"\n` +
|
|
99
|
-
` })
|
|
100
|
-
`For more information, visit: https://hot-updater.dev/docs/react-native-api/wrap
|
|
170
|
+
` });\n\n` +
|
|
171
|
+
`For more information, visit: https://hot-updater.dev/docs/react-native-api/wrap ` +
|
|
172
|
+
`or https://hot-updater.dev/docs/react-native-api/init`,
|
|
101
173
|
);
|
|
102
174
|
}
|
|
103
175
|
return globalConfig.resolver;
|
|
@@ -129,42 +201,31 @@ function createHotUpdaterClient() {
|
|
|
129
201
|
* ```
|
|
130
202
|
*/
|
|
131
203
|
wrap: (options: HotUpdaterOptions) => {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
204
|
+
const normalizedOptions = normalizeOptions(options, "wrap");
|
|
205
|
+
configureGlobal(normalizedOptions, options);
|
|
206
|
+
|
|
207
|
+
return wrap(normalizedOptions);
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Initializes HotUpdater without wrapping a React component.
|
|
212
|
+
*
|
|
213
|
+
* Use this for manual update flows in runtimes where a root component HOC
|
|
214
|
+
* is not convenient. It has the same initialization effect as
|
|
215
|
+
* `HotUpdater.wrap({ updateMode: "manual" })(App)`.
|
|
216
|
+
*/
|
|
217
|
+
init: (options: ManualUpdateOptions): void => {
|
|
218
|
+
const normalizedOptions = normalizeOptions(options, "init");
|
|
219
|
+
if (normalizedOptions.updateMode !== "manual") {
|
|
143
220
|
throw new Error(
|
|
144
|
-
`[HotUpdater]
|
|
145
|
-
`
|
|
146
|
-
` export default HotUpdater.wrap({\n` +
|
|
147
|
-
` baseURL: "<your-update-server-url>",\n` +
|
|
148
|
-
` updateStrategy: "appVersion",\n` +
|
|
149
|
-
` updateMode: "auto"\n` +
|
|
150
|
-
` })(App);\n\n` +
|
|
151
|
-
`Option 2: Using custom resolver (advanced)\n` +
|
|
152
|
-
` export default HotUpdater.wrap({\n` +
|
|
153
|
-
` resolver: {\n` +
|
|
154
|
-
` checkUpdate: async (params) => { /* custom logic */ },\n` +
|
|
155
|
-
` notifyAppReady: async (params) => { /* custom logic */ }\n` +
|
|
156
|
-
` },\n` +
|
|
157
|
-
` updateMode: "manual"\n` +
|
|
158
|
-
` })(App);\n\n` +
|
|
159
|
-
`For more information, visit: https://hot-updater.dev/docs/react-native-api/wrap`,
|
|
221
|
+
`[HotUpdater] HotUpdater.init() only supports updateMode: "manual". ` +
|
|
222
|
+
`Use HotUpdater.wrap() for automatic updates.`,
|
|
160
223
|
);
|
|
161
224
|
}
|
|
162
225
|
|
|
163
|
-
|
|
164
|
-
globalConfig.requestHeaders = options.requestHeaders;
|
|
165
|
-
globalConfig.requestTimeout = options.requestTimeout;
|
|
226
|
+
configureGlobal(normalizedOptions, options);
|
|
166
227
|
|
|
167
|
-
|
|
228
|
+
init(normalizedOptions);
|
|
168
229
|
},
|
|
169
230
|
|
|
170
231
|
/**
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const addListenerMock = vi.hoisted(() => vi.fn());
|
|
4
|
+
|
|
5
|
+
vi.mock("./native", () => ({
|
|
6
|
+
addListener: addListenerMock,
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
const importStore = async () => {
|
|
10
|
+
const { hotUpdaterStore } = await import("./store");
|
|
11
|
+
return hotUpdaterStore;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe("hotUpdaterStore", () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
vi.resetModules();
|
|
17
|
+
addListenerMock.mockReset();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("does not notify subscribers when state values are unchanged", async () => {
|
|
21
|
+
const store = await importStore();
|
|
22
|
+
const listener = vi.fn();
|
|
23
|
+
|
|
24
|
+
const unsubscribe = store.subscribe(listener);
|
|
25
|
+
|
|
26
|
+
store.setState({});
|
|
27
|
+
store.setState({ progress: 0 });
|
|
28
|
+
store.setState({ isUpdateDownloaded: false });
|
|
29
|
+
|
|
30
|
+
expect(listener).not.toHaveBeenCalled();
|
|
31
|
+
|
|
32
|
+
unsubscribe();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("notifies subscribers only when progress events change the snapshot", async () => {
|
|
36
|
+
const store = await importStore();
|
|
37
|
+
const listener = vi.fn();
|
|
38
|
+
const progressListener = addListenerMock.mock.calls[0][1] as (state: {
|
|
39
|
+
progress: number;
|
|
40
|
+
}) => void;
|
|
41
|
+
|
|
42
|
+
const unsubscribe = store.subscribe(listener);
|
|
43
|
+
|
|
44
|
+
progressListener({ progress: 0.5 });
|
|
45
|
+
progressListener({ progress: 0.5 });
|
|
46
|
+
progressListener({ progress: 1 });
|
|
47
|
+
store.setState({ isUpdateDownloaded: true });
|
|
48
|
+
|
|
49
|
+
expect(listener).toHaveBeenCalledTimes(2);
|
|
50
|
+
expect(store.getSnapshot()).toEqual({
|
|
51
|
+
isUpdateDownloaded: true,
|
|
52
|
+
progress: 1,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
unsubscribe();
|
|
56
|
+
});
|
|
57
|
+
});
|
package/src/store.ts
CHANGED
|
@@ -46,6 +46,13 @@ const createHotUpdaterStore = () => {
|
|
|
46
46
|
nextState.isUpdateDownloaded = newState.isUpdateDownloaded;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
if (
|
|
50
|
+
state.progress === nextState.progress &&
|
|
51
|
+
state.isUpdateDownloaded === nextState.isUpdateDownloaded
|
|
52
|
+
) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
49
56
|
state = nextState;
|
|
50
57
|
emitChange();
|
|
51
58
|
};
|
package/src/wrap.spec.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const mocks = vi.hoisted(() => ({
|
|
4
|
+
addListener: vi.fn(() => () => {}),
|
|
5
|
+
checkForUpdate: vi.fn(),
|
|
6
|
+
getBundleId: vi.fn(() => "bundle-id"),
|
|
7
|
+
notifyAppReady: vi.fn(() => ({ status: "STABLE" })),
|
|
8
|
+
reload: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock("./checkForUpdate", () => ({
|
|
12
|
+
checkForUpdate: mocks.checkForUpdate,
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock("./native", () => ({
|
|
16
|
+
addListener: mocks.addListener,
|
|
17
|
+
getBundleId: mocks.getBundleId,
|
|
18
|
+
notifyAppReady: mocks.notifyAppReady,
|
|
19
|
+
reload: mocks.reload,
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
describe("HotUpdater wrap initialization", () => {
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
vi.resetModules();
|
|
25
|
+
vi.unstubAllGlobals();
|
|
26
|
+
vi.useRealTimers();
|
|
27
|
+
|
|
28
|
+
for (const mock of Object.values(mocks)) {
|
|
29
|
+
mock.mockReset();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
mocks.checkForUpdate.mockResolvedValue(null);
|
|
33
|
+
mocks.addListener.mockReturnValue(() => {});
|
|
34
|
+
mocks.getBundleId.mockReturnValue("bundle-id");
|
|
35
|
+
mocks.notifyAppReady.mockReturnValue({ status: "STABLE" });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("returns void from init and defers notifyAppReady to the next frame", async () => {
|
|
39
|
+
vi.useFakeTimers();
|
|
40
|
+
|
|
41
|
+
const requestAnimationFrame = vi.fn(
|
|
42
|
+
(callback: (timestamp: number) => void) => {
|
|
43
|
+
setTimeout(() => callback(0), 0);
|
|
44
|
+
return 1;
|
|
45
|
+
},
|
|
46
|
+
);
|
|
47
|
+
vi.stubGlobal("requestAnimationFrame", requestAnimationFrame);
|
|
48
|
+
|
|
49
|
+
const resolver = {
|
|
50
|
+
checkUpdate: vi.fn(),
|
|
51
|
+
notifyAppReady: vi.fn().mockResolvedValue(undefined),
|
|
52
|
+
};
|
|
53
|
+
const { init } = await import("./wrap");
|
|
54
|
+
|
|
55
|
+
const result = init({
|
|
56
|
+
resolver,
|
|
57
|
+
requestHeaders: {
|
|
58
|
+
Authorization: "Bearer token",
|
|
59
|
+
},
|
|
60
|
+
requestTimeout: 1000,
|
|
61
|
+
updateMode: "manual",
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
expect(result).toBeUndefined();
|
|
65
|
+
expect(mocks.notifyAppReady).not.toHaveBeenCalled();
|
|
66
|
+
expect(resolver.notifyAppReady).not.toHaveBeenCalled();
|
|
67
|
+
|
|
68
|
+
expect(requestAnimationFrame).toHaveBeenCalled();
|
|
69
|
+
|
|
70
|
+
await vi.runOnlyPendingTimersAsync();
|
|
71
|
+
|
|
72
|
+
expect(mocks.notifyAppReady).toHaveBeenCalledWith();
|
|
73
|
+
expect(resolver.notifyAppReady).toHaveBeenCalledWith({
|
|
74
|
+
status: "STABLE",
|
|
75
|
+
crashedBundleId: undefined,
|
|
76
|
+
requestHeaders: {
|
|
77
|
+
Authorization: "Bearer token",
|
|
78
|
+
},
|
|
79
|
+
requestTimeout: 1000,
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|
package/src/wrap.tsx
CHANGED
|
@@ -204,10 +204,30 @@ type InternalManualUpdateOptions = InternalCommonOptions & {
|
|
|
204
204
|
updateMode: "manual";
|
|
205
205
|
};
|
|
206
206
|
|
|
207
|
+
export type InternalInitOptions = InternalManualUpdateOptions;
|
|
208
|
+
|
|
207
209
|
export type InternalWrapOptions =
|
|
208
210
|
| InternalAutoUpdateOptions
|
|
209
211
|
| InternalManualUpdateOptions;
|
|
210
212
|
|
|
213
|
+
type RequestAnimationFrame = (callback: (timestamp: number) => void) => number;
|
|
214
|
+
|
|
215
|
+
const waitForNextFrame = () =>
|
|
216
|
+
new Promise<void>((resolve) => {
|
|
217
|
+
const requestAnimationFrame = (
|
|
218
|
+
globalThis as typeof globalThis & {
|
|
219
|
+
requestAnimationFrame?: RequestAnimationFrame;
|
|
220
|
+
}
|
|
221
|
+
)?.requestAnimationFrame;
|
|
222
|
+
|
|
223
|
+
if (requestAnimationFrame) {
|
|
224
|
+
requestAnimationFrame(() => resolve());
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
void Promise.resolve().then(resolve);
|
|
229
|
+
});
|
|
230
|
+
|
|
211
231
|
/**
|
|
212
232
|
* Helper function to handle notifyAppReady flow
|
|
213
233
|
*/
|
|
@@ -217,6 +237,8 @@ const handleNotifyAppReady = async (options: {
|
|
|
217
237
|
requestTimeout?: number;
|
|
218
238
|
onNotifyAppReady?: (result: NotifyAppReadyResult) => void;
|
|
219
239
|
}): Promise<void> => {
|
|
240
|
+
await waitForNextFrame();
|
|
241
|
+
|
|
220
242
|
try {
|
|
221
243
|
const nativeResult = nativeNotifyAppReady();
|
|
222
244
|
|
|
@@ -240,6 +262,10 @@ const handleNotifyAppReady = async (options: {
|
|
|
240
262
|
}
|
|
241
263
|
};
|
|
242
264
|
|
|
265
|
+
export function init(options: InternalInitOptions): void {
|
|
266
|
+
void handleNotifyAppReady(options);
|
|
267
|
+
}
|
|
268
|
+
|
|
243
269
|
export function wrap<P extends React.JSX.IntrinsicAttributes = object>(
|
|
244
270
|
options: InternalWrapOptions,
|
|
245
271
|
): (WrappedComponent: React.ComponentType<P>) => React.ComponentType<P> {
|