react-native-update 10.56.1 → 10.57.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.
@@ -3,6 +3,8 @@ import deviceInfo from '@ohos.deviceInfo';
3
3
  import logger from './Logger';
4
4
  import NativePatchCore from './NativePatchCore';
5
5
  import type { UpdateContext } from './UpdateContext';
6
+ import { NativeUpdateRound, nativeUpdateResult } from './NativeUpdateResult';
7
+ import type { NativeUpdateResult } from './NativeUpdateResult';
6
8
  import { isSafePathComponent } from './PathUtils';
7
9
  import { monotonicNowMs } from './MonotonicClock';
8
10
  import {
@@ -106,6 +108,94 @@ interface RespCacheEntry {
106
108
  }
107
109
 
108
110
  let scheduled = false;
111
+ // The host and delayed check use one promise, including its settled result.
112
+ const hostRound = new NativeUpdateRound();
113
+ let scheduledContext: UpdateContext | undefined;
114
+ let scheduledRollback = '';
115
+ let roundGeneration = -1;
116
+ let roundConfigGeneration = -1;
117
+ let roundConfigJson: string | undefined;
118
+ let roundResult = nativeUpdateResult('failed', 'check_failed');
119
+
120
+ function startNativeRound(
121
+ context: UpdateContext,
122
+ launchRolledBackVersion: string,
123
+ ): Promise<NativeUpdateResult> {
124
+ const preflight = configurationError(context);
125
+ if (preflight !== undefined) {
126
+ return Promise.resolve(preflight);
127
+ }
128
+ return hostRound.run(async () => {
129
+ try {
130
+ await runOnce(context, launchRolledBackVersion);
131
+ } catch (e) {
132
+ logger.error(TAG, `native check failed: ${getErrorMessage(e)}`);
133
+ roundResult = nativeUpdateResult('failed', 'internal_error');
134
+ }
135
+ return roundResult;
136
+ });
137
+ }
138
+
139
+ // A preflight skip must not consume the process's only round: configuration
140
+ // may arrive after the delayed startup timer on a first-ever launch.
141
+ function configurationError(context: UpdateContext): NativeUpdateResult | undefined {
142
+ const json = context.getKv(KEY_CONFIG);
143
+ if (!json) {
144
+ return nativeUpdateResult('skipped', 'not_configured');
145
+ }
146
+ try {
147
+ const config = JSON.parse(json) as NativeConfig;
148
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
149
+ return nativeUpdateResult('failed', 'invalid_config');
150
+ }
151
+ if (config.disabled) {
152
+ return nativeUpdateResult('skipped', 'disabled');
153
+ }
154
+ if (typeof config.appKey !== 'string' || config.appKey.trim().length === 0) {
155
+ return nativeUpdateResult('failed', 'invalid_config');
156
+ }
157
+ } catch (e) {
158
+ return nativeUpdateResult('failed', 'invalid_config');
159
+ }
160
+ return undefined;
161
+ }
162
+
163
+ export async function checkAndUpdateNative(
164
+ context: UpdateContext,
165
+ ): Promise<NativeUpdateResult> {
166
+ if (scheduledContext !== context) {
167
+ return nativeUpdateResult('skipped', 'not_initialized');
168
+ }
169
+ const configJson = context.getKv(KEY_CONFIG);
170
+ if (!configJson) {
171
+ return nativeUpdateResult('skipped', 'not_configured');
172
+ }
173
+ try {
174
+ const config = JSON.parse(configJson) as NativeConfig;
175
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
176
+ return nativeUpdateResult('failed', 'invalid_config');
177
+ }
178
+ if (config.disabled) {
179
+ return nativeUpdateResult('skipped', 'disabled');
180
+ }
181
+ if (typeof config.appKey !== 'string' || !config.appKey) {
182
+ return nativeUpdateResult('failed', 'invalid_config');
183
+ }
184
+ } catch (e) {
185
+ return nativeUpdateResult('failed', 'invalid_config');
186
+ }
187
+ const result = await startNativeRound(context, scheduledRollback);
188
+ if (roundConfigGeneration !== context.getNativeConfigGeneration()
189
+ || configJson !== roundConfigJson || configJson !== context.getKv(KEY_CONFIG)) {
190
+ return nativeUpdateResult('cancelled', 'config_changed');
191
+ }
192
+ if (roundGeneration !== context.getResetGeneration()) {
193
+ return nativeUpdateResult('cancelled', 'reset');
194
+ }
195
+ // Do not let a caller mutate the cached result observed by later callers.
196
+ return nativeUpdateResult(result.status, result.reason, result.hash, result.activated);
197
+ }
198
+
109
199
  // JS 在本进程内已拿到有效检查响应时对应的配置 JSON(markJsCheckCompleted)。
