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