@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.
Files changed (70) hide show
  1. package/android/src/main/java/com/scalebun/rn/ota/BundleDownloader.kt +10 -0
  2. package/android/src/main/java/com/scalebun/rn/ota/DeviceIntegrity.kt +75 -0
  3. package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaModule.kt +18 -3
  4. package/android/src/main/java/com/scalebun/rn/ota/SlotManager.kt +62 -1
  5. package/android/src/main/java/com/scalebun/rn/ota/TlsPinning.kt +118 -0
  6. package/android/src/oldarch/java/com/scalebun/rn/ota/ScaleBunOtaSpec.kt +2 -0
  7. package/dist/scalebun.full.js +840 -114
  8. package/dist/scalebun.slim.js +838 -112
  9. package/ios/ReplaySdk.swift +65 -2
  10. package/lib/commonjs/bootstrap/SDKBootstrapper.js +30 -0
  11. package/lib/commonjs/core/config/schema.js +16 -0
  12. package/lib/commonjs/core/constants/version.js +1 -1
  13. package/lib/commonjs/features/journey/ScaleBunDebugRoot.js +293 -5
  14. package/lib/commonjs/features/journey/ScaleBunScrollView.js +28 -0
  15. package/lib/commonjs/features/journey/autoInstrumentScroll.js +161 -0
  16. package/lib/commonjs/features/journey/interactionProtocol.js +131 -10
  17. package/lib/commonjs/features/journey/scrollContext.js +146 -0
  18. package/lib/commonjs/features/journey/targetGeometry.js +164 -0
  19. package/lib/commonjs/features/journey/touchTarget.js +152 -24
  20. package/lib/commonjs/features/journey/uiState.js +81 -1
  21. package/lib/commonjs/features/navigation/AutoScreenDetector.js +74 -5
  22. package/lib/commonjs/features/ota/OtaOrchestrator.js +69 -22
  23. package/lib/commonjs/features/session/SessionManager.js +63 -0
  24. package/lib/commonjs/public/ScaleBunFacade.js +18 -16
  25. package/lib/module/bootstrap/SDKBootstrapper.js +30 -0
  26. package/lib/module/core/config/schema.js +16 -0
  27. package/lib/module/core/constants/version.js +1 -1
  28. package/lib/module/features/journey/ScaleBunDebugRoot.js +295 -7
  29. package/lib/module/features/journey/ScaleBunScrollView.js +28 -0
  30. package/lib/module/features/journey/autoInstrumentScroll.js +155 -0
  31. package/lib/module/features/journey/interactionProtocol.js +128 -8
  32. package/lib/module/features/journey/scrollContext.js +135 -0
  33. package/lib/module/features/journey/targetGeometry.js +155 -0
  34. package/lib/module/features/journey/touchTarget.js +151 -24
  35. package/lib/module/features/journey/uiState.js +78 -1
  36. package/lib/module/features/navigation/AutoScreenDetector.js +74 -5
  37. package/lib/module/features/ota/OtaOrchestrator.js +69 -22
  38. package/lib/module/features/session/SessionManager.js +63 -0
  39. package/lib/module/public/ScaleBunFacade.js +18 -16
  40. package/lib/typescript/core/config/schema.d.ts +2 -0
  41. package/lib/typescript/core/constants/version.d.ts +1 -1
  42. package/lib/typescript/features/journey/autoInstrumentScroll.d.ts +46 -0
  43. package/lib/typescript/features/journey/interactionProtocol.d.ts +139 -1
  44. package/lib/typescript/features/journey/scrollContext.d.ts +85 -0
  45. package/lib/typescript/features/journey/targetGeometry.d.ts +114 -0
  46. package/lib/typescript/features/journey/touchTarget.d.ts +148 -15
  47. package/lib/typescript/features/journey/uiState.d.ts +39 -0
  48. package/lib/typescript/features/navigation/AutoScreenDetector.d.ts +48 -2
  49. package/lib/typescript/features/ota/OtaOrchestrator.d.ts +7 -0
  50. package/lib/typescript/features/session/SessionManager.d.ts +55 -0
  51. package/lib/typescript/public/types.d.ts +10 -0
  52. package/lib/typescript/specs/NativeScaleBunOta.d.ts +2 -0
  53. package/package.json +2 -2
  54. package/src/bootstrap/SDKBootstrapper.ts +40 -0
  55. package/src/core/config/schema.ts +18 -0
  56. package/src/core/constants/version.ts +1 -1
  57. package/src/features/journey/ScaleBunDebugRoot.tsx +288 -7
  58. package/src/features/journey/ScaleBunScrollView.tsx +29 -0
  59. package/src/features/journey/autoInstrumentScroll.ts +155 -0
  60. package/src/features/journey/interactionProtocol.ts +188 -10
  61. package/src/features/journey/scrollContext.ts +137 -0
  62. package/src/features/journey/targetGeometry.ts +176 -0
  63. package/src/features/journey/touchTarget.ts +237 -26
  64. package/src/features/journey/uiState.ts +80 -1
  65. package/src/features/navigation/AutoScreenDetector.ts +65 -5
  66. package/src/features/ota/OtaOrchestrator.ts +76 -22
  67. package/src/features/session/SessionManager.ts +113 -0
  68. package/src/public/ScaleBunFacade.ts +18 -16
  69. package/src/public/types.ts +10 -0
  70. 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 && v) declared.set(n, v);
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
- * Check if a manual screen is currently active.
121
- * Used by auto-detection to yield to manual overrides.
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
- if (this._manualScreen && this.isManualScreenActive()) {
505
- return this._manualScreen;
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 this._autoScreen;
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
- __DEV__ && logger.debug(`[OTA] Delivered ${items.length} telemetry event(s)`);
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
- __DEV__ && logger.debug(`[OTA] Telemetry delivery failed (will retry next sync): ${err?.message}`);
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
- __DEV__ && 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
+ 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
- __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 ?? '?'}` : ''})`);
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
- __DEV__ && logger.debug(`[OTA] Running bundle v${record.version} (${record.bundleId})`);
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
- __DEV__ && logger.debug(`[OTA] Running an OTA bundle (sha ${String(current.sha256).slice(0, 12)}…) with no local install record.`);
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
- __DEV__ && logger.debug('[OTA] Install verified — running bundle matches what was installed.');
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
- __DEV__ && logger.debug('[OTA] Sync started…');
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
- __DEV__ && logger.debug('[OTA] App is up to date');
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
- __DEV__ && logger.debug(`[OTA] Downloading update v${bundle.version}…`);
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
- __DEV__ && logger.debug(`[OTA] Patch available (${(bundle.patch.size / 1024).toFixed(0)} KB vs ` + `${(bundle.size / 1024).toFixed(0)} KB full) — trying patch…`);
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
- __DEV__ && logger.debug('[OTA] Patch applied successfully');
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
- __DEV__ && logger.debug(`[OTA] Update v${bundle.version} installed successfully!`);
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
- const stateJson = NativeScaleBunOta.getSlotState();
896
- const state = JSON.parse(stateJson);
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.bootMarkerPresent) {
947
+ if (!state?.bootMarkerPresent) {
901
948
  return;
902
949
  }
903
950
  const healthyMs = this.bootGuardConfig.healthyAfterMs ?? DEFAULT_HEALTHY_AFTER_MS;
904
- __DEV__ && logger.debug(`[OTA] Boot guard active — marking healthy after ${healthyMs}ms`);
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
- __DEV__ && logger.info('[OTA] Boot guard cleared — bundle marked healthy ✓');
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
- __DEV__ && logger.info('[OTA] App resumed — activating the staged update now');
1010
+ logger.info('[OTA] App resumed — activating the staged update now');
964
1011
  this.restart();
965
1012
  }
