@hot-updater/react-native 0.30.12 → 0.31.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.
Files changed (119) hide show
  1. package/HotUpdater.podspec +1 -0
  2. package/android/build.gradle +1 -0
  3. package/android/src/main/java/com/hotupdater/BsdiffPatch.kt +284 -0
  4. package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +885 -5
  5. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +13 -2
  6. package/android/src/main/java/com/hotupdater/OkHttpDownloadService.kt +37 -8
  7. package/android/src/main/java/com/hotupdater/ReactNativeValueConverters.kt +48 -12
  8. package/android/src/main/java/com/hotupdater/SignatureVerifier.kt +60 -22
  9. package/android/src/newarch/HotUpdater.kt +14 -2
  10. package/android/src/newarch/HotUpdaterModule.kt +112 -1
  11. package/android/src/oldarch/HotUpdater.kt +14 -2
  12. package/android/src/oldarch/HotUpdaterModule.kt +107 -3
  13. package/android/src/test/java/com/hotupdater/BsdiffPatchTest.kt +46 -0
  14. package/android/src/test/java/com/hotupdater/BundleFileStorageServiceTest.kt +29 -1
  15. package/android/src/test/java/com/hotupdater/HotUpdaterImplTest.kt +4 -1
  16. package/ios/HotUpdater/Internal/BsdiffPatchBridge.h +16 -0
  17. package/ios/HotUpdater/Internal/BsdiffPatchBridge.mm +343 -0
  18. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +1093 -47
  19. package/ios/HotUpdater/Internal/HotUpdater-Bridging-Header.h +2 -0
  20. package/ios/HotUpdater/Internal/HotUpdater.mm +12 -2
  21. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +64 -5
  22. package/ios/HotUpdater/Internal/NotificationExtension.swift +2 -1
  23. package/ios/HotUpdater/Internal/SignatureVerifier.swift +28 -5
  24. package/ios/HotUpdater/Internal/StreamingTarArchiveExtractor.swift +11 -0
  25. package/ios/HotUpdater/Internal/URLSessionDownloadService.swift +11 -5
  26. package/lib/commonjs/DefaultResolver.js +6 -2
  27. package/lib/commonjs/DefaultResolver.js.map +1 -1
  28. package/lib/commonjs/DefaultResolver.spec.js +79 -3
  29. package/lib/commonjs/DefaultResolver.spec.js.map +1 -1
  30. package/lib/commonjs/checkForUpdate.js +6 -0
  31. package/lib/commonjs/checkForUpdate.js.map +1 -1
  32. package/lib/commonjs/index.js +53 -18
  33. package/lib/commonjs/index.js.map +1 -1
  34. package/lib/commonjs/index.spec.js +117 -1
  35. package/lib/commonjs/index.spec.js.map +1 -1
  36. package/lib/commonjs/native.js +40 -16
  37. package/lib/commonjs/native.js.map +1 -1
  38. package/lib/commonjs/native.spec.js +119 -3
  39. package/lib/commonjs/native.spec.js.map +1 -1
  40. package/lib/commonjs/sdkVersion.js +8 -0
  41. package/lib/commonjs/sdkVersion.js.map +1 -0
  42. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  43. package/lib/commonjs/store.js +72 -5
  44. package/lib/commonjs/store.js.map +1 -1
  45. package/lib/commonjs/store.spec.js +265 -8
  46. package/lib/commonjs/store.spec.js.map +1 -1
  47. package/lib/commonjs/wrap.js +19 -5
  48. package/lib/commonjs/wrap.js.map +1 -1
  49. package/lib/commonjs/wrap.spec.js +74 -0
  50. package/lib/commonjs/wrap.spec.js.map +1 -1
  51. package/lib/module/DefaultResolver.js +6 -2
  52. package/lib/module/DefaultResolver.js.map +1 -1
  53. package/lib/module/DefaultResolver.spec.js +78 -3
  54. package/lib/module/DefaultResolver.spec.js.map +1 -1
  55. package/lib/module/checkForUpdate.js +6 -0
  56. package/lib/module/checkForUpdate.js.map +1 -1
  57. package/lib/module/index.js +53 -18
  58. package/lib/module/index.js.map +1 -1
  59. package/lib/module/index.spec.js +117 -1
  60. package/lib/module/index.spec.js.map +1 -1
  61. package/lib/module/native.js +40 -16
  62. package/lib/module/native.js.map +1 -1
  63. package/lib/module/native.spec.js +119 -3
  64. package/lib/module/native.spec.js.map +1 -1
  65. package/lib/module/sdkVersion.js +4 -0
  66. package/lib/module/sdkVersion.js.map +1 -0
  67. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  68. package/lib/module/store.js +72 -5
  69. package/lib/module/store.js.map +1 -1
  70. package/lib/module/store.spec.js +265 -8
  71. package/lib/module/store.spec.js.map +1 -1
  72. package/lib/module/wrap.js +19 -5
  73. package/lib/module/wrap.js.map +1 -1
  74. package/lib/module/wrap.spec.js +74 -0
  75. package/lib/module/wrap.spec.js.map +1 -1
  76. package/lib/typescript/commonjs/DefaultResolver.d.ts.map +1 -1
  77. package/lib/typescript/commonjs/checkForUpdate.d.ts +2 -2
  78. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  79. package/lib/typescript/commonjs/index.d.ts +36 -8
  80. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  81. package/lib/typescript/commonjs/native.d.ts +56 -3
  82. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  83. package/lib/typescript/commonjs/sdkVersion.d.ts +2 -0
  84. package/lib/typescript/commonjs/sdkVersion.d.ts.map +1 -0
  85. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +27 -0
  86. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  87. package/lib/typescript/commonjs/store.d.ts +5 -0
  88. package/lib/typescript/commonjs/store.d.ts.map +1 -1
  89. package/lib/typescript/commonjs/wrap.d.ts +43 -29
  90. package/lib/typescript/commonjs/wrap.d.ts.map +1 -1
  91. package/lib/typescript/module/DefaultResolver.d.ts.map +1 -1
  92. package/lib/typescript/module/checkForUpdate.d.ts +2 -2
  93. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  94. package/lib/typescript/module/index.d.ts +36 -8
  95. package/lib/typescript/module/index.d.ts.map +1 -1
  96. package/lib/typescript/module/native.d.ts +56 -3
  97. package/lib/typescript/module/native.d.ts.map +1 -1
  98. package/lib/typescript/module/sdkVersion.d.ts +2 -0
  99. package/lib/typescript/module/sdkVersion.d.ts.map +1 -0
  100. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +27 -0
  101. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  102. package/lib/typescript/module/store.d.ts +5 -0
  103. package/lib/typescript/module/store.d.ts.map +1 -1
  104. package/lib/typescript/module/wrap.d.ts +43 -29
  105. package/lib/typescript/module/wrap.d.ts.map +1 -1
  106. package/package.json +17 -9
  107. package/src/DefaultResolver.spec.ts +103 -3
  108. package/src/DefaultResolver.ts +9 -2
  109. package/src/checkForUpdate.ts +9 -2
  110. package/src/index.spec.ts +149 -1
  111. package/src/index.ts +99 -40
  112. package/src/native.spec.ts +139 -1
  113. package/src/native.ts +145 -31
  114. package/src/sdkVersion.ts +1 -0
  115. package/src/specs/NativeHotUpdater.ts +28 -0
  116. package/src/store.spec.ts +324 -9
  117. package/src/store.ts +133 -7
  118. package/src/wrap.spec.ts +91 -0
  119. package/src/wrap.tsx +79 -42
