@scalebun/react-native 1.13.0 → 2.0.1
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/build.gradle +8 -0
- package/android/src/androidTest/java/com/scalebun/rn/ota/ScaleBunOtaVerifierInstrumentedTest.kt +112 -0
- package/android/src/main/java/com/scalebun/rn/ota/BundleDownloader.kt +10 -0
- package/android/src/main/java/com/scalebun/rn/ota/DeviceIntegrity.kt +75 -0
- package/android/src/main/java/com/scalebun/rn/ota/OtaProtocol.kt +244 -0
- package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaKeyRegistry.kt +100 -0
- package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaModule.kt +132 -62
- package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaReleaseVerifier.kt +129 -0
- package/android/src/main/java/com/scalebun/rn/ota/SlotManager.kt +80 -1
- package/android/src/main/java/com/scalebun/rn/ota/TlsPinning.kt +118 -0
- package/android/src/oldarch/java/com/scalebun/rn/ota/ScaleBunOtaSpec.kt +6 -8
- package/dist/scalebun.full.js +964 -533
- package/dist/scalebun.slim.js +962 -531
- package/ios/Ota/OtaProtocol.swift +242 -0
- package/ios/Ota/OtaSlotManager.swift +26 -0
- package/ios/Ota/ScaleBunOtaBridge.mm +6 -3
- package/ios/Ota/ScaleBunOtaKeyRegistry.swift +147 -0
- package/ios/Ota/ScaleBunOtaModule.swift +125 -53
- package/ios/Ota/ScaleBunOtaReleaseVerifier.swift +115 -0
- package/ios/Ota/ScaleBunOtaVerifierTests.swift +92 -0
- package/ios/ReplaySdk.swift +65 -2
- package/lib/commonjs/bootstrap/SDKBootstrapper.js +30 -0
- package/lib/commonjs/core/config/schema.js +19 -12
- package/lib/commonjs/core/constants/version.js +1 -1
- package/lib/commonjs/features/journey/ScaleBunDebugRoot.js +293 -5
- package/lib/commonjs/features/journey/ScaleBunScrollView.js +28 -0
- package/lib/commonjs/features/journey/autoInstrumentScroll.js +161 -0
- package/lib/commonjs/features/journey/interactionProtocol.js +131 -10
- package/lib/commonjs/features/journey/scrollContext.js +146 -0
- package/lib/commonjs/features/journey/targetGeometry.js +164 -0
- package/lib/commonjs/features/journey/touchTarget.js +152 -24
- package/lib/commonjs/features/journey/uiState.js +81 -1
- package/lib/commonjs/features/navigation/AutoScreenDetector.js +74 -5
- package/lib/commonjs/features/network/NetworkFeature.js +39 -5
- package/lib/commonjs/features/network/thirdParty.js +90 -0
- package/lib/commonjs/features/ota/OtaOrchestrator.js +175 -80
- package/lib/commonjs/features/session/SessionManager.js +63 -0
- package/lib/commonjs/public/ScaleBunFacade.js +23 -57
- package/lib/module/bootstrap/SDKBootstrapper.js +30 -0
- package/lib/module/core/config/schema.js +19 -12
- package/lib/module/core/constants/version.js +1 -1
- package/lib/module/features/journey/ScaleBunDebugRoot.js +295 -7
- package/lib/module/features/journey/ScaleBunScrollView.js +28 -0
- package/lib/module/features/journey/autoInstrumentScroll.js +155 -0
- package/lib/module/features/journey/interactionProtocol.js +128 -8
- package/lib/module/features/journey/scrollContext.js +135 -0
- package/lib/module/features/journey/targetGeometry.js +155 -0
- package/lib/module/features/journey/touchTarget.js +151 -24
- package/lib/module/features/journey/uiState.js +78 -1
- package/lib/module/features/navigation/AutoScreenDetector.js +74 -5
- package/lib/module/features/network/NetworkFeature.js +38 -4
- package/lib/module/features/network/thirdParty.js +83 -0
- package/lib/module/features/ota/OtaOrchestrator.js +175 -80
- package/lib/module/features/session/SessionManager.js +63 -0
- package/lib/module/public/ScaleBunFacade.js +23 -57
- package/lib/typescript/core/config/schema.d.ts +2 -3
- package/lib/typescript/core/constants/version.d.ts +1 -1
- package/lib/typescript/features/journey/autoInstrumentScroll.d.ts +46 -0
- package/lib/typescript/features/journey/interactionProtocol.d.ts +139 -1
- package/lib/typescript/features/journey/scrollContext.d.ts +85 -0
- package/lib/typescript/features/journey/targetGeometry.d.ts +114 -0
- package/lib/typescript/features/journey/touchTarget.d.ts +148 -15
- package/lib/typescript/features/journey/uiState.d.ts +39 -0
- package/lib/typescript/features/navigation/AutoScreenDetector.d.ts +48 -2
- package/lib/typescript/features/network/index.d.ts +18 -0
- package/lib/typescript/features/network/thirdParty.d.ts +65 -0
- package/lib/typescript/features/ota/OtaOrchestrator.d.ts +12 -8
- package/lib/typescript/features/ota/OtaTypes.d.ts +22 -0
- package/lib/typescript/features/session/SessionManager.d.ts +55 -0
- package/lib/typescript/public/ScaleBunFacade.d.ts +5 -11
- package/lib/typescript/public/types.d.ts +10 -0
- package/lib/typescript/specs/NativeScaleBunOta.d.ts +32 -20
- package/package.json +3 -13
- package/scalebun-react-native.podspec +4 -0
- package/src/bootstrap/SDKBootstrapper.ts +40 -0
- package/src/core/config/schema.ts +21 -9
- package/src/core/constants/version.ts +1 -1
- package/src/features/journey/ScaleBunDebugRoot.tsx +288 -7
- package/src/features/journey/ScaleBunScrollView.tsx +29 -0
- package/src/features/journey/autoInstrumentScroll.ts +155 -0
- package/src/features/journey/interactionProtocol.ts +188 -10
- package/src/features/journey/scrollContext.ts +137 -0
- package/src/features/journey/targetGeometry.ts +176 -0
- package/src/features/journey/touchTarget.ts +237 -26
- package/src/features/journey/uiState.ts +80 -1
- package/src/features/navigation/AutoScreenDetector.ts +65 -5
- package/src/features/network/NetworkFeature.ts +41 -4
- package/src/features/network/index.ts +18 -0
- package/src/features/network/thirdParty.ts +92 -0
- package/src/features/ota/OtaOrchestrator.ts +190 -90
- package/src/features/ota/OtaTypes.ts +23 -1
- package/src/features/session/SessionManager.ts +113 -0
- package/src/public/ScaleBunFacade.ts +23 -69
- package/src/public/types.ts +10 -0
- package/src/specs/NativeScaleBunOta.ts +35 -20
- package/lib/commonjs/features/ota/crypto/builtinVerifier.js +0 -248
- package/lib/commonjs/features/ota/crypto/loadEd25519.js +0 -40
- package/lib/commonjs/features/ota/crypto/loadSha512.js +0 -40
- package/lib/commonjs/features/ota/crypto/nativeVerifier.js +0 -121
- package/lib/commonjs/features/ota/signature.js +0 -175
- package/lib/module/features/ota/crypto/builtinVerifier.js +0 -240
- package/lib/module/features/ota/crypto/loadEd25519.js +0 -34
- package/lib/module/features/ota/crypto/loadSha512.js +0 -34
- package/lib/module/features/ota/crypto/nativeVerifier.js +0 -113
- package/lib/module/features/ota/signature.js +0 -169
- package/lib/typescript/features/ota/crypto/builtinVerifier.d.ts +0 -53
- package/lib/typescript/features/ota/crypto/loadEd25519.d.ts +0 -30
- package/lib/typescript/features/ota/crypto/loadSha512.d.ts +0 -15
- package/lib/typescript/features/ota/crypto/nativeVerifier.d.ts +0 -35
- package/lib/typescript/features/ota/signature.d.ts +0 -81
- package/src/features/ota/crypto/builtinVerifier.ts +0 -257
- package/src/features/ota/crypto/loadEd25519.ts +0 -41
- package/src/features/ota/crypto/loadSha512.ts +0 -35
- package/src/features/ota/crypto/nativeVerifier.ts +0 -117
- package/src/features/ota/signature.ts +0 -206
|
@@ -14,7 +14,6 @@ import { otaEventEmitter } from './OtaEventEmitter';
|
|
|
14
14
|
import { getDeviceCountry, prefetchDeviceCountry } from './geoCountry';
|
|
15
15
|
import { deviceTargetingAttributes } from './deviceAttributes';
|
|
16
16
|
import { resolveInstallationId } from '../../core/id/installationId';
|
|
17
|
-
import { verifyBundleSignature, type SignatureConfig } from './signature';
|
|
18
17
|
import { detectOtaEnvironment, type OtaEnvironment } from './environment';
|
|
19
18
|
import { retryWithBackoff, isRetryableNetworkError, HttpStatusError } from './retry';
|
|
20
19
|
import { createStorageBackend, type StorageBackend } from '../../storage/StorageBackend';
|
|
@@ -138,12 +137,12 @@ async function deliverOtaEvents(params: {
|
|
|
138
137
|
},
|
|
139
138
|
);
|
|
140
139
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
141
|
-
|
|
140
|
+
logger.debug(`[OTA] Delivered ${items.length} telemetry event(s)`);
|
|
142
141
|
} catch (err: any) {
|
|
143
142
|
// Failed delivery must not fail the update — but the events must not be
|
|
144
143
|
// lost either: requeue so the next sync retries them.
|
|
145
144
|
for (const e of events) otaEventEmitter.emit(e);
|
|
146
|
-
|
|
145
|
+
logger.debug(`[OTA] Telemetry delivery failed (will retry next sync): ${err?.message}`);
|
|
147
146
|
}
|
|
148
147
|
}
|
|
149
148
|
|
|
@@ -189,9 +188,10 @@ export class OtaOrchestrator {
|
|
|
189
188
|
return this.currentBundle;
|
|
190
189
|
}
|
|
191
190
|
private isRestartRequiredState = false;
|
|
191
|
+
/** Root/jailbreak indicators observed at init; empty when none found. */
|
|
192
|
+
private integrityIndicators: string[] = [];
|
|
192
193
|
private healthyTimer: ReturnType<typeof setTimeout> | null = null;
|
|
193
194
|
private bootGuardConfig: BootGuardConfig = {};
|
|
194
|
-
private signatureConfig: SignatureConfig | undefined;
|
|
195
195
|
private environment: OtaEnvironment | null = null;
|
|
196
196
|
/** One-time boot checks have run in this process (init is idempotent). */
|
|
197
197
|
private initialized = false;
|
|
@@ -200,11 +200,12 @@ export class OtaOrchestrator {
|
|
|
200
200
|
/**
|
|
201
201
|
* Initialize the orchestrator. Checks for boot-guard recovery state.
|
|
202
202
|
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
203
|
+
* Signature enforcement is no longer a JS concern: signed releases are
|
|
204
|
+
* verified and staged in NATIVE code against RSA keys embedded in the app
|
|
205
|
+
* package (SB-OTA-RSA-SHA256-V1), and unsigned releases keep working on
|
|
206
|
+
* SHA-256 integrity alone. There is nothing for the host to configure here.
|
|
206
207
|
*/
|
|
207
|
-
init(config?: BootGuardConfig
|
|
208
|
+
init(config?: BootGuardConfig): void {
|
|
208
209
|
noThrow(() => {
|
|
209
210
|
// Idempotent: init() runs its one-time checks ONCE per process. Found
|
|
210
211
|
// live — a host that calls init() again after installing an update (any
|
|
@@ -215,7 +216,6 @@ export class OtaOrchestrator {
|
|
|
215
216
|
// them. Config is still refreshed so late signature setup works.
|
|
216
217
|
if (this.initialized) {
|
|
217
218
|
this.bootGuardConfig = config ?? this.bootGuardConfig;
|
|
218
|
-
this.signatureConfig = config?.signature ?? this.signatureConfig;
|
|
219
219
|
return;
|
|
220
220
|
}
|
|
221
221
|
|
|
@@ -249,7 +249,7 @@ export class OtaOrchestrator {
|
|
|
249
249
|
// We cannot see which form the host used — the identity check after
|
|
250
250
|
// restart is what proves it — but naming the right fix up front turns
|
|
251
251
|
// a confusing silent failure into a one-line correction.
|
|
252
|
-
|
|
252
|
+
logger.debug(
|
|
253
253
|
'[OTA] React Native >= 0.82 detected. The only valid Android integration is ' +
|
|
254
254
|
'getDefaultReactHost(..., jsBundleFilePath = ScaleBunOtaModule.getJSBundleFile(ctx)) — ' +
|
|
255
255
|
'ReactNativeHost and its getJSBundleFile() override no longer exist.',
|
|
@@ -258,9 +258,30 @@ export class OtaOrchestrator {
|
|
|
258
258
|
|
|
259
259
|
this.initialized = true;
|
|
260
260
|
this.enabled = true;
|
|
261
|
+
|
|
262
|
+
// Device integrity signal.
|
|
263
|
+
//
|
|
264
|
+
// Reported, not enforced. Root defeats any check made from inside the app,
|
|
265
|
+
// so this is not a boundary — it is context. It tells an operator that a
|
|
266
|
+
// device's OTA slot is writable by other processes, which is what makes the
|
|
267
|
+
// launch-time hash re-check in SlotManager meaningful rather than paranoid.
|
|
268
|
+
// A host app that wants to withhold updates on such devices can read
|
|
269
|
+
// `deviceIntegrityIndicators()` and decide for itself.
|
|
270
|
+
try {
|
|
271
|
+
const raw = NativeScaleBunOta?.getDeviceIntegrity?.();
|
|
272
|
+
this.integrityIndicators = raw ? (JSON.parse(raw) as string[]) : [];
|
|
273
|
+
if (this.integrityIndicators.length > 0) {
|
|
274
|
+
logger.warn(
|
|
275
|
+
`[OTA] Device integrity indicators present: ${this.integrityIndicators.join(', ')}. ` +
|
|
276
|
+
'Updates still install and are still signature-verified, but an installed ' +
|
|
277
|
+
'bundle on this device can be modified by other privileged processes.',
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
} catch {
|
|
281
|
+
this.integrityIndicators = [];
|
|
282
|
+
}
|
|
261
283
|
this.bootGuardConfig = config ?? {};
|
|
262
|
-
|
|
263
|
-
__DEV__ && logger.debug(
|
|
284
|
+
logger.debug(
|
|
264
285
|
`[OTA] Orchestrator initialized (RN ${this.environment.rnVersionString ?? 'unknown'}` +
|
|
265
286
|
`${this.environment.bridgeless ? ', bridgeless' : ''}` +
|
|
266
287
|
`${this.environment.hermes ? `, Hermes HBC v${this.environment.hermesBytecodeVersion ?? '?'}` : ''})`,
|
|
@@ -295,6 +316,22 @@ export class OtaOrchestrator {
|
|
|
295
316
|
|
|
296
317
|
// Prove the bundle we installed is the bundle that loaded.
|
|
297
318
|
this.verifyRunningBundleIdentity();
|
|
319
|
+
|
|
320
|
+
// Boot-guard health is a LOCAL fact: this process booted from the staged
|
|
321
|
+
// bundle and did not crash. The heartbeat used to start only on the
|
|
322
|
+
// `action === 'NONE'` branch of sync(), so confirming a good bundle needed
|
|
323
|
+
// a successful network round-trip that returned exactly that answer — a
|
|
324
|
+
// device offline, rate-limited, or still being offered an update could
|
|
325
|
+
// never clear its marker, and the guard reverted a bundle that was working.
|
|
326
|
+
//
|
|
327
|
+
// It runs HERE, last, and is handed the slot state that was already read.
|
|
328
|
+
// Starting it earlier made it call getSlotState() itself, and that read is
|
|
329
|
+
// consume-on-read for the revert record — it swallowed the revert before
|
|
330
|
+
// checkBootGuardRecovery() could name the bundle that failed, so a
|
|
331
|
+
// crash-rollback went unreported. It also read bootGuardConfig before
|
|
332
|
+
// init() had assigned it, so healthyAfterMs silently fell back to the
|
|
333
|
+
// default.
|
|
334
|
+
this.startBootGuardHeartbeat(slotState);
|
|
298
335
|
});
|
|
299
336
|
}
|
|
300
337
|
|
|
@@ -334,7 +371,7 @@ export class OtaOrchestrator {
|
|
|
334
371
|
releaseId: record.releaseId ?? undefined,
|
|
335
372
|
sha256: record.sha256,
|
|
336
373
|
} as OtaBundlePayload;
|
|
337
|
-
|
|
374
|
+
logger.debug(`[OTA] Running bundle v${record.version} (${record.bundleId})`);
|
|
338
375
|
return;
|
|
339
376
|
}
|
|
340
377
|
|
|
@@ -342,7 +379,7 @@ export class OtaOrchestrator {
|
|
|
342
379
|
// a reinstall of the app, cleared storage, or a bundle staged by an
|
|
343
380
|
// older SDK. Report the hash so the server can still recognise it.
|
|
344
381
|
this.currentBundle = { sha256: current.sha256 } as OtaBundlePayload;
|
|
345
|
-
|
|
382
|
+
logger.debug(
|
|
346
383
|
`[OTA] Running an OTA bundle (sha ${String(current.sha256).slice(0, 12)}…) with no local install record.`,
|
|
347
384
|
);
|
|
348
385
|
} catch {
|
|
@@ -394,7 +431,7 @@ export class OtaOrchestrator {
|
|
|
394
431
|
if (!record.identityToken) return;
|
|
395
432
|
|
|
396
433
|
if (running === record.identityToken) {
|
|
397
|
-
|
|
434
|
+
logger.debug('[OTA] Install verified — running bundle matches what was installed.');
|
|
398
435
|
this.retireIdentityToken(record);
|
|
399
436
|
return;
|
|
400
437
|
}
|
|
@@ -566,6 +603,14 @@ export class OtaOrchestrator {
|
|
|
566
603
|
return this.enabled;
|
|
567
604
|
}
|
|
568
605
|
|
|
606
|
+
/**
|
|
607
|
+
* Root/jailbreak indicators detected on this device, as stable reason strings.
|
|
608
|
+
* Empty means nothing was detected — not proof the device is clean.
|
|
609
|
+
*/
|
|
610
|
+
deviceIntegrityIndicators(): string[] {
|
|
611
|
+
return [...this.integrityIndicators];
|
|
612
|
+
}
|
|
613
|
+
|
|
569
614
|
isRestartRequired(): boolean {
|
|
570
615
|
return this.isRestartRequiredState;
|
|
571
616
|
}
|
|
@@ -595,12 +640,27 @@ export class OtaOrchestrator {
|
|
|
595
640
|
// OTA has no bundle for a non-mobile target; claiming Android there would install the wrong one.
|
|
596
641
|
const mobileOS = resolveMobileOS();
|
|
597
642
|
if (!mobileOS) throw new Error('OTA is not supported on this platform');
|
|
643
|
+
// SDK trust coverage (CICDPAGE §12): report which trusted key IDs are
|
|
644
|
+
// embedded in this binary so the dashboard can measure key-rotation
|
|
645
|
+
// adoption. Telemetry only — the server never trusts a key a device claims.
|
|
646
|
+
// Defensive: optional native method, never let it break a check.
|
|
647
|
+
let trustedKeyIds: string[] | undefined;
|
|
648
|
+
try {
|
|
649
|
+
const raw = NativeScaleBunOta?.getTrustedKeyIds?.();
|
|
650
|
+
if (raw) {
|
|
651
|
+
const arr = JSON.parse(raw);
|
|
652
|
+
if (Array.isArray(arr) && arr.length) trustedKeyIds = arr.slice(0, 16);
|
|
653
|
+
}
|
|
654
|
+
} catch {
|
|
655
|
+
/* trust reporting is best-effort — never break the update check */
|
|
656
|
+
}
|
|
598
657
|
const payload: OtaCheckRequest = {
|
|
599
658
|
appVersion: requestParams.appVersion,
|
|
600
659
|
platform: mobileOS,
|
|
601
660
|
installationId: requestParams.installationId,
|
|
602
661
|
currentBundleId: this.currentBundle?.id,
|
|
603
662
|
currentBundleHash: this.currentBundle?.sha256,
|
|
663
|
+
trustedKeyIds,
|
|
604
664
|
channelName: requestParams.channelName,
|
|
605
665
|
// Host-supplied country wins; otherwise the cached edge-Worker lookup
|
|
606
666
|
// fills in (see geoCountry.ts). Undefined when neither knows — the
|
|
@@ -686,7 +746,7 @@ export class OtaOrchestrator {
|
|
|
686
746
|
return { status: 'ERROR', error: 'OTA native module disabled or missing' };
|
|
687
747
|
}
|
|
688
748
|
|
|
689
|
-
|
|
749
|
+
logger.debug('[OTA] Sync started…');
|
|
690
750
|
|
|
691
751
|
// The funnel's denominator, for devices already on an OTA bundle. Emitted
|
|
692
752
|
// before the request, so a check that fails outright still counts as a
|
|
@@ -707,7 +767,7 @@ export class OtaOrchestrator {
|
|
|
707
767
|
const checkRes = await this.checkForUpdate(params);
|
|
708
768
|
|
|
709
769
|
if (checkRes.action === 'NONE') {
|
|
710
|
-
|
|
770
|
+
logger.debug('[OTA] App is up to date');
|
|
711
771
|
// If we booted from an OTA bundle and it's healthy, clear the boot guard
|
|
712
772
|
this.startBootGuardHeartbeat();
|
|
713
773
|
return { status: 'UP_TO_DATE' };
|
|
@@ -747,35 +807,18 @@ export class OtaOrchestrator {
|
|
|
747
807
|
version: bundle.version,
|
|
748
808
|
});
|
|
749
809
|
|
|
750
|
-
// ── VERIFY AUTHENTICITY (OTA-03) ─────────────────────────────────────
|
|
751
|
-
// Before anything touches the disk. SHA-256 proves the bytes arrived
|
|
752
|
-
// intact; only the signature proves they came from you. Checking after
|
|
753
|
-
// staging would mean writing unverified code to the slot directory first.
|
|
754
|
-
const signatureOutcome = await verifyBundleSignature(
|
|
755
|
-
bundle.sha256,
|
|
756
|
-
bundle.signature,
|
|
757
|
-
this.signatureConfig,
|
|
758
|
-
);
|
|
759
|
-
if (!signatureOutcome.ok) {
|
|
760
|
-
otaEventEmitter.emitSimple('APPLY_FAILED', bundle.id, {
|
|
761
|
-
releaseId: bundle.releaseId,
|
|
762
|
-
error: `Signature check failed: ${signatureOutcome.reason}`,
|
|
763
|
-
version: bundle.version,
|
|
764
|
-
});
|
|
765
|
-
return {
|
|
766
|
-
status: 'ERROR',
|
|
767
|
-
error: `Bundle signature check failed (${signatureOutcome.reason})`,
|
|
768
|
-
bundle,
|
|
769
|
-
};
|
|
770
|
-
}
|
|
771
|
-
|
|
772
810
|
// ── DOWNLOAD ─────────────────────────────────────────────────────────
|
|
811
|
+
// Authenticity is enforced NATIVELY now (see the envelope branch below):
|
|
812
|
+
// SHA-256 proves the bytes arrived intact, and for signed releases the
|
|
813
|
+
// RSA signature — checked in native code against keys embedded in the app
|
|
814
|
+
// package — proves they came from you. JS neither pins keys nor verifies
|
|
815
|
+
// signatures.
|
|
773
816
|
otaEventEmitter.emitSimple('DOWNLOAD_STARTED', bundle.id, {
|
|
774
817
|
releaseId: bundle.releaseId,
|
|
775
818
|
version: bundle.version,
|
|
776
819
|
});
|
|
777
820
|
const downloadStart = Date.now();
|
|
778
|
-
|
|
821
|
+
logger.debug(`[OTA] Downloading update v${bundle.version}…`);
|
|
779
822
|
|
|
780
823
|
// Live progress: native tick → DOWNLOAD_PROGRESS (feeds useOtaUpdate) →
|
|
781
824
|
// throttled POST to the live tracker. `bundle.size` is the authoritative
|
|
@@ -824,51 +867,102 @@ export class OtaOrchestrator {
|
|
|
824
867
|
|
|
825
868
|
let staged = false;
|
|
826
869
|
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
870
|
+
if (bundle.envelope) {
|
|
871
|
+
// ── NATIVE-ENFORCED VERIFY + STAGE (SB-OTA-RSA-SHA256-V1) ───────────
|
|
872
|
+
// Hand the whole envelope to native code, which verifies the RSA
|
|
873
|
+
// signature against the keys EMBEDDED in the app package, then downloads
|
|
874
|
+
// the artifact, enforces the SIGNED size + SHA-256, and stages it into
|
|
875
|
+
// the `next` slot — all outside JS. JavaScript never stages bytes it has
|
|
876
|
+
// not proven authentic. No patch path here: a signed offer is always a
|
|
877
|
+
// full verified download (native patch is a documented follow-up).
|
|
878
|
+
const envelopeJson = JSON.stringify({ ...bundle.envelope, downloadUrl: bundle.url });
|
|
879
|
+
let verdict: {
|
|
880
|
+
ok?: boolean;
|
|
881
|
+
code?: string;
|
|
882
|
+
message?: string;
|
|
883
|
+
};
|
|
833
884
|
try {
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
885
|
+
const verdictJson = await NativeScaleBunOta.verifyAndStageRelease(envelopeJson);
|
|
886
|
+
verdict = JSON.parse(verdictJson);
|
|
887
|
+
} catch (err: any) {
|
|
888
|
+
// A rejected promise (or an unparseable verdict) is machinery failure,
|
|
889
|
+
// never a valid result — treat it as a hard failure.
|
|
890
|
+
verdict = { ok: false, code: 'verify_stage_threw', message: err?.message ?? String(err) };
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
if (!verdict.ok) {
|
|
894
|
+
unsubscribeProgress();
|
|
895
|
+
postProgress(0, 'FAILED');
|
|
896
|
+
logger.error(
|
|
897
|
+
`[OTA] Native verify/stage refused the release: ${verdict.code ?? 'unknown'}` +
|
|
898
|
+
`${verdict.message ? ` — ${verdict.message}` : ''}`,
|
|
838
899
|
);
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
900
|
+
otaEventEmitter.emitSimple('APPLY_FAILED', bundle.id, {
|
|
901
|
+
releaseId: bundle.releaseId,
|
|
902
|
+
// `error` is mapped onto the wire `errorCode` by deliverOtaEvents —
|
|
903
|
+
// carry the stable native verdict code there.
|
|
904
|
+
error: verdict.code ?? 'verify_and_stage_release_failed',
|
|
905
|
+
version: bundle.version,
|
|
906
|
+
});
|
|
907
|
+
return {
|
|
908
|
+
status: 'ERROR',
|
|
909
|
+
error: `Release verification failed (${verdict.code ?? 'unknown'})`,
|
|
910
|
+
bundle,
|
|
911
|
+
};
|
|
847
912
|
}
|
|
848
|
-
}
|
|
849
913
|
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
// dropped connection are indistinguishable here. Retrying a
|
|
862
|
-
// genuine mismatch a couple of times is cheap; not retrying a
|
|
863
|
-
// dropped connection costs the whole update.
|
|
864
|
-
throw new Error('stageBundle returned false');
|
|
865
|
-
}
|
|
866
|
-
return ok;
|
|
867
|
-
},
|
|
868
|
-
{ label: `download v${bundle.version}`, attempts: 3 },
|
|
914
|
+
staged = true;
|
|
915
|
+
} else {
|
|
916
|
+
// ── LEGACY / UNSIGNED (SHA-256 integrity only) ─────────────────────
|
|
917
|
+
// No envelope means the app embeds no trusted keys and the release is
|
|
918
|
+
// unsigned: keep the pre-RSA behaviour so those apps still update.
|
|
919
|
+
|
|
920
|
+
// Sprint 7: Try patch first if offered, fall back to full bundle
|
|
921
|
+
if (bundle.patch && NativeScaleBunOta.stagePatch) {
|
|
922
|
+
logger.debug(
|
|
923
|
+
`[OTA] Patch available (${(bundle.patch.size / 1024).toFixed(0)} KB vs ` +
|
|
924
|
+
`${(bundle.size / 1024).toFixed(0)} KB full) — trying patch…`,
|
|
869
925
|
);
|
|
870
|
-
|
|
871
|
-
|
|
926
|
+
try {
|
|
927
|
+
staged = await NativeScaleBunOta.stagePatch(
|
|
928
|
+
bundle.patch.url,
|
|
929
|
+
bundle.patch.sha256,
|
|
930
|
+
bundle.sha256, // expected full-bundle hash after patching
|
|
931
|
+
);
|
|
932
|
+
if (staged) {
|
|
933
|
+
patchUsed = true;
|
|
934
|
+
logger.debug('[OTA] Patch applied successfully');
|
|
935
|
+
} else {
|
|
936
|
+
logger.warn('[OTA] Patch staging returned false — falling back to full bundle');
|
|
937
|
+
}
|
|
938
|
+
} catch (patchErr: any) {
|
|
939
|
+
logger.warn(`[OTA] Patch staging failed: ${patchErr.message} — falling back to full bundle`);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// Full-bundle fallback (or primary path when no patch is available).
|
|
944
|
+
// Retried with backoff: on a mobile network a transient drop mid-download
|
|
945
|
+
// is the common case, and previously one failure meant waiting for the
|
|
946
|
+
// next cold start — often hours away, sometimes never.
|
|
947
|
+
if (!staged) {
|
|
948
|
+
try {
|
|
949
|
+
staged = await retryWithBackoff(
|
|
950
|
+
async () => {
|
|
951
|
+
const ok = await NativeScaleBunOta!.stageBundle(bundle.url, bundle.sha256);
|
|
952
|
+
if (!ok) {
|
|
953
|
+
// Native returns a bare boolean, so a hash mismatch and a
|
|
954
|
+
// dropped connection are indistinguishable here. Retrying a
|
|
955
|
+
// genuine mismatch a couple of times is cheap; not retrying a
|
|
956
|
+
// dropped connection costs the whole update.
|
|
957
|
+
throw new Error('stageBundle returned false');
|
|
958
|
+
}
|
|
959
|
+
return ok;
|
|
960
|
+
},
|
|
961
|
+
{ label: `download v${bundle.version}`, attempts: 3 },
|
|
962
|
+
);
|
|
963
|
+
} catch {
|
|
964
|
+
staged = false;
|
|
965
|
+
}
|
|
872
966
|
}
|
|
873
967
|
}
|
|
874
968
|
|
|
@@ -926,7 +1020,7 @@ export class OtaOrchestrator {
|
|
|
926
1020
|
releaseId: bundle.releaseId,
|
|
927
1021
|
version: bundle.version,
|
|
928
1022
|
});
|
|
929
|
-
|
|
1023
|
+
logger.debug(`[OTA] Update v${bundle.version} installed successfully!`);
|
|
930
1024
|
|
|
931
1025
|
// Deliver telemetry BEFORE a restart tears the JS runtime down —
|
|
932
1026
|
// an INSTALLED event queued but not sent would vanish with the process.
|
|
@@ -970,7 +1064,7 @@ export class OtaOrchestrator {
|
|
|
970
1064
|
* native boot-guard marker. If JS crashes before the timer fires, the
|
|
971
1065
|
* marker stays set and native code reverts on next cold start.
|
|
972
1066
|
*/
|
|
973
|
-
private startBootGuardHeartbeat(): void {
|
|
1067
|
+
private startBootGuardHeartbeat(preRead?: Record<string, any> | null): void {
|
|
974
1068
|
if (!NativeScaleBunOta) return;
|
|
975
1069
|
|
|
976
1070
|
// Cancel any existing timer
|
|
@@ -980,22 +1074,28 @@ export class OtaOrchestrator {
|
|
|
980
1074
|
}
|
|
981
1075
|
|
|
982
1076
|
try {
|
|
983
|
-
|
|
984
|
-
|
|
1077
|
+
// getSlotState() is CONSUME-ON-READ for the revert record, so a caller that
|
|
1078
|
+
// has already read it passes the parsed result down rather than letting
|
|
1079
|
+
// this read again and swallow a rollback. `null` is a real answer (module
|
|
1080
|
+
// absent or payload unreadable) — only `undefined` means "nobody read it".
|
|
1081
|
+
const state =
|
|
1082
|
+
preRead !== undefined
|
|
1083
|
+
? preRead
|
|
1084
|
+
: (JSON.parse(NativeScaleBunOta.getSlotState()) as Record<string, any>);
|
|
985
1085
|
|
|
986
1086
|
// Only run the heartbeat if boot-guard marker is present
|
|
987
1087
|
// (meaning we're running from a freshly-applied OTA bundle)
|
|
988
|
-
if (!state
|
|
1088
|
+
if (!state?.bootMarkerPresent) {
|
|
989
1089
|
return;
|
|
990
1090
|
}
|
|
991
1091
|
|
|
992
1092
|
const healthyMs = this.bootGuardConfig.healthyAfterMs ?? DEFAULT_HEALTHY_AFTER_MS;
|
|
993
|
-
|
|
1093
|
+
logger.debug(`[OTA] Boot guard active — marking healthy after ${healthyMs}ms`);
|
|
994
1094
|
|
|
995
1095
|
this.healthyTimer = setTimeout(() => {
|
|
996
1096
|
noThrow(() => {
|
|
997
1097
|
NativeScaleBunOta?.markHealthy();
|
|
998
|
-
|
|
1098
|
+
logger.info('[OTA] Boot guard cleared — bundle marked healthy ✓');
|
|
999
1099
|
this.healthyTimer = null;
|
|
1000
1100
|
|
|
1001
1101
|
// The honest activation signal. INSTALLED is emitted optimistically,
|
|
@@ -1051,11 +1151,11 @@ export class OtaOrchestrator {
|
|
|
1051
1151
|
/* the restart below makes cleanup moot */
|
|
1052
1152
|
}
|
|
1053
1153
|
this.resumeRestartArmed = false;
|
|
1054
|
-
|
|
1154
|
+
logger.info('[OTA] App resumed — activating the staged update now');
|
|
1055
1155
|
this.restart();
|
|
1056
1156
|
}
|
|
1057
1157
|
});
|
|
1058
|
-
|
|
1158
|
+
logger.debug('[OTA] Update staged — will activate on next resume (ON_NEXT_RESUME)');
|
|
1059
1159
|
} catch {
|
|
1060
1160
|
// AppState unavailable (tests, exotic hosts): the update still activates
|
|
1061
1161
|
// on the next cold start, which is the documented fallback behaviour.
|
|
@@ -1075,7 +1175,7 @@ export class OtaOrchestrator {
|
|
|
1075
1175
|
*/
|
|
1076
1176
|
restart(): void {
|
|
1077
1177
|
noThrow(() => {
|
|
1078
|
-
|
|
1178
|
+
logger.info('[OTA] Triggering app restart…');
|
|
1079
1179
|
if (NativeScaleBunOta && typeof NativeScaleBunOta.restartApp === 'function') {
|
|
1080
1180
|
NativeScaleBunOta.restartApp();
|
|
1081
1181
|
} else if (DevSettings && typeof DevSettings.reload === 'function') {
|
|
@@ -54,6 +54,11 @@ export interface OtaCheckRequest {
|
|
|
54
54
|
installationId: string;
|
|
55
55
|
currentBundleId?: string;
|
|
56
56
|
currentBundleHash?: string;
|
|
57
|
+
/**
|
|
58
|
+
* Trusted key IDs embedded in this app binary, for SDK trust coverage
|
|
59
|
+
* (CICDPAGE §12). Telemetry only — never a source of server-side trust.
|
|
60
|
+
*/
|
|
61
|
+
trustedKeyIds?: string[];
|
|
57
62
|
|
|
58
63
|
// ── C1: channel + targeting context ────────────────────────────────────────
|
|
59
64
|
// The backend DTO has accepted all of these since Sprint 9. The SDK declared
|
|
@@ -112,7 +117,24 @@ export interface OtaBundlePayload {
|
|
|
112
117
|
url: string; // presigned GET — short-TTL
|
|
113
118
|
size: number;
|
|
114
119
|
sha256: string;
|
|
115
|
-
signature?: string; // ed25519 detached signature (hex)
|
|
120
|
+
signature?: string; // ed25519 detached signature (hex) — legacy, unused by native RSA path
|
|
121
|
+
/**
|
|
122
|
+
* SB-OTA-RSA-SHA256-V1 release envelope. Present on signed releases; carries
|
|
123
|
+
* the protocol id, the id of the trusted RSA key the release was signed with,
|
|
124
|
+
* the canonical signed payload, and its signature. When present, the SDK hands
|
|
125
|
+
* the whole envelope to the native module (`verifyAndStageRelease`), which
|
|
126
|
+
* verifies the signature against the RSA public keys EMBEDDED in the app
|
|
127
|
+
* package and then downloads + integrity-checks + stages the artifact — JS
|
|
128
|
+
* never stages bytes it has not proven authentic, and never pins or verifies a
|
|
129
|
+
* key itself. Absent on legacy/unsigned releases, which fall back to
|
|
130
|
+
* `stageBundle` on SHA-256 integrity alone.
|
|
131
|
+
*/
|
|
132
|
+
envelope?: {
|
|
133
|
+
protocol: string;
|
|
134
|
+
keyId: string;
|
|
135
|
+
signedPayload: string;
|
|
136
|
+
signature: string;
|
|
137
|
+
};
|
|
116
138
|
isMandatory: boolean;
|
|
117
139
|
/**
|
|
118
140
|
* How to activate the bundle after install:
|
|
@@ -1105,6 +1105,56 @@ export class SessionManager {
|
|
|
1105
1105
|
targetId?: string;
|
|
1106
1106
|
screenName?: string;
|
|
1107
1107
|
emitAutomaticAnalytics?: boolean;
|
|
1108
|
+
/**
|
|
1109
|
+
* RN-6 — the native↔JS join's own verdict, and it MUST be declared here.
|
|
1110
|
+
*
|
|
1111
|
+
* This signature ENUMERATES its fields rather than spreading, and the only caller
|
|
1112
|
+
* (ScaleBunDebugRoot's native listener) invokes it through `(sm as any)`. So a field
|
|
1113
|
+
* added at the call site and not added here is dropped in silence, with no type
|
|
1114
|
+
* error anywhere — which is precisely how these three nearly shipped as a no-op.
|
|
1115
|
+
*
|
|
1116
|
+
* 'exact' = one JS start was clearly closest. 'ambiguous' = two were within
|
|
1117
|
+
* CORRELATION_MARGIN_MS of each other, so target/ui/screenName were deliberately
|
|
1118
|
+
* withheld rather than guessed. 'none' = no start within tolerance, meaning the JS
|
|
1119
|
+
* handler never fired. The last two are kept distinct because they have different
|
|
1120
|
+
* fixes and collapsing them would hide which is happening in production.
|
|
1121
|
+
*/
|
|
1122
|
+
correlation?: 'exact' | 'ambiguous' | 'none';
|
|
1123
|
+
correlationDeltaMs?: number | null;
|
|
1124
|
+
correlationRunnerUpDeltaMs?: number | null;
|
|
1125
|
+
/**
|
|
1126
|
+
* RN-1/RN-2 — which kind of identity `targetId` is. Declared here for the same reason as
|
|
1127
|
+
* the correlation fields above: this signature enumerates, and its only caller goes
|
|
1128
|
+
* through `(sm as any)`, so an undeclared field is dropped in silence.
|
|
1129
|
+
*/
|
|
1130
|
+
targetSource?: 'testID' | 'path' | 'component' | 'none';
|
|
1131
|
+
/** RN-8: where the screen name came from. Declared here because this signature enumerates. */
|
|
1132
|
+
screenSource?: 'manual' | 'navigation' | 'none';
|
|
1133
|
+
/**
|
|
1134
|
+
* RN-13: the target control's rectangle in capture-base fractions. Declared here because
|
|
1135
|
+
* this signature ENUMERATES — the sixth field to need it, after three correlation fields,
|
|
1136
|
+
* targetSource and screenSource.
|
|
1137
|
+
*/
|
|
1138
|
+
targetRect?: { x: number; y: number; width: number; height: number };
|
|
1139
|
+
/**
|
|
1140
|
+
* RN-3: content offset of the nearest ancestor scroller. Declared here because this
|
|
1141
|
+
* signature ENUMERATES — the seventh field to need it.
|
|
1142
|
+
*/
|
|
1143
|
+
scrollX?: number;
|
|
1144
|
+
scrollY?: number;
|
|
1145
|
+
scrollSource?: 'wrapper' | 'none';
|
|
1146
|
+
/**
|
|
1147
|
+
* RN-4: list row identity. Declared here because this signature ENUMERATES — the eighth
|
|
1148
|
+
* and ninth fields to need it.
|
|
1149
|
+
*/
|
|
1150
|
+
itemKey?: string;
|
|
1151
|
+
itemIndex?: number;
|
|
1152
|
+
/**
|
|
1153
|
+
* RN-15: tap position within the target control. Declared here because this signature
|
|
1154
|
+
* ENUMERATES — the tenth and eleventh fields to need it.
|
|
1155
|
+
*/
|
|
1156
|
+
localU?: number;
|
|
1157
|
+
localV?: number;
|
|
1108
1158
|
}): void {
|
|
1109
1159
|
// Exact-duplicate suppression — see _lastGestureSig. Signature is the gesture type plus
|
|
1110
1160
|
// raw coordinates verbatim; String() keeps undefined coords distinct from 0 (a payload
|
|
@@ -1136,6 +1186,69 @@ export class SessionManager {
|
|
|
1136
1186
|
state_status: stateStatus,
|
|
1137
1187
|
ui: details?.ui,
|
|
1138
1188
|
target_id: details?.targetId,
|
|
1189
|
+
/**
|
|
1190
|
+
* RN-6 — shipped with the row so the join is auditable instead of assumed.
|
|
1191
|
+
*
|
|
1192
|
+
* Without it, an interaction with no `target_id` is ambiguous in the worst way:
|
|
1193
|
+
* it could mean the control genuinely had no identity, or that the correlation
|
|
1194
|
+
* refused and the identity exists but could not be attributed. Those are
|
|
1195
|
+
* different defects with different owners, and the accounting lane cannot give
|
|
1196
|
+
* an honest unplaceable REASON without knowing which.
|
|
1197
|
+
*
|
|
1198
|
+
* Undefined on the JS-fallback path (no native event, so nothing was joined),
|
|
1199
|
+
* which is itself the correct answer there rather than a fabricated 'exact'.
|
|
1200
|
+
*/
|
|
1201
|
+
correlation: details?.correlation,
|
|
1202
|
+
correlation_delta_ms: details?.correlationDeltaMs,
|
|
1203
|
+
correlation_runner_up_delta_ms: details?.correlationRunnerUpDeltaMs,
|
|
1204
|
+
/**
|
|
1205
|
+
* RN-1/RN-2 — stored alongside `target_id` so a reader can tell an authored
|
|
1206
|
+
* identifier from a structural inference. 'path' means the app authored no testID
|
|
1207
|
+
* and the identity was derived from component names, which is useful but will
|
|
1208
|
+
* change under refactoring; a dashboard that presents the two identically invites
|
|
1209
|
+
* someone to trust a key that is about to move.
|
|
1210
|
+
*/
|
|
1211
|
+
target_source: details?.targetSource,
|
|
1212
|
+
/**
|
|
1213
|
+
* RN-8 — stored so "13.8% of interactions have a screen" becomes answerable.
|
|
1214
|
+
* 'none' means the app instrumented nothing the SDK could see; 'manual' means a
|
|
1215
|
+
* <ScaleBunScreen> beacon; 'navigation' means the React Navigation hook. Three
|
|
1216
|
+
* different owners behind one number that today has none.
|
|
1217
|
+
*/
|
|
1218
|
+
screen_source: details?.screenSource,
|
|
1219
|
+
/**
|
|
1220
|
+
* RN-13 — what makes "the heat is on this control" checkable instead of assumed.
|
|
1221
|
+
* Absent when the control could not be measured synchronously, which is a fact worth
|
|
1222
|
+
* storing: an unmeasurable control and a mis-placed tap are different findings.
|
|
1223
|
+
*/
|
|
1224
|
+
target_rect: details?.targetRect,
|
|
1225
|
+
/**
|
|
1226
|
+
* RN-3 — the backend already reads scrollX/scrollY off the payload into the fact
|
|
1227
|
+
* table's own columns (it has done since the web SDK, which sends `sx`/`sy`), so
|
|
1228
|
+
* these need no migration. Mobile simply never sent them.
|
|
1229
|
+
*
|
|
1230
|
+
* `scroll_source` says whether an offset was available at all: 'none' means the app
|
|
1231
|
+
* uses a plain <ScrollView> rather than the SDK's wrapper, which is a coverage gap in
|
|
1232
|
+
* the integration and not a tap at the top of a list.
|
|
1233
|
+
*/
|
|
1234
|
+
scrollX: details?.scrollX,
|
|
1235
|
+
scrollY: details?.scrollY,
|
|
1236
|
+
scroll_source: details?.scrollSource,
|
|
1237
|
+
/**
|
|
1238
|
+
* RN-4 — what stops a recycled native view merging two different rows into one
|
|
1239
|
+
* hotspot. The key is the app's own keyExtractor output, so it is stable across
|
|
1240
|
+
* scrolling and across sessions; the index is carried so a recycled slot (same index,
|
|
1241
|
+
* different key) can be proven rather than inferred.
|
|
1242
|
+
*/
|
|
1243
|
+
item_key: details?.itemKey,
|
|
1244
|
+
item_index: details?.itemIndex,
|
|
1245
|
+
/**
|
|
1246
|
+
* RN-15 — where in the control, not just which control. Turns "they tapped Add to
|
|
1247
|
+
* Cart" into "they tapped its left edge", which is what tells you whether a hit area
|
|
1248
|
+
* is right. Outside 0..1 is a hitSlop tap and is kept as such.
|
|
1249
|
+
*/
|
|
1250
|
+
local_u: details?.localU,
|
|
1251
|
+
local_v: details?.localV,
|
|
1139
1252
|
gestureType,
|
|
1140
1253
|
x: details?.x,
|
|
1141
1254
|
y: details?.y,
|