966
1013
  });
967
- __DEV__ && logger.debug('[OTA] Update staged — will activate on next resume (ON_NEXT_RESUME)');
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
- __DEV__ && logger.info('[OTA] Triggering app restart…');
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
- __DEV__ && logger.debug(`[ScaleBun] Draining ${buffered.length} pre-init events into persistent queue`);
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) return;
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
- __DEV__ && logger.info('[ScaleBun] Debug auto-connected via init({ debug }).');
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
- __DEV__ && logger.info('[ScaleBun] Envelope event tracking disabled — no appId in init config.');
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
- __DEV__ && logger.info('[ScaleBun] Envelope event tracking started → /v1/batch.');
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
- __DEV__ && logger.info(`[Engage] debugFetchInAppMessages → ${messages.length} message(s), ${previews.length} test preview(s)`);
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
- __DEV__ && logger.debug('Tracking event:', name);
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
- __DEV__ && logger.debug('Flushing events from persistent queue...');
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
- __DEV__ && logger.debug('Performance feature linked from bootstrap');
1591
+ logger.debug('Performance feature linked from bootstrap');
1590
1592
  }
1591
- __DEV__ && logger.info('Debug enabled');
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
- __DEV__ && logger.info('session.start() ✅ sessionId:', sm.currentSessionId);
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
- __DEV__ && logger.info('session.end() ✅');
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
- __DEV__ && logger.debug('[report] Privacy config updated');
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
- __DEV__ && logger.debug('[report] Branding config updated');
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
- __DEV__ && logger.info('[ScaleBun] Navigation ref set → auto screen detection active');
1873
+ logger.info('[ScaleBun] Navigation ref set → auto screen detection active');
1872
1874
  } catch (err) {
1873
- __DEV__ && logger.debug('[ScaleBun] Navigation ref stashed (detector not ready):', err?.message);
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?: {