@hot-updater/react-native 0.1.2 → 0.1.4
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/android/src/main/java/com/hotupdater/HotUpdater.kt +2 -2
- package/ios/HotUpdater/HotUpdater.mm +2 -2
- package/package.json +3 -2
- package/src/checkForUpdate.test.ts +517 -0
- package/src/checkForUpdate.ts +111 -0
- package/src/error.ts +6 -0
- package/src/index.ts +15 -0
- package/src/init.tsx +51 -0
- package/src/native.ts +72 -0
- package/src/specs/NativeHotUpdaterModule.ts +16 -0
- package/src/utils.ts +2 -0
|
@@ -271,14 +271,14 @@ class HotUpdater internal constructor(context: Context, reactNativeHost: ReactNa
|
|
|
271
271
|
}
|
|
272
272
|
|
|
273
273
|
val extractedDirectory = File(extractedPath)
|
|
274
|
-
val indexFile = extractedDirectory.walk().find { it.name == "index.android.bundle
|
|
274
|
+
val indexFile = extractedDirectory.walk().find { it.name == "index.android.bundle" }
|
|
275
275
|
|
|
276
276
|
if (indexFile != null) {
|
|
277
277
|
val bundlePath = indexFile.path
|
|
278
278
|
Log.d("HotUpdater", "Setting bundle URL: $bundlePath")
|
|
279
279
|
setBundleURL(bundlePath)
|
|
280
280
|
} else {
|
|
281
|
-
Log.d("HotUpdater", "index.android.bundle
|
|
281
|
+
Log.d("HotUpdater", "index.android.bundle not found.")
|
|
282
282
|
return false
|
|
283
283
|
}
|
|
284
284
|
|
|
@@ -146,7 +146,7 @@ RCT_EXPORT_MODULE();
|
|
|
146
146
|
NSDirectoryEnumerator *enumerator = [fileManager enumeratorAtPath:extractedPath];
|
|
147
147
|
NSString *filename = nil;
|
|
148
148
|
for (NSString *file in enumerator) {
|
|
149
|
-
if ([file isEqualToString:@"index.ios.bundle
|
|
149
|
+
if ([file isEqualToString:@"index.ios.bundle"]) {
|
|
150
150
|
filename = file;
|
|
151
151
|
break;
|
|
152
152
|
}
|
|
@@ -157,7 +157,7 @@ RCT_EXPORT_MODULE();
|
|
|
157
157
|
NSLog(@"Setting bundle URL: %@", bundlePath);
|
|
158
158
|
[self setBundleURL:bundlePath];
|
|
159
159
|
} else {
|
|
160
|
-
NSLog(@"index.ios.bundle
|
|
160
|
+
NSLog(@"index.ios.bundle not found.");
|
|
161
161
|
return NO;
|
|
162
162
|
}
|
|
163
163
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hot-updater/react-native",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "React Native OTA solution for self-hosted",
|
|
6
6
|
"main": "dist/index.cjs",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"types": "dist/index.d.ts",
|
|
10
10
|
"files": [
|
|
11
11
|
"dist",
|
|
12
|
+
"src",
|
|
12
13
|
"android",
|
|
13
14
|
"ios",
|
|
14
15
|
"cpp",
|
|
@@ -58,7 +59,7 @@
|
|
|
58
59
|
"react-native": "^0.72.6"
|
|
59
60
|
},
|
|
60
61
|
"dependencies": {
|
|
61
|
-
"@hot-updater/utils": "0.1.
|
|
62
|
+
"@hot-updater/utils": "0.1.4"
|
|
62
63
|
},
|
|
63
64
|
"scripts": {
|
|
64
65
|
"build": "tsup src/index.ts --format esm,cjs --dts",
|
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
import type { UpdateSource } from "@hot-updater/utils";
|
|
2
|
+
import { beforeAll, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { checkForUpdate } from "./checkForUpdate";
|
|
4
|
+
import * as natives from "./native";
|
|
5
|
+
|
|
6
|
+
vi.mock("./native", () => ({
|
|
7
|
+
getAppVersion: async () => "1.0",
|
|
8
|
+
getBundleVersion: async () => 1,
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock("react-native", () => ({
|
|
12
|
+
Platform: {
|
|
13
|
+
OS: "ios",
|
|
14
|
+
},
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
describe("appVersion 1.0, bundleVersion null", async () => {
|
|
18
|
+
beforeAll(() => {
|
|
19
|
+
vi.spyOn(natives, "getAppVersion").mockImplementation(async () => "1.0");
|
|
20
|
+
vi.spyOn(natives, "getBundleVersion").mockImplementation(async () => -1);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("should return null if no update information is available", async () => {
|
|
24
|
+
const updateSources: UpdateSource[] = [];
|
|
25
|
+
|
|
26
|
+
const update = await checkForUpdate(updateSources);
|
|
27
|
+
expect(update).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("should return null if no update is available when the app version is higher", async () => {
|
|
31
|
+
const updateSources: UpdateSource[] = [
|
|
32
|
+
{
|
|
33
|
+
platform: "ios",
|
|
34
|
+
targetVersion: "1.1",
|
|
35
|
+
enabled: true,
|
|
36
|
+
bundleVersion: 1,
|
|
37
|
+
forceUpdate: false,
|
|
38
|
+
file: "http://example.com/bundle.zip",
|
|
39
|
+
hash: "hash",
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const update = await checkForUpdate(updateSources);
|
|
44
|
+
expect(update).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("should update if a higher bundle with semver version exists", async () => {
|
|
48
|
+
const updateSources: UpdateSource[] = [
|
|
49
|
+
{
|
|
50
|
+
platform: "ios",
|
|
51
|
+
targetVersion: "1.x.x",
|
|
52
|
+
enabled: true,
|
|
53
|
+
bundleVersion: 1,
|
|
54
|
+
forceUpdate: false,
|
|
55
|
+
file: "http://example.com/bundle.zip",
|
|
56
|
+
hash: "hash",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
platform: "ios",
|
|
60
|
+
targetVersion: "1.0",
|
|
61
|
+
enabled: true,
|
|
62
|
+
bundleVersion: 2,
|
|
63
|
+
forceUpdate: false,
|
|
64
|
+
file: "http://example.com/bundle.zip",
|
|
65
|
+
hash: "hash",
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
const update = await checkForUpdate(updateSources);
|
|
70
|
+
expect(update).toStrictEqual({
|
|
71
|
+
bundleVersion: 2,
|
|
72
|
+
forceUpdate: false,
|
|
73
|
+
status: "UPDATE",
|
|
74
|
+
file: "http://example.com/bundle.zip",
|
|
75
|
+
hash: "hash",
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("should update if a higher bundle version exists and forceUpdate is set to true", async () => {
|
|
80
|
+
const updateSources: UpdateSource[] = [
|
|
81
|
+
{
|
|
82
|
+
platform: "ios",
|
|
83
|
+
targetVersion: "1.0",
|
|
84
|
+
enabled: true,
|
|
85
|
+
bundleVersion: 1,
|
|
86
|
+
forceUpdate: true,
|
|
87
|
+
file: "http://example.com/bundle.zip",
|
|
88
|
+
hash: "hash",
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
const update = await checkForUpdate(updateSources);
|
|
92
|
+
|
|
93
|
+
expect(update).toStrictEqual({
|
|
94
|
+
bundleVersion: 1,
|
|
95
|
+
forceUpdate: true,
|
|
96
|
+
status: "UPDATE",
|
|
97
|
+
file: "http://example.com/bundle.zip",
|
|
98
|
+
hash: "hash",
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("should update if a higher bundle version exists and forceUpdate is set to false", async () => {
|
|
103
|
+
const updateSources: UpdateSource[] = [
|
|
104
|
+
{
|
|
105
|
+
platform: "ios",
|
|
106
|
+
targetVersion: "1.0",
|
|
107
|
+
file: "http://example.com/bundle.zip",
|
|
108
|
+
hash: "hash",
|
|
109
|
+
enabled: true,
|
|
110
|
+
bundleVersion: 1,
|
|
111
|
+
forceUpdate: false,
|
|
112
|
+
},
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
const update = await checkForUpdate(updateSources);
|
|
116
|
+
expect(update).toStrictEqual({
|
|
117
|
+
file: "http://example.com/bundle.zip",
|
|
118
|
+
hash: "hash",
|
|
119
|
+
bundleVersion: 1,
|
|
120
|
+
forceUpdate: false,
|
|
121
|
+
status: "UPDATE",
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("should update even if the app version is the same and the bundle version is significantly higher", async () => {
|
|
126
|
+
const updateSources: UpdateSource[] = [
|
|
127
|
+
{
|
|
128
|
+
platform: "ios",
|
|
129
|
+
targetVersion: "1.0",
|
|
130
|
+
file: "http://example.com/bundle.zip",
|
|
131
|
+
hash: "hash",
|
|
132
|
+
forceUpdate: false,
|
|
133
|
+
enabled: true,
|
|
134
|
+
bundleVersion: 5,
|
|
135
|
+
},
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
const update = await checkForUpdate(updateSources);
|
|
139
|
+
expect(update).toStrictEqual({
|
|
140
|
+
bundleVersion: 5,
|
|
141
|
+
forceUpdate: false,
|
|
142
|
+
status: "UPDATE",
|
|
143
|
+
file: "http://example.com/bundle.zip",
|
|
144
|
+
hash: "hash",
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("should update if the latest version is not available but a previous version is available", async () => {
|
|
149
|
+
const updateSources: UpdateSource[] = [
|
|
150
|
+
{
|
|
151
|
+
platform: "ios",
|
|
152
|
+
targetVersion: "1.0",
|
|
153
|
+
file: "http://example.com/bundle.zip",
|
|
154
|
+
hash: "hash",
|
|
155
|
+
forceUpdate: true,
|
|
156
|
+
enabled: false, // Disabled
|
|
157
|
+
bundleVersion: 2,
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
platform: "ios",
|
|
161
|
+
targetVersion: "1.0",
|
|
162
|
+
file: "http://example.com/bundle.zip",
|
|
163
|
+
hash: "hash",
|
|
164
|
+
forceUpdate: false,
|
|
165
|
+
enabled: true,
|
|
166
|
+
bundleVersion: 1,
|
|
167
|
+
},
|
|
168
|
+
];
|
|
169
|
+
|
|
170
|
+
const update = await checkForUpdate(updateSources);
|
|
171
|
+
expect(update).toStrictEqual({
|
|
172
|
+
file: "http://example.com/bundle.zip",
|
|
173
|
+
hash: "hash",
|
|
174
|
+
bundleVersion: 1,
|
|
175
|
+
forceUpdate: false,
|
|
176
|
+
status: "UPDATE",
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("should not update if all updates are disabled", async () => {
|
|
181
|
+
const updateSources: UpdateSource[] = [
|
|
182
|
+
{
|
|
183
|
+
platform: "ios",
|
|
184
|
+
targetVersion: "1.0",
|
|
185
|
+
file: "http://example.com/bundle.zip",
|
|
186
|
+
hash: "hash",
|
|
187
|
+
forceUpdate: true,
|
|
188
|
+
enabled: false, // Disabled
|
|
189
|
+
bundleVersion: 2,
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
platform: "ios",
|
|
193
|
+
targetVersion: "1.0",
|
|
194
|
+
file: "http://example.com/bundle.zip",
|
|
195
|
+
hash: "hash",
|
|
196
|
+
forceUpdate: false,
|
|
197
|
+
enabled: false, // Disabled
|
|
198
|
+
bundleVersion: 1,
|
|
199
|
+
},
|
|
200
|
+
];
|
|
201
|
+
|
|
202
|
+
const update = await checkForUpdate(updateSources);
|
|
203
|
+
expect(update).toBeNull();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("should rollback to the original bundle when receiving the latest bundle but all updates are disabled", async () => {
|
|
207
|
+
const updateSources: UpdateSource[] = [
|
|
208
|
+
{
|
|
209
|
+
platform: "ios",
|
|
210
|
+
targetVersion: "1.0",
|
|
211
|
+
file: "http://example.com/bundle.zip",
|
|
212
|
+
hash: "hash",
|
|
213
|
+
forceUpdate: true,
|
|
214
|
+
enabled: false, // Disabled
|
|
215
|
+
bundleVersion: 2,
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
platform: "ios",
|
|
219
|
+
targetVersion: "1.0",
|
|
220
|
+
file: "http://example.com/bundle.zip",
|
|
221
|
+
hash: "hash",
|
|
222
|
+
forceUpdate: false,
|
|
223
|
+
enabled: false, // Disabled
|
|
224
|
+
bundleVersion: 1,
|
|
225
|
+
},
|
|
226
|
+
];
|
|
227
|
+
|
|
228
|
+
const update = await checkForUpdate(updateSources);
|
|
229
|
+
expect(update).toStrictEqual(null);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("should update if the latest version is available and the app version is the same", async () => {
|
|
233
|
+
const updateSources: UpdateSource[] = [
|
|
234
|
+
{
|
|
235
|
+
forceUpdate: false,
|
|
236
|
+
platform: "ios",
|
|
237
|
+
file: "20240722210327/ios/build.zip",
|
|
238
|
+
hash: "a5cbf59a627759a88d472c502423ff55a4f6cd1aafeed3536f6a5f6e870c2290",
|
|
239
|
+
description: "",
|
|
240
|
+
targetVersion: "1.0",
|
|
241
|
+
bundleVersion: 20240722210327,
|
|
242
|
+
enabled: true,
|
|
243
|
+
},
|
|
244
|
+
];
|
|
245
|
+
|
|
246
|
+
const update = await checkForUpdate(updateSources);
|
|
247
|
+
expect(update).toStrictEqual({
|
|
248
|
+
bundleVersion: 20240722210327,
|
|
249
|
+
forceUpdate: false,
|
|
250
|
+
status: "UPDATE",
|
|
251
|
+
file: "20240722210327/ios/build.zip",
|
|
252
|
+
hash: "a5cbf59a627759a88d472c502423ff55a4f6cd1aafeed3536f6a5f6e870c2290",
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe("appVersion 1.0, bundleVersion v2", async () => {
|
|
258
|
+
beforeAll(() => {
|
|
259
|
+
vi.spyOn(natives, "getAppVersion").mockImplementation(async () => "1.0");
|
|
260
|
+
vi.spyOn(natives, "getBundleVersion").mockImplementation(async () => 2);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("should return null if no update information is available", async () => {
|
|
264
|
+
const updateSources: UpdateSource[] = [];
|
|
265
|
+
|
|
266
|
+
const update = await checkForUpdate(updateSources);
|
|
267
|
+
expect(update).toBeNull();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("should return null if no update is available when the app version is higher", async () => {
|
|
271
|
+
const updateSources: UpdateSource[] = [
|
|
272
|
+
{
|
|
273
|
+
platform: "ios",
|
|
274
|
+
targetVersion: "1.0",
|
|
275
|
+
file: "http://example.com/bundle.zip",
|
|
276
|
+
hash: "hash",
|
|
277
|
+
forceUpdate: false,
|
|
278
|
+
enabled: true,
|
|
279
|
+
bundleVersion: 2,
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
platform: "ios",
|
|
283
|
+
targetVersion: "1.0",
|
|
284
|
+
file: "http://example.com/bundle.zip",
|
|
285
|
+
hash: "hash",
|
|
286
|
+
forceUpdate: false,
|
|
287
|
+
enabled: true,
|
|
288
|
+
bundleVersion: 1,
|
|
289
|
+
},
|
|
290
|
+
];
|
|
291
|
+
|
|
292
|
+
const update = await checkForUpdate(updateSources);
|
|
293
|
+
expect(update).toBeNull();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("should rollback if the latest bundle is deleted", async () => {
|
|
297
|
+
const updateSources: UpdateSource[] = [
|
|
298
|
+
{
|
|
299
|
+
platform: "ios",
|
|
300
|
+
targetVersion: "1.0",
|
|
301
|
+
file: "http://example.com/bundle.zip",
|
|
302
|
+
hash: "hash",
|
|
303
|
+
forceUpdate: false,
|
|
304
|
+
enabled: true,
|
|
305
|
+
bundleVersion: 1,
|
|
306
|
+
},
|
|
307
|
+
];
|
|
308
|
+
|
|
309
|
+
const update = await checkForUpdate(updateSources);
|
|
310
|
+
expect(update).toStrictEqual({
|
|
311
|
+
file: "http://example.com/bundle.zip",
|
|
312
|
+
hash: "hash",
|
|
313
|
+
bundleVersion: 1,
|
|
314
|
+
forceUpdate: true,
|
|
315
|
+
status: "ROLLBACK",
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("should update if a higher bundle version exists and forceUpdate is set to false", async () => {
|
|
320
|
+
const updateSources: UpdateSource[] = [
|
|
321
|
+
{
|
|
322
|
+
platform: "ios",
|
|
323
|
+
targetVersion: "1.0",
|
|
324
|
+
file: "http://example.com/bundle.zip",
|
|
325
|
+
hash: "hash",
|
|
326
|
+
forceUpdate: false,
|
|
327
|
+
enabled: true,
|
|
328
|
+
bundleVersion: 3,
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
platform: "ios",
|
|
332
|
+
targetVersion: "1.0",
|
|
333
|
+
file: "http://example.com/bundle.zip",
|
|
334
|
+
hash: "hash",
|
|
335
|
+
forceUpdate: false,
|
|
336
|
+
enabled: true,
|
|
337
|
+
bundleVersion: 2,
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
platform: "ios",
|
|
341
|
+
targetVersion: "1.0",
|
|
342
|
+
file: "http://example.com/bundle.zip",
|
|
343
|
+
hash: "hash",
|
|
344
|
+
forceUpdate: false,
|
|
345
|
+
enabled: true,
|
|
346
|
+
bundleVersion: 1,
|
|
347
|
+
},
|
|
348
|
+
];
|
|
349
|
+
|
|
350
|
+
const update = await checkForUpdate(updateSources);
|
|
351
|
+
expect(update).toStrictEqual({
|
|
352
|
+
file: "http://example.com/bundle.zip",
|
|
353
|
+
hash: "hash",
|
|
354
|
+
bundleVersion: 3,
|
|
355
|
+
forceUpdate: false,
|
|
356
|
+
status: "UPDATE",
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("should update even if the app version is the same and the bundle version is significantly higher", async () => {
|
|
361
|
+
const updateSources: UpdateSource[] = [
|
|
362
|
+
{
|
|
363
|
+
platform: "ios",
|
|
364
|
+
targetVersion: "1.0",
|
|
365
|
+
file: "http://example.com/bundle.zip",
|
|
366
|
+
hash: "hash",
|
|
367
|
+
forceUpdate: false,
|
|
368
|
+
enabled: true,
|
|
369
|
+
bundleVersion: 5, // Higher than the current version
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
platform: "ios",
|
|
373
|
+
targetVersion: "1.0",
|
|
374
|
+
file: "http://example.com/bundle.zip",
|
|
375
|
+
hash: "hash",
|
|
376
|
+
forceUpdate: false,
|
|
377
|
+
enabled: true,
|
|
378
|
+
bundleVersion: 4,
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
platform: "ios",
|
|
382
|
+
targetVersion: "1.0",
|
|
383
|
+
file: "http://example.com/bundle.zip",
|
|
384
|
+
hash: "hash",
|
|
385
|
+
forceUpdate: false,
|
|
386
|
+
enabled: true,
|
|
387
|
+
bundleVersion: 3,
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
platform: "ios",
|
|
391
|
+
targetVersion: "1.0",
|
|
392
|
+
file: "http://example.com/bundle.zip",
|
|
393
|
+
hash: "hash",
|
|
394
|
+
forceUpdate: false,
|
|
395
|
+
enabled: true,
|
|
396
|
+
bundleVersion: 2,
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
platform: "ios",
|
|
400
|
+
targetVersion: "1.0",
|
|
401
|
+
file: "http://example.com/bundle.zip",
|
|
402
|
+
hash: "hash",
|
|
403
|
+
forceUpdate: false,
|
|
404
|
+
enabled: true,
|
|
405
|
+
bundleVersion: 1,
|
|
406
|
+
},
|
|
407
|
+
];
|
|
408
|
+
|
|
409
|
+
const update = await checkForUpdate(updateSources);
|
|
410
|
+
expect(update).toStrictEqual({
|
|
411
|
+
file: "http://example.com/bundle.zip",
|
|
412
|
+
hash: "hash",
|
|
413
|
+
bundleVersion: 5,
|
|
414
|
+
forceUpdate: false,
|
|
415
|
+
status: "UPDATE",
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("should not update if the latest version is disabled and matches the current version", async () => {
|
|
420
|
+
const updateSources: UpdateSource[] = [
|
|
421
|
+
{
|
|
422
|
+
platform: "ios",
|
|
423
|
+
targetVersion: "1.0",
|
|
424
|
+
file: "http://example.com/bundle.zip",
|
|
425
|
+
hash: "hash",
|
|
426
|
+
forceUpdate: true,
|
|
427
|
+
enabled: false, // Disabled
|
|
428
|
+
bundleVersion: 3,
|
|
429
|
+
},
|
|
430
|
+
{
|
|
431
|
+
platform: "ios",
|
|
432
|
+
targetVersion: "1.0",
|
|
433
|
+
file: "http://example.com/bundle.zip",
|
|
434
|
+
hash: "hash",
|
|
435
|
+
forceUpdate: true,
|
|
436
|
+
enabled: true,
|
|
437
|
+
bundleVersion: 2,
|
|
438
|
+
},
|
|
439
|
+
{
|
|
440
|
+
platform: "ios",
|
|
441
|
+
targetVersion: "1.0",
|
|
442
|
+
file: "http://example.com/bundle.zip",
|
|
443
|
+
hash: "hash",
|
|
444
|
+
forceUpdate: false,
|
|
445
|
+
enabled: true,
|
|
446
|
+
bundleVersion: 1,
|
|
447
|
+
},
|
|
448
|
+
];
|
|
449
|
+
|
|
450
|
+
const update = await checkForUpdate(updateSources);
|
|
451
|
+
expect(update).toBeNull();
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("should rollback to a previous version if the current version is disabled", async () => {
|
|
455
|
+
const updateSources: UpdateSource[] = [
|
|
456
|
+
{
|
|
457
|
+
platform: "ios",
|
|
458
|
+
targetVersion: "1.0",
|
|
459
|
+
file: "http://example.com/bundle.zip",
|
|
460
|
+
hash: "hash",
|
|
461
|
+
forceUpdate: true,
|
|
462
|
+
enabled: false, // Disabled
|
|
463
|
+
bundleVersion: 2,
|
|
464
|
+
},
|
|
465
|
+
{
|
|
466
|
+
platform: "ios",
|
|
467
|
+
targetVersion: "1.0",
|
|
468
|
+
file: "http://example.com/bundle.zip",
|
|
469
|
+
hash: "hash",
|
|
470
|
+
forceUpdate: false,
|
|
471
|
+
enabled: true,
|
|
472
|
+
bundleVersion: 1,
|
|
473
|
+
},
|
|
474
|
+
];
|
|
475
|
+
|
|
476
|
+
const update = await checkForUpdate(updateSources);
|
|
477
|
+
expect(update).toStrictEqual({
|
|
478
|
+
file: "http://example.com/bundle.zip",
|
|
479
|
+
hash: "hash",
|
|
480
|
+
bundleVersion: 1,
|
|
481
|
+
forceUpdate: true, // Cause the app to reload
|
|
482
|
+
status: "ROLLBACK",
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it("should rollback to the original bundle when receiving the latest bundle but all updates are disabled", async () => {
|
|
487
|
+
const updateSources: UpdateSource[] = [
|
|
488
|
+
{
|
|
489
|
+
platform: "ios",
|
|
490
|
+
targetVersion: "1.0",
|
|
491
|
+
file: "http://example.com/bundle.zip",
|
|
492
|
+
hash: "hash",
|
|
493
|
+
forceUpdate: true,
|
|
494
|
+
enabled: false, // Disabled
|
|
495
|
+
bundleVersion: 2,
|
|
496
|
+
},
|
|
497
|
+
{
|
|
498
|
+
platform: "ios",
|
|
499
|
+
targetVersion: "1.0",
|
|
500
|
+
file: "http://example.com/bundle.zip",
|
|
501
|
+
hash: "hash",
|
|
502
|
+
forceUpdate: false,
|
|
503
|
+
enabled: false, // Disabled
|
|
504
|
+
bundleVersion: 1,
|
|
505
|
+
},
|
|
506
|
+
];
|
|
507
|
+
|
|
508
|
+
const update = await checkForUpdate(updateSources);
|
|
509
|
+
expect(update).toStrictEqual({
|
|
510
|
+
file: null,
|
|
511
|
+
hash: null,
|
|
512
|
+
bundleVersion: 0,
|
|
513
|
+
forceUpdate: true, // Cause the app to reload
|
|
514
|
+
status: "ROLLBACK",
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { UpdateSource, UpdateSourceArg } from "@hot-updater/utils";
|
|
2
|
+
import { filterTargetVersion } from "@hot-updater/utils";
|
|
3
|
+
import { Platform } from "react-native";
|
|
4
|
+
import { getAppVersion, getBundleVersion } from "./native";
|
|
5
|
+
import { isNullable } from "./utils";
|
|
6
|
+
export type UpdateStatus = "ROLLBACK" | "UPDATE";
|
|
7
|
+
|
|
8
|
+
const findLatestSources = (sources: UpdateSource[]) => {
|
|
9
|
+
return (
|
|
10
|
+
sources
|
|
11
|
+
?.filter((item) => item.enabled)
|
|
12
|
+
?.sort((a, b) => b.bundleVersion - a.bundleVersion)?.[0] ?? null
|
|
13
|
+
);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const checkForRollback = (
|
|
17
|
+
sources: UpdateSource[],
|
|
18
|
+
currentBundleVersion: number,
|
|
19
|
+
) => {
|
|
20
|
+
const enabled = sources?.find(
|
|
21
|
+
(item) => item.bundleVersion === currentBundleVersion,
|
|
22
|
+
)?.enabled;
|
|
23
|
+
const availableOldVersion = sources?.find(
|
|
24
|
+
(item) => item.bundleVersion < currentBundleVersion && item.enabled,
|
|
25
|
+
)?.enabled;
|
|
26
|
+
|
|
27
|
+
if (isNullable(enabled)) {
|
|
28
|
+
return availableOldVersion;
|
|
29
|
+
}
|
|
30
|
+
return !enabled;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const ensureUpdateSource = async (updateSource: UpdateSourceArg) => {
|
|
34
|
+
let source: UpdateSource[] | null = null;
|
|
35
|
+
if (typeof updateSource === "string") {
|
|
36
|
+
if (updateSource.startsWith("http")) {
|
|
37
|
+
const response = await fetch(updateSource);
|
|
38
|
+
source = (await response.json()) as UpdateSource[];
|
|
39
|
+
}
|
|
40
|
+
} else if (typeof updateSource === "function") {
|
|
41
|
+
source = await updateSource();
|
|
42
|
+
} else {
|
|
43
|
+
source = updateSource;
|
|
44
|
+
}
|
|
45
|
+
if (!source) {
|
|
46
|
+
throw new Error("Invalid source");
|
|
47
|
+
}
|
|
48
|
+
return source;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const checkForUpdate = async (updateSources: UpdateSourceArg) => {
|
|
52
|
+
const sources = await ensureUpdateSource(updateSources);
|
|
53
|
+
|
|
54
|
+
const currentAppVersion = await getAppVersion();
|
|
55
|
+
const platform = Platform.OS as "ios" | "android";
|
|
56
|
+
|
|
57
|
+
const appVersionSources = currentAppVersion
|
|
58
|
+
? filterTargetVersion(sources, currentAppVersion, platform)
|
|
59
|
+
: [];
|
|
60
|
+
const currentBundleVersion = await getBundleVersion();
|
|
61
|
+
|
|
62
|
+
const isRollback = checkForRollback(appVersionSources, currentBundleVersion);
|
|
63
|
+
const latestSource = await findLatestSources(appVersionSources);
|
|
64
|
+
|
|
65
|
+
if (!latestSource) {
|
|
66
|
+
if (isRollback) {
|
|
67
|
+
return {
|
|
68
|
+
bundleVersion: 0,
|
|
69
|
+
forceUpdate: true,
|
|
70
|
+
file: null,
|
|
71
|
+
hash: null,
|
|
72
|
+
status: "ROLLBACK" as UpdateStatus,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (latestSource.file)
|
|
79
|
+
if (isRollback) {
|
|
80
|
+
if (latestSource.bundleVersion === currentBundleVersion) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
if (latestSource.bundleVersion > currentBundleVersion) {
|
|
84
|
+
return {
|
|
85
|
+
bundleVersion: latestSource.bundleVersion,
|
|
86
|
+
forceUpdate: latestSource.forceUpdate,
|
|
87
|
+
file: latestSource.file,
|
|
88
|
+
hash: latestSource.hash,
|
|
89
|
+
status: "UPDATE" as UpdateStatus,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
bundleVersion: latestSource.bundleVersion,
|
|
94
|
+
forceUpdate: true,
|
|
95
|
+
file: latestSource.file,
|
|
96
|
+
hash: latestSource.hash,
|
|
97
|
+
status: "ROLLBACK" as UpdateStatus,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (latestSource.bundleVersion > currentBundleVersion) {
|
|
102
|
+
return {
|
|
103
|
+
bundleVersion: latestSource.bundleVersion,
|
|
104
|
+
forceUpdate: latestSource.forceUpdate,
|
|
105
|
+
file: latestSource.file,
|
|
106
|
+
hash: latestSource.hash,
|
|
107
|
+
status: "UPDATE" as UpdateStatus,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
};
|
package/src/error.ts
ADDED
package/src/index.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { checkForUpdate } from "./checkForUpdate";
|
|
2
|
+
import { init } from "./init";
|
|
3
|
+
import { getAppVersion, getBundleVersion, reload } from "./native";
|
|
4
|
+
|
|
5
|
+
export type * from "./init";
|
|
6
|
+
export type * from "./checkForUpdate";
|
|
7
|
+
export type * from "./native";
|
|
8
|
+
|
|
9
|
+
export const HotUpdater = {
|
|
10
|
+
init,
|
|
11
|
+
reload,
|
|
12
|
+
checkForUpdate,
|
|
13
|
+
getAppVersion,
|
|
14
|
+
getBundleVersion,
|
|
15
|
+
};
|
package/src/init.tsx
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { UpdateSourceArg } from "@hot-updater/utils";
|
|
2
|
+
import { Platform } from "react-native";
|
|
3
|
+
import { checkForUpdate } from "./checkForUpdate";
|
|
4
|
+
import { HotUpdaterError } from "./error";
|
|
5
|
+
import { initializeOnAppUpdate, reload, updateBundle } from "./native";
|
|
6
|
+
|
|
7
|
+
export type HotUpdaterStatus = "INSTALLING_UPDATE" | "UP_TO_DATE";
|
|
8
|
+
|
|
9
|
+
export interface HotUpdaterInitConfig {
|
|
10
|
+
source: UpdateSourceArg;
|
|
11
|
+
onSuccess?: (status: HotUpdaterStatus) => void;
|
|
12
|
+
onError?: (error: HotUpdaterError) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const init = async (config: HotUpdaterInitConfig) => {
|
|
16
|
+
if (__DEV__) {
|
|
17
|
+
console.warn(
|
|
18
|
+
"[HotUpdater] __DEV__ is true, HotUpdater is only supported in production",
|
|
19
|
+
);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!["ios", "android"].includes(Platform.OS)) {
|
|
24
|
+
const error = new HotUpdaterError(
|
|
25
|
+
"HotUpdater is only supported on iOS and Android",
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
config?.onError?.(error);
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
await initializeOnAppUpdate();
|
|
32
|
+
|
|
33
|
+
const update = await checkForUpdate(config.source);
|
|
34
|
+
if (!update) {
|
|
35
|
+
config?.onSuccess?.("UP_TO_DATE");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const isSuccess = await updateBundle(update.bundleVersion, update.file);
|
|
41
|
+
if (isSuccess && update.forceUpdate) {
|
|
42
|
+
reload();
|
|
43
|
+
config?.onSuccess?.("INSTALLING_UPDATE");
|
|
44
|
+
}
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (error instanceof HotUpdaterError) {
|
|
47
|
+
config?.onError?.(error);
|
|
48
|
+
}
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
};
|
package/src/native.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { NativeModules } from "react-native";
|
|
2
|
+
import { HotUpdaterError } from "./error";
|
|
3
|
+
|
|
4
|
+
const { HotUpdater } = NativeModules;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Fetches the current bundle version id.
|
|
8
|
+
*
|
|
9
|
+
* @async
|
|
10
|
+
* @returns {Promise<number>} Resolves with the current version id or null if not available.
|
|
11
|
+
*/
|
|
12
|
+
export const getBundleVersion = async (): Promise<number> => {
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
HotUpdater.getBundleVersion((version: number | null) => {
|
|
15
|
+
resolve(version ?? -1);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Downloads files from given URLs.
|
|
22
|
+
*
|
|
23
|
+
* @async
|
|
24
|
+
* @param {string} bundleVersion - identifier for the bundle version.
|
|
25
|
+
* @param {string | null} zipUrl - zip file URL.
|
|
26
|
+
* @returns {Promise<boolean>} Resolves with true if download was successful, otherwise rejects with an error.
|
|
27
|
+
*/
|
|
28
|
+
export const updateBundle = (
|
|
29
|
+
bundleVersion: number,
|
|
30
|
+
zipUrl: string | null,
|
|
31
|
+
): Promise<boolean> => {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
HotUpdater.updateBundle(
|
|
34
|
+
String(bundleVersion),
|
|
35
|
+
zipUrl,
|
|
36
|
+
(success: boolean) => {
|
|
37
|
+
if (success) {
|
|
38
|
+
resolve(success);
|
|
39
|
+
} else {
|
|
40
|
+
reject(
|
|
41
|
+
new HotUpdaterError("Failed to download and install the update"),
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Fetches the current app version.
|
|
51
|
+
*/
|
|
52
|
+
export const getAppVersion = async (): Promise<string | null> => {
|
|
53
|
+
return new Promise((resolve) => {
|
|
54
|
+
HotUpdater.getAppVersion((version: string | null) => {
|
|
55
|
+
resolve(version);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Reloads the app.
|
|
62
|
+
*/
|
|
63
|
+
export const reload = () => {
|
|
64
|
+
HotUpdater.reload();
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Initializes the HotUpdater.
|
|
69
|
+
*/
|
|
70
|
+
export const initializeOnAppUpdate = () => {
|
|
71
|
+
HotUpdater.initializeOnAppUpdate();
|
|
72
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { TurboModule } from "react-native";
|
|
2
|
+
import { TurboModuleRegistry } from "react-native";
|
|
3
|
+
|
|
4
|
+
interface Spec extends TurboModule {
|
|
5
|
+
reload(): void;
|
|
6
|
+
updateBundle(
|
|
7
|
+
prefix: string,
|
|
8
|
+
zipUrl: string | null,
|
|
9
|
+
callback: (success: boolean) => void,
|
|
10
|
+
): Promise<boolean>;
|
|
11
|
+
initializeOnAppUpdate(): void;
|
|
12
|
+
getAppVersion(): Promise<string | null>;
|
|
13
|
+
getBundleVersion(): Promise<number | null>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default TurboModuleRegistry.get<Spec>("HotUpdater");
|
package/src/utils.ts
ADDED