@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
|
@@ -7,42 +7,38 @@
|
|
|
7
7
|
*/
|
|
8
8
|
/**
|
|
9
9
|
* ═══════════════════════════════════════════════════════════════════════════
|
|
10
|
-
* OTA-TELEMETRY-SPEC —
|
|
10
|
+
* OTA-TELEMETRY-SPEC — status (spec §84-86)
|
|
11
11
|
* ═══════════════════════════════════════════════════════════════════════════
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* types + this spec, not the runtime wiring.
|
|
12
|
+
* Items 1-3 are DONE. All were additive and backward-compatible: old installed
|
|
13
|
+
* clients simply omit the new fields and never emit the new types, and the
|
|
14
|
+
* backend ingests nullable columns. Because they alter device runtime behaviour,
|
|
15
|
+
* verify against a real RN build + device kill-test before release — Metro
|
|
16
|
+
* cannot exercise the boot guard.
|
|
18
17
|
*
|
|
19
|
-
* 1. releaseId end-to-end
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* - set `releaseId: bundle.releaseId` in the delivery mapper
|
|
24
|
-
* OtaOrchestrator.ts deliverOtaEvents (~:105) — currently only bundleId.
|
|
25
|
-
* Then ota_events carry releaseId and the funnel keys by release.
|
|
18
|
+
* 1. releaseId end-to-end — DONE. `recordInstallExpectation` persists the
|
|
19
|
+
* release id (so a rollback reported launches later can still name it),
|
|
20
|
+
* `hydrateCurrentBundleFromSlots` restores it, every emit site passes it, and
|
|
21
|
+
* `deliverOtaEvents` maps it onto the wire item.
|
|
26
22
|
*
|
|
27
|
-
* 2.
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* -
|
|
31
|
-
*
|
|
23
|
+
* 2. CHECK + OFFERED — DONE. CHECK is emitted at the top of `sync()` for devices
|
|
24
|
+
* already on an OTA bundle (a device on the factory bundle has no owned
|
|
25
|
+
* bundle id to name, and the server drops rows it cannot resolve — those are
|
|
26
|
+
* counted server-side instead). OFFERED is emitted on `action === 'DOWNLOAD'`
|
|
27
|
+
* before any bytes move.
|
|
32
28
|
*
|
|
33
|
-
* 3.
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* add 'VERIFIED' after signature check (~:635).
|
|
29
|
+
* 3. BOOT_SUCCESS — DONE. Emitted from the boot-guard heartbeat once the bundle
|
|
30
|
+
* has actually booted and survived to `healthyAfterMs`. INSTALLED is kept and
|
|
31
|
+
* still means "staged + swapped", but activation should be measured on
|
|
32
|
+
* BOOT_SUCCESS: INSTALLED fires before the bundle has run, so it credits
|
|
33
|
+
* bundles that installed and then crash-reverted.
|
|
39
34
|
*
|
|
40
|
-
* 4. Stamp the running OTA bundle onto session/crash telemetry
|
|
41
|
-
* SessionMetadata.bundleId is the NATIVE app package id, not the OTA bundle
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
35
|
+
* 4. Stamp the running OTA bundle onto session/crash telemetry — NOT DONE.
|
|
36
|
+
* `SessionMetadata.bundleId` is the NATIVE app package id, not the OTA bundle,
|
|
37
|
+
* so crashes cannot currently be attributed to the bundle that produced them
|
|
38
|
+
* (release-health crash impact). Needs optional otaBundleId?/otaBundleVersion?
|
|
39
|
+
* (distinct fields — do NOT overload bundleId) sourced from
|
|
40
|
+
* `otaOrchestrator.getCurrentBundle()` at session start, plus the matching
|
|
41
|
+
* backend columns and a Prisma migration.
|
|
46
42
|
*
|
|
47
43
|
* Do NOT repurpose errorCode (it already collapses failure-error vs rollback-
|
|
48
44
|
* reason); add a new optional field if the two must be distinguished.
|
|
@@ -126,7 +122,8 @@ export interface OtaPatchPayload {
|
|
|
126
122
|
sha256: string;
|
|
127
123
|
baseBundleId: string;
|
|
128
124
|
}
|
|
129
|
-
|
|
125
|
+
import type { OtaEventType as EmittedOtaEventType } from './OtaEventEmitter';
|
|
126
|
+
export type OtaEventType = Exclude<EmittedOtaEventType, 'DOWNLOAD_PROGRESS'>;
|
|
130
127
|
export interface OtaEventItem {
|
|
131
128
|
kind: 'ota_event';
|
|
132
129
|
bundleId: string;
|
|
@@ -82,6 +82,33 @@ declare class ScaleBunFacade {
|
|
|
82
82
|
* than letting the app discover it as a silent no-update condition.
|
|
83
83
|
*/
|
|
84
84
|
private _maybeStartOta;
|
|
85
|
+
/** Guards against overlapping config-driven OTA checks. */
|
|
86
|
+
private _otaCheckInFlight;
|
|
87
|
+
/** Wall clock of the last config-driven check, for the foreground floor. */
|
|
88
|
+
private _otaLastCheckAt;
|
|
89
|
+
private _otaForegroundListener;
|
|
90
|
+
/**
|
|
91
|
+
* Minimum gap between config-driven checks.
|
|
92
|
+
*
|
|
93
|
+
* A foreground transition is cheap to trigger — app switchers, permission
|
|
94
|
+
* dialogs and share sheets all produce one — so an unthrottled check would
|
|
95
|
+
* put a request on the hot path every time the user glanced away. Ten
|
|
96
|
+
* minutes is well below any realistic release cadence and well above that
|
|
97
|
+
* noise. A host that wants a check on demand calls `useOtaUpdate().sync()`,
|
|
98
|
+
* which is never throttled.
|
|
99
|
+
*/
|
|
100
|
+
private static readonly OTA_MIN_CHECK_INTERVAL_MS;
|
|
101
|
+
/**
|
|
102
|
+
* Drive OTA checks from init config: once at startup, then on each
|
|
103
|
+
* foreground when `checkOnForeground` is on (the schema default).
|
|
104
|
+
*
|
|
105
|
+
* `appVersion` is resolved from the native bridge rather than asked of the
|
|
106
|
+
* integrator, because it gates the server's `targetAppVersion` semver check
|
|
107
|
+
* — sending a wrong or invented value is worse than sending none, and there
|
|
108
|
+
* is no honest default. If it cannot be resolved, the check is skipped with
|
|
109
|
+
* a warning instead of guessing.
|
|
110
|
+
*/
|
|
111
|
+
private _startOtaChecks;
|
|
85
112
|
private _autoEnableDebug;
|
|
86
113
|
/** Boot the Phase 1 envelope tracking lane if an appId is configured. */
|
|
87
114
|
private _maybeStartEventTracker;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scalebun/react-native",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.13.0",
|
|
4
4
|
"description": "React Native SDK for ScaleBun",
|
|
5
5
|
"main": "lib/commonjs/index",
|
|
6
6
|
"module": "lib/module/index",
|
|
@@ -121,7 +121,7 @@
|
|
|
121
121
|
"@babel/runtime": "^7.25.0",
|
|
122
122
|
"@jridgewell/sourcemap-codec": "1.5.5",
|
|
123
123
|
"@jridgewell/trace-mapping": "0.3.31",
|
|
124
|
-
"@scalebun/cli": "^1.
|
|
124
|
+
"@scalebun/cli": "^1.13.0"
|
|
125
125
|
},
|
|
126
126
|
"codegenConfig": {
|
|
127
127
|
"name": "ScaleBunSpec",
|
|
@@ -9,11 +9,17 @@
|
|
|
9
9
|
// ── Event types ───────────────────────────────────────────────────────────────
|
|
10
10
|
|
|
11
11
|
export type OtaEventType =
|
|
12
|
+
/** A check was made against the server. The denominator of the funnel. */
|
|
12
13
|
| 'CHECK'
|
|
14
|
+
/** The server answered with a bundle for this device — the offer, before any bytes move. */
|
|
15
|
+
| 'OFFERED'
|
|
13
16
|
| 'DOWNLOAD_STARTED'
|
|
14
17
|
| 'DOWNLOAD_PROGRESS'
|
|
15
18
|
| 'DOWNLOAD_COMPLETE'
|
|
19
|
+
/** Staged and swapped. Emitted optimistically, BEFORE the bundle has booted. */
|
|
16
20
|
| 'INSTALLED'
|
|
21
|
+
/** The bundle booted and survived to the healthy mark — the honest activation signal. */
|
|
22
|
+
| 'BOOT_SUCCESS'
|
|
17
23
|
| 'APPLY_FAILED'
|
|
18
24
|
| 'AUTO_ROLLBACK'
|
|
19
25
|
| 'MANUAL_ROLLBACK';
|
|
@@ -21,6 +27,12 @@ export type OtaEventType =
|
|
|
21
27
|
export interface OtaEvent {
|
|
22
28
|
type: OtaEventType;
|
|
23
29
|
bundleId: string;
|
|
30
|
+
/**
|
|
31
|
+
* The release this bundle was served as. Optional because a bundle installed
|
|
32
|
+
* by an older SDK has no recorded release; present on everything emitted by a
|
|
33
|
+
* current one, so the delivery funnel can key by release rather than bundle.
|
|
34
|
+
*/
|
|
35
|
+
releaseId?: string;
|
|
24
36
|
version?: number;
|
|
25
37
|
/** 0–100 for DOWNLOAD_PROGRESS */
|
|
26
38
|
progress?: number;
|
|
@@ -106,6 +106,11 @@ async function deliverOtaEvents(params: {
|
|
|
106
106
|
kind: 'ota_event' as const,
|
|
107
107
|
type: e.type,
|
|
108
108
|
bundleId: e.bundleId,
|
|
109
|
+
// Without this the backend stored a null releaseId on every row it ingested,
|
|
110
|
+
// while serving the release id on every check — so the delivery funnel could
|
|
111
|
+
// only ever be grouped by bundle, and a bundle re-promoted under a second
|
|
112
|
+
// release merged the two into one indistinguishable series.
|
|
113
|
+
releaseId: e.releaseId,
|
|
109
114
|
installationId: params.installationId,
|
|
110
115
|
// OTA bundles are compiled per platform, so this genuinely is ios|android. Narrowed via
|
|
111
116
|
// resolveMobileOS so a non-mobile RN target is skipped rather than served an Android bundle.
|
|
@@ -158,14 +163,18 @@ export interface BootGuardConfig {
|
|
|
158
163
|
*/
|
|
159
164
|
healthyAfterMs?: number;
|
|
160
165
|
/**
|
|
161
|
-
*
|
|
162
|
-
*
|
|
166
|
+
* @deprecated Not honoured, and never was — nothing in JS reads this field.
|
|
167
|
+
*
|
|
168
|
+
* The boot-attempt limit lives in native code, where the counter it bounds is
|
|
169
|
+
* incremented (`MAX_BOOT_ATTEMPTS` in `SlotManager.kt` / `OtaSlotManager.swift`,
|
|
170
|
+
* both 2). Setting it here has no effect; the field is kept only so existing
|
|
171
|
+
* call sites keep compiling. Change the limit natively, or file a request for
|
|
172
|
+
* it to be plumbed through `initOutbox`-style native config.
|
|
163
173
|
*/
|
|
164
174
|
maxRevertAttempts?: number;
|
|
165
175
|
}
|
|
166
176
|
|
|
167
177
|
const DEFAULT_HEALTHY_AFTER_MS = 10_000;
|
|
168
|
-
const DEFAULT_MAX_REVERT_ATTEMPTS = 2;
|
|
169
178
|
|
|
170
179
|
export class OtaOrchestrator {
|
|
171
180
|
private enabled = false;
|
|
@@ -257,22 +266,35 @@ export class OtaOrchestrator {
|
|
|
257
266
|
`${this.environment.hermes ? `, Hermes HBC v${this.environment.hermesBytecodeVersion ?? '?'}` : ''})`,
|
|
258
267
|
);
|
|
259
268
|
|
|
269
|
+
// ONE read of the slot state, shared by both boot-time consumers.
|
|
270
|
+
//
|
|
271
|
+
// `getSlotState()` is CONSUME-ON-READ for the revert record: both native
|
|
272
|
+
// implementations delete it as they serialise, so that a rollback is
|
|
273
|
+
// reported exactly once instead of on every launch forever. Calling it
|
|
274
|
+
// twice therefore means the second caller never sees the revert — which is
|
|
275
|
+
// precisely what happened, since hydration ran first and swallowed it. It
|
|
276
|
+
// is also a blocking synchronous bridge call, so one read is cheaper.
|
|
277
|
+
const slotState = this.readSlotState();
|
|
278
|
+
|
|
260
279
|
// Rehydrate which bundle we are running from the native slot state.
|
|
261
280
|
// Without this `currentBundle` stays null for the whole process after a
|
|
262
281
|
// restart, so every check reported no current bundle and the backend had
|
|
263
282
|
// no way to know what the device was actually on.
|
|
264
|
-
this.hydrateCurrentBundleFromSlots();
|
|
283
|
+
this.hydrateCurrentBundleFromSlots(slotState);
|
|
265
284
|
|
|
266
285
|
// Warm the device-country cache (edge Worker lookup) so checks can carry
|
|
267
286
|
// a country when the API itself sits behind no geo-stamping CDN.
|
|
268
287
|
// Fire-and-forget and failure-soft — a check without a country is valid.
|
|
269
288
|
void prefetchDeviceCountry();
|
|
270
289
|
|
|
290
|
+
// Check if the boot guard fired on this launch (native reverted before JS
|
|
291
|
+
// loaded). BEFORE the identity check, because both read the install record
|
|
292
|
+
// and the identity check retires it — a revert must get its chance to name
|
|
293
|
+
// the bundle that failed while the record still describes it.
|
|
294
|
+
this.checkBootGuardRecovery(slotState);
|
|
295
|
+
|
|
271
296
|
// Prove the bundle we installed is the bundle that loaded.
|
|
272
297
|
this.verifyRunningBundleIdentity();
|
|
273
|
-
|
|
274
|
-
// Check if the boot guard fired on this launch (native reverted before JS loaded)
|
|
275
|
-
this.checkBootGuardRecovery();
|
|
276
298
|
});
|
|
277
299
|
}
|
|
278
300
|
|
|
@@ -280,10 +302,21 @@ export class OtaOrchestrator {
|
|
|
280
302
|
* Read the active slot back into `currentBundle` so the next check reports
|
|
281
303
|
* what this device is genuinely running.
|
|
282
304
|
*/
|
|
283
|
-
|
|
284
|
-
|
|
305
|
+
/**
|
|
306
|
+
* Parse the native slot state once. Returns null when the module is absent or
|
|
307
|
+
* the payload is unreadable — every caller treats that as "factory bundle".
|
|
308
|
+
*/
|
|
309
|
+
private readSlotState(): Record<string, any> | null {
|
|
310
|
+
if (!NativeScaleBunOta) return null;
|
|
311
|
+
try {
|
|
312
|
+
return JSON.parse(NativeScaleBunOta.getSlotState());
|
|
313
|
+
} catch {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private hydrateCurrentBundleFromSlots(state: Record<string, any> | null): void {
|
|
285
319
|
try {
|
|
286
|
-
const state = JSON.parse(NativeScaleBunOta.getSlotState());
|
|
287
320
|
const current = state?.current;
|
|
288
321
|
if (!current?.sha256) return;
|
|
289
322
|
|
|
@@ -298,6 +331,7 @@ export class OtaOrchestrator {
|
|
|
298
331
|
this.currentBundle = {
|
|
299
332
|
id: record.bundleId,
|
|
300
333
|
version: record.version,
|
|
334
|
+
releaseId: record.releaseId ?? undefined,
|
|
301
335
|
sha256: record.sha256,
|
|
302
336
|
} as OtaBundlePayload;
|
|
303
337
|
__DEV__ && logger.debug(`[OTA] Running bundle v${record.version} (${record.bundleId})`);
|
|
@@ -335,49 +369,50 @@ export class OtaOrchestrator {
|
|
|
335
369
|
if (!this.currentBundle) return;
|
|
336
370
|
|
|
337
371
|
const running = readRunningBundleMarker();
|
|
338
|
-
const
|
|
372
|
+
const record = this.readInstallRecord();
|
|
339
373
|
|
|
340
|
-
//
|
|
341
|
-
|
|
374
|
+
// No record at all — installed by an SDK that predates install records, or
|
|
375
|
+
// local storage was cleared. NOTHING can be concluded here and nothing is
|
|
376
|
+
// reported: the marker is a random per-publish token, never the bundle id,
|
|
377
|
+
// so comparing the two would flag a false mismatch on every healthy launch.
|
|
378
|
+
if (!record) return;
|
|
379
|
+
|
|
380
|
+
// The record describes a bundle that is no longer the active one (a revert,
|
|
381
|
+
// or a bundle staged by another path). It cannot verify this launch, and
|
|
382
|
+
// keeping it would make the next launch mis-report what is running.
|
|
383
|
+
if (record.sha256 !== this.currentBundle.sha256) {
|
|
342
384
|
this.clearInstallExpectation();
|
|
343
385
|
return;
|
|
344
386
|
}
|
|
345
387
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
`${running ?? 'no marker at all'}. The app is executing different code than the slot ` +
|
|
358
|
-
'manager believes. Check that the host app resolves the OTA bundle path at launch ' +
|
|
359
|
-
'(see the ScaleBunOta integration for your React Native version).',
|
|
360
|
-
);
|
|
361
|
-
otaEventEmitter.emitSimple('APPLY_FAILED', this.currentBundle.id, {
|
|
362
|
-
error: `install_not_effective — expected ${expected.identityToken}, running ${running ?? 'none'}`,
|
|
363
|
-
});
|
|
364
|
-
// Deliberately NOT cleared: the condition is still true on the next boot
|
|
365
|
-
// and should keep reporting until the integration is fixed. Clearing here
|
|
366
|
-
// would make a permanently broken install look like a one-off.
|
|
388
|
+
// Already proven on an earlier launch. The token is retired once verified
|
|
389
|
+
// while the REST of the record stays — it is also the sha256 -> bundleId map
|
|
390
|
+
// that `hydrateCurrentBundleFromSlots` reads to report what this device is
|
|
391
|
+
// running. Wiping the whole record here is what made every launch after the
|
|
392
|
+
// first send `currentBundleId: undefined`, which the server reads as "not on
|
|
393
|
+
// this bundle" and answers by serving the same bundle again, forever.
|
|
394
|
+
if (!record.identityToken) return;
|
|
395
|
+
|
|
396
|
+
if (running === record.identityToken) {
|
|
397
|
+
__DEV__ && logger.debug('[OTA] Install verified — running bundle matches what was installed.');
|
|
398
|
+
this.retireIdentityToken(record);
|
|
367
399
|
return;
|
|
368
400
|
}
|
|
369
401
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
}
|
|
380
|
-
}
|
|
402
|
+
logger.error(
|
|
403
|
+
`[OTA] INSTALL DID NOT TAKE EFFECT — bundle ${this.currentBundle.id} was installed and ` +
|
|
404
|
+
`carries a known identity marker, but the running bundle reports ` +
|
|
405
|
+
`${running ?? 'no marker at all'}. The app is executing different code than the slot ` +
|
|
406
|
+
'manager believes. Check that the host app resolves the OTA bundle path at launch ' +
|
|
407
|
+
'(see the ScaleBunOta integration for your React Native version).',
|
|
408
|
+
);
|
|
409
|
+
otaEventEmitter.emitSimple('APPLY_FAILED', this.currentBundle.id, {
|
|
410
|
+
releaseId: this.currentBundle.releaseId,
|
|
411
|
+
error: `install_not_effective — expected ${record.identityToken}, running ${running ?? 'none'}`,
|
|
412
|
+
});
|
|
413
|
+
// Deliberately NOT retired: the condition is still true on the next boot and
|
|
414
|
+
// should keep reporting until the integration is fixed. Retiring the token
|
|
415
|
+
// here would make a permanently broken install look like a one-off.
|
|
381
416
|
}
|
|
382
417
|
|
|
383
418
|
// ── Install expectation ────────────────────────────────────────────────────
|
|
@@ -394,6 +429,11 @@ export class OtaOrchestrator {
|
|
|
394
429
|
JSON.stringify({
|
|
395
430
|
bundleId: bundle.id,
|
|
396
431
|
version: bundle.version,
|
|
432
|
+
// Carried so telemetry emitted on a LATER launch (a boot-guard
|
|
433
|
+
// rollback, an ineffective install) can still be attributed to the
|
|
434
|
+
// release, not merely the bundle. The check response is long gone by
|
|
435
|
+
// then; this record is the only thing that remembers.
|
|
436
|
+
releaseId: bundle.releaseId ?? null,
|
|
397
437
|
// The join key back to the native slot, which records sha256 and
|
|
398
438
|
// nothing else identifying.
|
|
399
439
|
sha256: bundle.sha256,
|
|
@@ -408,6 +448,7 @@ export class OtaOrchestrator {
|
|
|
408
448
|
private readInstallRecord(): {
|
|
409
449
|
bundleId: string;
|
|
410
450
|
version: number;
|
|
451
|
+
releaseId?: string | null;
|
|
411
452
|
sha256: string;
|
|
412
453
|
identityToken: string | null;
|
|
413
454
|
} | null {
|
|
@@ -423,11 +464,35 @@ export class OtaOrchestrator {
|
|
|
423
464
|
}
|
|
424
465
|
}
|
|
425
466
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
467
|
+
/**
|
|
468
|
+
* Drop the identity token once the install has been proven, keeping the rest
|
|
469
|
+
* of the record.
|
|
470
|
+
*
|
|
471
|
+
* The record does two jobs: it proves an install took effect (once), and it
|
|
472
|
+
* maps the native slot's sha256 back to a bundle id (for the life of that
|
|
473
|
+
* bundle). Only the first job is finished after a successful verification, so
|
|
474
|
+
* only the token is retired.
|
|
475
|
+
*/
|
|
476
|
+
private retireIdentityToken(record: {
|
|
477
|
+
bundleId: string;
|
|
478
|
+
version: number;
|
|
479
|
+
releaseId?: string | null;
|
|
480
|
+
sha256: string;
|
|
481
|
+
}): void {
|
|
482
|
+
try {
|
|
483
|
+
this.storage().set(
|
|
484
|
+
OtaOrchestrator.INSTALL_EXPECTATION_KEY,
|
|
485
|
+
JSON.stringify({
|
|
486
|
+
bundleId: record.bundleId,
|
|
487
|
+
version: record.version,
|
|
488
|
+
releaseId: record.releaseId ?? null,
|
|
489
|
+
sha256: record.sha256,
|
|
490
|
+
identityToken: null,
|
|
491
|
+
}),
|
|
492
|
+
);
|
|
493
|
+
} catch {
|
|
494
|
+
/* non-fatal */
|
|
495
|
+
}
|
|
431
496
|
}
|
|
432
497
|
|
|
433
498
|
private clearInstallExpectation(): void {
|
|
@@ -448,19 +513,50 @@ export class OtaOrchestrator {
|
|
|
448
513
|
* If getSlotState() shows bootMarkerPresent=false but we have a 'previous' slot
|
|
449
514
|
* and no 'current' OTA bundle, the native layer already reverted.
|
|
450
515
|
*/
|
|
451
|
-
private checkBootGuardRecovery(): void {
|
|
452
|
-
if (!NativeScaleBunOta) return;
|
|
453
|
-
|
|
516
|
+
private checkBootGuardRecovery(state: Record<string, any> | null): void {
|
|
454
517
|
try {
|
|
455
|
-
|
|
456
|
-
|
|
518
|
+
if (!state?.bootGuardReverted) return;
|
|
519
|
+
|
|
520
|
+
// WHICH bundle was rolled back, and it has to come from the install
|
|
521
|
+
// record. The obvious source — `state.previous` — is wrong twice over:
|
|
522
|
+
// slot meta.json carries only `{sha256, installedAt}` so it has no bundle
|
|
523
|
+
// id to read, and `revert()` deletes the previous slot as its last step,
|
|
524
|
+
// so by the time this runs there is no previous meta at all. The event
|
|
525
|
+
// therefore reported `bundleId: 'unknown'` on every rollback, and the
|
|
526
|
+
// backend drops any ota_event whose bundleId does not resolve to a bundle
|
|
527
|
+
// the app owns — so the crash-guard's own rollback signal never arrived.
|
|
528
|
+
//
|
|
529
|
+
// Native now stamps the sha256 of the bundle it reverted AWAY FROM into
|
|
530
|
+
// the revert record; the install record maps that back to a bundle id.
|
|
531
|
+
const record = this.readInstallRecord();
|
|
532
|
+
const revertedSha: string | undefined = state.bootGuardRevertedSha256;
|
|
533
|
+
const matchesRecord = !!record && (!revertedSha || record.sha256 === revertedSha);
|
|
534
|
+
|
|
535
|
+
// Report the reason native actually determined — a hash mismatch and a
|
|
536
|
+
// crash loop are different incidents and were being collapsed into one.
|
|
537
|
+
const reason: string = state.bootGuardRevertReason || 'boot_crash_guard';
|
|
457
538
|
|
|
458
|
-
if (
|
|
459
|
-
logger.warn(
|
|
460
|
-
|
|
461
|
-
|
|
539
|
+
if (matchesRecord && record) {
|
|
540
|
+
logger.warn(
|
|
541
|
+
`[OTA] Boot guard fired — reverted away from bundle ${record.bundleId} ` +
|
|
542
|
+
`(v${record.version}); reason: ${reason}`,
|
|
543
|
+
);
|
|
544
|
+
otaEventEmitter.emitSimple('AUTO_ROLLBACK', record.bundleId, {
|
|
545
|
+
releaseId: record.releaseId ?? undefined,
|
|
546
|
+
version: record.version,
|
|
547
|
+
reason,
|
|
462
548
|
});
|
|
549
|
+
// The record describes a bundle this device is no longer running.
|
|
550
|
+
this.clearInstallExpectation();
|
|
551
|
+
return;
|
|
463
552
|
}
|
|
553
|
+
|
|
554
|
+
// No usable record (older SDK, cleared storage). Say so rather than
|
|
555
|
+
// emitting an event the server is obliged to discard.
|
|
556
|
+
logger.warn(
|
|
557
|
+
`[OTA] Boot guard fired (reason: ${reason}) but the rolled-back bundle could not be ` +
|
|
558
|
+
'identified locally — no install record. The rollback is not reported to the server.',
|
|
559
|
+
);
|
|
464
560
|
} catch {
|
|
465
561
|
// Slot state parsing failed — non-fatal
|
|
466
562
|
}
|
|
@@ -591,6 +687,23 @@ export class OtaOrchestrator {
|
|
|
591
687
|
}
|
|
592
688
|
|
|
593
689
|
__DEV__ && logger.debug('[OTA] Sync started…');
|
|
690
|
+
|
|
691
|
+
// The funnel's denominator, for devices already on an OTA bundle. Emitted
|
|
692
|
+
// before the request, so a check that fails outright still counts as a
|
|
693
|
+
// check — the only CHECK rows before this came from a server-side geo
|
|
694
|
+
// side-effect that is skipped whenever the request carries no country.
|
|
695
|
+
//
|
|
696
|
+
// Only when a current bundle is known: every ota_event must name a bundle
|
|
697
|
+
// the app owns or the server drops it, and a device still on the binary's
|
|
698
|
+
// factory bundle has no such id to give. Those devices are counted
|
|
699
|
+
// server-side when they are offered something.
|
|
700
|
+
if (this.currentBundle?.id) {
|
|
701
|
+
otaEventEmitter.emitSimple('CHECK', this.currentBundle.id, {
|
|
702
|
+
releaseId: this.currentBundle.releaseId,
|
|
703
|
+
version: this.currentBundle.version,
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
|
|
594
707
|
const checkRes = await this.checkForUpdate(params);
|
|
595
708
|
|
|
596
709
|
if (checkRes.action === 'NONE') {
|
|
@@ -603,7 +716,9 @@ export class OtaOrchestrator {
|
|
|
603
716
|
// Handle server-initiated rollback (Sprint 4)
|
|
604
717
|
if (checkRes.action === 'ROLLBACK') {
|
|
605
718
|
logger.warn('[OTA] Server requested ROLLBACK — reverting to previous bundle');
|
|
606
|
-
otaEventEmitter.emitSimple('MANUAL_ROLLBACK', this.currentBundle?.id ?? 'unknown'
|
|
719
|
+
otaEventEmitter.emitSimple('MANUAL_ROLLBACK', this.currentBundle?.id ?? 'unknown', {
|
|
720
|
+
releaseId: this.currentBundle?.releaseId,
|
|
721
|
+
});
|
|
607
722
|
const reverted = await NativeScaleBunOta.revertToPrevious();
|
|
608
723
|
if (reverted) {
|
|
609
724
|
this.currentBundle = null;
|
|
@@ -623,6 +738,15 @@ export class OtaOrchestrator {
|
|
|
623
738
|
const bundle = checkRes.bundle;
|
|
624
739
|
let patchUsed = false;
|
|
625
740
|
|
|
741
|
+
// The offer itself. Everything downstream (download, install, activation)
|
|
742
|
+
// is a conversion against this, so without it the top of the funnel was
|
|
743
|
+
// unmeasurable and a rollout that never reached devices looked identical
|
|
744
|
+
// to one whose devices all declined to download.
|
|
745
|
+
otaEventEmitter.emitSimple('OFFERED', bundle.id, {
|
|
746
|
+
releaseId: bundle.releaseId,
|
|
747
|
+
version: bundle.version,
|
|
748
|
+
});
|
|
749
|
+
|
|
626
750
|
// ── VERIFY AUTHENTICITY (OTA-03) ─────────────────────────────────────
|
|
627
751
|
// Before anything touches the disk. SHA-256 proves the bytes arrived
|
|
628
752
|
// intact; only the signature proves they came from you. Checking after
|
|
@@ -634,6 +758,7 @@ export class OtaOrchestrator {
|
|
|
634
758
|
);
|
|
635
759
|
if (!signatureOutcome.ok) {
|
|
636
760
|
otaEventEmitter.emitSimple('APPLY_FAILED', bundle.id, {
|
|
761
|
+
releaseId: bundle.releaseId,
|
|
637
762
|
error: `Signature check failed: ${signatureOutcome.reason}`,
|
|
638
763
|
version: bundle.version,
|
|
639
764
|
});
|
|
@@ -645,7 +770,10 @@ export class OtaOrchestrator {
|
|
|
645
770
|
}
|
|
646
771
|
|
|
647
772
|
// ── DOWNLOAD ─────────────────────────────────────────────────────────
|
|
648
|
-
otaEventEmitter.emitSimple('DOWNLOAD_STARTED', bundle.id, {
|
|
773
|
+
otaEventEmitter.emitSimple('DOWNLOAD_STARTED', bundle.id, {
|
|
774
|
+
releaseId: bundle.releaseId,
|
|
775
|
+
version: bundle.version,
|
|
776
|
+
});
|
|
649
777
|
const downloadStart = Date.now();
|
|
650
778
|
__DEV__ && logger.debug(`[OTA] Downloading update v${bundle.version}…`);
|
|
651
779
|
|
|
@@ -750,6 +878,7 @@ export class OtaOrchestrator {
|
|
|
750
878
|
postProgress(0, 'FAILED');
|
|
751
879
|
logger.error('[OTA] Staging bundle failed after retries');
|
|
752
880
|
otaEventEmitter.emitSimple('APPLY_FAILED', bundle.id, {
|
|
881
|
+
releaseId: bundle.releaseId,
|
|
753
882
|
error: 'Staging failed — SHA-256 mismatch or download error',
|
|
754
883
|
version: bundle.version,
|
|
755
884
|
});
|
|
@@ -760,6 +889,7 @@ export class OtaOrchestrator {
|
|
|
760
889
|
|
|
761
890
|
const downloadDuration = Date.now() - downloadStart;
|
|
762
891
|
otaEventEmitter.emitSimple('DOWNLOAD_COMPLETE', bundle.id, {
|
|
892
|
+
releaseId: bundle.releaseId,
|
|
763
893
|
version: bundle.version,
|
|
764
894
|
durationMs: downloadDuration,
|
|
765
895
|
patchUsed,
|
|
@@ -771,6 +901,7 @@ export class OtaOrchestrator {
|
|
|
771
901
|
if (!applied) {
|
|
772
902
|
logger.error('[OTA] Applying update failed');
|
|
773
903
|
otaEventEmitter.emitSimple('APPLY_FAILED', bundle.id, {
|
|
904
|
+
releaseId: bundle.releaseId,
|
|
774
905
|
error: 'Atomic slot swap failed',
|
|
775
906
|
version: bundle.version,
|
|
776
907
|
});
|
|
@@ -791,7 +922,10 @@ export class OtaOrchestrator {
|
|
|
791
922
|
// it is skipped when there is no marker to compare.
|
|
792
923
|
this.recordInstallExpectation(bundle);
|
|
793
924
|
|
|
794
|
-
otaEventEmitter.emitSimple('INSTALLED', bundle.id, {
|
|
925
|
+
otaEventEmitter.emitSimple('INSTALLED', bundle.id, {
|
|
926
|
+
releaseId: bundle.releaseId,
|
|
927
|
+
version: bundle.version,
|
|
928
|
+
});
|
|
795
929
|
__DEV__ && logger.debug(`[OTA] Update v${bundle.version} installed successfully!`);
|
|
796
930
|
|
|
797
931
|
// Deliver telemetry BEFORE a restart tears the JS runtime down —
|
|
@@ -863,6 +997,19 @@ export class OtaOrchestrator {
|
|
|
863
997
|
NativeScaleBunOta?.markHealthy();
|
|
864
998
|
__DEV__ && logger.info('[OTA] Boot guard cleared — bundle marked healthy ✓');
|
|
865
999
|
this.healthyTimer = null;
|
|
1000
|
+
|
|
1001
|
+
// The honest activation signal. INSTALLED is emitted optimistically,
|
|
1002
|
+
// BEFORE the bundle has ever executed, so counting it as adoption
|
|
1003
|
+
// credits bundles that were installed and then crash-reverted. This
|
|
1004
|
+
// fires only once the bundle has actually booted and survived.
|
|
1005
|
+
const running = this.currentBundle;
|
|
1006
|
+
if (running?.id) {
|
|
1007
|
+
otaEventEmitter.emitSimple('BOOT_SUCCESS', running.id, {
|
|
1008
|
+
releaseId: running.releaseId,
|
|
1009
|
+
version: running.version,
|
|
1010
|
+
durationMs: healthyMs,
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
866
1013
|
});
|
|
867
1014
|
}, healthyMs);
|
|
868
1015
|
} catch {
|