@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
|
@@ -6,7 +6,6 @@ import { otaEventEmitter } from './OtaEventEmitter';
|
|
|
6
6
|
import { getDeviceCountry, prefetchDeviceCountry } from './geoCountry';
|
|
7
7
|
import { deviceTargetingAttributes } from './deviceAttributes';
|
|
8
8
|
import { resolveInstallationId } from '../../core/id/installationId';
|
|
9
|
-
import { verifyBundleSignature } from './signature';
|
|
10
9
|
import { detectOtaEnvironment } from './environment';
|
|
11
10
|
import { retryWithBackoff, isRetryableNetworkError, HttpStatusError } from './retry';
|
|
12
11
|
import { createStorageBackend } from '../../storage/StorageBackend';
|
|
@@ -114,12 +113,12 @@ async function deliverOtaEvents(params) {
|
|
|
114
113
|
})
|
|
115
114
|
});
|
|
116
115
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
117
|
-
|
|
116
|
+
logger.debug(`[OTA] Delivered ${items.length} telemetry event(s)`);
|
|
118
117
|
} catch (err) {
|
|
119
118
|
// Failed delivery must not fail the update — but the events must not be
|
|
120
119
|
// lost either: requeue so the next sync retries them.
|
|
121
120
|
for (const e of events) otaEventEmitter.emit(e);
|
|
122
|
-
|
|
121
|
+
logger.debug(`[OTA] Telemetry delivery failed (will retry next sync): ${err?.message}`);
|
|
123
122
|
}
|
|
124
123
|
}
|
|
125
124
|
|
|
@@ -139,6 +138,8 @@ export class OtaOrchestrator {
|
|
|
139
138
|
return this.currentBundle;
|
|
140
139
|
}
|
|
141
140
|
isRestartRequiredState = false;
|
|
141
|
+
/** Root/jailbreak indicators observed at init; empty when none found. */
|
|
142
|
+
integrityIndicators = [];
|
|
142
143
|
healthyTimer = null;
|
|
143
144
|
bootGuardConfig = {};
|
|
144
145
|
environment = null;
|
|
@@ -149,9 +150,10 @@ export class OtaOrchestrator {
|
|
|
149
150
|
/**
|
|
150
151
|
* Initialize the orchestrator. Checks for boot-guard recovery state.
|
|
151
152
|
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
153
|
+
* Signature enforcement is no longer a JS concern: signed releases are
|
|
154
|
+
* verified and staged in NATIVE code against RSA keys embedded in the app
|
|
155
|
+
* package (SB-OTA-RSA-SHA256-V1), and unsigned releases keep working on
|
|
156
|
+
* SHA-256 integrity alone. There is nothing for the host to configure here.
|
|
155
157
|
*/
|
|
156
158
|
init(config) {
|
|
157
159
|
noThrow(() => {
|
|
@@ -164,7 +166,6 @@ export class OtaOrchestrator {
|
|
|
164
166
|
// them. Config is still refreshed so late signature setup works.
|
|
165
167
|
if (this.initialized) {
|
|
166
168
|
this.bootGuardConfig = config ?? this.bootGuardConfig;
|
|
167
|
-
this.signatureConfig = config?.signature ?? this.signatureConfig;
|
|
168
169
|
return;
|
|
169
170
|
}
|
|
170
171
|
if (!NativeScaleBunOta) {
|
|
@@ -194,13 +195,30 @@ export class OtaOrchestrator {
|
|
|
194
195
|
// We cannot see which form the host used — the identity check after
|
|
195
196
|
// restart is what proves it — but naming the right fix up front turns
|
|
196
197
|
// a confusing silent failure into a one-line correction.
|
|
197
|
-
|
|
198
|
+
logger.debug('[OTA] React Native >= 0.82 detected. The only valid Android integration is ' + 'getDefaultReactHost(..., jsBundleFilePath = ScaleBunOtaModule.getJSBundleFile(ctx)) — ' + 'ReactNativeHost and its getJSBundleFile() override no longer exist.');
|
|
198
199
|
}
|
|
199
200
|
this.initialized = true;
|
|
200
201
|
this.enabled = true;
|
|
202
|
+
|
|
203
|
+
// Device integrity signal.
|
|
204
|
+
//
|
|
205
|
+
// Reported, not enforced. Root defeats any check made from inside the app,
|
|
206
|
+
// so this is not a boundary — it is context. It tells an operator that a
|
|
207
|
+
// device's OTA slot is writable by other processes, which is what makes the
|
|
208
|
+
// launch-time hash re-check in SlotManager meaningful rather than paranoid.
|
|
209
|
+
// A host app that wants to withhold updates on such devices can read
|
|
210
|
+
// `deviceIntegrityIndicators()` and decide for itself.
|
|
211
|
+
try {
|
|
212
|
+
const raw = NativeScaleBunOta?.getDeviceIntegrity?.();
|
|
213
|
+
this.integrityIndicators = raw ? JSON.parse(raw) : [];
|
|
214
|
+
if (this.integrityIndicators.length > 0) {
|
|
215
|
+
logger.warn(`[OTA] Device integrity indicators present: ${this.integrityIndicators.join(', ')}. ` + 'Updates still install and are still signature-verified, but an installed ' + 'bundle on this device can be modified by other privileged processes.');
|
|
216
|
+
}
|
|
217
|
+
} catch {
|
|
218
|
+
this.integrityIndicators = [];
|
|
219
|
+
}
|
|
201
220
|
this.bootGuardConfig = config ?? {};
|
|
202
|
-
this.
|
|
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 ?? '?'}` : ''})`);
|
|
221
|
+
logger.debug(`[OTA] Orchestrator initialized (RN ${this.environment.rnVersionString ?? 'unknown'}` + `${this.environment.bridgeless ? ', bridgeless' : ''}` + `${this.environment.hermes ? `, Hermes HBC v${this.environment.hermesBytecodeVersion ?? '?'}` : ''})`);
|
|
204
222
|
|
|
205
223
|
// ONE read of the slot state, shared by both boot-time consumers.
|
|
206
224
|
//
|
|
@@ -231,6 +249,22 @@ export class OtaOrchestrator {
|
|
|
231
249
|
|
|
232
250
|
// Prove the bundle we installed is the bundle that loaded.
|
|
233
251
|
this.verifyRunningBundleIdentity();
|
|
252
|
+
|
|
253
|
+
// Boot-guard health is a LOCAL fact: this process booted from the staged
|
|
254
|
+
// bundle and did not crash. The heartbeat used to start only on the
|
|
255
|
+
// `action === 'NONE'` branch of sync(), so confirming a good bundle needed
|
|
256
|
+
// a successful network round-trip that returned exactly that answer — a
|
|
257
|
+
// device offline, rate-limited, or still being offered an update could
|
|
258
|
+
// never clear its marker, and the guard reverted a bundle that was working.
|
|
259
|
+
//
|
|
260
|
+
// It runs HERE, last, and is handed the slot state that was already read.
|
|
261
|
+
// Starting it earlier made it call getSlotState() itself, and that read is
|
|
262
|
+
// consume-on-read for the revert record — it swallowed the revert before
|
|
263
|
+
// checkBootGuardRecovery() could name the bundle that failed, so a
|
|
264
|
+
// crash-rollback went unreported. It also read bootGuardConfig before
|
|
265
|
+
// init() had assigned it, so healthyAfterMs silently fell back to the
|
|
266
|
+
// default.
|
|
267
|
+
this.startBootGuardHeartbeat(slotState);
|
|
234
268
|
});
|
|
235
269
|
}
|
|
236
270
|
|
|
@@ -269,7 +303,7 @@ export class OtaOrchestrator {
|
|
|
269
303
|
releaseId: record.releaseId ?? undefined,
|
|
270
304
|
sha256: record.sha256
|
|
271
305
|
};
|
|
272
|
-
|
|
306
|
+
logger.debug(`[OTA] Running bundle v${record.version} (${record.bundleId})`);
|
|
273
307
|
return;
|
|
274
308
|
}
|
|
275
309
|
|
|
@@ -279,7 +313,7 @@ export class OtaOrchestrator {
|
|
|
279
313
|
this.currentBundle = {
|
|
280
314
|
sha256: current.sha256
|
|
281
315
|
};
|
|
282
|
-
|
|
316
|
+
logger.debug(`[OTA] Running an OTA bundle (sha ${String(current.sha256).slice(0, 12)}…) with no local install record.`);
|
|
283
317
|
} catch {
|
|
284
318
|
// Slot state unreadable — treat as factory bundle.
|
|
285
319
|
}
|
|
@@ -327,7 +361,7 @@ export class OtaOrchestrator {
|
|
|
327
361
|
// this bundle" and answers by serving the same bundle again, forever.
|
|
328
362
|
if (!record.identityToken) return;
|
|
329
363
|
if (running === record.identityToken) {
|
|
330
|
-
|
|
364
|
+
logger.debug('[OTA] Install verified — running bundle matches what was installed.');
|
|
331
365
|
this.retireIdentityToken(record);
|
|
332
366
|
return;
|
|
333
367
|
}
|
|
@@ -460,6 +494,14 @@ export class OtaOrchestrator {
|
|
|
460
494
|
isEnabled() {
|
|
461
495
|
return this.enabled;
|
|
462
496
|
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Root/jailbreak indicators detected on this device, as stable reason strings.
|
|
500
|
+
* Empty means nothing was detected — not proof the device is clean.
|
|
501
|
+
*/
|
|
502
|
+
deviceIntegrityIndicators() {
|
|
503
|
+
return [...this.integrityIndicators];
|
|
504
|
+
}
|
|
463
505
|
isRestartRequired() {
|
|
464
506
|
return this.isRestartRequiredState;
|
|
465
507
|
}
|
|
@@ -479,12 +521,27 @@ export class OtaOrchestrator {
|
|
|
479
521
|
// OTA has no bundle for a non-mobile target; claiming Android there would install the wrong one.
|
|
480
522
|
const mobileOS = resolveMobileOS();
|
|
481
523
|
if (!mobileOS) throw new Error('OTA is not supported on this platform');
|
|
524
|
+
// SDK trust coverage (CICDPAGE §12): report which trusted key IDs are
|
|
525
|
+
// embedded in this binary so the dashboard can measure key-rotation
|
|
526
|
+
// adoption. Telemetry only — the server never trusts a key a device claims.
|
|
527
|
+
// Defensive: optional native method, never let it break a check.
|
|
528
|
+
let trustedKeyIds;
|
|
529
|
+
try {
|
|
530
|
+
const raw = NativeScaleBunOta?.getTrustedKeyIds?.();
|
|
531
|
+
if (raw) {
|
|
532
|
+
const arr = JSON.parse(raw);
|
|
533
|
+
if (Array.isArray(arr) && arr.length) trustedKeyIds = arr.slice(0, 16);
|
|
534
|
+
}
|
|
535
|
+
} catch {
|
|
536
|
+
/* trust reporting is best-effort — never break the update check */
|
|
537
|
+
}
|
|
482
538
|
const payload = {
|
|
483
539
|
appVersion: requestParams.appVersion,
|
|
484
540
|
platform: mobileOS,
|
|
485
541
|
installationId: requestParams.installationId,
|
|
486
542
|
currentBundleId: this.currentBundle?.id,
|
|
487
543
|
currentBundleHash: this.currentBundle?.sha256,
|
|
544
|
+
trustedKeyIds,
|
|
488
545
|
channelName: requestParams.channelName,
|
|
489
546
|
// Host-supplied country wins; otherwise the cached edge-Worker lookup
|
|
490
547
|
// fills in (see geoCountry.ts). Undefined when neither knows — the
|
|
@@ -552,7 +609,7 @@ export class OtaOrchestrator {
|
|
|
552
609
|
error: 'OTA native module disabled or missing'
|
|
553
610
|
};
|
|
554
611
|
}
|
|
555
|
-
|
|
612
|
+
logger.debug('[OTA] Sync started…');
|
|
556
613
|
|
|
557
614
|
// The funnel's denominator, for devices already on an OTA bundle. Emitted
|
|
558
615
|
// before the request, so a check that fails outright still counts as a
|
|
@@ -571,7 +628,7 @@ export class OtaOrchestrator {
|
|
|
571
628
|
}
|
|
572
629
|
const checkRes = await this.checkForUpdate(params);
|
|
573
630
|
if (checkRes.action === 'NONE') {
|
|
574
|
-
|
|
631
|
+
logger.debug('[OTA] App is up to date');
|
|
575
632
|
// If we booted from an OTA bundle and it's healthy, clear the boot guard
|
|
576
633
|
this.startBootGuardHeartbeat();
|
|
577
634
|
return {
|
|
@@ -618,31 +675,18 @@ export class OtaOrchestrator {
|
|
|
618
675
|
version: bundle.version
|
|
619
676
|
});
|
|
620
677
|
|
|
621
|
-
// ── VERIFY AUTHENTICITY (OTA-03) ─────────────────────────────────────
|
|
622
|
-
// Before anything touches the disk. SHA-256 proves the bytes arrived
|
|
623
|
-
// intact; only the signature proves they came from you. Checking after
|
|
624
|
-
// staging would mean writing unverified code to the slot directory first.
|
|
625
|
-
const signatureOutcome = await verifyBundleSignature(bundle.sha256, bundle.signature, this.signatureConfig);
|
|
626
|
-
if (!signatureOutcome.ok) {
|
|
627
|
-
otaEventEmitter.emitSimple('APPLY_FAILED', bundle.id, {
|
|
628
|
-
releaseId: bundle.releaseId,
|
|
629
|
-
error: `Signature check failed: ${signatureOutcome.reason}`,
|
|
630
|
-
version: bundle.version
|
|
631
|
-
});
|
|
632
|
-
return {
|
|
633
|
-
status: 'ERROR',
|
|
634
|
-
error: `Bundle signature check failed (${signatureOutcome.reason})`,
|
|
635
|
-
bundle
|
|
636
|
-
};
|
|
637
|
-
}
|
|
638
|
-
|
|
639
678
|
// ── DOWNLOAD ─────────────────────────────────────────────────────────
|
|
679
|
+
// Authenticity is enforced NATIVELY now (see the envelope branch below):
|
|
680
|
+
// SHA-256 proves the bytes arrived intact, and for signed releases the
|
|
681
|
+
// RSA signature — checked in native code against keys embedded in the app
|
|
682
|
+
// package — proves they came from you. JS neither pins keys nor verifies
|
|
683
|
+
// signatures.
|
|
640
684
|
otaEventEmitter.emitSimple('DOWNLOAD_STARTED', bundle.id, {
|
|
641
685
|
releaseId: bundle.releaseId,
|
|
642
686
|
version: bundle.version
|
|
643
687
|
});
|
|
644
688
|
const downloadStart = Date.now();
|
|
645
|
-
|
|
689
|
+
logger.debug(`[OTA] Downloading update v${bundle.version}…`);
|
|
646
690
|
|
|
647
691
|
// Live progress: native tick → DOWNLOAD_PROGRESS (feeds useOtaUpdate) →
|
|
648
692
|
// throttled POST to the live tracker. `bundle.size` is the authoritative
|
|
@@ -690,46 +734,94 @@ export class OtaOrchestrator {
|
|
|
690
734
|
}
|
|
691
735
|
});
|
|
692
736
|
let staged = false;
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
737
|
+
if (bundle.envelope) {
|
|
738
|
+
// ── NATIVE-ENFORCED VERIFY + STAGE (SB-OTA-RSA-SHA256-V1) ───────────
|
|
739
|
+
// Hand the whole envelope to native code, which verifies the RSA
|
|
740
|
+
// signature against the keys EMBEDDED in the app package, then downloads
|
|
741
|
+
// the artifact, enforces the SIGNED size + SHA-256, and stages it into
|
|
742
|
+
// the `next` slot — all outside JS. JavaScript never stages bytes it has
|
|
743
|
+
// not proven authentic. No patch path here: a signed offer is always a
|
|
744
|
+
// full verified download (native patch is a documented follow-up).
|
|
745
|
+
const envelopeJson = JSON.stringify({
|
|
746
|
+
...bundle.envelope,
|
|
747
|
+
downloadUrl: bundle.url
|
|
748
|
+
});
|
|
749
|
+
let verdict;
|
|
697
750
|
try {
|
|
698
|
-
|
|
699
|
-
);
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
751
|
+
const verdictJson = await NativeScaleBunOta.verifyAndStageRelease(envelopeJson);
|
|
752
|
+
verdict = JSON.parse(verdictJson);
|
|
753
|
+
} catch (err) {
|
|
754
|
+
// A rejected promise (or an unparseable verdict) is machinery failure,
|
|
755
|
+
// never a valid result — treat it as a hard failure.
|
|
756
|
+
verdict = {
|
|
757
|
+
ok: false,
|
|
758
|
+
code: 'verify_stage_threw',
|
|
759
|
+
message: err?.message ?? String(err)
|
|
760
|
+
};
|
|
708
761
|
}
|
|
709
|
-
|
|
762
|
+
if (!verdict.ok) {
|
|
763
|
+
unsubscribeProgress();
|
|
764
|
+
postProgress(0, 'FAILED');
|
|
765
|
+
logger.error(`[OTA] Native verify/stage refused the release: ${verdict.code ?? 'unknown'}` + `${verdict.message ? ` — ${verdict.message}` : ''}`);
|
|
766
|
+
otaEventEmitter.emitSimple('APPLY_FAILED', bundle.id, {
|
|
767
|
+
releaseId: bundle.releaseId,
|
|
768
|
+
// `error` is mapped onto the wire `errorCode` by deliverOtaEvents —
|
|
769
|
+
// carry the stable native verdict code there.
|
|
770
|
+
error: verdict.code ?? 'verify_and_stage_release_failed',
|
|
771
|
+
version: bundle.version
|
|
772
|
+
});
|
|
773
|
+
return {
|
|
774
|
+
status: 'ERROR',
|
|
775
|
+
error: `Release verification failed (${verdict.code ?? 'unknown'})`,
|
|
776
|
+
bundle
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
staged = true;
|
|
780
|
+
} else {
|
|
781
|
+
// ── LEGACY / UNSIGNED (SHA-256 integrity only) ─────────────────────
|
|
782
|
+
// No envelope means the app embeds no trusted keys and the release is
|
|
783
|
+
// unsigned: keep the pre-RSA behaviour so those apps still update.
|
|
710
784
|
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
// genuine mismatch a couple of times is cheap; not retrying a
|
|
723
|
-
// dropped connection costs the whole update.
|
|
724
|
-
throw new Error('stageBundle returned false');
|
|
785
|
+
// Sprint 7: Try patch first if offered, fall back to full bundle
|
|
786
|
+
if (bundle.patch && NativeScaleBunOta.stagePatch) {
|
|
787
|
+
logger.debug(`[OTA] Patch available (${(bundle.patch.size / 1024).toFixed(0)} KB vs ` + `${(bundle.size / 1024).toFixed(0)} KB full) — trying patch…`);
|
|
788
|
+
try {
|
|
789
|
+
staged = await NativeScaleBunOta.stagePatch(bundle.patch.url, bundle.patch.sha256, bundle.sha256 // expected full-bundle hash after patching
|
|
790
|
+
);
|
|
791
|
+
if (staged) {
|
|
792
|
+
patchUsed = true;
|
|
793
|
+
logger.debug('[OTA] Patch applied successfully');
|
|
794
|
+
} else {
|
|
795
|
+
logger.warn('[OTA] Patch staging returned false — falling back to full bundle');
|
|
725
796
|
}
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
797
|
+
} catch (patchErr) {
|
|
798
|
+
logger.warn(`[OTA] Patch staging failed: ${patchErr.message} — falling back to full bundle`);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// Full-bundle fallback (or primary path when no patch is available).
|
|
803
|
+
// Retried with backoff: on a mobile network a transient drop mid-download
|
|
804
|
+
// is the common case, and previously one failure meant waiting for the
|
|
805
|
+
// next cold start — often hours away, sometimes never.
|
|
806
|
+
if (!staged) {
|
|
807
|
+
try {
|
|
808
|
+
staged = await retryWithBackoff(async () => {
|
|
809
|
+
const ok = await NativeScaleBunOta.stageBundle(bundle.url, bundle.sha256);
|
|
810
|
+
if (!ok) {
|
|
811
|
+
// Native returns a bare boolean, so a hash mismatch and a
|
|
812
|
+
// dropped connection are indistinguishable here. Retrying a
|
|
813
|
+
// genuine mismatch a couple of times is cheap; not retrying a
|
|
814
|
+
// dropped connection costs the whole update.
|
|
815
|
+
throw new Error('stageBundle returned false');
|
|
816
|
+
}
|
|
817
|
+
return ok;
|
|
818
|
+
}, {
|
|
819
|
+
label: `download v${bundle.version}`,
|
|
820
|
+
attempts: 3
|
|
821
|
+
});
|
|
822
|
+
} catch {
|
|
823
|
+
staged = false;
|
|
824
|
+
}
|
|
733
825
|
}
|
|
734
826
|
}
|
|
735
827
|
unsubscribeProgress();
|
|
@@ -789,7 +881,7 @@ export class OtaOrchestrator {
|
|
|
789
881
|
releaseId: bundle.releaseId,
|
|
790
882
|
version: bundle.version
|
|
791
883
|
});
|
|
792
|
-
|
|
884
|
+
logger.debug(`[OTA] Update v${bundle.version} installed successfully!`);
|
|
793
885
|
|
|
794
886
|
// Deliver telemetry BEFORE a restart tears the JS runtime down —
|
|
795
887
|
// an INSTALLED event queued but not sent would vanish with the process.
|
|
@@ -835,7 +927,7 @@ export class OtaOrchestrator {
|
|
|
835
927
|
* native boot-guard marker. If JS crashes before the timer fires, the
|
|
836
928
|
* marker stays set and native code reverts on next cold start.
|
|
837
929
|
*/
|
|
838
|
-
startBootGuardHeartbeat() {
|
|
930
|
+
startBootGuardHeartbeat(preRead) {
|
|
839
931
|
if (!NativeScaleBunOta) return;
|
|
840
932
|
|
|
841
933
|
// Cancel any existing timer
|
|
@@ -844,20 +936,23 @@ export class OtaOrchestrator {
|
|
|
844
936
|
this.healthyTimer = null;
|
|
845
937
|
}
|
|
846
938
|
try {
|
|
847
|
-
|
|
848
|
-
|
|
939
|
+
// getSlotState() is CONSUME-ON-READ for the revert record, so a caller that
|
|
940
|
+
// has already read it passes the parsed result down rather than letting
|
|
941
|
+
// this read again and swallow a rollback. `null` is a real answer (module
|
|
942
|
+
// absent or payload unreadable) — only `undefined` means "nobody read it".
|
|
943
|
+
const state = preRead !== undefined ? preRead : JSON.parse(NativeScaleBunOta.getSlotState());
|
|
849
944
|
|
|
850
945
|
// Only run the heartbeat if boot-guard marker is present
|
|
851
946
|
// (meaning we're running from a freshly-applied OTA bundle)
|
|
852
|
-
if (!state
|
|
947
|
+
if (!state?.bootMarkerPresent) {
|
|
853
948
|
return;
|
|
854
949
|
}
|
|
855
950
|
const healthyMs = this.bootGuardConfig.healthyAfterMs ?? DEFAULT_HEALTHY_AFTER_MS;
|
|
856
|
-
|
|
951
|
+
logger.debug(`[OTA] Boot guard active — marking healthy after ${healthyMs}ms`);
|
|
857
952
|
this.healthyTimer = setTimeout(() => {
|
|
858
953
|
noThrow(() => {
|
|
859
954
|
NativeScaleBunOta?.markHealthy();
|
|
860
|
-
|
|
955
|
+
logger.info('[OTA] Boot guard cleared — bundle marked healthy ✓');
|
|
861
956
|
this.healthyTimer = null;
|
|
862
957
|
|
|
863
958
|
// The honest activation signal. INSTALLED is emitted optimistically,
|
|
@@ -912,11 +1007,11 @@ export class OtaOrchestrator {
|
|
|
912
1007
|
/* the restart below makes cleanup moot */
|
|
913
1008
|
}
|
|
914
1009
|
this.resumeRestartArmed = false;
|
|
915
|
-
|
|
1010
|
+
logger.info('[OTA] App resumed — activating the staged update now');
|
|
916
1011
|
this.restart();
|
|
917
1012
|
}
|
|
918
1013
|
});
|
|
919
|
-
|
|
1014
|
+
logger.debug('[OTA] Update staged — will activate on next resume (ON_NEXT_RESUME)');
|
|
920
1015
|
} catch {
|
|
921
1016
|
// AppState unavailable (tests, exotic hosts): the update still activates
|
|
922
1017
|
// on the next cold start, which is the documented fallback behaviour.
|
|
@@ -936,7 +1031,7 @@ export class OtaOrchestrator {
|
|
|
936
1031
|
*/
|
|
937
1032
|
restart() {
|
|
938
1033
|
noThrow(() => {
|
|
939
|
-
|
|
1034
|
+
logger.info('[OTA] Triggering app restart…');
|
|
940
1035
|
if (NativeScaleBunOta && typeof NativeScaleBunOta.restartApp === 'function') {
|
|
941
1036
|
NativeScaleBunOta.restartApp();
|
|
942
1037
|
} else if (DevSettings && typeof DevSettings.reload === 'function') {
|
|
@@ -954,6 +954,69 @@ export class SessionManager {
|
|
|
954
954
|
state_status: stateStatus,
|
|
955
955
|
ui: details?.ui,
|
|
956
956
|
target_id: details?.targetId,
|
|
957
|
+
/**
|
|
958
|
+
* RN-6 — shipped with the row so the join is auditable instead of assumed.
|
|
959
|
+
*
|
|
960
|
+
* Without it, an interaction with no `target_id` is ambiguous in the worst way:
|
|
961
|
+
* it could mean the control genuinely had no identity, or that the correlation
|
|
962
|
+
* refused and the identity exists but could not be attributed. Those are
|
|
963
|
+
* different defects with different owners, and the accounting lane cannot give
|
|
964
|
+
* an honest unplaceable REASON without knowing which.
|
|
965
|
+
*
|
|
966
|
+
* Undefined on the JS-fallback path (no native event, so nothing was joined),
|
|
967
|
+
* which is itself the correct answer there rather than a fabricated 'exact'.
|
|
968
|
+
*/
|
|
969
|
+
correlation: details?.correlation,
|
|
970
|
+
correlation_delta_ms: details?.correlationDeltaMs,
|
|
971
|
+
correlation_runner_up_delta_ms: details?.correlationRunnerUpDeltaMs,
|
|
972
|
+
/**
|
|
973
|
+
* RN-1/RN-2 — stored alongside `target_id` so a reader can tell an authored
|
|
974
|
+
* identifier from a structural inference. 'path' means the app authored no testID
|
|
975
|
+
* and the identity was derived from component names, which is useful but will
|
|
976
|
+
* change under refactoring; a dashboard that presents the two identically invites
|
|
977
|
+
* someone to trust a key that is about to move.
|
|
978
|
+
*/
|
|
979
|
+
target_source: details?.targetSource,
|
|
980
|
+
/**
|
|
981
|
+
* RN-8 — stored so "13.8% of interactions have a screen" becomes answerable.
|
|
982
|
+
* 'none' means the app instrumented nothing the SDK could see; 'manual' means a
|
|
983
|
+
* <ScaleBunScreen> beacon; 'navigation' means the React Navigation hook. Three
|
|
984
|
+
* different owners behind one number that today has none.
|
|
985
|
+
*/
|
|
986
|
+
screen_source: details?.screenSource,
|
|
987
|
+
/**
|
|
988
|
+
* RN-13 — what makes "the heat is on this control" checkable instead of assumed.
|
|
989
|
+
* Absent when the control could not be measured synchronously, which is a fact worth
|
|
990
|
+
* storing: an unmeasurable control and a mis-placed tap are different findings.
|
|
991
|
+
*/
|
|
992
|
+
target_rect: details?.targetRect,
|
|
993
|
+
/**
|
|
994
|
+
* RN-3 — the backend already reads scrollX/scrollY off the payload into the fact
|
|
995
|
+
* table's own columns (it has done since the web SDK, which sends `sx`/`sy`), so
|
|
996
|
+
* these need no migration. Mobile simply never sent them.
|
|
997
|
+
*
|
|
998
|
+
* `scroll_source` says whether an offset was available at all: 'none' means the app
|
|
999
|
+
* uses a plain <ScrollView> rather than the SDK's wrapper, which is a coverage gap in
|
|
1000
|
+
* the integration and not a tap at the top of a list.
|
|
1001
|
+
*/
|
|
1002
|
+
scrollX: details?.scrollX,
|
|
1003
|
+
scrollY: details?.scrollY,
|
|
1004
|
+
scroll_source: details?.scrollSource,
|
|
1005
|
+
/**
|
|
1006
|
+
* RN-4 — what stops a recycled native view merging two different rows into one
|
|
1007
|
+
* hotspot. The key is the app's own keyExtractor output, so it is stable across
|
|
1008
|
+
* scrolling and across sessions; the index is carried so a recycled slot (same index,
|
|
1009
|
+
* different key) can be proven rather than inferred.
|
|
1010
|
+
*/
|
|
1011
|
+
item_key: details?.itemKey,
|
|
1012
|
+
item_index: details?.itemIndex,
|
|
1013
|
+
/**
|
|
1014
|
+
* RN-15 — where in the control, not just which control. Turns "they tapped Add to
|
|
1015
|
+
* Cart" into "they tapped its left edge", which is what tells you whether a hit area
|
|
1016
|
+
* is right. Outside 0..1 is a hitSlop tap and is kept as such.
|
|
1017
|
+
*/
|
|
1018
|
+
local_u: details?.localU,
|
|
1019
|
+
local_v: details?.localV,
|
|
957
1020
|
gestureType,
|
|
958
1021
|
x: details?.x,
|
|
959
1022
|
y: details?.y,
|