@scalebun/react-native 1.11.0 → 1.13.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/android/src/main/java/com/scalebun/replaysdk/tracking/InteractionTracker.kt +0 -1
- package/android/src/main/java/com/scalebun/rn/ota/SlotManager.kt +15 -3
- package/dist/scalebun.full.js +232 -69
- package/dist/scalebun.slim.js +232 -69
- package/ios/Ota/OtaSlotManager.swift +19 -5
- package/lib/commonjs/core/constants/version.js +1 -1
- package/lib/commonjs/features/ota/OtaOrchestrator.js +174 -48
- package/lib/commonjs/features/ota/OtaTypes.js +4 -0
- package/lib/commonjs/features/ota/useOtaUpdate.js +11 -2
- package/lib/commonjs/public/ScaleBunFacade.js +115 -2
- package/lib/module/core/constants/version.js +1 -1
- package/lib/module/features/ota/OtaOrchestrator.js +174 -48
- package/lib/module/features/ota/OtaTypes.js +1 -1
- package/lib/module/features/ota/useOtaUpdate.js +11 -2
- package/lib/module/public/ScaleBunFacade.js +115 -2
- package/lib/typescript/core/constants/version.d.ts +1 -1
- package/lib/typescript/features/ota/OtaEventEmitter.d.ts +15 -1
- package/lib/typescript/features/ota/OtaOrchestrator.d.ts +22 -3
- package/lib/typescript/features/ota/OtaTypes.d.ts +29 -32
- package/lib/typescript/public/ScaleBunFacade.d.ts +27 -0
- package/package.json +2 -2
- package/src/core/constants/version.ts +1 -1
- package/src/features/ota/OtaEventEmitter.ts +12 -0
- package/src/features/ota/OtaOrchestrator.ts +209 -62
- package/src/features/ota/OtaTypes.ts +37 -39
- package/src/features/ota/useOtaUpdate.ts +11 -2
- package/src/public/ScaleBunFacade.ts +127 -3
|
@@ -84,6 +84,11 @@ async function deliverOtaEvents(params) {
|
|
|
84
84
|
kind: 'ota_event',
|
|
85
85
|
type: e.type,
|
|
86
86
|
bundleId: e.bundleId,
|
|
87
|
+
// Without this the backend stored a null releaseId on every row it ingested,
|
|
88
|
+
// while serving the release id on every check — so the delivery funnel could
|
|
89
|
+
// only ever be grouped by bundle, and a bundle re-promoted under a second
|
|
90
|
+
// release merged the two into one indistinguishable series.
|
|
91
|
+
releaseId: e.releaseId,
|
|
87
92
|
installationId: params.installationId,
|
|
88
93
|
// OTA bundles are compiled per platform, so this genuinely is ios|android. Narrowed via
|
|
89
94
|
// resolveMobileOS so a non-mobile RN target is skipped rather than served an Android bundle.
|
|
@@ -121,7 +126,6 @@ async function deliverOtaEvents(params) {
|
|
|
121
126
|
/** Boot-guard configuration */
|
|
122
127
|
|
|
123
128
|
const DEFAULT_HEALTHY_AFTER_MS = 10_000;
|
|
124
|
-
const DEFAULT_MAX_REVERT_ATTEMPTS = 2;
|
|
125
129
|
export class OtaOrchestrator {
|
|
126
130
|
enabled = false;
|
|
127
131
|
currentBundle = null;
|
|
@@ -198,22 +202,35 @@ export class OtaOrchestrator {
|
|
|
198
202
|
this.signatureConfig = config?.signature;
|
|
199
203
|
__DEV__ && logger.debug(`[OTA] Orchestrator initialized (RN ${this.environment.rnVersionString ?? 'unknown'}` + `${this.environment.bridgeless ? ', bridgeless' : ''}` + `${this.environment.hermes ? `, Hermes HBC v${this.environment.hermesBytecodeVersion ?? '?'}` : ''})`);
|
|
200
204
|
|
|
205
|
+
// ONE read of the slot state, shared by both boot-time consumers.
|
|
206
|
+
//
|
|
207
|
+
// `getSlotState()` is CONSUME-ON-READ for the revert record: both native
|
|
208
|
+
// implementations delete it as they serialise, so that a rollback is
|
|
209
|
+
// reported exactly once instead of on every launch forever. Calling it
|
|
210
|
+
// twice therefore means the second caller never sees the revert — which is
|
|
211
|
+
// precisely what happened, since hydration ran first and swallowed it. It
|
|
212
|
+
// is also a blocking synchronous bridge call, so one read is cheaper.
|
|
213
|
+
const slotState = this.readSlotState();
|
|
214
|
+
|
|
201
215
|
// Rehydrate which bundle we are running from the native slot state.
|
|
202
216
|
// Without this `currentBundle` stays null for the whole process after a
|
|
203
217
|
// restart, so every check reported no current bundle and the backend had
|
|
204
218
|
// no way to know what the device was actually on.
|
|
205
|
-
this.hydrateCurrentBundleFromSlots();
|
|
219
|
+
this.hydrateCurrentBundleFromSlots(slotState);
|
|
206
220
|
|
|
207
221
|
// Warm the device-country cache (edge Worker lookup) so checks can carry
|
|
208
222
|
// a country when the API itself sits behind no geo-stamping CDN.
|
|
209
223
|
// Fire-and-forget and failure-soft — a check without a country is valid.
|
|
210
224
|
void prefetchDeviceCountry();
|
|
211
225
|
|
|
226
|
+
// Check if the boot guard fired on this launch (native reverted before JS
|
|
227
|
+
// loaded). BEFORE the identity check, because both read the install record
|
|
228
|
+
// and the identity check retires it — a revert must get its chance to name
|
|
229
|
+
// the bundle that failed while the record still describes it.
|
|
230
|
+
this.checkBootGuardRecovery(slotState);
|
|
231
|
+
|
|
212
232
|
// Prove the bundle we installed is the bundle that loaded.
|
|
213
233
|
this.verifyRunningBundleIdentity();
|
|
214
|
-
|
|
215
|
-
// Check if the boot guard fired on this launch (native reverted before JS loaded)
|
|
216
|
-
this.checkBootGuardRecovery();
|
|
217
234
|
});
|
|
218
235
|
}
|
|
219
236
|
|
|
@@ -221,10 +238,20 @@ export class OtaOrchestrator {
|
|
|
221
238
|
* Read the active slot back into `currentBundle` so the next check reports
|
|
222
239
|
* what this device is genuinely running.
|
|
223
240
|
*/
|
|
224
|
-
|
|
225
|
-
|
|
241
|
+
/**
|
|
242
|
+
* Parse the native slot state once. Returns null when the module is absent or
|
|
243
|
+
* the payload is unreadable — every caller treats that as "factory bundle".
|
|
244
|
+
*/
|
|
245
|
+
readSlotState() {
|
|
246
|
+
if (!NativeScaleBunOta) return null;
|
|
247
|
+
try {
|
|
248
|
+
return JSON.parse(NativeScaleBunOta.getSlotState());
|
|
249
|
+
} catch {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
hydrateCurrentBundleFromSlots(state) {
|
|
226
254
|
try {
|
|
227
|
-
const state = JSON.parse(NativeScaleBunOta.getSlotState());
|
|
228
255
|
const current = state?.current;
|
|
229
256
|
if (!current?.sha256) return;
|
|
230
257
|
|
|
@@ -239,6 +266,7 @@ export class OtaOrchestrator {
|
|
|
239
266
|
this.currentBundle = {
|
|
240
267
|
id: record.bundleId,
|
|
241
268
|
version: record.version,
|
|
269
|
+
releaseId: record.releaseId ?? undefined,
|
|
242
270
|
sha256: record.sha256
|
|
243
271
|
};
|
|
244
272
|
__DEV__ && logger.debug(`[OTA] Running bundle v${record.version} (${record.bundleId})`);
|
|
@@ -275,37 +303,42 @@ export class OtaOrchestrator {
|
|
|
275
303
|
verifyRunningBundleIdentity() {
|
|
276
304
|
if (!this.currentBundle) return;
|
|
277
305
|
const running = readRunningBundleMarker();
|
|
278
|
-
const
|
|
306
|
+
const record = this.readInstallRecord();
|
|
307
|
+
|
|
308
|
+
// No record at all — installed by an SDK that predates install records, or
|
|
309
|
+
// local storage was cleared. NOTHING can be concluded here and nothing is
|
|
310
|
+
// reported: the marker is a random per-publish token, never the bundle id,
|
|
311
|
+
// so comparing the two would flag a false mismatch on every healthy launch.
|
|
312
|
+
if (!record) return;
|
|
279
313
|
|
|
280
|
-
//
|
|
281
|
-
|
|
314
|
+
// The record describes a bundle that is no longer the active one (a revert,
|
|
315
|
+
// or a bundle staged by another path). It cannot verify this launch, and
|
|
316
|
+
// keeping it would make the next launch mis-report what is running.
|
|
317
|
+
if (record.sha256 !== this.currentBundle.sha256) {
|
|
282
318
|
this.clearInstallExpectation();
|
|
283
319
|
return;
|
|
284
320
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
// Deliberately NOT cleared: the condition is still true on the next boot
|
|
297
|
-
// and should keep reporting until the integration is fixed. Clearing here
|
|
298
|
-
// would make a permanently broken install look like a one-off.
|
|
321
|
+
|
|
322
|
+
// Already proven on an earlier launch. The token is retired once verified
|
|
323
|
+
// while the REST of the record stays — it is also the sha256 -> bundleId map
|
|
324
|
+
// that `hydrateCurrentBundleFromSlots` reads to report what this device is
|
|
325
|
+
// running. Wiping the whole record here is what made every launch after the
|
|
326
|
+
// first send `currentBundleId: undefined`, which the server reads as "not on
|
|
327
|
+
// this bundle" and answers by serving the same bundle again, forever.
|
|
328
|
+
if (!record.identityToken) return;
|
|
329
|
+
if (running === record.identityToken) {
|
|
330
|
+
__DEV__ && logger.debug('[OTA] Install verified — running bundle matches what was installed.');
|
|
331
|
+
this.retireIdentityToken(record);
|
|
299
332
|
return;
|
|
300
333
|
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
334
|
+
logger.error(`[OTA] INSTALL DID NOT TAKE EFFECT — bundle ${this.currentBundle.id} was installed and ` + `carries a known identity marker, but the running bundle reports ` + `${running ?? 'no marker at all'}. The app is executing different code than the slot ` + 'manager believes. Check that the host app resolves the OTA bundle path at launch ' + '(see the ScaleBunOta integration for your React Native version).');
|
|
335
|
+
otaEventEmitter.emitSimple('APPLY_FAILED', this.currentBundle.id, {
|
|
336
|
+
releaseId: this.currentBundle.releaseId,
|
|
337
|
+
error: `install_not_effective — expected ${record.identityToken}, running ${running ?? 'none'}`
|
|
338
|
+
});
|
|
339
|
+
// Deliberately NOT retired: the condition is still true on the next boot and
|
|
340
|
+
// should keep reporting until the integration is fixed. Retiring the token
|
|
341
|
+
// here would make a permanently broken install look like a one-off.
|
|
309
342
|
}
|
|
310
343
|
|
|
311
344
|
// ── Install expectation ────────────────────────────────────────────────────
|
|
@@ -319,6 +352,11 @@ export class OtaOrchestrator {
|
|
|
319
352
|
this.storage().set(OtaOrchestrator.INSTALL_EXPECTATION_KEY, JSON.stringify({
|
|
320
353
|
bundleId: bundle.id,
|
|
321
354
|
version: bundle.version,
|
|
355
|
+
// Carried so telemetry emitted on a LATER launch (a boot-guard
|
|
356
|
+
// rollback, an ineffective install) can still be attributed to the
|
|
357
|
+
// release, not merely the bundle. The check response is long gone by
|
|
358
|
+
// then; this record is the only thing that remembers.
|
|
359
|
+
releaseId: bundle.releaseId ?? null,
|
|
322
360
|
// The join key back to the native slot, which records sha256 and
|
|
323
361
|
// nothing else identifying.
|
|
324
362
|
sha256: bundle.sha256,
|
|
@@ -338,12 +376,28 @@ export class OtaOrchestrator {
|
|
|
338
376
|
return null;
|
|
339
377
|
}
|
|
340
378
|
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Drop the identity token once the install has been proven, keeping the rest
|
|
382
|
+
* of the record.
|
|
383
|
+
*
|
|
384
|
+
* The record does two jobs: it proves an install took effect (once), and it
|
|
385
|
+
* maps the native slot's sha256 back to a bundle id (for the life of that
|
|
386
|
+
* bundle). Only the first job is finished after a successful verification, so
|
|
387
|
+
* only the token is retired.
|
|
388
|
+
*/
|
|
389
|
+
retireIdentityToken(record) {
|
|
390
|
+
try {
|
|
391
|
+
this.storage().set(OtaOrchestrator.INSTALL_EXPECTATION_KEY, JSON.stringify({
|
|
392
|
+
bundleId: record.bundleId,
|
|
393
|
+
version: record.version,
|
|
394
|
+
releaseId: record.releaseId ?? null,
|
|
395
|
+
sha256: record.sha256,
|
|
396
|
+
identityToken: null
|
|
397
|
+
}));
|
|
398
|
+
} catch {
|
|
399
|
+
/* non-fatal */
|
|
400
|
+
}
|
|
347
401
|
}
|
|
348
402
|
clearInstallExpectation() {
|
|
349
403
|
try {
|
|
@@ -362,17 +416,43 @@ export class OtaOrchestrator {
|
|
|
362
416
|
* If getSlotState() shows bootMarkerPresent=false but we have a 'previous' slot
|
|
363
417
|
* and no 'current' OTA bundle, the native layer already reverted.
|
|
364
418
|
*/
|
|
365
|
-
checkBootGuardRecovery() {
|
|
366
|
-
if (!NativeScaleBunOta) return;
|
|
419
|
+
checkBootGuardRecovery(state) {
|
|
367
420
|
try {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
421
|
+
if (!state?.bootGuardReverted) return;
|
|
422
|
+
|
|
423
|
+
// WHICH bundle was rolled back, and it has to come from the install
|
|
424
|
+
// record. The obvious source — `state.previous` — is wrong twice over:
|
|
425
|
+
// slot meta.json carries only `{sha256, installedAt}` so it has no bundle
|
|
426
|
+
// id to read, and `revert()` deletes the previous slot as its last step,
|
|
427
|
+
// so by the time this runs there is no previous meta at all. The event
|
|
428
|
+
// therefore reported `bundleId: 'unknown'` on every rollback, and the
|
|
429
|
+
// backend drops any ota_event whose bundleId does not resolve to a bundle
|
|
430
|
+
// the app owns — so the crash-guard's own rollback signal never arrived.
|
|
431
|
+
//
|
|
432
|
+
// Native now stamps the sha256 of the bundle it reverted AWAY FROM into
|
|
433
|
+
// the revert record; the install record maps that back to a bundle id.
|
|
434
|
+
const record = this.readInstallRecord();
|
|
435
|
+
const revertedSha = state.bootGuardRevertedSha256;
|
|
436
|
+
const matchesRecord = !!record && (!revertedSha || record.sha256 === revertedSha);
|
|
437
|
+
|
|
438
|
+
// Report the reason native actually determined — a hash mismatch and a
|
|
439
|
+
// crash loop are different incidents and were being collapsed into one.
|
|
440
|
+
const reason = state.bootGuardRevertReason || 'boot_crash_guard';
|
|
441
|
+
if (matchesRecord && record) {
|
|
442
|
+
logger.warn(`[OTA] Boot guard fired — reverted away from bundle ${record.bundleId} ` + `(v${record.version}); reason: ${reason}`);
|
|
443
|
+
otaEventEmitter.emitSimple('AUTO_ROLLBACK', record.bundleId, {
|
|
444
|
+
releaseId: record.releaseId ?? undefined,
|
|
445
|
+
version: record.version,
|
|
446
|
+
reason
|
|
374
447
|
});
|
|
448
|
+
// The record describes a bundle this device is no longer running.
|
|
449
|
+
this.clearInstallExpectation();
|
|
450
|
+
return;
|
|
375
451
|
}
|
|
452
|
+
|
|
453
|
+
// No usable record (older SDK, cleared storage). Say so rather than
|
|
454
|
+
// emitting an event the server is obliged to discard.
|
|
455
|
+
logger.warn(`[OTA] Boot guard fired (reason: ${reason}) but the rolled-back bundle could not be ` + 'identified locally — no install record. The rollback is not reported to the server.');
|
|
376
456
|
} catch {
|
|
377
457
|
// Slot state parsing failed — non-fatal
|
|
378
458
|
}
|
|
@@ -473,6 +553,22 @@ export class OtaOrchestrator {
|
|
|
473
553
|
};
|
|
474
554
|
}
|
|
475
555
|
__DEV__ && logger.debug('[OTA] Sync started…');
|
|
556
|
+
|
|
557
|
+
// The funnel's denominator, for devices already on an OTA bundle. Emitted
|
|
558
|
+
// before the request, so a check that fails outright still counts as a
|
|
559
|
+
// check — the only CHECK rows before this came from a server-side geo
|
|
560
|
+
// side-effect that is skipped whenever the request carries no country.
|
|
561
|
+
//
|
|
562
|
+
// Only when a current bundle is known: every ota_event must name a bundle
|
|
563
|
+
// the app owns or the server drops it, and a device still on the binary's
|
|
564
|
+
// factory bundle has no such id to give. Those devices are counted
|
|
565
|
+
// server-side when they are offered something.
|
|
566
|
+
if (this.currentBundle?.id) {
|
|
567
|
+
otaEventEmitter.emitSimple('CHECK', this.currentBundle.id, {
|
|
568
|
+
releaseId: this.currentBundle.releaseId,
|
|
569
|
+
version: this.currentBundle.version
|
|
570
|
+
});
|
|
571
|
+
}
|
|
476
572
|
const checkRes = await this.checkForUpdate(params);
|
|
477
573
|
if (checkRes.action === 'NONE') {
|
|
478
574
|
__DEV__ && logger.debug('[OTA] App is up to date');
|
|
@@ -486,7 +582,9 @@ export class OtaOrchestrator {
|
|
|
486
582
|
// Handle server-initiated rollback (Sprint 4)
|
|
487
583
|
if (checkRes.action === 'ROLLBACK') {
|
|
488
584
|
logger.warn('[OTA] Server requested ROLLBACK — reverting to previous bundle');
|
|
489
|
-
otaEventEmitter.emitSimple('MANUAL_ROLLBACK', this.currentBundle?.id ?? 'unknown'
|
|
585
|
+
otaEventEmitter.emitSimple('MANUAL_ROLLBACK', this.currentBundle?.id ?? 'unknown', {
|
|
586
|
+
releaseId: this.currentBundle?.releaseId
|
|
587
|
+
});
|
|
490
588
|
const reverted = await NativeScaleBunOta.revertToPrevious();
|
|
491
589
|
if (reverted) {
|
|
492
590
|
this.currentBundle = null;
|
|
@@ -511,6 +609,15 @@ export class OtaOrchestrator {
|
|
|
511
609
|
const bundle = checkRes.bundle;
|
|
512
610
|
let patchUsed = false;
|
|
513
611
|
|
|
612
|
+
// The offer itself. Everything downstream (download, install, activation)
|
|
613
|
+
// is a conversion against this, so without it the top of the funnel was
|
|
614
|
+
// unmeasurable and a rollout that never reached devices looked identical
|
|
615
|
+
// to one whose devices all declined to download.
|
|
616
|
+
otaEventEmitter.emitSimple('OFFERED', bundle.id, {
|
|
617
|
+
releaseId: bundle.releaseId,
|
|
618
|
+
version: bundle.version
|
|
619
|
+
});
|
|
620
|
+
|
|
514
621
|
// ── VERIFY AUTHENTICITY (OTA-03) ─────────────────────────────────────
|
|
515
622
|
// Before anything touches the disk. SHA-256 proves the bytes arrived
|
|
516
623
|
// intact; only the signature proves they came from you. Checking after
|
|
@@ -518,6 +625,7 @@ export class OtaOrchestrator {
|
|
|
518
625
|
const signatureOutcome = await verifyBundleSignature(bundle.sha256, bundle.signature, this.signatureConfig);
|
|
519
626
|
if (!signatureOutcome.ok) {
|
|
520
627
|
otaEventEmitter.emitSimple('APPLY_FAILED', bundle.id, {
|
|
628
|
+
releaseId: bundle.releaseId,
|
|
521
629
|
error: `Signature check failed: ${signatureOutcome.reason}`,
|
|
522
630
|
version: bundle.version
|
|
523
631
|
});
|
|
@@ -530,6 +638,7 @@ export class OtaOrchestrator {
|
|
|
530
638
|
|
|
531
639
|
// ── DOWNLOAD ─────────────────────────────────────────────────────────
|
|
532
640
|
otaEventEmitter.emitSimple('DOWNLOAD_STARTED', bundle.id, {
|
|
641
|
+
releaseId: bundle.releaseId,
|
|
533
642
|
version: bundle.version
|
|
534
643
|
});
|
|
535
644
|
const downloadStart = Date.now();
|
|
@@ -628,6 +737,7 @@ export class OtaOrchestrator {
|
|
|
628
737
|
postProgress(0, 'FAILED');
|
|
629
738
|
logger.error('[OTA] Staging bundle failed after retries');
|
|
630
739
|
otaEventEmitter.emitSimple('APPLY_FAILED', bundle.id, {
|
|
740
|
+
releaseId: bundle.releaseId,
|
|
631
741
|
error: 'Staging failed — SHA-256 mismatch or download error',
|
|
632
742
|
version: bundle.version
|
|
633
743
|
});
|
|
@@ -640,6 +750,7 @@ export class OtaOrchestrator {
|
|
|
640
750
|
postProgress(bundle.size, 'COMPLETED');
|
|
641
751
|
const downloadDuration = Date.now() - downloadStart;
|
|
642
752
|
otaEventEmitter.emitSimple('DOWNLOAD_COMPLETE', bundle.id, {
|
|
753
|
+
releaseId: bundle.releaseId,
|
|
643
754
|
version: bundle.version,
|
|
644
755
|
durationMs: downloadDuration,
|
|
645
756
|
patchUsed
|
|
@@ -651,6 +762,7 @@ export class OtaOrchestrator {
|
|
|
651
762
|
if (!applied) {
|
|
652
763
|
logger.error('[OTA] Applying update failed');
|
|
653
764
|
otaEventEmitter.emitSimple('APPLY_FAILED', bundle.id, {
|
|
765
|
+
releaseId: bundle.releaseId,
|
|
654
766
|
error: 'Atomic slot swap failed',
|
|
655
767
|
version: bundle.version
|
|
656
768
|
});
|
|
@@ -674,6 +786,7 @@ export class OtaOrchestrator {
|
|
|
674
786
|
// it is skipped when there is no marker to compare.
|
|
675
787
|
this.recordInstallExpectation(bundle);
|
|
676
788
|
otaEventEmitter.emitSimple('INSTALLED', bundle.id, {
|
|
789
|
+
releaseId: bundle.releaseId,
|
|
677
790
|
version: bundle.version
|
|
678
791
|
});
|
|
679
792
|
__DEV__ && logger.debug(`[OTA] Update v${bundle.version} installed successfully!`);
|
|
@@ -746,6 +859,19 @@ export class OtaOrchestrator {
|
|
|
746
859
|
NativeScaleBunOta?.markHealthy();
|
|
747
860
|
__DEV__ && logger.info('[OTA] Boot guard cleared — bundle marked healthy ✓');
|
|
748
861
|
this.healthyTimer = null;
|
|
862
|
+
|
|
863
|
+
// The honest activation signal. INSTALLED is emitted optimistically,
|
|
864
|
+
// BEFORE the bundle has ever executed, so counting it as adoption
|
|
865
|
+
// credits bundles that were installed and then crash-reverted. This
|
|
866
|
+
// fires only once the bundle has actually booted and survived.
|
|
867
|
+
const running = this.currentBundle;
|
|
868
|
+
if (running?.id) {
|
|
869
|
+
otaEventEmitter.emitSimple('BOOT_SUCCESS', running.id, {
|
|
870
|
+
releaseId: running.releaseId,
|
|
871
|
+
version: running.version,
|
|
872
|
+
durationMs: healthyMs
|
|
873
|
+
});
|
|
874
|
+
}
|
|
749
875
|
});
|
|
750
876
|
}, healthyMs);
|
|
751
877
|
} catch {
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
|
|
1
|
+
export {};
|
|
2
2
|
//# sourceMappingURL=OtaTypes.js.map
|
|
@@ -60,7 +60,13 @@ export function useOtaUpdate(options) {
|
|
|
60
60
|
setIsSyncing(true);
|
|
61
61
|
setDownloadProgress(0);
|
|
62
62
|
try {
|
|
63
|
-
|
|
63
|
+
// Read through the ref, not the closed-over `options`. The dependency list
|
|
64
|
+
// below cannot name the targeting fields (`attributes` and `segmentIds` are
|
|
65
|
+
// fresh object/array identities on every render, so listing them would
|
|
66
|
+
// rebuild this callback each time), which left a host that changed
|
|
67
|
+
// `channelName` or `lifecycleStage` syncing against the values captured on
|
|
68
|
+
// first render. The ref is assigned on every render, so it is always current.
|
|
69
|
+
const result = await otaOrchestrator.sync(optionsRef.current);
|
|
64
70
|
setSyncResult(result);
|
|
65
71
|
|
|
66
72
|
// Handle mandatory update blocking
|
|
@@ -71,7 +77,10 @@ export function useOtaUpdate(options) {
|
|
|
71
77
|
} finally {
|
|
72
78
|
setIsSyncing(false);
|
|
73
79
|
}
|
|
74
|
-
|
|
80
|
+
// Empty: everything this reads comes from `optionsRef`, so the callback has a
|
|
81
|
+
// stable identity and never needs rebuilding. A host can safely pass it to a
|
|
82
|
+
// memoised child or an effect dependency list.
|
|
83
|
+
}, []);
|
|
75
84
|
const restart = useCallback(() => {
|
|
76
85
|
setMandatoryUpdatePending(false);
|
|
77
86
|
otaOrchestrator.restart();
|
|
@@ -253,6 +253,11 @@ class ScaleBunFacade {
|
|
|
253
253
|
logger.warn('[ScaleBun] A signing key is pinned but no signature verifier is available. ' + 'Signature checking is fail-closed: updates will be REJECTED until one exists. ' + 'Install the optional peers `@noble/ed25519` + `@noble/hashes` (no further ' + 'code needed), or supply `ota.verifySignature`.');
|
|
254
254
|
}
|
|
255
255
|
}
|
|
256
|
+
// `healthyAfterMs` is the field BootGuardConfig actually declares.
|
|
257
|
+
// This passed `healthyTimeoutMs`, which nothing reads, so a host that
|
|
258
|
+
// tuned the boot-guard window silently got the 10s default instead.
|
|
259
|
+
// Both spellings are accepted so the older one keeps working.
|
|
260
|
+
const healthyAfterMs = typeof ota.healthyAfterMs === 'number' ? ota.healthyAfterMs : typeof ota.healthyTimeoutMs === 'number' ? ota.healthyTimeoutMs : undefined;
|
|
256
261
|
otaOrchestrator.init({
|
|
257
262
|
...(signingRequested ? {
|
|
258
263
|
signature: {
|
|
@@ -262,15 +267,123 @@ class ScaleBunFacade {
|
|
|
262
267
|
verifier
|
|
263
268
|
}
|
|
264
269
|
} : {}),
|
|
265
|
-
...(
|
|
266
|
-
|
|
270
|
+
...(healthyAfterMs !== undefined ? {
|
|
271
|
+
healthyAfterMs
|
|
267
272
|
} : {})
|
|
268
273
|
});
|
|
269
274
|
logger.info('[ScaleBun] OTA enabled from init config.');
|
|
275
|
+
|
|
276
|
+
// Run the checks the config asked for. Until this existed, `ota.enabled`
|
|
277
|
+
// initialised the orchestrator and then never checked anything: the
|
|
278
|
+
// documented `checkOnForeground` and `channelOverride` options were read
|
|
279
|
+
// by no code at all, and an app following the documented config received
|
|
280
|
+
// updates only if it ALSO drove `useOtaUpdate` or the CodePush shim by
|
|
281
|
+
// hand. Nothing logged, because "no update available" and "never asked"
|
|
282
|
+
// look identical from the outside.
|
|
283
|
+
this._startOtaChecks(ota);
|
|
270
284
|
} catch (err) {
|
|
271
285
|
logger.warn(`[ScaleBun] OTA init failed: ${err?.message ?? err}`);
|
|
272
286
|
}
|
|
273
287
|
}
|
|
288
|
+
|
|
289
|
+
/** Guards against overlapping config-driven OTA checks. */
|
|
290
|
+
_otaCheckInFlight = false;
|
|
291
|
+
/** Wall clock of the last config-driven check, for the foreground floor. */
|
|
292
|
+
_otaLastCheckAt = 0;
|
|
293
|
+
_otaForegroundListener = null;
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Minimum gap between config-driven checks.
|
|
297
|
+
*
|
|
298
|
+
* A foreground transition is cheap to trigger — app switchers, permission
|
|
299
|
+
* dialogs and share sheets all produce one — so an unthrottled check would
|
|
300
|
+
* put a request on the hot path every time the user glanced away. Ten
|
|
301
|
+
* minutes is well below any realistic release cadence and well above that
|
|
302
|
+
* noise. A host that wants a check on demand calls `useOtaUpdate().sync()`,
|
|
303
|
+
* which is never throttled.
|
|
304
|
+
*/
|
|
305
|
+
static OTA_MIN_CHECK_INTERVAL_MS = 10 * 60 * 1000;
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Drive OTA checks from init config: once at startup, then on each
|
|
309
|
+
* foreground when `checkOnForeground` is on (the schema default).
|
|
310
|
+
*
|
|
311
|
+
* `appVersion` is resolved from the native bridge rather than asked of the
|
|
312
|
+
* integrator, because it gates the server's `targetAppVersion` semver check
|
|
313
|
+
* — sending a wrong or invented value is worse than sending none, and there
|
|
314
|
+
* is no honest default. If it cannot be resolved, the check is skipped with
|
|
315
|
+
* a warning instead of guessing.
|
|
316
|
+
*/
|
|
317
|
+
_startOtaChecks(ota) {
|
|
318
|
+
// Never in a debug build. `ScaleBunOtaModule.getJSBundleFile()` returns null
|
|
319
|
+
// there on purpose so Metro keeps ownership of the bundle — so a bundle
|
|
320
|
+
// downloaded in dev is installed into a slot that will never be loaded, and
|
|
321
|
+
// the identity check on the next launch then correctly observes that the
|
|
322
|
+
// running code is not what was installed and reports APPLY_FAILED. Checking
|
|
323
|
+
// at all in dev buys nothing and manufactures that false alarm. A developer
|
|
324
|
+
// testing the OTA path drives `useOtaUpdate().sync()` explicitly.
|
|
325
|
+
if (__DEV__) {
|
|
326
|
+
logger.info('[ScaleBun] OTA checks are skipped in debug builds (Metro owns the bundle).');
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const runCheck = async trigger => {
|
|
330
|
+
if (this._otaCheckInFlight) return;
|
|
331
|
+
if (trigger === 'foreground' && Date.now() - this._otaLastCheckAt < ScaleBunFacade.OTA_MIN_CHECK_INTERVAL_MS) {
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const clientKey = this._clientKey;
|
|
335
|
+
const apiUrl = this._apiBaseUrl;
|
|
336
|
+
if (!clientKey || !apiUrl) return;
|
|
337
|
+
this._otaCheckInFlight = true;
|
|
338
|
+
try {
|
|
339
|
+
const info = await bridgeAdapter.getDeviceInfo();
|
|
340
|
+
const appVersion = info?.appVersion;
|
|
341
|
+
if (!appVersion) {
|
|
342
|
+
logger.warn('[ScaleBun] OTA check skipped — the app version could not be read from the ' + 'native bridge. Rebuild the native app, or drive checks yourself with ' + 'useOtaUpdate({ appVersion }).');
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
this._otaLastCheckAt = Date.now();
|
|
346
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
347
|
+
const {
|
|
348
|
+
otaOrchestrator
|
|
349
|
+
} = require('../features/ota/OtaOrchestrator');
|
|
350
|
+
await otaOrchestrator.sync({
|
|
351
|
+
apiUrl,
|
|
352
|
+
clientKey,
|
|
353
|
+
appVersion,
|
|
354
|
+
// The documented option, finally connected. Omitted means the
|
|
355
|
+
// server's `default` channel, exactly as before.
|
|
356
|
+
channelName: typeof ota.channelOverride === 'string' ? ota.channelOverride : undefined
|
|
357
|
+
// Never forced from config: the release's own installMode
|
|
358
|
+
// decides when the app restarts, and yanking the screen out
|
|
359
|
+
// from under a user is not a decision this switch should make.
|
|
360
|
+
});
|
|
361
|
+
} catch (err) {
|
|
362
|
+
logger.warn(`[ScaleBun] OTA check failed: ${err?.message ?? err}`);
|
|
363
|
+
} finally {
|
|
364
|
+
this._otaCheckInFlight = false;
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
void runCheck('startup');
|
|
368
|
+
if (ota.checkOnForeground === false) return;
|
|
369
|
+
if (this._otaForegroundListener) return; // idempotent across repeated init()
|
|
370
|
+
try {
|
|
371
|
+
// Through `appLifecycle`, not a second AppState subscription: the SDK
|
|
372
|
+
// already owns one and fanning out from it keeps every consumer on the
|
|
373
|
+
// same transition sequence.
|
|
374
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
375
|
+
const {
|
|
376
|
+
appLifecycle
|
|
377
|
+
} = require('../core/lifecycle/appLifecycle');
|
|
378
|
+
this._otaForegroundListener = state => {
|
|
379
|
+
if (state === 'active') void runCheck('foreground');
|
|
380
|
+
};
|
|
381
|
+
appLifecycle.addListener(this._otaForegroundListener);
|
|
382
|
+
} catch {
|
|
383
|
+
// Lifecycle unavailable (tests, exotic hosts) — the startup check stands.
|
|
384
|
+
this._otaForegroundListener = null;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
274
387
|
_autoEnableDebug(debugConfig) {
|
|
275
388
|
try {
|
|
276
389
|
const dbgConfig = buildDebugConfigFromInitConfig(debugConfig);
|
|
@@ -8,5 +8,5 @@
|
|
|
8
8
|
* value, so a stale one makes a rollout unobservable — which is the exact problem sending an SDK
|
|
9
9
|
* version was introduced to solve.
|
|
10
10
|
*/
|
|
11
|
-
export declare const SDK_VERSION = "1.
|
|
11
|
+
export declare const SDK_VERSION = "1.13.0";
|
|
12
12
|
//# sourceMappingURL=version.d.ts.map
|
|
@@ -5,10 +5,24 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Sprint 5 deliverable (S5-NAT-2, S5-NAT-3).
|
|
7
7
|
*/
|
|
8
|
-
export type OtaEventType =
|
|
8
|
+
export type OtaEventType =
|
|
9
|
+
/** A check was made against the server. The denominator of the funnel. */
|
|
10
|
+
'CHECK'
|
|
11
|
+
/** The server answered with a bundle for this device — the offer, before any bytes move. */
|
|
12
|
+
| 'OFFERED' | 'DOWNLOAD_STARTED' | 'DOWNLOAD_PROGRESS' | 'DOWNLOAD_COMPLETE'
|
|
13
|
+
/** Staged and swapped. Emitted optimistically, BEFORE the bundle has booted. */
|
|
14
|
+
| 'INSTALLED'
|
|
15
|
+
/** The bundle booted and survived to the healthy mark — the honest activation signal. */
|
|
16
|
+
| 'BOOT_SUCCESS' | 'APPLY_FAILED' | 'AUTO_ROLLBACK' | 'MANUAL_ROLLBACK';
|
|
9
17
|
export interface OtaEvent {
|
|
10
18
|
type: OtaEventType;
|
|
11
19
|
bundleId: string;
|
|
20
|
+
/**
|
|
21
|
+
* The release this bundle was served as. Optional because a bundle installed
|
|
22
|
+
* by an older SDK has no recorded release; present on everything emitted by a
|
|
23
|
+
* current one, so the delivery funnel can key by release rather than bundle.
|
|
24
|
+
*/
|
|
25
|
+
releaseId?: string;
|
|
12
26
|
version?: number;
|
|
13
27
|
/** 0–100 for DOWNLOAD_PROGRESS */
|
|
14
28
|
progress?: number;
|
|
@@ -15,8 +15,13 @@ export interface BootGuardConfig {
|
|
|
15
15
|
*/
|
|
16
16
|
healthyAfterMs?: number;
|
|
17
17
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
18
|
+
* @deprecated Not honoured, and never was — nothing in JS reads this field.
|
|
19
|
+
*
|
|
20
|
+
* The boot-attempt limit lives in native code, where the counter it bounds is
|
|
21
|
+
* incremented (`MAX_BOOT_ATTEMPTS` in `SlotManager.kt` / `OtaSlotManager.swift`,
|
|
22
|
+
* both 2). Setting it here has no effect; the field is kept only so existing
|
|
23
|
+
* call sites keep compiling. Change the limit natively, or file a request for
|
|
24
|
+
* it to be plumbed through `initOutbox`-style native config.
|
|
20
25
|
*/
|
|
21
26
|
maxRevertAttempts?: number;
|
|
22
27
|
}
|
|
@@ -51,6 +56,11 @@ export declare class OtaOrchestrator {
|
|
|
51
56
|
* Read the active slot back into `currentBundle` so the next check reports
|
|
52
57
|
* what this device is genuinely running.
|
|
53
58
|
*/
|
|
59
|
+
/**
|
|
60
|
+
* Parse the native slot state once. Returns null when the module is absent or
|
|
61
|
+
* the payload is unreadable — every caller treats that as "factory bundle".
|
|
62
|
+
*/
|
|
63
|
+
private readSlotState;
|
|
54
64
|
private hydrateCurrentBundleFromSlots;
|
|
55
65
|
/**
|
|
56
66
|
* Compare the bundle the slot manager believes is active against the identity
|
|
@@ -71,7 +81,16 @@ export declare class OtaOrchestrator {
|
|
|
71
81
|
private static readonly INSTALL_EXPECTATION_KEY;
|
|
72
82
|
private recordInstallExpectation;
|
|
73
83
|
private readInstallRecord;
|
|
74
|
-
|
|
84
|
+
/**
|
|
85
|
+
* Drop the identity token once the install has been proven, keeping the rest
|
|
86
|
+
* of the record.
|
|
87
|
+
*
|
|
88
|
+
* The record does two jobs: it proves an install took effect (once), and it
|
|
89
|
+
* maps the native slot's sha256 back to a bundle id (for the life of that
|
|
90
|
+
* bundle). Only the first job is finished after a successful verification, so
|
|
91
|
+
* only the token is retired.
|
|
92
|
+
*/
|
|
93
|
+
private retireIdentityToken;
|
|
75
94
|
private clearInstallExpectation;
|
|
76
95
|
private storage;
|
|
77
96
|
/**
|