110
200
  // 进程级:下次启动无信号,冷启动轮次照常运行。
111
201
  let jsCompletedConfig: string | undefined;
@@ -131,6 +221,8 @@ export function scheduleNativeCheck(
131
221
  return;
132
222
  }
133
223
  scheduled = true;
224
+ scheduledContext = context;
225
+ scheduledRollback = launchRolledBackVersion;
134
226
  // 结果本来就是"下次启动生效",延迟几秒让开冷启动关键路径(§7 R5)——
135
227
  // 除非上个进程死于轮中(残留标记),那时每一秒启动时间都要用来续传。
136
228
  const delayMs = context.getKv(KEY_ROUND_INCOMPLETE) ? 0 : START_DELAY_MS;
@@ -142,7 +234,7 @@ export function scheduleNativeCheck(
142
234
  );
143
235
  return;
144
236
  }
145
- runOnce(context, launchRolledBackVersion).catch((e: Object) => {
237
+ startNativeRound(context, launchRolledBackVersion).catch((e: Object) => {
146
238
  // 救援路径自身绝不能把应用拖垮。
147
239
  logger.error(TAG, `native check failed: ${getErrorMessage(e)}`);
148
240
  });
@@ -156,22 +248,34 @@ async function runOnce(
156
248
  // 在任何 IO 之前采样:resetToPackagedBundle 会递增它,本轮运行期间发生的
157
249
  // reset 必须赢过本轮的决策。
158
250
  const resetGeneration = context.getResetGeneration();
251
+ roundGeneration = resetGeneration;
252
+ roundConfigGeneration = context.getNativeConfigGeneration();
253
+ roundResult = nativeUpdateResult('failed', 'check_failed');
159
254
  const configJson = context.getKv(KEY_CONFIG);
255
+ roundConfigJson = configJson;
160
256
  if (!configJson) {
161
- // 无落盘配置(老接入/首启):静默不跑——这就是灰度开关。
257
+ // No persisted configuration: report the rollout gate to native callers.
258
+ roundResult = nativeUpdateResult('skipped', 'not_configured');
162
259
  return;
163
260
  }
164
261
  let config: NativeConfig;
165
262
  try {
166
263
  config = JSON.parse(configJson) as NativeConfig;
264
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
265
+ roundResult = nativeUpdateResult('failed', 'invalid_config');
266
+ return;
267
+ }
167
268
  } catch (e) {
269
+ roundResult = nativeUpdateResult('failed', 'invalid_config');
168
270
  return;
169
271
  }
170
272
  if (config.disabled) {
273
+ roundResult = nativeUpdateResult('skipped', 'disabled');
171
274
  return;
172
275
  }
173
276
  const appKey = config.appKey ?? '';
174
277
  if (!appKey) {
278
+ roundResult = nativeUpdateResult('failed', 'invalid_config');
175
279
  return;
176
280
  }
177
281
  // 从这里起本轮开始做真实工作:留下面包屑,死于轮中时下次启动零延迟续传。
@@ -267,6 +371,7 @@ async function runConfiguredRound(
267
371
  };
268
372
  const body = NativePatchCore.buildCheckRequestBody(JSON.stringify(input));
269
373
  if (!body) {
374
+ roundResult = nativeUpdateResult('failed', 'invalid_request');
270
375
  return;
271
376
  }
272
377
 
@@ -285,23 +390,28 @@ async function runConfiguredRound(
285
390
  config.afterDownload ?? '',
286
391
  );
287
392
  if (!decisionJson) {
393
+ roundResult = nativeUpdateResult('failed', 'invalid_response');
288
394
  return;
289
395
  }
290
396
  const decision = JSON.parse(decisionJson) as Decision;
291
397
  if (decision.action !== 'download') {
292
- await context.commitNativeCheckResult(
398
+ const committed = await context.commitNativeCheckResult(
293
399
  resetGeneration,
294
400
  '',
295
401
  '',
296
402
  false,
297
403
  buildResponseCacheJson(configJson, body, responseText, responseAtSeconds),
298
404
  );
405
+ roundResult = committed
406
+ ? nativeUpdateResult('noUpdate', decision.reason ?? '')
407
+ : nativeUpdateResult('cancelled', 'reset');
299
408
  logger.info(TAG, `nothing to do (${decision.reason ?? ''})`);
300
409
  return;
301
410
  }
302
411
  const hash = decision.hash ?? '';
303
412
  if (!isSafePathComponent(hash)) {
304
413
  logger.warn(TAG, 'decision carries an unsafe hash, ignoring');
414
+ roundResult = nativeUpdateResult('failed', 'invalid_response');
305
415
  return;
306
416
  }
307
417
 
@@ -320,13 +430,16 @@ async function runConfiguredRound(
320
430
  }
321
431
  if (!downloaded) {
322
432
  logger.warn(TAG, `all download attempts for ${hash} failed`);
323
- await context.commitNativeCheckResult(
433
+ const committed = await context.commitNativeCheckResult(
324
434
  resetGeneration,
325
435
  '',
326
436
  '',
327
437
  false,
328
438
  buildResponseCacheJson(configJson, body, responseText, responseAtSeconds),
329
439
  );
440
+ roundResult = committed
441
+ ? nativeUpdateResult('failed', 'download_failed')
442
+ : nativeUpdateResult('cancelled', 'reset');
330
443
  return;
331
444
  }
332
445
 
@@ -368,6 +481,7 @@ async function runConfiguredRound(
368
481
  );
369
482
  } catch (e) {
370
483
  logger.error(TAG, `commit failed: ${getErrorMessage(e)}`);
484
+ roundResult = nativeUpdateResult('failed', 'commit_failed');
371
485
  return;
372
486
  }
373
487
  if (!committed) {
@@ -377,6 +491,9 @@ async function runConfiguredRound(
377
491
  } else {
378
492
  logger.info(TAG, `downloaded ${hash}, activation left to JS`);
379
493
  }
494
+ roundResult = committed
495
+ ? nativeUpdateResult('downloaded', '', hash, activate)
496
+ : nativeUpdateResult('cancelled', 'reset');
380
497
  }
381
498
 
382
499
  function buildResponseCacheJson(
@@ -0,0 +1,102 @@
1
+ /** Options accepted by PushyFileJSBundleProvider.configure(). */
2
+ export interface NativeUpdateConfig {
3
+ appKey: string;
4
+ /** Omitted: Pushy's built-in endpoints. Custom endpoints never inherit discovery URLs. */
5
+ endpoints?: string[];
6
+ queryUrls?: string[];
7
+ /** Default: none. setNeedUpdate selects a downloaded bundle for the NEXT launch. */
8
+ afterDownload?: 'none' | 'setNeedUpdate';
9
+ disabled?: boolean;
10
+ /** Omit to use the installed application's version. */
11
+ packageVersion?: string;
12
+ /** Optional telemetry version strings; not required to check or install an update. */
13
+ rnu?: string;
14
+ rn?: string;
15
+ }
16
+
17
+ interface PersistedNativeUpdateConfig {
18
+ appKey: string;
19
+ endpoints: string[];
20
+ queryUrls: string[];
21
+ afterDownload: string;
22
+ disabled: boolean;
23
+ packageVersion?: string;
24
+ rnu: string;
25
+ rn: string;
26
+ }
27
+
28
+ const DEFAULT_ENDPOINTS: string[] = [
29
+ 'https://update.react-native.cn/api',
30
+ 'https://update.reactnative.cn/api',
31
+ ];
32
+ const DEFAULT_QUERY_URLS: string[] = [
33
+ 'https://gitee.com/sunnylqm/react-native-pushy/raw/master/endpoints.json',
34
+ 'https://cdn.jsdelivr.net/gh/reactnativecn/react-native-update@master/endpoints.json',
35
+ ];
36
+ const CONFIG_KEYS: string[] = [
37
+ 'appKey', 'endpoints', 'queryUrls', 'afterDownload', 'disabled',
38
+ 'packageVersion', 'rnu', 'rn',
39
+ ];
40
+
41
+ function configString(value: string, name: string, allowEmpty: boolean): string {
42
+ if (typeof value !== 'string' || (!allowEmpty && value.trim().length === 0)) {
43
+ throw new Error(`Invalid native configuration: ${name} must be a string${allowEmpty ? '' : ' and must not be blank'}`);
44
+ }
45
+ return value;
46
+ }
47
+
48
+ function configUrls(values: string[], name: string, base: boolean): string[] {
49
+ if (!Array.isArray(values) || (base && values.length === 0)) {
50
+ throw new Error(`Invalid native configuration: ${name} must be ${base ? 'a non-empty' : 'an'} array`);
51
+ }
52
+ const result: string[] = [];
53
+ for (const value of values) {
54
+ const url = configString(value, name, false).trim();
55
+ // Restrict the authority and scheme, without relying on browser URL globals
56
+ // unavailable in ArkTS. Platform networking performs the final URL parsing.
57
+ if (!/^https?:\/\/(\[[0-9a-fA-F:]+\]|[a-zA-Z0-9.-]+)(:[0-9]+)?([/?#]|$)/.test(url)
58
+ || /\s|\\/.test(url) || (base && /[?#]/.test(url))) {
59
+ throw new Error(`Invalid native configuration: ${name} requires absolute HTTP(S) URLs without credentials${base ? ', queries or fragments' : ''}`);
60
+ }
61
+ const normalized = base ? url.replace(/\/+$/, '') : url;
62
+ if (!result.includes(normalized)) {
63
+ result.push(normalized);
64
+ }
65
+ }
66
+ return result;
67
+ }
68
+
69
+ /** Pure validation, before any native state is touched. Does not mutate options. */
70
+ export function normalizeNativeUpdateConfig(options: NativeUpdateConfig): string {
71
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
72
+ throw new Error('Invalid native configuration: expected an object');
73
+ }
74
+ for (const key of Object.keys(options)) {
75
+ if (!CONFIG_KEYS.includes(key)) {
76
+ throw new Error(`Invalid native configuration: unknown option ${key}`);
77
+ }
78
+ }
79
+ const appKey = configString(options.appKey, 'appKey', false);
80
+ const customEndpoints = options.endpoints !== undefined;
81
+ const endpoints = configUrls(customEndpoints ? options.endpoints! : DEFAULT_ENDPOINTS, 'endpoints', true);
82
+ const queryUrls = configUrls(
83
+ options.queryUrls !== undefined ? options.queryUrls : (customEndpoints ? [] : DEFAULT_QUERY_URLS),
84
+ 'queryUrls', false,
85
+ );
86
+ const afterDownload = options.afterDownload === undefined ? 'none' : options.afterDownload;
87
+ if (afterDownload !== 'none' && afterDownload !== 'setNeedUpdate') {
88
+ throw new Error('Invalid native configuration: afterDownload must be none or setNeedUpdate');
89
+ }
90
+ if (options.disabled !== undefined && typeof options.disabled !== 'boolean') {
91
+ throw new Error('Invalid native configuration: disabled must be a boolean');
92
+ }
93
+ const result: PersistedNativeUpdateConfig = {
94
+ appKey, endpoints, queryUrls, afterDownload, disabled: options.disabled ?? false,
95
+ rnu: options.rnu === undefined ? '' : configString(options.rnu, 'rnu', true),
96
+ rn: options.rn === undefined ? '' : configString(options.rn, 'rn', true),
97
+ };
98
+ if (options.packageVersion !== undefined) {
99
+ result.packageVersion = configString(options.packageVersion, 'packageVersion', false);
100
+ }
101
+ return JSON.stringify(result);
102
+ }
@@ -0,0 +1,32 @@
1
+ /** A native round snapshot, not the current state of the running RN instance. */
2
+ export interface NativeUpdateResult {
3
+ /** skipped, noUpdate, downloaded, failed, or cancelled. */
4
+ status: string;
5
+ reason: string;
6
+ hash: string;
7
+ /** The version was selected for the next launch; no reload is performed. */
8
+ activated: boolean;
9
+ }
10
+
11
+ export function nativeUpdateResult(
12
+ status: string,
13
+ reason: string = '',
14
+ hash: string = '',
15
+ activated: boolean = false,
16
+ ): NativeUpdateResult {
17
+ return { status, reason, hash, activated };
18
+ }
19
+
20
+ /** Internal gate shared by the delayed check and the native host API. */
21
+ export class NativeUpdateRound {
22
+ private task: Promise<NativeUpdateResult> | undefined;
23
+
24
+ run(operation: () => Promise<NativeUpdateResult>): Promise<NativeUpdateResult> {
25
+ if (this.task !== undefined) {
26
+ return this.task;
27
+ }
28
+ // Defer invocation until the promise is stored, including reentrant callers.
29
+ this.task = Promise.resolve().then(operation);
30
+ return this.task;
31
+ }
32
+ }
@@ -6,6 +6,10 @@ import {
6
6
  import common from '@ohos.app.ability.common';
7
7
  import fs from '@ohos.file.fs';
8
8
  import { UpdateContext } from './UpdateContext';
9
+ import { checkAndUpdateNative } from './NativeCheckOrchestrator';
10
+ import type { NativeUpdateResult } from './NativeUpdateResult';
11
+ import { normalizeNativeUpdateConfig } from './NativeUpdateConfig';
12
+ import type { NativeUpdateConfig } from './NativeUpdateConfig';
9
13
 
10
14
  export class PushyFileJSBundleProvider extends JSBundleProvider {
11
15
  private updateContext: UpdateContext;
@@ -43,6 +47,17 @@ export class PushyFileJSBundleProvider extends JSBundleProvider {
43
47
  }
44
48
  }
45
49
 
50
+ /** Configure before normal bundle resolution; no JS or network work is required. */
51
+ async configure(options: NativeUpdateConfig): Promise<void> {
52
+ const config = normalizeNativeUpdateConfig(options);
53
+ await this.updateContext.setNativeConfig(config);
54
+ }
55
+
56
+ /** Call after the host's real bundle resolution; never resolves it again. */
57
+ checkAndUpdate(): Promise<NativeUpdateResult> {
58
+ return checkAndUpdateNative(this.updateContext);
59
+ }
60
+
46
61
  getAppKeys(): string[] {
47
62
  return [];
48
63
  }
@@ -273,7 +273,7 @@ export class PushyTurboModule extends UITurboModule {
273
273
  );
274
274
  }
275
275
  try {
276
- await this.context.setKv(KEY_CONFIG, config);
276
+ await this.context.setNativeConfig(config);
277
277
  } catch (error) {
278
278
  throw toUpdateError(error, ERROR_FILE_OPERATION_FAILED);
279
279
  }
@@ -12,7 +12,9 @@ import { bundleManager } from '@kit.AbilityKit';
12
12
  import { util } from '@kit.ArkTS';
13
13
  import logger from './Logger';
14
14
  import {
15
+ KEY_CONFIG,
15
16
  KEY_RESP_CACHE,
17
+ markJsCheckCompleted,
16
18
  scheduleNativeCheck,
17
19
  } from './NativeCheckOrchestrator';
18
20
  import NativePatchCore, {
@@ -458,6 +460,29 @@ export class UpdateContext {
458
460
  }
459
461
 
460
462
  /** 写入并落盘;flushSync 不可用时以 flush() 的结果拒绝。 */
463
+ /** Shared JS/native configuration writer; all mutations precede the first await. */
464
+ private static nativeConfigGeneration: number = 0;
465
+
466
+ public getNativeConfigGeneration(): number {
467
+ return UpdateContext.nativeConfigGeneration;
468
+ }
469
+
470
+ public setNativeConfig(config: string): Promise<void> {
471
+ if (!this.getKv('uuid')) {
472
+ this.preferences.putSync('uuid', util.generateRandomUUID());
473
+ }
474
+ if (this.getKv(KEY_CONFIG) !== config) {
475
+ // Also guards A -> B -> A replacements and late download commits.
476
+ UpdateContext.resetGeneration += 1;
477
+ UpdateContext.nativeConfigGeneration += 1;
478
+ this.preferences.putSync(KEY_CONFIG, config);
479
+ this.preferences.deleteSync(KEY_RESP_CACHE);
480
+ markJsCheckCompleted('');
481
+ }
482
+ // Flush even an equal value so a retry after a storage error can succeed.
483
+ return this.flushPreferences('configure native update');
484
+ }
485
+
461
486
  public setKv(key: string, value: string): Promise<void> {
462
487
  this.preferences.putSync(key, value);
463
488
  return this.flushPreferences(`set key ${key}`);
package/harmony/pushy.har CHANGED
Binary file
@@ -1,9 +1,33 @@
1
1
  #import <React/RCTBridgeModule.h>
2
2
  #import <React/RCTEventEmitter.h>
3
3
 
4
+ typedef void (^RCTPushyNativeConfigurationCompletion)(NSError * _Nullable error);
5
+
6
+ typedef void (^RCTPushyNativeUpdateCompletion)(NSDictionary<NSString *, id> * _Nonnull result);
4
7
 
5
8
  @interface RCTPushy : RCTEventEmitter<RCTBridgeModule>
6
9
 
7
10
  + (NSURL *)bundleURL;
8
11
 
12
+ /** Validate and persist native options without JS, network work or bundle resolution.
13
+ * Completion is on the main queue; nil error means success. Call before the
14
+ * normal launch bundle resolution for first-install native-only provisioning.
15
+ */
16
+ + (void)configure:(NSDictionary<NSString *, id> * _Nonnull)options
17
+ completion:(RCTPushyNativeConfigurationCompletion _Nullable)completion
18
+ NS_SWIFT_NAME(configure(_:completion:));
19
+
20
+ /**
21
+ * Start, join, or reuse this process's native update round. Call after the
22
+ * host's real bundleURL resolution; this method never resolves the bundle
23
+ * again, reloads React Native, or displays UI. Configuration is the one
24
+ * persisted by the JS SDK. The optional completion runs on the main queue.
25
+ *
26
+ * Result keys: status (skipped/noUpdate/downloaded/failed/cancelled), reason,
27
+ * hash (empty when nothing was installed), activated (selected for NEXT
28
+ * launch, not a reload of the running instance).
29
+ */
30
+ + (void)checkAndUpdateWithCompletion:(RCTPushyNativeUpdateCompletion _Nullable)completion
31
+ NS_SWIFT_NAME(checkAndUpdate(completion:));
32
+
9
33
  @end