react-native-update 10.49.0 → 10.51.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/BUNDLEHASH_MIGRATION.md +22 -2
- package/NATIVE_CHECKUPDATE_DESIGN.md +251 -21
- package/NATIVE_CHECK_FOLLOWUPS.md +365 -0
- package/README-CN.md +14 -1
- package/README.md +14 -1
- package/android/jni/Android.mk +5 -1
- package/android/lib/arm64-v8a/librnupdate.so +0 -0
- package/android/lib/armeabi-v7a/librnupdate.so +0 -0
- package/android/lib/x86/librnupdate.so +0 -0
- package/android/lib/x86_64/librnupdate.so +0 -0
- package/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java +42 -9
- package/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +98 -22
- package/android/src/main/java/cn/reactnative/modules/update/DownloadTaskParams.java +3 -0
- package/android/src/main/java/cn/reactnative/modules/update/NativeCheckOrchestrator.java +455 -0
- package/android/src/main/java/cn/reactnative/modules/update/NativeUpdateFlow.java +23 -0
- package/android/src/main/java/cn/reactnative/modules/update/PatchFailedException.java +16 -0
- package/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +196 -42
- package/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java +56 -3
- package/android/src/newarch/cn/reactnative/modules/update/UpdateModule.java +10 -0
- package/android/src/oldarch/cn/reactnative/modules/update/UpdateModule.java +10 -0
- package/cpp/patch_core/digest.cpp +53 -0
- package/cpp/patch_core/digest.h +18 -0
- package/cpp/patch_core/patch_core.cpp +17 -1
- package/cpp/patch_core/patch_core.h +8 -0
- package/cpp/patch_core/tests/patch_core_test.cpp +99 -0
- package/cpp/update_flow_core/flow_json.cpp +522 -0
- package/cpp/update_flow_core/flow_json.h +103 -0
- package/cpp/update_flow_core/tests/flow_vectors.json +1401 -0
- package/cpp/update_flow_core/tests/update_flow_core_test.cpp +317 -0
- package/cpp/update_flow_core/update_flow_core.cpp +301 -0
- package/cpp/update_flow_core/update_flow_core.h +77 -0
- package/cpp/update_flow_core/update_flow_jni.cpp +121 -0
- package/harmony/pushy/src/main/cpp/CMakeLists.txt +7 -0
- package/harmony/pushy/src/main/cpp/pushy.cpp +150 -1
- package/harmony/pushy/src/main/ets/DownloadTask.ts +133 -9
- package/harmony/pushy/src/main/ets/DownloadTaskParams.ts +3 -0
- package/harmony/pushy/src/main/ets/MonotonicClock.ts +13 -0
- package/harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts +500 -0
- package/harmony/pushy/src/main/ets/NativePatchCore.ts +16 -0
- package/harmony/pushy/src/main/ets/PathUtils.ts +21 -0
- package/harmony/pushy/src/main/ets/PushyTurboModule.ts +22 -0
- package/harmony/pushy/src/main/ets/UpdateContext.ts +152 -52
- package/harmony/pushy.har +0 -0
- package/ios/RCTPushy/RCTPushy.mm +840 -68
- package/ios/RCTPushy/RCTPushyDownloader.h +1 -0
- package/ios/RCTPushy/RCTPushyDownloader.mm +4 -3
- package/package.json +3 -1
- package/react-native-update.podspec +2 -0
- package/src/NativePushy.ts +14 -0
- package/src/client.ts +439 -122
- package/src/endpoint.ts +105 -80
- package/src/error.ts +4 -0
- package/src/locales/en.ts +4 -0
- package/src/locales/zh.ts +4 -0
- package/src/provider.tsx +74 -9
- package/src/telemetry.ts +8 -0
- package/src/type.ts +31 -0
- package/src/updateFlowCore.ts +320 -0
- package/src/utils.ts +14 -12
- package/src/isInRollout.ts +0 -86
- package/src/resolveCheckResult.ts +0 -30
package/src/client.ts
CHANGED
|
@@ -41,13 +41,18 @@ import type {
|
|
|
41
41
|
UpdateCheckState,
|
|
42
42
|
UpdateServerConfig,
|
|
43
43
|
} from './type';
|
|
44
|
+
import {
|
|
45
|
+
buildCheckRequestBody,
|
|
46
|
+
type DownloadPlan,
|
|
47
|
+
type DownloadStrategyType,
|
|
48
|
+
decideDownload,
|
|
49
|
+
} from './updateFlowCore';
|
|
44
50
|
import {
|
|
45
51
|
assertWeb,
|
|
46
52
|
computeProgress,
|
|
47
53
|
DEFAULT_FETCH_TIMEOUT_MS,
|
|
48
54
|
fetchWithTimeout,
|
|
49
55
|
info,
|
|
50
|
-
joinUrls,
|
|
51
56
|
log,
|
|
52
57
|
noop,
|
|
53
58
|
promiseAny,
|
|
@@ -91,6 +96,11 @@ const cloneServerConfig = (server: UpdateServerConfig): UpdateServerConfig => ({
|
|
|
91
96
|
queryUrls: server.queryUrls ? [...server.queryUrls] : undefined,
|
|
92
97
|
});
|
|
93
98
|
|
|
99
|
+
// Persist an object (rather than an empty string) so every native bridge keeps
|
|
100
|
+
// accepting the payload while the orchestrators read `disabled` and skip the
|
|
101
|
+
// cold-start check entirely.
|
|
102
|
+
const NATIVE_CONFIG_DISABLED_JSON = '{"disabled":true}';
|
|
103
|
+
|
|
94
104
|
const excludeConfiguredEndpoints = (
|
|
95
105
|
endpoints: string[],
|
|
96
106
|
configuredEndpoints: string[]
|
|
@@ -133,6 +143,13 @@ export const sharedState: {
|
|
|
133
143
|
applyingUpdate: false,
|
|
134
144
|
};
|
|
135
145
|
|
|
146
|
+
// The SDK is a process-level singleton: module-level sharedState, the global
|
|
147
|
+
// i18n locale and the native update state are all per-process, so a second
|
|
148
|
+
// client would silently share (and fight over) them. Constructing one is a
|
|
149
|
+
// hard integration error, except for the idempotent re-creation of the same
|
|
150
|
+
// client (same type + appKey), which dev fast-refresh triggers legitimately.
|
|
151
|
+
let activeClient: Pushy | undefined;
|
|
152
|
+
|
|
136
153
|
const assertHash = (hash: string) => {
|
|
137
154
|
if (!sharedState.downloadedHash) {
|
|
138
155
|
log(`no downloaded hash yet, ignore switch to ${hash}`);
|
|
@@ -154,6 +171,10 @@ export class Pushy {
|
|
|
154
171
|
// Endpoint that most recently served a successful checkUpdate; telemetry
|
|
155
172
|
// reuses it instead of re-running the fallback race.
|
|
156
173
|
private lastWorkingEndpoint?: string;
|
|
174
|
+
private syncedNativeConfigJson?: string;
|
|
175
|
+
private pendingNativeConfigJson?: string;
|
|
176
|
+
private nativeConfigSyncInFlight = false;
|
|
177
|
+
private reportedInvalidUpdates = new Set<string>();
|
|
157
178
|
|
|
158
179
|
version = cInfo.rnu;
|
|
159
180
|
loggerPromise = (() => {
|
|
@@ -169,6 +190,23 @@ export class Pushy {
|
|
|
169
190
|
|
|
170
191
|
constructor(options: ClientOptions, clientType?: 'Pushy' | 'Cresc') {
|
|
171
192
|
this.clientType = clientType || 'Pushy';
|
|
193
|
+
if (activeClient) {
|
|
194
|
+
if (
|
|
195
|
+
activeClient.clientType === this.clientType &&
|
|
196
|
+
activeClient.options.appKey === options.appKey
|
|
197
|
+
) {
|
|
198
|
+
// Same client re-created (e.g. fast refresh re-running the module
|
|
199
|
+
// that builds it): apply the latest options and hand back the
|
|
200
|
+
// existing instance instead of forking process-level state.
|
|
201
|
+
activeClient.setOptions(options);
|
|
202
|
+
// biome-ignore lint/correctness/noConstructorReturn: intentional singleton — identical re-creation must yield the existing instance
|
|
203
|
+
return activeClient;
|
|
204
|
+
}
|
|
205
|
+
throw new UpdateError(
|
|
206
|
+
i18n.t('error_client_singleton'),
|
|
207
|
+
'SINGLETON_VIOLATION'
|
|
208
|
+
);
|
|
209
|
+
}
|
|
172
210
|
this.options.server = cloneServerConfig(SERVER_PRESETS[this.clientType]);
|
|
173
211
|
|
|
174
212
|
i18n.setLocale(
|
|
@@ -193,8 +231,28 @@ export class Pushy {
|
|
|
193
231
|
},
|
|
194
232
|
});
|
|
195
233
|
}
|
|
234
|
+
activeClient = this;
|
|
196
235
|
}
|
|
197
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Bumped on every setOptions call. `options` is mutated in place (its
|
|
239
|
+
* identity never changes), so reactive consumers (the UpdateProvider)
|
|
240
|
+
* subscribe via onOptionsChange and re-read using this version as the
|
|
241
|
+
* change signal.
|
|
242
|
+
*/
|
|
243
|
+
optionsVersion = 0;
|
|
244
|
+
private optionsListeners = new Set<() => void>();
|
|
245
|
+
/**
|
|
246
|
+
* Subscribe to option changes (any setOptions call). Returns an
|
|
247
|
+
* unsubscribe function.
|
|
248
|
+
*/
|
|
249
|
+
onOptionsChange = (listener: () => void) => {
|
|
250
|
+
this.optionsListeners.add(listener);
|
|
251
|
+
return () => {
|
|
252
|
+
this.optionsListeners.delete(listener);
|
|
253
|
+
};
|
|
254
|
+
};
|
|
255
|
+
|
|
198
256
|
setOptions = (options: Partial<ClientOptions>) => {
|
|
199
257
|
for (const [key, value] of Object.entries(options)) {
|
|
200
258
|
if (value !== undefined) {
|
|
@@ -207,6 +265,192 @@ export class Pushy {
|
|
|
207
265
|
}
|
|
208
266
|
}
|
|
209
267
|
}
|
|
268
|
+
this.optionsVersion++;
|
|
269
|
+
for (const listener of this.optionsListeners) {
|
|
270
|
+
try {
|
|
271
|
+
listener();
|
|
272
|
+
} catch (e: any) {
|
|
273
|
+
log('onOptionsChange listener error:', e?.message || e);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
this.syncNativeConfig();
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
/** Build the subset of options used by the native cold-start check. */
|
|
280
|
+
private getNativeConfig = (): Record<string, unknown> | undefined => {
|
|
281
|
+
if (
|
|
282
|
+
Platform.OS === 'web' ||
|
|
283
|
+
typeof PushyModule.syncNativeConfig !== 'function'
|
|
284
|
+
) {
|
|
285
|
+
// Older natives lack the method; on web PushyModule is a noop Proxy
|
|
286
|
+
// and the feature-detect would false-positive.
|
|
287
|
+
return undefined;
|
|
288
|
+
}
|
|
289
|
+
const {
|
|
290
|
+
appKey,
|
|
291
|
+
server,
|
|
292
|
+
updateStrategy,
|
|
293
|
+
checkStrategy,
|
|
294
|
+
disableNativeCheck,
|
|
295
|
+
} = this.options;
|
|
296
|
+
if (disableNativeCheck) {
|
|
297
|
+
// Explicit opt-out: keep the identity fields so the persisted config
|
|
298
|
+
// still describes this app, and let the orchestrators bail on `disabled`
|
|
299
|
+
// before any IO.
|
|
300
|
+
return { disabled: true, appKey, rnu: cInfo.rnu, rn: cInfo.rn };
|
|
301
|
+
}
|
|
302
|
+
if (!appKey || !server?.main?.length) {
|
|
303
|
+
// Unusable rather than merely absent: the JS check would fail on these
|
|
304
|
+
// options too (NO_ENDPOINTS / APPKEY_REQUIRED). The caller turns this
|
|
305
|
+
// into an explicit disabled state.
|
|
306
|
+
return undefined;
|
|
307
|
+
}
|
|
308
|
+
// An app that turned automatic checks off (checkStrategy: null) must not
|
|
309
|
+
// be handed a version switch it never asked for. The cold-start check
|
|
310
|
+
// still runs and still downloads — that is what keeps a bricked device
|
|
311
|
+
// rescuable — but activation waits for the JS side, or for the server's
|
|
312
|
+
// explicit per-version forceBoot directive (shouldActivateAfterDownload).
|
|
313
|
+
const autoCheckEnabled = checkStrategy != null;
|
|
314
|
+
return {
|
|
315
|
+
appKey,
|
|
316
|
+
packageVersion: this.getEffectivePackageVersion(),
|
|
317
|
+
endpoints: server.main,
|
|
318
|
+
queryUrls: server.queryUrls ?? [],
|
|
319
|
+
// The native check may activate a downloaded version (next launch)
|
|
320
|
+
// only under the silent strategies; alert-style strategies keep
|
|
321
|
+
// activation with the JS side (§6/§10.1).
|
|
322
|
+
afterDownload:
|
|
323
|
+
autoCheckEnabled &&
|
|
324
|
+
(updateStrategy === 'silentAndNow' ||
|
|
325
|
+
updateStrategy === 'silentAndLater')
|
|
326
|
+
? 'setNeedUpdate'
|
|
327
|
+
: 'none',
|
|
328
|
+
rnu: cInfo.rnu,
|
|
329
|
+
rn: cInfo.rn,
|
|
330
|
+
};
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
private getNativeConfigJson = (): string | undefined => {
|
|
334
|
+
const config = this.getNativeConfig();
|
|
335
|
+
return config ? JSON.stringify(config) : undefined;
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
private flushNativeConfig = () => {
|
|
339
|
+
if (this.nativeConfigSyncInFlight) {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const configJson = this.pendingNativeConfigJson;
|
|
343
|
+
this.pendingNativeConfigJson = undefined;
|
|
344
|
+
if (!configJson || configJson === this.syncedNativeConfigJson) {
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
this.nativeConfigSyncInFlight = true;
|
|
348
|
+
let syncResult: Promise<void>;
|
|
349
|
+
try {
|
|
350
|
+
syncResult = Promise.resolve(PushyModule.syncNativeConfig(configJson));
|
|
351
|
+
} catch (e: any) {
|
|
352
|
+
this.nativeConfigSyncInFlight = false;
|
|
353
|
+
log('syncNativeConfig failed:', e?.message || e);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
syncResult
|
|
357
|
+
.then(() => {
|
|
358
|
+
this.syncedNativeConfigJson = configJson;
|
|
359
|
+
})
|
|
360
|
+
.catch((e: any) => {
|
|
361
|
+
log('syncNativeConfig failed:', e?.message || e);
|
|
362
|
+
})
|
|
363
|
+
.finally(() => {
|
|
364
|
+
this.nativeConfigSyncInFlight = false;
|
|
365
|
+
this.flushNativeConfig();
|
|
366
|
+
});
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
private syncNativeConfig = () => {
|
|
370
|
+
if (
|
|
371
|
+
Platform.OS === 'web' ||
|
|
372
|
+
typeof PushyModule.syncNativeConfig !== 'function'
|
|
373
|
+
) {
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const configJson =
|
|
377
|
+
this.getNativeConfigJson() ?? NATIVE_CONFIG_DISABLED_JSON;
|
|
378
|
+
// Always record the latest desired value, even when it matches the last
|
|
379
|
+
// completed write. Example: A synced -> B in flight -> options revert to
|
|
380
|
+
// A. Comparing only with synced(A) would drop the revert and leave native
|
|
381
|
+
// storage at B after that in-flight write completes.
|
|
382
|
+
// Coalesce rapid setOptions calls, but serialize bridge writes so an older
|
|
383
|
+
// completion can never overwrite the newest desired configuration.
|
|
384
|
+
this.pendingNativeConfigJson = configJson;
|
|
385
|
+
this.flushNativeConfig();
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
/** Package version used by every server-side update decision. */
|
|
389
|
+
getEffectivePackageVersion = () =>
|
|
390
|
+
this.options.overridePackageVersion || packageVersion;
|
|
391
|
+
|
|
392
|
+
private jsonValuesEqual = (left: unknown, right: unknown): boolean => {
|
|
393
|
+
const compare = (a: unknown, b: unknown): boolean => {
|
|
394
|
+
if (a === b) {
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
398
|
+
return (
|
|
399
|
+
Array.isArray(a) &&
|
|
400
|
+
Array.isArray(b) &&
|
|
401
|
+
a.length === b.length &&
|
|
402
|
+
a.every((value, index) => compare(value, b[index]))
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
if (
|
|
406
|
+
a === null ||
|
|
407
|
+
b === null ||
|
|
408
|
+
typeof a !== 'object' ||
|
|
409
|
+
typeof b !== 'object'
|
|
410
|
+
) {
|
|
411
|
+
return false;
|
|
412
|
+
}
|
|
413
|
+
const leftObject = a as Record<string, unknown>;
|
|
414
|
+
const rightObject = b as Record<string, unknown>;
|
|
415
|
+
// Match JSON.stringify semantics for request extras: object properties
|
|
416
|
+
// whose value is undefined are omitted from the wire fingerprint.
|
|
417
|
+
const leftKeys = Object.keys(leftObject)
|
|
418
|
+
.filter((key) => leftObject[key] !== undefined)
|
|
419
|
+
.sort();
|
|
420
|
+
const rightKeys = Object.keys(rightObject)
|
|
421
|
+
.filter((key) => rightObject[key] !== undefined)
|
|
422
|
+
.sort();
|
|
423
|
+
return (
|
|
424
|
+
leftKeys.length === rightKeys.length &&
|
|
425
|
+
leftKeys.every(
|
|
426
|
+
(key, index) =>
|
|
427
|
+
key === rightKeys[index] &&
|
|
428
|
+
compare(leftObject[key], rightObject[key])
|
|
429
|
+
)
|
|
430
|
+
);
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
return compare(left, right);
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
private providerMounted = false;
|
|
437
|
+
/**
|
|
438
|
+
* Called by UpdateProvider on mount. A second concurrently mounted
|
|
439
|
+
* provider is the same integration error as a second client — fail hard
|
|
440
|
+
* instead of double-subscribing app-state listeners and update checks.
|
|
441
|
+
* Returns the release function used on unmount.
|
|
442
|
+
*/
|
|
443
|
+
claimProviderMount = () => {
|
|
444
|
+
if (this.providerMounted) {
|
|
445
|
+
throw new UpdateError(
|
|
446
|
+
this.t('error_provider_singleton'),
|
|
447
|
+
'SINGLETON_VIOLATION'
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
this.providerMounted = true;
|
|
451
|
+
return () => {
|
|
452
|
+
this.providerMounted = false;
|
|
453
|
+
};
|
|
210
454
|
};
|
|
211
455
|
|
|
212
456
|
/**
|
|
@@ -219,6 +463,25 @@ export class Pushy {
|
|
|
219
463
|
return i18n.t(key as any, values);
|
|
220
464
|
};
|
|
221
465
|
|
|
466
|
+
reportInvalidUpdateOnce = (
|
|
467
|
+
reason: 'missingHash' | 'noArtifact',
|
|
468
|
+
hash = ''
|
|
469
|
+
) => {
|
|
470
|
+
const key = `${this.options.appKey}:${reason}:${hash}`;
|
|
471
|
+
if (this.reportedInvalidUpdates.has(key)) {
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
this.reportedInvalidUpdates.add(key);
|
|
475
|
+
this.report({
|
|
476
|
+
type: 'errorUpdate',
|
|
477
|
+
message:
|
|
478
|
+
reason === 'missingHash'
|
|
479
|
+
? 'update response is missing a version hash'
|
|
480
|
+
: 'update response contains no downloadable artifact',
|
|
481
|
+
...(hash ? { data: { newVersion: hash } } : {}),
|
|
482
|
+
});
|
|
483
|
+
};
|
|
484
|
+
|
|
222
485
|
report = async ({
|
|
223
486
|
type,
|
|
224
487
|
message = '',
|
|
@@ -313,8 +576,7 @@ export class Pushy {
|
|
|
313
576
|
body: JSON.stringify({
|
|
314
577
|
type: payloadType,
|
|
315
578
|
hash,
|
|
316
|
-
packageVersion:
|
|
317
|
-
this.options.overridePackageVersion || packageVersion,
|
|
579
|
+
packageVersion: this.getEffectivePackageVersion(),
|
|
318
580
|
cInfo,
|
|
319
581
|
detail: truncateDetail(detail),
|
|
320
582
|
}),
|
|
@@ -452,11 +714,12 @@ export class Pushy {
|
|
|
452
714
|
};
|
|
453
715
|
requestCheckResult = async (
|
|
454
716
|
endpoint: string,
|
|
455
|
-
fetchPayload: Parameters<typeof fetch>[1]
|
|
717
|
+
fetchPayload: Parameters<typeof fetch>[1],
|
|
718
|
+
signal?: AbortSignal
|
|
456
719
|
) => {
|
|
457
720
|
const resp = await fetchWithTimeout(
|
|
458
721
|
this.getCheckUrl(endpoint),
|
|
459
|
-
fetchPayload,
|
|
722
|
+
signal ? { ...fetchPayload, signal } : fetchPayload,
|
|
460
723
|
DEFAULT_FETCH_TIMEOUT_MS
|
|
461
724
|
);
|
|
462
725
|
|
|
@@ -478,9 +741,13 @@ export class Pushy {
|
|
|
478
741
|
const { endpoint, value } = await executeEndpointFallback<CheckResult>({
|
|
479
742
|
configuredEndpoints: this.getConfiguredCheckEndpoints(),
|
|
480
743
|
getRemoteEndpoints: this.getRemoteEndpoints,
|
|
481
|
-
tryEndpoint: async (currentEndpoint) => {
|
|
744
|
+
tryEndpoint: async (currentEndpoint, signal) => {
|
|
482
745
|
try {
|
|
483
|
-
return await this.requestCheckResult(
|
|
746
|
+
return await this.requestCheckResult(
|
|
747
|
+
currentEndpoint,
|
|
748
|
+
fetchPayload,
|
|
749
|
+
signal
|
|
750
|
+
);
|
|
484
751
|
} catch (e) {
|
|
485
752
|
log('check endpoint failed', currentEndpoint, e);
|
|
486
753
|
throw e;
|
|
@@ -500,6 +767,57 @@ export class Pushy {
|
|
|
500
767
|
this.lastWorkingEndpoint = endpoint;
|
|
501
768
|
return value;
|
|
502
769
|
};
|
|
770
|
+
/**
|
|
771
|
+
* Reuse the native cold-start check's cached response when fresh
|
|
772
|
+
* (NATIVE_CHECKUPDATE_DESIGN §10.3) instead of re-checking. Returns
|
|
773
|
+
* undefined whenever the cache is absent, stale, or unreadable — any
|
|
774
|
+
* failure falls through to a normal network check.
|
|
775
|
+
*/
|
|
776
|
+
private readNativeCheckCache = async (
|
|
777
|
+
requestBody: Record<string, any>
|
|
778
|
+
): Promise<CheckResult | undefined> => {
|
|
779
|
+
try {
|
|
780
|
+
if (__DEV__ || typeof PushyModule.getNativeCheckCache !== 'function') {
|
|
781
|
+
return undefined;
|
|
782
|
+
}
|
|
783
|
+
const raw = await Promise.resolve(PushyModule.getNativeCheckCache());
|
|
784
|
+
if (!raw || typeof raw !== 'string') {
|
|
785
|
+
return undefined;
|
|
786
|
+
}
|
|
787
|
+
const entry = JSON.parse(raw);
|
|
788
|
+
const config = this.getNativeConfig();
|
|
789
|
+
const cachedRequest =
|
|
790
|
+
typeof entry?.request === 'string'
|
|
791
|
+
? JSON.parse(entry.request)
|
|
792
|
+
: undefined;
|
|
793
|
+
const cachedConfig =
|
|
794
|
+
typeof entry?.config === 'string'
|
|
795
|
+
? JSON.parse(entry.config)
|
|
796
|
+
: undefined;
|
|
797
|
+
if (
|
|
798
|
+
typeof entry?.ts !== 'number' ||
|
|
799
|
+
typeof entry?.body !== 'string' ||
|
|
800
|
+
!this.jsonValuesEqual(cachedRequest, requestBody) ||
|
|
801
|
+
!config ||
|
|
802
|
+
!this.jsonValuesEqual(cachedConfig, config)
|
|
803
|
+
) {
|
|
804
|
+
return undefined;
|
|
805
|
+
}
|
|
806
|
+
const ageSeconds = Date.now() / 1000 - entry.ts;
|
|
807
|
+
if (ageSeconds < 0 || ageSeconds > 120) {
|
|
808
|
+
return undefined;
|
|
809
|
+
}
|
|
810
|
+
const result = JSON.parse(entry.body);
|
|
811
|
+
if (!result || typeof result !== 'object') {
|
|
812
|
+
return undefined;
|
|
813
|
+
}
|
|
814
|
+
log('reusing native check response cache');
|
|
815
|
+
return result as CheckResult;
|
|
816
|
+
} catch {
|
|
817
|
+
return undefined;
|
|
818
|
+
}
|
|
819
|
+
};
|
|
820
|
+
|
|
503
821
|
assertDebug = (matter: string) => {
|
|
504
822
|
if (__DEV__ && !this.options.debug) {
|
|
505
823
|
info(this.t('dev_debug_disabled', { matter }));
|
|
@@ -620,19 +938,16 @@ export class Pushy {
|
|
|
620
938
|
}
|
|
621
939
|
}
|
|
622
940
|
this.lastChecking = now;
|
|
623
|
-
const fetchBody
|
|
624
|
-
packageVersion: this.
|
|
625
|
-
|
|
941
|
+
const fetchBody = buildCheckRequestBody({
|
|
942
|
+
packageVersion: this.getEffectivePackageVersion(),
|
|
943
|
+
currentVersion,
|
|
626
944
|
buildTime,
|
|
627
945
|
cInfo,
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
};
|
|
633
|
-
if (__DEV__) {
|
|
634
|
-
delete fetchBody.buildTime;
|
|
635
|
-
}
|
|
946
|
+
supportedDiffVersion,
|
|
947
|
+
bundleHash,
|
|
948
|
+
isDev: __DEV__,
|
|
949
|
+
extra,
|
|
950
|
+
});
|
|
636
951
|
const stringifyBody = JSON.stringify(fetchBody);
|
|
637
952
|
// harmony fetch body is not string
|
|
638
953
|
let body: any = fetchBody;
|
|
@@ -653,7 +968,18 @@ export class Pushy {
|
|
|
653
968
|
type: 'checking',
|
|
654
969
|
message: `${this.options.appKey}: ${stringifyBody}`,
|
|
655
970
|
});
|
|
656
|
-
|
|
971
|
+
// The native cold-start check may have a fresh response on disk
|
|
972
|
+
// (§10.3); reuse it instead of re-checking. The read happens INSIDE
|
|
973
|
+
// the promise so no await lands between the dedup window above and the
|
|
974
|
+
// lastRespJson assignment below (the JS2-1 double-send lesson).
|
|
975
|
+
const respJsonPromise = (async (): Promise<CheckResult> => {
|
|
976
|
+
// While bundleHash prefetch is still pending, the JS request omits
|
|
977
|
+
// that key whereas the native request always includes its synchronously
|
|
978
|
+
// computed value. That narrow first-launch window intentionally misses
|
|
979
|
+
// the cache rather than delaying checkUpdate for hashing.
|
|
980
|
+
const cached = await this.readNativeCheckCache(fetchBody);
|
|
981
|
+
return cached ?? (await this.fetchCheckResult(fetchPayload));
|
|
982
|
+
})();
|
|
657
983
|
this.lastRespJson = respJsonPromise;
|
|
658
984
|
const result: CheckResult = await respJsonPromise;
|
|
659
985
|
|
|
@@ -689,7 +1015,6 @@ export class Pushy {
|
|
|
689
1015
|
updateInfo: CheckResult,
|
|
690
1016
|
onDownloadProgress?: (data: ProgressData) => void
|
|
691
1017
|
) => {
|
|
692
|
-
const { hash } = updateInfo;
|
|
693
1018
|
if (
|
|
694
1019
|
this.options.beforeDownloadUpdate &&
|
|
695
1020
|
(await this.options.beforeDownloadUpdate(updateInfo)) === false
|
|
@@ -697,17 +1022,26 @@ export class Pushy {
|
|
|
697
1022
|
log('beforeDownloadUpdate returned false, skipping download');
|
|
698
1023
|
return;
|
|
699
1024
|
}
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
1025
|
+
const decision = decideDownload(
|
|
1026
|
+
updateInfo,
|
|
1027
|
+
{ currentVersion, rolledBackVersion },
|
|
1028
|
+
__DEV__
|
|
1029
|
+
);
|
|
1030
|
+
if (decision.action === 'none') {
|
|
1031
|
+
if (decision.reason === 'alreadyCurrent') {
|
|
1032
|
+
log(`current hash ${currentVersion}, ignored`);
|
|
1033
|
+
} else if (decision.reason === 'rolledBack') {
|
|
1034
|
+
log(`rolledback hash ${rolledBackVersion}, ignored`);
|
|
1035
|
+
} else if (decision.reason === 'noArtifact') {
|
|
1036
|
+
// A server response that advertises an update but provides no usable
|
|
1037
|
+
// artifact is a bad release signal, not an ordinary no-update result.
|
|
1038
|
+
// Keep the user flow silent and report at most once per bad release in
|
|
1039
|
+
// this process: repeated checks must not inflate download_fail health.
|
|
1040
|
+
this.reportInvalidUpdateOnce('noArtifact', updateInfo.hash || '');
|
|
1041
|
+
}
|
|
709
1042
|
return;
|
|
710
1043
|
}
|
|
1044
|
+
const { hash } = decision;
|
|
711
1045
|
if (sharedState.downloadedHash === hash) {
|
|
712
1046
|
log(`duplicated downloaded hash ${sharedState.downloadedHash}, ignored`);
|
|
713
1047
|
return sharedState.downloadedHash;
|
|
@@ -724,7 +1058,7 @@ export class Pushy {
|
|
|
724
1058
|
}
|
|
725
1059
|
return existingTask;
|
|
726
1060
|
}
|
|
727
|
-
const task = this.performDownload(updateInfo, onDownloadProgress);
|
|
1061
|
+
const task = this.performDownload(updateInfo, decision, onDownloadProgress);
|
|
728
1062
|
sharedState.downloadingTasks[hash] = task;
|
|
729
1063
|
try {
|
|
730
1064
|
return await task;
|
|
@@ -734,21 +1068,11 @@ export class Pushy {
|
|
|
734
1068
|
};
|
|
735
1069
|
private performDownload = async (
|
|
736
1070
|
updateInfo: CheckResult,
|
|
1071
|
+
plan: DownloadPlan,
|
|
737
1072
|
onDownloadProgress?: (data: ProgressData) => void
|
|
738
1073
|
) => {
|
|
739
|
-
const {
|
|
740
|
-
|
|
741
|
-
diff,
|
|
742
|
-
pdiff,
|
|
743
|
-
full,
|
|
744
|
-
paths = [],
|
|
745
|
-
name,
|
|
746
|
-
description = '',
|
|
747
|
-
metaInfo,
|
|
748
|
-
} = updateInfo;
|
|
749
|
-
if (!hash) {
|
|
750
|
-
return;
|
|
751
|
-
}
|
|
1074
|
+
const { name, description = '', metaInfo } = updateInfo;
|
|
1075
|
+
const { hash, attempts, devNoop } = plan;
|
|
752
1076
|
const patchStartTime = Date.now();
|
|
753
1077
|
// One native listener per hash dispatching to a callback set, so
|
|
754
1078
|
// concurrent callers deduped onto this task can each observe progress
|
|
@@ -793,61 +1117,35 @@ export class Pushy {
|
|
|
793
1117
|
let lastError: any;
|
|
794
1118
|
const errorMessages: string[] = [];
|
|
795
1119
|
|
|
796
|
-
//
|
|
797
|
-
//
|
|
798
|
-
// download
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
1120
|
+
// The ordered attempts come from decideDownload (the pure decision layer);
|
|
1121
|
+
// this side only executes them: probe candidate URLs, run the matching
|
|
1122
|
+
// native download, fall through to the next attempt on failure.
|
|
1123
|
+
const runners: Record<
|
|
1124
|
+
DownloadStrategyType,
|
|
1125
|
+
(url: string) => Promise<void>
|
|
1126
|
+
> = {
|
|
1127
|
+
diff: (url) =>
|
|
1128
|
+
PushyModule.downloadPatchFromPpk({
|
|
1129
|
+
updateUrl: url,
|
|
1130
|
+
hash,
|
|
1131
|
+
originHash: currentVersion,
|
|
1132
|
+
}),
|
|
1133
|
+
pdiff: (url) =>
|
|
1134
|
+
PushyModule.downloadPatchFromPackage({
|
|
1135
|
+
updateUrl: url,
|
|
1136
|
+
hash,
|
|
1137
|
+
}),
|
|
1138
|
+
full: (url) =>
|
|
1139
|
+
PushyModule.downloadFullUpdate({
|
|
1140
|
+
updateUrl: url,
|
|
1141
|
+
hash,
|
|
1142
|
+
}),
|
|
811
1143
|
};
|
|
812
|
-
const
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
skipInDev: true,
|
|
818
|
-
devNoopWhenNoUrl: false,
|
|
819
|
-
run: (url) =>
|
|
820
|
-
PushyModule.downloadPatchFromPpk({
|
|
821
|
-
updateUrl: url,
|
|
822
|
-
hash,
|
|
823
|
-
originHash: currentVersion,
|
|
824
|
-
}),
|
|
825
|
-
},
|
|
826
|
-
{
|
|
827
|
-
name: 'pdiff',
|
|
828
|
-
candidate: pdiff,
|
|
829
|
-
errorKey: 'error_pdiff_failed',
|
|
830
|
-
skipInDev: true,
|
|
831
|
-
devNoopWhenNoUrl: false,
|
|
832
|
-
run: (url) =>
|
|
833
|
-
PushyModule.downloadPatchFromPackage({
|
|
834
|
-
updateUrl: url,
|
|
835
|
-
hash,
|
|
836
|
-
}),
|
|
837
|
-
},
|
|
838
|
-
{
|
|
839
|
-
name: 'full',
|
|
840
|
-
candidate: full,
|
|
841
|
-
errorKey: 'error_full_patch_failed',
|
|
842
|
-
skipInDev: false,
|
|
843
|
-
devNoopWhenNoUrl: true,
|
|
844
|
-
run: (url) =>
|
|
845
|
-
PushyModule.downloadFullUpdate({
|
|
846
|
-
updateUrl: url,
|
|
847
|
-
hash,
|
|
848
|
-
}),
|
|
849
|
-
},
|
|
850
|
-
];
|
|
1144
|
+
const errorKeys = {
|
|
1145
|
+
diff: 'error_diff_failed',
|
|
1146
|
+
pdiff: 'error_pdiff_failed',
|
|
1147
|
+
full: 'error_full_patch_failed',
|
|
1148
|
+
} as const;
|
|
851
1149
|
|
|
852
1150
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
853
1151
|
if (attempt > 0) {
|
|
@@ -865,34 +1163,36 @@ export class Pushy {
|
|
|
865
1163
|
attempt,
|
|
866
1164
|
},
|
|
867
1165
|
});
|
|
868
|
-
|
|
1166
|
+
if (devNoop) {
|
|
1167
|
+
log(this.t('dev_incremental_update_disabled'));
|
|
1168
|
+
succeeded = 'full';
|
|
1169
|
+
}
|
|
1170
|
+
for (const { type, urls } of attempts) {
|
|
869
1171
|
if (succeeded) {
|
|
870
1172
|
break;
|
|
871
1173
|
}
|
|
872
|
-
const url = await testUrls(
|
|
873
|
-
if (url
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
)
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
log(this.t('dev_incremental_update_disabled'));
|
|
895
|
-
succeeded = strategy.name;
|
|
1174
|
+
const url = await testUrls(urls);
|
|
1175
|
+
if (!url) {
|
|
1176
|
+
continue;
|
|
1177
|
+
}
|
|
1178
|
+
log(`downloading ${type}`);
|
|
1179
|
+
try {
|
|
1180
|
+
await runners[type](url);
|
|
1181
|
+
succeeded = type;
|
|
1182
|
+
} catch (e: any) {
|
|
1183
|
+
const errorMessage = this.t(errorKeys[type], {
|
|
1184
|
+
message: e.message,
|
|
1185
|
+
});
|
|
1186
|
+
errorMessages.push(errorMessage);
|
|
1187
|
+
// Keep the i18n message for display, but preserve the native
|
|
1188
|
+
// rejection's stable code (e.g. PATCH_FAILED vs DOWNLOAD_FAILED —
|
|
1189
|
+
// telemetry classifies on it) and the original error as cause.
|
|
1190
|
+
lastError = new UpdateError(
|
|
1191
|
+
errorMessage,
|
|
1192
|
+
asUpdateErrorCode(e?.code) ?? 'DOWNLOAD_FAILED',
|
|
1193
|
+
{ cause: e }
|
|
1194
|
+
);
|
|
1195
|
+
log(errorMessage);
|
|
896
1196
|
}
|
|
897
1197
|
}
|
|
898
1198
|
if (succeeded) {
|
|
@@ -904,6 +1204,23 @@ export class Pushy {
|
|
|
904
1204
|
delete sharedState.progressHandlers[hash];
|
|
905
1205
|
}
|
|
906
1206
|
delete sharedState.progressCallbacks[hash];
|
|
1207
|
+
if (succeeded && errorMessages.length > 0) {
|
|
1208
|
+
// An earlier strategy failed and a later one rescued the download
|
|
1209
|
+
// (e.g. pdiff copiesCrc mismatch on a rebuilt binary → full). Surface
|
|
1210
|
+
// the degradation: it is invisible to the end user but tells the
|
|
1211
|
+
// platform that incremental delivery is failing for this binary.
|
|
1212
|
+
this.report({
|
|
1213
|
+
type: 'downloadFallback',
|
|
1214
|
+
// UpdateError.code already carries the classified native rejection
|
|
1215
|
+
// code (PATCH_FAILED vs DOWNLOAD_FAILED).
|
|
1216
|
+
code: lastError?.code,
|
|
1217
|
+
data: {
|
|
1218
|
+
newVersion: hash,
|
|
1219
|
+
succeeded,
|
|
1220
|
+
},
|
|
1221
|
+
message: errorMessages.join(';'),
|
|
1222
|
+
});
|
|
1223
|
+
}
|
|
907
1224
|
if (!succeeded) {
|
|
908
1225
|
const message = errorMessages.join(';');
|
|
909
1226
|
if (lastError) {
|