@@ -2,6 +2,21 @@ import type { TurboModule } from "react-native";
2
2
  import { TurboModuleRegistry } from "react-native";
3
3
  import type { UnsafeObject } from "react-native/Libraries/Types/CodegenTypes";
4
4
 
5
+ export interface ChangedAsset {
6
+ file?: {
7
+ compression?: "br" | null;
8
+ url: string;
9
+ } | null;
10
+ fileHash: string;
11
+ patch?: {
12
+ algorithm: "bsdiff";
13
+ baseBundleId: string;
14
+ baseFileHash: string;
15
+ patchFileHash: string;
16
+ patchUrl: string;
17
+ } | null;
18
+ }
19
+
5
20
  export interface UpdateBundleParams {
6
21
  bundleId: string;
7
22
  channel?: string;
@@ -16,6 +31,19 @@ export interface UpdateBundleParams {
16
31
  * Native determines verification mode by checking for "sig:" prefix.
17
32
  */
18
33
  fileHash: string | null;
34
+ /**
35
+ * Optional signed manifest URL for manifest-driven installation.
36
+ */
37
+ manifestUrl?: string | null;
38
+ /**
39
+ * File hash/signature for the manifest file itself.
40
+ */
41
+ manifestFileHash?: string | null;
42
+ /**
43
+ * Per-file URLs for assets that must be downloaded instead of reused from
44
+ * the currently active bundle.
45
+ */
46
+ changedAssets?: UnsafeObject | null;
19
47
  }
20
48
 
21
49
  export interface Spec extends TurboModule {
package/src/store.spec.ts CHANGED
@@ -1,9 +1,18 @@
1
1
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
2
 
3
- const addListenerMock = vi.hoisted(() => vi.fn());
3
+ const listeners = vi.hoisted(
4
+ () => new Map<string, (event: Record<string, unknown>) => void>(),
5
+ );
4
6
 
5
7
  vi.mock("./native", () => ({
6
- addListener: addListenerMock,
8
+ addListener: vi.fn(
9
+ (eventName: string, listener: (event: Record<string, unknown>) => void) => {
10
+ listeners.set(eventName, listener);
11
+ return () => {
12
+ listeners.delete(eventName);
13
+ };
14
+ },
15
+ ),
7
16
  }));
8
17
 
9
18
  const importStore = async () => {
@@ -13,8 +22,8 @@ const importStore = async () => {
13
22
 
14
23
  describe("hotUpdaterStore", () => {
15
24
  beforeEach(() => {
25
+ listeners.clear();
16
26
  vi.resetModules();
17
- addListenerMock.mockReset();
18
27
  });
19
28
 
20
29
  it("does not notify subscribers when state values are unchanged", async () => {
@@ -35,23 +44,329 @@ describe("hotUpdaterStore", () => {
35
44
  it("notifies subscribers only when progress events change the snapshot", async () => {
36
45
  const store = await importStore();
37
46
  const listener = vi.fn();
38
- const progressListener = addListenerMock.mock.calls[0][1] as (state: {
39
- progress: number;
40
- }) => void;
47
+ const emitProgress = listeners.get("onProgress");
48
+
49
+ expect(emitProgress).toBeTypeOf("function");
41
50
 
42
51
  const unsubscribe = store.subscribe(listener);
43
52
 
44
- progressListener({ progress: 0.5 });
45
- progressListener({ progress: 0.5 });
46
- progressListener({ progress: 1 });
53
+ emitProgress?.({
54
+ artifactType: "archive",
55
+ progress: 0.5,
56
+ });
57
+ emitProgress?.({
58
+ artifactType: "archive",
59
+ progress: 0.5,
60
+ });
61
+ emitProgress?.({
62
+ artifactType: "archive",
63
+ progress: 1,
64
+ });
47
65
  store.setState({ isUpdateDownloaded: true });
48
66
 
49
67
  expect(listener).toHaveBeenCalledTimes(2);
50
68
  expect(store.getSnapshot()).toEqual({
69
+ artifactType: "archive",
70
+ details: null,
71
+ downloadedBytes: undefined,
51
72
  isUpdateDownloaded: true,
52
73
  progress: 1,
74
+ totalBytes: undefined,
53
75
  });
54
76
 
55
77
  unsubscribe();
56
78
  });
79
+
80
+ it("stores archive byte metadata and notifies when only bytes change", async () => {
81
+ const store = await importStore();
82
+ const listener = vi.fn();
83
+ const emitProgress = listeners.get("onProgress");
84
+
85
+ const unsubscribe = store.subscribe(listener);
86
+
87
+ emitProgress?.({
88
+ artifactType: "archive",
89
+ downloadedBytes: 100.4,
90
+ progress: 0.5,
91
+ totalBytes: 1_000,
92
+ });
93
+ emitProgress?.({
94
+ artifactType: "archive",
95
+ downloadedBytes: 250,
96
+ progress: 0.5,
97
+ totalBytes: 1_000,
98
+ });
99
+
100
+ expect(listener).toHaveBeenCalledTimes(2);
101
+ expect(store.getSnapshot()).toEqual({
102
+ artifactType: "archive",
103
+ details: null,
104
+ downloadedBytes: 250,
105
+ isUpdateDownloaded: false,
106
+ progress: 0.5,
107
+ totalBytes: 1_000,
108
+ });
109
+
110
+ unsubscribe();
111
+ });
112
+
113
+ it("stores manifest progress metadata while keeping overall progress", async () => {
114
+ const store = await importStore();
115
+ const emitProgress = listeners.get("onProgress");
116
+
117
+ expect(emitProgress).toBeTypeOf("function");
118
+
119
+ emitProgress?.({
120
+ artifactType: "diff",
121
+ details: {
122
+ completedFilesCount: 1,
123
+ files: [
124
+ {
125
+ downloadPath: "index.ios.bundle",
126
+ order: 0,
127
+ path: "index.ios.bundle",
128
+ progress: 1,
129
+ status: "downloaded",
130
+ },
131
+ {
132
+ downloadPath: "assets/logo.png",
133
+ order: 1,
134
+ path: "assets/logo.png",
135
+ progress: 0.5,
136
+ status: "downloading",
137
+ },
138
+ ],
139
+ totalFilesCount: 2,
140
+ },
141
+ progress: 0.42,
142
+ });
143
+
144
+ expect(store.getSnapshot()).toEqual({
145
+ artifactType: "diff",
146
+ details: {
147
+ completedFilesCount: 1,
148
+ files: [
149
+ {
150
+ downloadPath: "index.ios.bundle",
151
+ order: 0,
152
+ path: "index.ios.bundle",
153
+ progress: 1,
154
+ status: "downloaded",
155
+ },
156
+ {
157
+ downloadPath: "assets/logo.png",
158
+ order: 1,
159
+ path: "assets/logo.png",
160
+ progress: 0.5,
161
+ status: "downloading",
162
+ },
163
+ ],
164
+ totalFilesCount: 2,
165
+ },
166
+ downloadedBytes: undefined,
167
+ isUpdateDownloaded: false,
168
+ progress: 0.42,
169
+ totalBytes: undefined,
170
+ });
171
+ });
172
+
173
+ it("preserves patch download metadata for manifest diff files", async () => {
174
+ const store = await importStore();
175
+ const emitProgress = listeners.get("onProgress");
176
+
177
+ emitProgress?.({
178
+ artifactType: "diff",
179
+ details: {
180
+ completedFilesCount: 0,
181
+ files: [
182
+ {
183
+ downloadPath: "index.ios.bundle.bsdiff",
184
+ order: 0,
185
+ path: "index.ios.bundle",
186
+ progress: 0.4,
187
+ status: "downloading",
188
+ },
189
+ ],
190
+ totalFilesCount: 1,
191
+ },
192
+ progress: 0.32,
193
+ });
194
+
195
+ expect(store.getSnapshot().details?.files).toEqual([
196
+ {
197
+ downloadPath: "index.ios.bundle.bsdiff",
198
+ downloadedBytes: undefined,
199
+ order: 0,
200
+ path: "index.ios.bundle",
201
+ progress: 0.4,
202
+ status: "downloading",
203
+ totalBytes: undefined,
204
+ },
205
+ ]);
206
+ });
207
+
208
+ it("stores diff snapshot transitions from downloading to downloaded", async () => {
209
+ const store = await importStore();
210
+ const emitProgress = listeners.get("onProgress");
211
+
212
+ emitProgress?.({
213
+ artifactType: "diff",
214
+ details: {
215
+ completedFilesCount: 0,
216
+ files: [
217
+ {
218
+ order: 0,
219
+ path: "index.ios.bundle",
220
+ progress: 0.6,
221
+ status: "downloading",
222
+ },
223
+ {
224
+ order: 1,
225
+ path: "assets/logo.png",
226
+ progress: 0,
227
+ status: "pending",
228
+ },
229
+ ],
230
+ totalFilesCount: 2,
231
+ },
232
+ progress: 0.4,
233
+ });
234
+
235
+ emitProgress?.({
236
+ artifactType: "diff",
237
+ details: {
238
+ completedFilesCount: 1,
239
+ files: [
240
+ {
241
+ downloadPath: "index.ios.bundle",
242
+ order: 0,
243
+ path: "index.ios.bundle",
244
+ progress: 1,
245
+ status: "downloaded",
246
+ },
247
+ {
248
+ downloadPath: "assets/logo.png",
249
+ order: 1,
250
+ path: "assets/logo.png",
251
+ progress: 0,
252
+ status: "pending",
253
+ },
254
+ ],
255
+ totalFilesCount: 2,
256
+ },
257
+ progress: 0.6,
258
+ });
259
+
260
+ expect(store.getSnapshot()).toEqual({
261
+ artifactType: "diff",
262
+ details: {
263
+ completedFilesCount: 1,
264
+ files: [
265
+ {
266
+ downloadPath: "index.ios.bundle",
267
+ order: 0,
268
+ path: "index.ios.bundle",
269
+ progress: 1,
270
+ status: "downloaded",
271
+ },
272
+ {
273
+ downloadPath: "assets/logo.png",
274
+ order: 1,
275
+ path: "assets/logo.png",
276
+ progress: 0,
277
+ status: "pending",
278
+ },
279
+ ],
280
+ totalFilesCount: 2,
281
+ },
282
+ downloadedBytes: undefined,
283
+ isUpdateDownloaded: false,
284
+ progress: 0.6,
285
+ totalBytes: undefined,
286
+ });
287
+ });
288
+
289
+ it("clears manifest metadata when archive progress events arrive", async () => {
290
+ const store = await importStore();
291
+ const emitProgress = listeners.get("onProgress");
292
+
293
+ emitProgress?.({
294
+ artifactType: "diff",
295
+ details: {
296
+ completedFilesCount: 2,
297
+ files: [
298
+ {
299
+ order: 0,
300
+ path: "index.ios.bundle",
301
+ progress: 1,
302
+ status: "downloaded",
303
+ },
304
+ {
305
+ order: 1,
306
+ path: "assets/logo.png",
307
+ progress: 1,
308
+ status: "downloaded",
309
+ },
310
+ {
311
+ order: 2,
312
+ path: "assets/bg.png",
313
+ progress: 0,
314
+ status: "pending",
315
+ },
316
+ ],
317
+ totalFilesCount: 3,
318
+ },
319
+ progress: 0.9,
320
+ });
321
+
322
+ emitProgress?.({
323
+ artifactType: "archive",
324
+ progress: 0.25,
325
+ });
326
+
327
+ expect(store.getSnapshot()).toEqual({
328
+ artifactType: "archive",
329
+ details: null,
330
+ downloadedBytes: undefined,
331
+ isUpdateDownloaded: false,
332
+ progress: 0.25,
333
+ totalBytes: undefined,
334
+ });
335
+ });
336
+
337
+ it("clears stale manifest metadata even when archive events omit optional fields", async () => {
338
+ const store = await importStore();
339
+ const emitProgress = listeners.get("onProgress");
340
+
341
+ emitProgress?.({
342
+ artifactType: "diff",
343
+ details: {
344
+ completedFilesCount: 0,
345
+ files: [
346
+ {
347
+ order: 0,
348
+ path: "manifest.asset.png",
349
+ progress: 0,
350
+ status: "failed",
351
+ },
352
+ ],
353
+ totalFilesCount: 1,
354
+ },
355
+ progress: 0.2,
356
+ });
357
+
358
+ emitProgress?.({
359
+ artifactType: "archive",
360
+ progress: 0.4,
361
+ });
362
+
363
+ expect(store.getSnapshot()).toEqual({
364
+ artifactType: "archive",
365
+ details: null,
366
+ downloadedBytes: undefined,
367
+ isUpdateDownloaded: false,
368
+ progress: 0.4,
369
+ totalBytes: undefined,
370
+ });
371
+ });
57
372
  });
package/src/store.ts CHANGED
@@ -1,18 +1,88 @@
1
1
  import useSyncExternalStoreExports from "use-sync-external-store/shim/with-selector";
2
2
 
3
+ import type {
4
+ HotUpdaterDiffFileSnapshot,
5
+ HotUpdaterDiffProgressDetails,
6
+ HotUpdaterProgressEvent,
7
+ } from "./native";
3
8
  import { addListener } from "./native";
4
9
 
5
10
  export type HotUpdaterState = {
6
11
  progress: number;
12
+ downloadedBytes: number | undefined;
13
+ totalBytes: number | undefined;
7
14
  isUpdateDownloaded: boolean;
15
+ artifactType: "archive" | "diff" | null;
16
+ details: HotUpdaterDiffProgressDetails | null;
8
17
  };
9
18
 
10
19
  const { useSyncExternalStoreWithSelector } = useSyncExternalStoreExports;
11
20
 
21
+ const areDiffDetailsEqual = (
22
+ left: HotUpdaterDiffProgressDetails | null,
23
+ right: HotUpdaterDiffProgressDetails | null,
24
+ ) => {
25
+ if (left === right) {
26
+ return true;
27
+ }
28
+
29
+ if (left === null || right === null) {
30
+ return false;
31
+ }
32
+
33
+ if (
34
+ left.totalFilesCount !== right.totalFilesCount ||
35
+ left.completedFilesCount !== right.completedFilesCount ||
36
+ left.files.length !== right.files.length
37
+ ) {
38
+ return false;
39
+ }
40
+
41
+ return left.files.every((leftFile, index) => {
42
+ const rightFile = right.files[index];
43
+ return (
44
+ rightFile !== undefined &&
45
+ leftFile.order === rightFile.order &&
46
+ leftFile.path === rightFile.path &&
47
+ leftFile.downloadPath === rightFile.downloadPath &&
48
+ leftFile.downloadedBytes === rightFile.downloadedBytes &&
49
+ leftFile.progress === rightFile.progress &&
50
+ leftFile.status === rightFile.status &&
51
+ leftFile.totalBytes === rightFile.totalBytes
52
+ );
53
+ });
54
+ };
55
+
56
+ const areStatesEqual = (left: HotUpdaterState, right: HotUpdaterState) => {
57
+ return (
58
+ left.progress === right.progress &&
59
+ left.downloadedBytes === right.downloadedBytes &&
60
+ left.totalBytes === right.totalBytes &&
61
+ left.isUpdateDownloaded === right.isUpdateDownloaded &&
62
+ left.artifactType === right.artifactType &&
63
+ areDiffDetailsEqual(left.details, right.details)
64
+ );
65
+ };
66
+
67
+ const normalizeByteCount = (value: number | undefined) => {
68
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
69
+ return undefined;
70
+ }
71
+ return Math.round(value);
72
+ };
73
+
74
+ const normalizeDownloadPath = (value: string | undefined) => {
75
+ return typeof value === "string" && value.length > 0 ? value : undefined;
76
+ };
77
+
12
78
  const createHotUpdaterStore = () => {
13
79
  let state: HotUpdaterState = {
14
80
  progress: 0,
81
+ downloadedBytes: undefined,
82
+ totalBytes: undefined,
15
83
  isUpdateDownloaded: false,
84
+ artifactType: null,
85
+ details: null,
16
86
  };
17
87
 
18
88
  const getSnapshot = () => {
@@ -27,8 +97,39 @@ const createHotUpdaterStore = () => {
27
97
  }
28
98
  };
29
99
 
100
+ const normalizeDiffDetails = (
101
+ details: HotUpdaterDiffProgressDetails,
102
+ ): HotUpdaterDiffProgressDetails => {
103
+ const totalFilesCount = Math.max(0, details.totalFilesCount);
104
+ const normalizedFiles: HotUpdaterDiffFileSnapshot[] = details.files
105
+ .map((file) => {
106
+ const downloadPath =
107
+ normalizeDownloadPath(file.downloadPath) ?? file.path;
108
+
109
+ return {
110
+ downloadPath,
111
+ downloadedBytes: normalizeByteCount(file.downloadedBytes),
112
+ order: Math.max(0, file.order),
113
+ path: file.path,
114
+ progress: Math.max(0, Math.min(file.progress, 1)),
115
+ status: file.status,
116
+ totalBytes: normalizeByteCount(file.totalBytes),
117
+ };
118
+ })
119
+ .sort((left, right) => left.order - right.order);
120
+
121
+ return {
122
+ completedFilesCount: Math.max(
123
+ 0,
124
+ Math.min(details.completedFilesCount, totalFilesCount),
125
+ ),
126
+ files: normalizedFiles,
127
+ totalFilesCount,
128
+ };
129
+ };
130
+
30
131
  const setState = (newState: Partial<HotUpdaterState>) => {
31
- // Merge first, then normalize derived fields
132
+ // Merge first, then normalize derived fields.
32
133
  const nextState: HotUpdaterState = {
33
134
  ...state,
34
135
  ...newState,
@@ -38,7 +139,8 @@ const createHotUpdaterStore = () => {
38
139
  // If `progress` is not provided but `isUpdateDownloaded` is,
39
140
  // honor the explicit value.
40
141
  if ("progress" in newState && typeof newState.progress === "number") {
41
- nextState.isUpdateDownloaded = newState.progress >= 1;
142
+ nextState.progress = Math.max(0, Math.min(newState.progress, 1));
143
+ nextState.isUpdateDownloaded = nextState.progress >= 1;
42
144
  } else if (
43
145
  "isUpdateDownloaded" in newState &&
44
146
  typeof newState.isUpdateDownloaded === "boolean"
@@ -46,10 +148,15 @@ const createHotUpdaterStore = () => {
46
148
  nextState.isUpdateDownloaded = newState.isUpdateDownloaded;
47
149
  }
48
150
 
49
- if (
50
- state.progress === nextState.progress &&
51
- state.isUpdateDownloaded === nextState.isUpdateDownloaded
52
- ) {
151
+ if (newState.details === undefined) {
152
+ nextState.details = state.details;
153
+ }
154
+
155
+ if (nextState.artifactType !== "diff") {
156
+ nextState.details = null;
157
+ }
158
+
159
+ if (areStatesEqual(state, nextState)) {
53
160
  return;
54
161
  }
55
162
 
@@ -57,6 +164,25 @@ const createHotUpdaterStore = () => {
57
164
  emitChange();
58
165
  };
59
166
 
167
+ const applyProgressEvent = (event: HotUpdaterProgressEvent) => {
168
+ setState({
169
+ artifactType: event.artifactType,
170
+ details:
171
+ event.artifactType === "diff"
172
+ ? normalizeDiffDetails(event.details)
173
+ : null,
174
+ downloadedBytes:
175
+ event.artifactType === "archive"
176
+ ? normalizeByteCount(event.downloadedBytes)
177
+ : undefined,
178
+ progress: event.progress,
179
+ totalBytes:
180
+ event.artifactType === "archive"
181
+ ? normalizeByteCount(event.totalBytes)
182
+ : undefined,
183
+ });
184
+ };
185
+
60
186
  const subscribe = (listener: () => void) => {
61
187
  listeners.add(listener);
62
188
  return () => listeners.delete(listener);
@@ -64,7 +190,7 @@ const createHotUpdaterStore = () => {
64
190
 
65
191
  // Subscribe to native onProgress events
66
192
  // This listener is registered once when the store is created
67
- addListener("onProgress", setState);
193
+ addListener("onProgress", applyProgressEvent);
68
194
 
69
195
  return { getSnapshot, setState, subscribe };
70
196
  };
package/src/wrap.spec.ts CHANGED
@@ -1,5 +1,8 @@
1
+ import type React from "react";
1
2
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
3
 
4
+ import type { HotUpdaterOptions } from "./wrap";
5
+
3
6
  const mocks = vi.hoisted(() => ({
4
7
  addListener: vi.fn(() => () => {}),
5
8
  checkForUpdate: vi.fn(),
@@ -78,4 +81,92 @@ describe("HotUpdater wrap initialization", () => {
78
81
  requestTimeout: 1000,
79
82
  });
80
83
  });
84
+
85
+ it("calls init onError when app-ready notification fails", async () => {
86
+ vi.useFakeTimers();
87
+
88
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
89
+ const error = new Error("notify failed");
90
+ const onError = vi.fn();
91
+ const requestAnimationFrame = vi.fn(
92
+ (callback: (timestamp: number) => void) => {
93
+ setTimeout(() => callback(0), 0);
94
+ return 1;
95
+ },
96
+ );
97
+ vi.stubGlobal("requestAnimationFrame", requestAnimationFrame);
98
+ const resolver = {
99
+ checkUpdate: vi.fn(),
100
+ notifyAppReady: vi.fn().mockRejectedValue(error),
101
+ };
102
+ const { init } = await import("./wrap");
103
+
104
+ init({
105
+ resolver,
106
+ onError,
107
+ });
108
+
109
+ await vi.runOnlyPendingTimersAsync();
110
+
111
+ expect(onError).toHaveBeenCalledWith(error);
112
+ warn.mockRestore();
113
+ });
114
+
115
+ it("warns when the deprecated manual wrap HOC is used", async () => {
116
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
117
+ const { wrap } = await import("./wrap");
118
+
119
+ wrap({
120
+ resolver: {
121
+ checkUpdate: vi.fn(),
122
+ notifyAppReady: vi.fn(),
123
+ },
124
+ updateMode: "manual",
125
+ });
126
+
127
+ expect(warn).toHaveBeenCalledWith(
128
+ expect.stringContaining(
129
+ 'HotUpdater.wrap({ updateMode: "manual" }) is deprecated',
130
+ ),
131
+ );
132
+ warn.mockRestore();
133
+ });
134
+
135
+ it("preserves wrapped component prop inference", async () => {
136
+ const { wrap } = await import("./wrap");
137
+ const Component: React.ComponentType<{ title: string }> = () => null;
138
+
139
+ const WrappedComponent = wrap({
140
+ resolver: {
141
+ checkUpdate: vi.fn(),
142
+ notifyAppReady: vi.fn(),
143
+ },
144
+ updateMode: "auto",
145
+ updateStrategy: "appVersion",
146
+ })(Component);
147
+
148
+ const acceptsTitleProps: React.ComponentType<{ title: string }> =
149
+ WrappedComponent;
150
+ expect(acceptsTitleProps).toBe(WrappedComponent);
151
+ });
152
+
153
+ it("types public wrap options as automatic mode by default", () => {
154
+ const autoOptions = {
155
+ baseURL: "https://updates.example.com",
156
+ updateStrategy: "appVersion",
157
+ } satisfies HotUpdaterOptions;
158
+ const explicitAutoOptions = {
159
+ baseURL: "https://updates.example.com",
160
+ updateMode: "auto",
161
+ updateStrategy: "appVersion",
162
+ } satisfies HotUpdaterOptions;
163
+ const manualOptions = {
164
+ baseURL: "https://updates.example.com",
165
+ updateMode: "manual",
166
+ } satisfies HotUpdaterOptions;
167
+
168
+ expect(autoOptions.updateStrategy).toBe("appVersion");
169
+ expect(explicitAutoOptions.updateMode).toBe("auto");
170
+ expect(manualOptions.updateMode).toBe("manual");
171
+ });
81
172
  });