@scalebun/react-native 2.0.0 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/src/main/java/com/scalebun/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/ScaleBunOtaModule.kt +18 -3
- package/android/src/main/java/com/scalebun/rn/ota/SlotManager.kt +62 -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 +2 -0
- package/dist/scalebun.full.js +840 -114
- package/dist/scalebun.slim.js +838 -112
- package/ios/ReplaySdk.swift +65 -2
- package/lib/commonjs/bootstrap/SDKBootstrapper.js +30 -0
- package/lib/commonjs/core/config/schema.js +16 -0
- 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/ota/OtaOrchestrator.js +69 -22
- package/lib/commonjs/features/session/SessionManager.js +63 -0
- package/lib/commonjs/public/ScaleBunFacade.js +18 -16
- package/lib/module/bootstrap/SDKBootstrapper.js +30 -0
- package/lib/module/core/config/schema.js +16 -0
- 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/ota/OtaOrchestrator.js +69 -22
- package/lib/module/features/session/SessionManager.js +63 -0
- package/lib/module/public/ScaleBunFacade.js +18 -16
- package/lib/typescript/core/config/schema.d.ts +2 -0
- 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/ota/OtaOrchestrator.d.ts +7 -0
- package/lib/typescript/features/session/SessionManager.d.ts +55 -0
- package/lib/typescript/public/types.d.ts +10 -0
- package/lib/typescript/specs/NativeScaleBunOta.d.ts +2 -0
- package/package.json +2 -2
- package/src/bootstrap/SDKBootstrapper.ts +40 -0
- package/src/core/config/schema.ts +18 -0
- 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/ota/OtaOrchestrator.ts +76 -22
- package/src/features/session/SessionManager.ts +113 -0
- package/src/public/ScaleBunFacade.ts +18 -16
- package/src/public/types.ts +10 -0
- package/src/specs/NativeScaleBunOta.ts +3 -0
|
@@ -49,7 +49,10 @@ const clean = s => typeof s === 'string' ? s.replace(/[;:|]/g, '').trim().slice(
|
|
|
49
49
|
export function setUiState(name, value) {
|
|
50
50
|
const n = clean(name);
|
|
51
51
|
const v = clean(value);
|
|
52
|
-
if (n
|
|
52
|
+
if (!n || !v) return;
|
|
53
|
+
if (declared.get(n) === v) return;
|
|
54
|
+
declared.set(n, v);
|
|
55
|
+
notifyChanged();
|
|
53
56
|
}
|
|
54
57
|
|
|
55
58
|
/**
|
|
@@ -61,7 +64,9 @@ export function setUiState(name, value) {
|
|
|
61
64
|
* screen.
|
|
62
65
|
*/
|
|
63
66
|
export function clearUiState(name) {
|
|
67
|
+
const before = declared.size;
|
|
64
68
|
if (name === undefined) declared.clear();else declared.delete(clean(name));
|
|
69
|
+
if (declared.size !== before) notifyChanged();
|
|
65
70
|
}
|
|
66
71
|
|
|
67
72
|
/**
|
|
@@ -83,4 +88,76 @@ export function uiStateSignature() {
|
|
|
83
88
|
}
|
|
84
89
|
return signature || undefined;
|
|
85
90
|
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* RN-11 — TELL SOMEONE THE STATE CHANGED, so a frame can exist for it.
|
|
94
|
+
*
|
|
95
|
+
* THE GAP. Declaring a state used to mutate a Map and nothing else. Nothing captured a frame, so
|
|
96
|
+
* the visual for a declared state existed only if a screenshot happened to be taken for some other
|
|
97
|
+
* reason — a navigation, a timer, or the interaction capture that fires AFTER the touch and
|
|
98
|
+
* photographs the outcome. A state could therefore be recorded as a fact with no picture of it
|
|
99
|
+
* anywhere, or with a picture of the state that replaced it.
|
|
100
|
+
*
|
|
101
|
+
* The native capture reasons are NAVIGATION, MANUAL, INTERACTION and TIMER. None of them is "the
|
|
102
|
+
* UI changed shape", which is exactly the moment a heatmap needs a frame.
|
|
103
|
+
*
|
|
104
|
+
* FIRING ON A REAL CHANGE ONLY, AND WHY THAT IS THE SDK'S JOB. The advice above says to call
|
|
105
|
+
* `setUiState` when the state changes rather than on every render — but that is advice to the
|
|
106
|
+
* host, not a guarantee. A component that declares its state inside render is ordinary React, and
|
|
107
|
+
* trusting the caller would mean a screenshot per render: a performance disaster in exactly the
|
|
108
|
+
* apps that instrument most carefully. So a redundant set — same dimension, same value — is now a
|
|
109
|
+
* no-op, and the change test lives here where it cannot be got wrong by a host.
|
|
110
|
+
*
|
|
111
|
+
* COALESCED, because a transition usually declares several dimensions in one tick ("drawer:open"
|
|
112
|
+
* and "overlay:visible" belong to one visual change). Notifying per call would capture the
|
|
113
|
+
* intermediate shapes, which are states the user never saw.
|
|
114
|
+
*
|
|
115
|
+
* The listener is deliberately NOT given the signature. A subscriber that wants it calls
|
|
116
|
+
* `uiStateSignature()` at the moment it acts, which is the value that will actually be attached
|
|
117
|
+
* to the next tap; handing over a snapshot invites acting on a state that has already moved on.
|
|
118
|
+
*/
|
|
119
|
+
|
|
120
|
+
const listeners = new Set();
|
|
121
|
+
let pending = null;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Subscribe to declared-state changes. Returns an unsubscribe.
|
|
125
|
+
*
|
|
126
|
+
* Registration is idempotent per function reference, so a component that re-subscribes on every
|
|
127
|
+
* render does not accumulate listeners — a leak that would only show up in long sessions.
|
|
128
|
+
*/
|
|
129
|
+
export function onUiStateChange(listener) {
|
|
130
|
+
listeners.add(listener);
|
|
131
|
+
return () => {
|
|
132
|
+
listeners.delete(listener);
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Drop every listener and any pending notification. For teardown between sessions. */
|
|
137
|
+
export function resetUiStateListeners() {
|
|
138
|
+
listeners.clear();
|
|
139
|
+
if (pending !== null) {
|
|
140
|
+
clearTimeout(pending);
|
|
141
|
+
pending = null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** For assertions that a re-subscribing caller has not leaked. */
|
|
146
|
+
export function uiStateListenerCount() {
|
|
147
|
+
return listeners.size;
|
|
148
|
+
}
|
|
149
|
+
function notifyChanged() {
|
|
150
|
+
if (pending !== null) return;
|
|
151
|
+
pending = setTimeout(() => {
|
|
152
|
+
pending = null;
|
|
153
|
+
for (const listener of listeners) {
|
|
154
|
+
try {
|
|
155
|
+
listener();
|
|
156
|
+
} catch {
|
|
157
|
+
/* One bad subscriber must not stop the others, and must never surface to the host: a
|
|
158
|
+
screenshot is not worth an app crash. */
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}, 0);
|
|
162
|
+
}
|
|
86
163
|
//# sourceMappingURL=uiState.js.map
|
|
@@ -36,6 +36,15 @@ export class AutoScreenDetector {
|
|
|
36
36
|
_manualScreen = null;
|
|
37
37
|
_manualScreenTs = 0;
|
|
38
38
|
_autoScreen = null;
|
|
39
|
+
/**
|
|
40
|
+
* When `_autoScreen` was last written.
|
|
41
|
+
*
|
|
42
|
+
* Needed so `getCurrentScreen()` can prefer whichever source spoke MOST RECENTLY.
|
|
43
|
+
* Without a timestamp the only comparison available was "is the manual value still
|
|
44
|
+
* fresh", which is a different question and gave the wrong answer — see the note on
|
|
45
|
+
* `_currentScreen()`.
|
|
46
|
+
*/
|
|
47
|
+
_autoScreenTs = 0;
|
|
39
48
|
/** `name#key` of the last auto-detected screen VISIT — see _extractActiveVisit. */
|
|
40
49
|
_autoVisitSig = null;
|
|
41
50
|
_navigationRef = null;
|
|
@@ -110,6 +119,7 @@ export class AutoScreenDetector {
|
|
|
110
119
|
if (this.isManualScreenActive()) return;
|
|
111
120
|
if (name !== this._autoScreen) {
|
|
112
121
|
this._autoScreen = name;
|
|
122
|
+
this._autoScreenTs = Date.now();
|
|
113
123
|
this._autoVisitSig = name; // native lifecycle carries no route key — name-only visit
|
|
114
124
|
this._emitScreenChange(name, 'heuristic');
|
|
115
125
|
}
|
|
@@ -117,8 +127,19 @@ export class AutoScreenDetector {
|
|
|
117
127
|
}
|
|
118
128
|
|
|
119
129
|
/**
|
|
120
|
-
*
|
|
121
|
-
*
|
|
130
|
+
* Should a WRITER defer to the manual screen right now?
|
|
131
|
+
*
|
|
132
|
+
* THIS IS A PRECEDENCE GATE, NOT A VALIDITY TEST, and conflating the two was a real
|
|
133
|
+
* bug. Its job is what the cooldown constant says: for a short window after a manual
|
|
134
|
+
* beacon fires, an auto source must not overwrite it — the poller runs every 1500 ms
|
|
135
|
+
* and would otherwise clobber a fresh declaration with whatever it inferred.
|
|
136
|
+
*
|
|
137
|
+
* `getCurrentScreen()` used to reuse this as "is the manual screen still the current
|
|
138
|
+
* screen", which made a declared screen READABLE for only 2000 ms. Nothing called
|
|
139
|
+
* `setManualScreen` in production, so the effect was latent — but the moment the
|
|
140
|
+
* `<ScaleBunScreen>` beacon was wired to it (RN-8), every tap more than two seconds
|
|
141
|
+
* after a screen mounted would have lost its name, which is very nearly every tap. A
|
|
142
|
+
* fix that introduced that would have been worse than the gap it closed.
|
|
122
143
|
*/
|
|
123
144
|
isManualScreenActive() {
|
|
124
145
|
if (!this._manualScreen) return false;
|
|
@@ -178,6 +199,7 @@ export class AutoScreenDetector {
|
|
|
178
199
|
const visit = this._extractActiveVisit(state);
|
|
179
200
|
if (visit && this._visitSig(visit) !== this._autoVisitSig) {
|
|
180
201
|
this._autoScreen = visit.name;
|
|
202
|
+
this._autoScreenTs = Date.now();
|
|
181
203
|
this._autoVisitSig = this._visitSig(visit);
|
|
182
204
|
this._emitScreenChange(visit.name, 'navRef');
|
|
183
205
|
}
|
|
@@ -196,6 +218,7 @@ export class AutoScreenDetector {
|
|
|
196
218
|
const visit = this._extractActiveVisit(state);
|
|
197
219
|
if (visit) {
|
|
198
220
|
this._autoScreen = visit.name;
|
|
221
|
+
this._autoScreenTs = Date.now();
|
|
199
222
|
this._autoVisitSig = this._visitSig(visit);
|
|
200
223
|
this._emitScreenChange(visit.name, 'navRef');
|
|
201
224
|
}
|
|
@@ -239,6 +262,7 @@ export class AutoScreenDetector {
|
|
|
239
262
|
const visit = this._extractActiveVisit(state);
|
|
240
263
|
if (visit && this._visitSig(visit) !== this._autoVisitSig) {
|
|
241
264
|
this._autoScreen = visit.name;
|
|
265
|
+
this._autoScreenTs = Date.now();
|
|
242
266
|
this._autoVisitSig = this._visitSig(visit);
|
|
243
267
|
this._emitScreenChange(visit.name, 'heuristic');
|
|
244
268
|
}
|
|
@@ -495,16 +519,61 @@ export class AutoScreenDetector {
|
|
|
495
519
|
this._listeners = [];
|
|
496
520
|
this._manualScreen = null;
|
|
497
521
|
this._autoScreen = null;
|
|
522
|
+
this._autoScreenTs = 0;
|
|
498
523
|
this._navigationRef = null;
|
|
499
524
|
_instance = null;
|
|
500
525
|
}
|
|
501
526
|
|
|
502
527
|
/** Get the current best-known screen name */
|
|
503
528
|
getCurrentScreen() {
|
|
504
|
-
|
|
505
|
-
|
|
529
|
+
return this._currentScreen().name;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* WHERE the current screen name came from, or 'none'.
|
|
534
|
+
*
|
|
535
|
+
* Recorded on every interaction (RN-8) so low screen coverage is DIAGNOSABLE rather
|
|
536
|
+
* than merely visible. Production shows 13.8% of Android interactions carrying a
|
|
537
|
+
* screen and 0.5% on iOS, and those numbers cannot today distinguish three different
|
|
538
|
+
* causes: the app never instrumented anything, the app instrumented manually and the
|
|
539
|
+
* value never reached interactions, or auto-detection ran and failed. Those have
|
|
540
|
+
* different owners and different fixes, and guessing between them is how a P0 stays
|
|
541
|
+
* open.
|
|
542
|
+
*/
|
|
543
|
+
getCurrentScreenSource() {
|
|
544
|
+
return this._currentScreen().source;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* LAST WRITER WINS between the manual beacon and auto-detection.
|
|
549
|
+
*
|
|
550
|
+
* A screen declared by `<ScaleBunScreen name>` stays current until something else
|
|
551
|
+
* declares a different one — that is what mounting a screen means. It does not
|
|
552
|
+
* expire, which is the correction here: the previous rule returned the manual name
|
|
553
|
+
* only while it was under 2000 ms old and then silently fell back to the auto value,
|
|
554
|
+
* which in a manually-instrumented app is `null`.
|
|
555
|
+
*
|
|
556
|
+
* Comparing timestamps rather than hard-coding a priority also handles the real
|
|
557
|
+
* interleaving: an app can use React Navigation for most screens AND drop a manual
|
|
558
|
+
* beacon on one modal, and whichever spoke last is the truth.
|
|
559
|
+
*/
|
|
560
|
+
_currentScreen() {
|
|
561
|
+
const manual = this._manualScreen;
|
|
562
|
+
const auto = this._autoScreen;
|
|
563
|
+
if (manual && (!auto || this._manualScreenTs >= this._autoScreenTs)) {
|
|
564
|
+
return {
|
|
565
|
+
name: manual,
|
|
566
|
+
source: 'manual'
|
|
567
|
+
};
|
|
506
568
|
}
|
|
507
|
-
return
|
|
569
|
+
if (auto) return {
|
|
570
|
+
name: auto,
|
|
571
|
+
source: 'navigation'
|
|
572
|
+
};
|
|
573
|
+
return {
|
|
574
|
+
name: null,
|
|
575
|
+
source: 'none'
|
|
576
|
+
};
|
|
508
577
|
}
|
|
509
578
|
|
|
510
579
|
// ─── Internal ───────────────────────────────────────────────────────
|
|
@@ -113,12 +113,12 @@ async function deliverOtaEvents(params) {
|
|
|
113
113
|
})
|
|
114
114
|
});
|
|
115
115
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
116
|
-
|
|
116
|
+
logger.debug(`[OTA] Delivered ${items.length} telemetry event(s)`);
|
|
117
117
|
} catch (err) {
|
|
118
118
|
// Failed delivery must not fail the update — but the events must not be
|
|
119
119
|
// lost either: requeue so the next sync retries them.
|
|
120
120
|
for (const e of events) otaEventEmitter.emit(e);
|
|
121
|
-
|
|
121
|
+
logger.debug(`[OTA] Telemetry delivery failed (will retry next sync): ${err?.message}`);
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
124
|
|
|
@@ -138,6 +138,8 @@ export class OtaOrchestrator {
|
|
|
138
138
|
return this.currentBundle;
|
|
139
139
|
}
|
|
140
140
|
isRestartRequiredState = false;
|
|
141
|
+
/** Root/jailbreak indicators observed at init; empty when none found. */
|
|
142
|
+
integrityIndicators = [];
|
|
141
143
|
healthyTimer = null;
|
|
142
144
|
bootGuardConfig = {};
|
|
143
145
|
environment = null;
|
|
@@ -193,12 +195,30 @@ export class OtaOrchestrator {
|
|
|
193
195
|
// We cannot see which form the host used — the identity check after
|
|
194
196
|
// restart is what proves it — but naming the right fix up front turns
|
|
195
197
|
// a confusing silent failure into a one-line correction.
|
|
196
|
-
|
|
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.');
|
|
197
199
|
}
|
|
198
200
|
this.initialized = true;
|
|
199
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
|
+
}
|
|
200
220
|
this.bootGuardConfig = config ?? {};
|
|
201
|
-
|
|
221
|
+
logger.debug(`[OTA] Orchestrator initialized (RN ${this.environment.rnVersionString ?? 'unknown'}` + `${this.environment.bridgeless ? ', bridgeless' : ''}` + `${this.environment.hermes ? `, Hermes HBC v${this.environment.hermesBytecodeVersion ?? '?'}` : ''})`);
|
|
202
222
|
|
|
203
223
|
// ONE read of the slot state, shared by both boot-time consumers.
|
|
204
224
|
//
|
|
@@ -229,6 +249,22 @@ export class OtaOrchestrator {
|
|
|
229
249
|
|
|
230
250
|
// Prove the bundle we installed is the bundle that loaded.
|
|
231
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);
|
|
232
268
|
});
|
|
233
269
|
}
|
|
234
270
|
|
|
@@ -267,7 +303,7 @@ export class OtaOrchestrator {
|
|
|
267
303
|
releaseId: record.releaseId ?? undefined,
|
|
268
304
|
sha256: record.sha256
|
|
269
305
|
};
|
|
270
|
-
|
|
306
|
+
logger.debug(`[OTA] Running bundle v${record.version} (${record.bundleId})`);
|
|
271
307
|
return;
|
|
272
308
|
}
|
|
273
309
|
|
|
@@ -277,7 +313,7 @@ export class OtaOrchestrator {
|
|
|
277
313
|
this.currentBundle = {
|
|
278
314
|
sha256: current.sha256
|
|
279
315
|
};
|
|
280
|
-
|
|
316
|
+
logger.debug(`[OTA] Running an OTA bundle (sha ${String(current.sha256).slice(0, 12)}…) with no local install record.`);
|
|
281
317
|
} catch {
|
|
282
318
|
// Slot state unreadable — treat as factory bundle.
|
|
283
319
|
}
|
|
@@ -325,7 +361,7 @@ export class OtaOrchestrator {
|
|
|
325
361
|
// this bundle" and answers by serving the same bundle again, forever.
|
|
326
362
|
if (!record.identityToken) return;
|
|
327
363
|
if (running === record.identityToken) {
|
|
328
|
-
|
|
364
|
+
logger.debug('[OTA] Install verified — running bundle matches what was installed.');
|
|
329
365
|
this.retireIdentityToken(record);
|
|
330
366
|
return;
|
|
331
367
|
}
|
|
@@ -458,6 +494,14 @@ export class OtaOrchestrator {
|
|
|
458
494
|
isEnabled() {
|
|
459
495
|
return this.enabled;
|
|
460
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
|
+
}
|
|
461
505
|
isRestartRequired() {
|
|
462
506
|
return this.isRestartRequiredState;
|
|
463
507
|
}
|
|
@@ -565,7 +609,7 @@ export class OtaOrchestrator {
|
|
|
565
609
|
error: 'OTA native module disabled or missing'
|
|
566
610
|
};
|
|
567
611
|
}
|
|
568
|
-
|
|
612
|
+
logger.debug('[OTA] Sync started…');
|
|
569
613
|
|
|
570
614
|
// The funnel's denominator, for devices already on an OTA bundle. Emitted
|
|
571
615
|
// before the request, so a check that fails outright still counts as a
|
|
@@ -584,7 +628,7 @@ export class OtaOrchestrator {
|
|
|
584
628
|
}
|
|
585
629
|
const checkRes = await this.checkForUpdate(params);
|
|
586
630
|
if (checkRes.action === 'NONE') {
|
|
587
|
-
|
|
631
|
+
logger.debug('[OTA] App is up to date');
|
|
588
632
|
// If we booted from an OTA bundle and it's healthy, clear the boot guard
|
|
589
633
|
this.startBootGuardHeartbeat();
|
|
590
634
|
return {
|
|
@@ -642,7 +686,7 @@ export class OtaOrchestrator {
|
|
|
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
|
|
@@ -740,13 +784,13 @@ export class OtaOrchestrator {
|
|
|
740
784
|
|
|
741
785
|
// Sprint 7: Try patch first if offered, fall back to full bundle
|
|
742
786
|
if (bundle.patch && NativeScaleBunOta.stagePatch) {
|
|
743
|
-
|
|
787
|
+
logger.debug(`[OTA] Patch available (${(bundle.patch.size / 1024).toFixed(0)} KB vs ` + `${(bundle.size / 1024).toFixed(0)} KB full) — trying patch…`);
|
|
744
788
|
try {
|
|
745
789
|
staged = await NativeScaleBunOta.stagePatch(bundle.patch.url, bundle.patch.sha256, bundle.sha256 // expected full-bundle hash after patching
|
|
746
790
|
);
|
|
747
791
|
if (staged) {
|
|
748
792
|
patchUsed = true;
|
|
749
|
-
|
|
793
|
+
logger.debug('[OTA] Patch applied successfully');
|
|
750
794
|
} else {
|
|
751
795
|
logger.warn('[OTA] Patch staging returned false — falling back to full bundle');
|
|
752
796
|
}
|
|
@@ -837,7 +881,7 @@ export class OtaOrchestrator {
|
|
|
837
881
|
releaseId: bundle.releaseId,
|
|
838
882
|
version: bundle.version
|
|
839
883
|
});
|
|
840
|
-
|
|
884
|
+
logger.debug(`[OTA] Update v${bundle.version} installed successfully!`);
|
|
841
885
|
|
|
842
886
|
// Deliver telemetry BEFORE a restart tears the JS runtime down —
|
|
843
887
|
// an INSTALLED event queued but not sent would vanish with the process.
|
|
@@ -883,7 +927,7 @@ export class OtaOrchestrator {
|
|
|
883
927
|
* native boot-guard marker. If JS crashes before the timer fires, the
|
|
884
928
|
* marker stays set and native code reverts on next cold start.
|
|
885
929
|
*/
|
|
886
|
-
startBootGuardHeartbeat() {
|
|
930
|
+
startBootGuardHeartbeat(preRead) {
|
|
887
931
|
if (!NativeScaleBunOta) return;
|
|
888
932
|
|
|
889
933
|
// Cancel any existing timer
|
|
@@ -892,20 +936,23 @@ export class OtaOrchestrator {
|
|
|
892
936
|
this.healthyTimer = null;
|
|
893
937
|
}
|
|
894
938
|
try {
|
|
895
|
-
|
|
896
|
-
|
|
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());
|
|
897
944
|
|
|
898
945
|
// Only run the heartbeat if boot-guard marker is present
|
|
899
946
|
// (meaning we're running from a freshly-applied OTA bundle)
|
|
900
|
-
if (!state
|
|
947
|
+
if (!state?.bootMarkerPresent) {
|
|
901
948
|
return;
|
|
902
949
|
}
|
|
903
950
|
const healthyMs = this.bootGuardConfig.healthyAfterMs ?? DEFAULT_HEALTHY_AFTER_MS;
|
|
904
|
-
|
|
951
|
+
logger.debug(`[OTA] Boot guard active — marking healthy after ${healthyMs}ms`);
|
|
905
952
|
this.healthyTimer = setTimeout(() => {
|
|
906
953
|
noThrow(() => {
|
|
907
954
|
NativeScaleBunOta?.markHealthy();
|
|
908
|
-
|
|
955
|
+
logger.info('[OTA] Boot guard cleared — bundle marked healthy ✓');
|
|
909
956
|
this.healthyTimer = null;
|
|
910
957
|
|
|
911
958
|
// The honest activation signal. INSTALLED is emitted optimistically,
|
|
@@ -960,11 +1007,11 @@ export class OtaOrchestrator {
|
|
|
960
1007
|
/* the restart below makes cleanup moot */
|
|
961
1008
|
}
|
|
962
1009
|
this.resumeRestartArmed = false;
|
|
963
|
-
|
|
1010
|
+
logger.info('[OTA] App resumed — activating the staged update now');
|
|
964
1011
|
this.restart();
|
|
965
1012
|
}
|
|
966
1013
|
});
|
|
967
|
-
|
|
1014
|
+
logger.debug('[OTA] Update staged — will activate on next resume (ON_NEXT_RESUME)');
|
|
968
1015
|
} catch {
|
|
969
1016
|
// AppState unavailable (tests, exotic hosts): the update still activates
|
|
970
1017
|
// on the next cold start, which is the documented fallback behaviour.
|
|
@@ -984,7 +1031,7 @@ export class OtaOrchestrator {
|
|
|
984
1031
|
*/
|
|
985
1032
|
restart() {
|
|
986
1033
|
noThrow(() => {
|
|
987
|
-
|
|
1034
|
+
logger.info('[OTA] Triggering app restart…');
|
|
988
1035
|
if (NativeScaleBunOta && typeof NativeScaleBunOta.restartApp === 'function') {
|
|
989
1036
|
NativeScaleBunOta.restartApp();
|
|
990
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,
|
|
@@ -169,7 +169,7 @@ class ScaleBunFacade {
|
|
|
169
169
|
const realQueue = coreContainer.get('PersistentQueue');
|
|
170
170
|
const buffered = this._preInitBuffer.popBatch(this._preInitBuffer.length());
|
|
171
171
|
if (buffered.length > 0) {
|
|
172
|
-
|
|
172
|
+
logger.debug(`[ScaleBun] Draining ${buffered.length} pre-init events into persistent queue`);
|
|
173
173
|
for (const envelope of buffered) {
|
|
174
174
|
realQueue.add(envelope);
|
|
175
175
|
}
|
|
@@ -297,7 +297,9 @@ class ScaleBunFacade {
|
|
|
297
297
|
}
|
|
298
298
|
const clientKey = this._clientKey;
|
|
299
299
|
const apiUrl = this._apiBaseUrl;
|
|
300
|
-
if (!clientKey || !apiUrl)
|
|
300
|
+
if (!clientKey || !apiUrl) {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
301
303
|
this._otaCheckInFlight = true;
|
|
302
304
|
try {
|
|
303
305
|
const info = await bridgeAdapter.getDeviceInfo();
|
|
@@ -354,7 +356,7 @@ class ScaleBunFacade {
|
|
|
354
356
|
if (!dbgConfig) return;
|
|
355
357
|
dbgConfig.__skipDeprecationWarning = true;
|
|
356
358
|
this.enableDebug(dbgConfig);
|
|
357
|
-
|
|
359
|
+
logger.info('[ScaleBun] Debug auto-connected via init({ debug }).');
|
|
358
360
|
} catch (err) {
|
|
359
361
|
logger.warn('[ScaleBun] Auto debug connection failed:', err?.message);
|
|
360
362
|
}
|
|
@@ -371,7 +373,7 @@ class ScaleBunFacade {
|
|
|
371
373
|
const apiBaseUrl = this._apiBaseUrl;
|
|
372
374
|
if (!enabled || !appId || !clientKey || !apiBaseUrl) {
|
|
373
375
|
if (enabled && !appId) {
|
|
374
|
-
|
|
376
|
+
logger.info('[ScaleBun] Envelope event tracking disabled — no appId in init config.');
|
|
375
377
|
}
|
|
376
378
|
return;
|
|
377
379
|
}
|
|
@@ -423,7 +425,7 @@ class ScaleBunFacade {
|
|
|
423
425
|
onEvent: (eventName, revenue) => this._skanManager?.onEvent(eventName, revenue)
|
|
424
426
|
});
|
|
425
427
|
this._eventTracker.start();
|
|
426
|
-
|
|
428
|
+
logger.info('[ScaleBun] Envelope event tracking started → /v1/batch.');
|
|
427
429
|
|
|
428
430
|
// Complete the device context from the native bridge.
|
|
429
431
|
//
|
|
@@ -1105,7 +1107,7 @@ class ScaleBunFacade {
|
|
|
1105
1107
|
const cfg = await (this._getEngageTransport()?.fetchEngageConfig(context) ?? Promise.resolve(null));
|
|
1106
1108
|
const messages = cfg?.inAppMessages ?? [];
|
|
1107
1109
|
const previews = messages.filter(m => m.preview === true || m.isTest === true);
|
|
1108
|
-
|
|
1110
|
+
logger.info(`[Engage] debugFetchInAppMessages → ${messages.length} message(s), ${previews.length} test preview(s)`);
|
|
1109
1111
|
return messages;
|
|
1110
1112
|
},
|
|
1111
1113
|
/** Upload a survey/NPS/feedback response (idempotent, rides the outbox). */
|
|
@@ -1390,7 +1392,7 @@ class ScaleBunFacade {
|
|
|
1390
1392
|
logger.warn('SDK not initialized. Event dropped:', name);
|
|
1391
1393
|
return;
|
|
1392
1394
|
}
|
|
1393
|
-
|
|
1395
|
+
logger.debug('Tracking event:', name);
|
|
1394
1396
|
|
|
1395
1397
|
// Fan this event out to the Engage trigger engine. Same single funnel,
|
|
1396
1398
|
// extra observer — lets behavioral triggers (custom_event, failed_action,
|
|
@@ -1563,7 +1565,7 @@ class ScaleBunFacade {
|
|
|
1563
1565
|
await Promise.all([this._eventTracker?.flush() ?? Promise.resolve(), adapter?.flushAnalytics() ?? Promise.resolve()]);
|
|
1564
1566
|
return;
|
|
1565
1567
|
}
|
|
1566
|
-
|
|
1568
|
+
logger.debug('Flushing events from persistent queue...');
|
|
1567
1569
|
const httpClient = coreContainer.get('HttpClient');
|
|
1568
1570
|
await flushQueue(this._getQueue(), httpClient, getDeviceId());
|
|
1569
1571
|
});
|
|
@@ -1586,9 +1588,9 @@ class ScaleBunFacade {
|
|
|
1586
1588
|
const bootstrapPerfFeature = _getPerformanceFeature();
|
|
1587
1589
|
if (bootstrapPerfFeature) {
|
|
1588
1590
|
this._performanceFeature = bootstrapPerfFeature;
|
|
1589
|
-
|
|
1591
|
+
logger.debug('Performance feature linked from bootstrap');
|
|
1590
1592
|
}
|
|
1591
|
-
|
|
1593
|
+
logger.info('Debug enabled');
|
|
1592
1594
|
});
|
|
1593
1595
|
}
|
|
1594
1596
|
|
|
@@ -1740,14 +1742,14 @@ class ScaleBunFacade {
|
|
|
1740
1742
|
return false;
|
|
1741
1743
|
}
|
|
1742
1744
|
sm.startSession(metadata);
|
|
1743
|
-
|
|
1745
|
+
logger.info('session.start() ✅ sessionId:', sm.currentSessionId);
|
|
1744
1746
|
return true;
|
|
1745
1747
|
},
|
|
1746
1748
|
end: reason => {
|
|
1747
1749
|
const sm = SessionManager.getExistingInstance();
|
|
1748
1750
|
if (!sm || !sm.isActive) return false;
|
|
1749
1751
|
sm.endSession(reason);
|
|
1750
|
-
|
|
1752
|
+
logger.info('session.end() ✅');
|
|
1751
1753
|
return true;
|
|
1752
1754
|
},
|
|
1753
1755
|
emitEvent: (type, data) => {
|
|
@@ -1805,7 +1807,7 @@ class ScaleBunFacade {
|
|
|
1805
1807
|
...this._privacyConfig,
|
|
1806
1808
|
...config
|
|
1807
1809
|
};
|
|
1808
|
-
|
|
1810
|
+
logger.debug('[report] Privacy config updated');
|
|
1809
1811
|
},
|
|
1810
1812
|
/** Set branding for exported reports. */
|
|
1811
1813
|
setBranding: config => {
|
|
@@ -1813,7 +1815,7 @@ class ScaleBunFacade {
|
|
|
1813
1815
|
...this._brandingConfig,
|
|
1814
1816
|
...config
|
|
1815
1817
|
};
|
|
1816
|
-
|
|
1818
|
+
logger.debug('[report] Branding config updated');
|
|
1817
1819
|
},
|
|
1818
1820
|
/** Get current privacy config. */
|
|
1819
1821
|
getPrivacy: () => ({
|
|
@@ -1868,9 +1870,9 @@ class ScaleBunFacade {
|
|
|
1868
1870
|
AutoScreenDetector
|
|
1869
1871
|
} = require('../features/navigation/AutoScreenDetector');
|
|
1870
1872
|
AutoScreenDetector.getInstance().setNavigationRef(ref);
|
|
1871
|
-
|
|
1873
|
+
logger.info('[ScaleBun] Navigation ref set → auto screen detection active');
|
|
1872
1874
|
} catch (err) {
|
|
1873
|
-
|
|
1875
|
+
logger.debug('[ScaleBun] Navigation ref stashed (detector not ready):', err?.message);
|
|
1874
1876
|
}
|
|
1875
1877
|
});
|
|
1876
1878
|
}
|
|
@@ -86,6 +86,8 @@ export interface ScaleBunConfig {
|
|
|
86
86
|
redactBodies?: boolean;
|
|
87
87
|
};
|
|
88
88
|
captureInteractionHeatmap: boolean;
|
|
89
|
+
/** RN-3 zero-config scroll instrumentation. OFF by default — it changes what the HOST renders. */
|
|
90
|
+
autoInstrumentScrollViews: boolean;
|
|
89
91
|
/** Opt-in: capture console.* into the Logs lane in SaaS mode. Default false. */
|
|
90
92
|
captureConsoleLogs: boolean;
|
|
91
93
|
ota?: {
|