@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
@@ -137,12 +137,12 @@ async function deliverOtaEvents(params: {
137
137
  },
138
138
  );
139
139
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
140
- __DEV__ && logger.debug(`[OTA] Delivered ${items.length} telemetry event(s)`);
140
+ logger.debug(`[OTA] Delivered ${items.length} telemetry event(s)`);
141
141
  } catch (err: any) {
142
142
  // Failed delivery must not fail the update — but the events must not be
143
143
  // lost either: requeue so the next sync retries them.
144
144
  for (const e of events) otaEventEmitter.emit(e);
145
- __DEV__ && logger.debug(`[OTA] Telemetry delivery failed (will retry next sync): ${err?.message}`);
145
+ logger.debug(`[OTA] Telemetry delivery failed (will retry next sync): ${err?.message}`);
146
146
  }
147
147
  }
148
148
 
@@ -188,6 +188,8 @@ export class OtaOrchestrator {
188
188
  return this.currentBundle;
189
189
  }
190
190
  private isRestartRequiredState = false;
191
+ /** Root/jailbreak indicators observed at init; empty when none found. */
192
+ private integrityIndicators: string[] = [];
191
193
  private healthyTimer: ReturnType<typeof setTimeout> | null = null;
192
194
  private bootGuardConfig: BootGuardConfig = {};
193
195
  private environment: OtaEnvironment | null = null;
@@ -247,7 +249,7 @@ export class OtaOrchestrator {
247
249
  // We cannot see which form the host used — the identity check after
248
250
  // restart is what proves it — but naming the right fix up front turns
249
251
  // a confusing silent failure into a one-line correction.
250
- __DEV__ && logger.debug(
252
+ logger.debug(
251
253
  '[OTA] React Native >= 0.82 detected. The only valid Android integration is ' +
252
254
  'getDefaultReactHost(..., jsBundleFilePath = ScaleBunOtaModule.getJSBundleFile(ctx)) — ' +
253
255
  'ReactNativeHost and its getJSBundleFile() override no longer exist.',
@@ -256,8 +258,30 @@ export class OtaOrchestrator {
256
258
 
257
259
  this.initialized = true;
258
260
  this.enabled = true;
261
+
262
+ // Device integrity signal.
263
+ //
264
+ // Reported, not enforced. Root defeats any check made from inside the app,
265
+ // so this is not a boundary — it is context. It tells an operator that a
266
+ // device's OTA slot is writable by other processes, which is what makes the
267
+ // launch-time hash re-check in SlotManager meaningful rather than paranoid.
268
+ // A host app that wants to withhold updates on such devices can read
269
+ // `deviceIntegrityIndicators()` and decide for itself.
270
+ try {
271
+ const raw = NativeScaleBunOta?.getDeviceIntegrity?.();
272
+ this.integrityIndicators = raw ? (JSON.parse(raw) as string[]) : [];
273
+ if (this.integrityIndicators.length > 0) {
274
+ logger.warn(
275
+ `[OTA] Device integrity indicators present: ${this.integrityIndicators.join(', ')}. ` +
276
+ 'Updates still install and are still signature-verified, but an installed ' +
277
+ 'bundle on this device can be modified by other privileged processes.',
278
+ );
279
+ }
280
+ } catch {
281
+ this.integrityIndicators = [];
282
+ }
259
283
  this.bootGuardConfig = config ?? {};
260
- __DEV__ && logger.debug(
284
+ logger.debug(
261
285
  `[OTA] Orchestrator initialized (RN ${this.environment.rnVersionString ?? 'unknown'}` +
262
286
  `${this.environment.bridgeless ? ', bridgeless' : ''}` +
263
287
  `${this.environment.hermes ? `, Hermes HBC v${this.environment.hermesBytecodeVersion ?? '?'}` : ''})`,
@@ -292,6 +316,22 @@ export class OtaOrchestrator {
292
316
 
293
317
  // Prove the bundle we installed is the bundle that loaded.
294
318
  this.verifyRunningBundleIdentity();
319
+
320
+ // Boot-guard health is a LOCAL fact: this process booted from the staged
321
+ // bundle and did not crash. The heartbeat used to start only on the
322
+ // `action === 'NONE'` branch of sync(), so confirming a good bundle needed
323
+ // a successful network round-trip that returned exactly that answer — a
324
+ // device offline, rate-limited, or still being offered an update could
325
+ // never clear its marker, and the guard reverted a bundle that was working.
326
+ //
327
+ // It runs HERE, last, and is handed the slot state that was already read.
328
+ // Starting it earlier made it call getSlotState() itself, and that read is
329
+ // consume-on-read for the revert record — it swallowed the revert before
330
+ // checkBootGuardRecovery() could name the bundle that failed, so a
331
+ // crash-rollback went unreported. It also read bootGuardConfig before
332
+ // init() had assigned it, so healthyAfterMs silently fell back to the
333
+ // default.
334
+ this.startBootGuardHeartbeat(slotState);
295
335
  });
296
336
  }
297
337
 
@@ -331,7 +371,7 @@ export class OtaOrchestrator {
331
371
  releaseId: record.releaseId ?? undefined,
332
372
  sha256: record.sha256,
333
373
  } as OtaBundlePayload;
334
- __DEV__ && logger.debug(`[OTA] Running bundle v${record.version} (${record.bundleId})`);
374
+ logger.debug(`[OTA] Running bundle v${record.version} (${record.bundleId})`);
335
375
  return;
336
376
  }
337
377
 
@@ -339,7 +379,7 @@ export class OtaOrchestrator {
339
379
  // a reinstall of the app, cleared storage, or a bundle staged by an
340
380
  // older SDK. Report the hash so the server can still recognise it.
341
381
  this.currentBundle = { sha256: current.sha256 } as OtaBundlePayload;
342
- __DEV__ && logger.debug(
382
+ logger.debug(
343
383
  `[OTA] Running an OTA bundle (sha ${String(current.sha256).slice(0, 12)}…) with no local install record.`,
344
384
  );
345
385
  } catch {
@@ -391,7 +431,7 @@ export class OtaOrchestrator {
391
431
  if (!record.identityToken) return;
392
432
 
393
433
  if (running === record.identityToken) {
394
- __DEV__ && logger.debug('[OTA] Install verified — running bundle matches what was installed.');
434
+ logger.debug('[OTA] Install verified — running bundle matches what was installed.');
395
435
  this.retireIdentityToken(record);
396
436
  return;
397
437
  }
@@ -563,6 +603,14 @@ export class OtaOrchestrator {
563
603
  return this.enabled;
564
604
  }
565
605
 
606
+ /**
607
+ * Root/jailbreak indicators detected on this device, as stable reason strings.
608
+ * Empty means nothing was detected — not proof the device is clean.
609
+ */
610
+ deviceIntegrityIndicators(): string[] {
611
+ return [...this.integrityIndicators];
612
+ }
613
+
566
614
  isRestartRequired(): boolean {
567
615
  return this.isRestartRequiredState;
568
616
  }
@@ -698,7 +746,7 @@ export class OtaOrchestrator {
698
746
  return { status: 'ERROR', error: 'OTA native module disabled or missing' };
699
747
  }
700
748
 
701
- __DEV__ && logger.debug('[OTA] Sync started…');
749
+ logger.debug('[OTA] Sync started…');
702
750
 
703
751
  // The funnel's denominator, for devices already on an OTA bundle. Emitted
704
752
  // before the request, so a check that fails outright still counts as a
@@ -719,7 +767,7 @@ export class OtaOrchestrator {
719
767
  const checkRes = await this.checkForUpdate(params);
720
768
 
721
769
  if (checkRes.action === 'NONE') {
722
- __DEV__ && logger.debug('[OTA] App is up to date');
770
+ logger.debug('[OTA] App is up to date');
723
771
  // If we booted from an OTA bundle and it's healthy, clear the boot guard
724
772
  this.startBootGuardHeartbeat();
725
773
  return { status: 'UP_TO_DATE' };
@@ -770,7 +818,7 @@ export class OtaOrchestrator {
770
818
  version: bundle.version,
771
819
  });
772
820
  const downloadStart = Date.now();
773
- __DEV__ && logger.debug(`[OTA] Downloading update v${bundle.version}…`);
821
+ logger.debug(`[OTA] Downloading update v${bundle.version}…`);
774
822
 
775
823
  // Live progress: native tick → DOWNLOAD_PROGRESS (feeds useOtaUpdate) →
776
824
  // throttled POST to the live tracker. `bundle.size` is the authoritative
@@ -871,7 +919,7 @@ export class OtaOrchestrator {
871
919
 
872
920
  // Sprint 7: Try patch first if offered, fall back to full bundle
873
921
  if (bundle.patch && NativeScaleBunOta.stagePatch) {
874
- __DEV__ && logger.debug(
922
+ logger.debug(
875
923
  `[OTA] Patch available (${(bundle.patch.size / 1024).toFixed(0)} KB vs ` +
876
924
  `${(bundle.size / 1024).toFixed(0)} KB full) — trying patch…`,
877
925
  );
@@ -883,7 +931,7 @@ export class OtaOrchestrator {
883
931
  );
884
932
  if (staged) {
885
933
  patchUsed = true;
886
- __DEV__ && logger.debug('[OTA] Patch applied successfully');
934
+ logger.debug('[OTA] Patch applied successfully');
887
935
  } else {
888
936
  logger.warn('[OTA] Patch staging returned false — falling back to full bundle');
889
937
  }
@@ -972,7 +1020,7 @@ export class OtaOrchestrator {
972
1020
  releaseId: bundle.releaseId,
973
1021
  version: bundle.version,
974
1022
  });
975
- __DEV__ && logger.debug(`[OTA] Update v${bundle.version} installed successfully!`);
1023
+ logger.debug(`[OTA] Update v${bundle.version} installed successfully!`);
976
1024
 
977
1025
  // Deliver telemetry BEFORE a restart tears the JS runtime down —
978
1026
  // an INSTALLED event queued but not sent would vanish with the process.
@@ -1016,7 +1064,7 @@ export class OtaOrchestrator {
1016
1064
  * native boot-guard marker. If JS crashes before the timer fires, the
1017
1065
  * marker stays set and native code reverts on next cold start.
1018
1066
  */
1019
- private startBootGuardHeartbeat(): void {
1067
+ private startBootGuardHeartbeat(preRead?: Record<string, any> | null): void {
1020
1068
  if (!NativeScaleBunOta) return;
1021
1069
 
1022
1070
  // Cancel any existing timer
@@ -1026,22 +1074,28 @@ export class OtaOrchestrator {
1026
1074
  }
1027
1075
 
1028
1076
  try {
1029
- const stateJson = NativeScaleBunOta.getSlotState();
1030
- const state = JSON.parse(stateJson);
1077
+ // getSlotState() is CONSUME-ON-READ for the revert record, so a caller that
1078
+ // has already read it passes the parsed result down rather than letting
1079
+ // this read again and swallow a rollback. `null` is a real answer (module
1080
+ // absent or payload unreadable) — only `undefined` means "nobody read it".
1081
+ const state =
1082
+ preRead !== undefined
1083
+ ? preRead
1084
+ : (JSON.parse(NativeScaleBunOta.getSlotState()) as Record<string, any>);
1031
1085
 
1032
1086
  // Only run the heartbeat if boot-guard marker is present
1033
1087
  // (meaning we're running from a freshly-applied OTA bundle)
1034
- if (!state.bootMarkerPresent) {
1088
+ if (!state?.bootMarkerPresent) {
1035
1089
  return;
1036
1090
  }
1037
1091
 
1038
1092
  const healthyMs = this.bootGuardConfig.healthyAfterMs ?? DEFAULT_HEALTHY_AFTER_MS;
1039
- __DEV__ && logger.debug(`[OTA] Boot guard active — marking healthy after ${healthyMs}ms`);
1093
+ logger.debug(`[OTA] Boot guard active — marking healthy after ${healthyMs}ms`);
1040
1094
 
1041
1095
  this.healthyTimer = setTimeout(() => {
1042
1096
  noThrow(() => {
1043
1097
  NativeScaleBunOta?.markHealthy();
1044
- __DEV__ && logger.info('[OTA] Boot guard cleared — bundle marked healthy ✓');
1098
+ logger.info('[OTA] Boot guard cleared — bundle marked healthy ✓');
1045
1099
  this.healthyTimer = null;
1046
1100
 
1047
1101
  // The honest activation signal. INSTALLED is emitted optimistically,
@@ -1097,11 +1151,11 @@ export class OtaOrchestrator {
1097
1151
  /* the restart below makes cleanup moot */
1098
1152
  }
1099
1153
  this.resumeRestartArmed = false;
1100
- __DEV__ && logger.info('[OTA] App resumed — activating the staged update now');
1154
+ logger.info('[OTA] App resumed — activating the staged update now');
1101
1155
  this.restart();
1102
1156
  }
1103
1157
  });
1104
- __DEV__ && logger.debug('[OTA] Update staged — will activate on next resume (ON_NEXT_RESUME)');
1158
+ logger.debug('[OTA] Update staged — will activate on next resume (ON_NEXT_RESUME)');
1105
1159
  } catch {
1106
1160
  // AppState unavailable (tests, exotic hosts): the update still activates
1107
1161
  // on the next cold start, which is the documented fallback behaviour.
@@ -1121,7 +1175,7 @@ export class OtaOrchestrator {
1121
1175
  */
1122
1176
  restart(): void {
1123
1177
  noThrow(() => {
1124
- __DEV__ && logger.info('[OTA] Triggering app restart…');
1178
+ logger.info('[OTA] Triggering app restart…');
1125
1179
  if (NativeScaleBunOta && typeof NativeScaleBunOta.restartApp === 'function') {
1126
1180
  NativeScaleBunOta.restartApp();
1127
1181
  } else if (DevSettings && typeof DevSettings.reload === 'function') {
@@ -1105,6 +1105,56 @@ export class SessionManager {
1105
1105
  targetId?: string;
1106
1106
  screenName?: string;
1107
1107
  emitAutomaticAnalytics?: boolean;
1108
+ /**
1109
+ * RN-6 — the native↔JS join's own verdict, and it MUST be declared here.
1110
+ *
1111
+ * This signature ENUMERATES its fields rather than spreading, and the only caller
1112
+ * (ScaleBunDebugRoot's native listener) invokes it through `(sm as any)`. So a field
1113
+ * added at the call site and not added here is dropped in silence, with no type
1114
+ * error anywhere — which is precisely how these three nearly shipped as a no-op.
1115
+ *
1116
+ * 'exact' = one JS start was clearly closest. 'ambiguous' = two were within
1117
+ * CORRELATION_MARGIN_MS of each other, so target/ui/screenName were deliberately
1118
+ * withheld rather than guessed. 'none' = no start within tolerance, meaning the JS
1119
+ * handler never fired. The last two are kept distinct because they have different
1120
+ * fixes and collapsing them would hide which is happening in production.
1121
+ */
1122
+ correlation?: 'exact' | 'ambiguous' | 'none';
1123
+ correlationDeltaMs?: number | null;
1124
+ correlationRunnerUpDeltaMs?: number | null;
1125
+ /**
1126
+ * RN-1/RN-2 — which kind of identity `targetId` is. Declared here for the same reason as
1127
+ * the correlation fields above: this signature enumerates, and its only caller goes
1128
+ * through `(sm as any)`, so an undeclared field is dropped in silence.
1129
+ */
1130
+ targetSource?: 'testID' | 'path' | 'component' | 'none';
1131
+ /** RN-8: where the screen name came from. Declared here because this signature enumerates. */
1132
+ screenSource?: 'manual' | 'navigation' | 'none';
1133
+ /**
1134
+ * RN-13: the target control's rectangle in capture-base fractions. Declared here because
1135
+ * this signature ENUMERATES — the sixth field to need it, after three correlation fields,
1136
+ * targetSource and screenSource.
1137
+ */
1138
+ targetRect?: { x: number; y: number; width: number; height: number };
1139
+ /**
1140
+ * RN-3: content offset of the nearest ancestor scroller. Declared here because this
1141
+ * signature ENUMERATES — the seventh field to need it.
1142
+ */
1143
+ scrollX?: number;
1144
+ scrollY?: number;
1145
+ scrollSource?: 'wrapper' | 'none';
1146
+ /**
1147
+ * RN-4: list row identity. Declared here because this signature ENUMERATES — the eighth
1148
+ * and ninth fields to need it.
1149
+ */
1150
+ itemKey?: string;
1151
+ itemIndex?: number;
1152
+ /**
1153
+ * RN-15: tap position within the target control. Declared here because this signature
1154
+ * ENUMERATES — the tenth and eleventh fields to need it.
1155
+ */
1156
+ localU?: number;
1157
+ localV?: number;
1108
1158
  }): void {
1109
1159
  // Exact-duplicate suppression — see _lastGestureSig. Signature is the gesture type plus
1110
1160
  // raw coordinates verbatim; String() keeps undefined coords distinct from 0 (a payload
@@ -1136,6 +1186,69 @@ export class SessionManager {
1136
1186
  state_status: stateStatus,
1137
1187
  ui: details?.ui,
1138
1188
  target_id: details?.targetId,
1189
+ /**
1190
+ * RN-6 — shipped with the row so the join is auditable instead of assumed.
1191
+ *
1192
+ * Without it, an interaction with no `target_id` is ambiguous in the worst way:
1193
+ * it could mean the control genuinely had no identity, or that the correlation
1194
+ * refused and the identity exists but could not be attributed. Those are
1195
+ * different defects with different owners, and the accounting lane cannot give
1196
+ * an honest unplaceable REASON without knowing which.
1197
+ *
1198
+ * Undefined on the JS-fallback path (no native event, so nothing was joined),
1199
+ * which is itself the correct answer there rather than a fabricated 'exact'.
1200
+ */
1201
+ correlation: details?.correlation,
1202
+ correlation_delta_ms: details?.correlationDeltaMs,
1203
+ correlation_runner_up_delta_ms: details?.correlationRunnerUpDeltaMs,
1204
+ /**
1205
+ * RN-1/RN-2 — stored alongside `target_id` so a reader can tell an authored
1206
+ * identifier from a structural inference. 'path' means the app authored no testID
1207
+ * and the identity was derived from component names, which is useful but will
1208
+ * change under refactoring; a dashboard that presents the two identically invites
1209
+ * someone to trust a key that is about to move.
1210
+ */
1211
+ target_source: details?.targetSource,
1212
+ /**
1213
+ * RN-8 — stored so "13.8% of interactions have a screen" becomes answerable.
1214
+ * 'none' means the app instrumented nothing the SDK could see; 'manual' means a
1215
+ * <ScaleBunScreen> beacon; 'navigation' means the React Navigation hook. Three
1216
+ * different owners behind one number that today has none.
1217
+ */
1218
+ screen_source: details?.screenSource,
1219
+ /**
1220
+ * RN-13 — what makes "the heat is on this control" checkable instead of assumed.
1221
+ * Absent when the control could not be measured synchronously, which is a fact worth
1222
+ * storing: an unmeasurable control and a mis-placed tap are different findings.
1223
+ */
1224
+ target_rect: details?.targetRect,
1225
+ /**
1226
+ * RN-3 — the backend already reads scrollX/scrollY off the payload into the fact
1227
+ * table's own columns (it has done since the web SDK, which sends `sx`/`sy`), so
1228
+ * these need no migration. Mobile simply never sent them.
1229
+ *
1230
+ * `scroll_source` says whether an offset was available at all: 'none' means the app
1231
+ * uses a plain <ScrollView> rather than the SDK's wrapper, which is a coverage gap in
1232
+ * the integration and not a tap at the top of a list.
1233
+ */
1234
+ scrollX: details?.scrollX,
1235
+ scrollY: details?.scrollY,
1236
+ scroll_source: details?.scrollSource,
1237
+ /**
1238
+ * RN-4 — what stops a recycled native view merging two different rows into one
1239
+ * hotspot. The key is the app's own keyExtractor output, so it is stable across
1240
+ * scrolling and across sessions; the index is carried so a recycled slot (same index,
1241
+ * different key) can be proven rather than inferred.
1242
+ */
1243
+ item_key: details?.itemKey,
1244
+ item_index: details?.itemIndex,
1245
+ /**
1246
+ * RN-15 — where in the control, not just which control. Turns "they tapped Add to
1247
+ * Cart" into "they tapped its left edge", which is what tells you whether a hit area
1248
+ * is right. Outside 0..1 is a hitSlop tap and is kept as such.
1249
+ */
1250
+ local_u: details?.localU,
1251
+ local_v: details?.localV,
1139
1252
  gestureType,
1140
1253
  x: details?.x,
1141
1254
  y: details?.y,
@@ -235,7 +235,7 @@ class ScaleBunFacade {
235
235
  const realQueue = coreContainer.get<PersistentQueue>('PersistentQueue');
236
236
  const buffered = this._preInitBuffer.popBatch(this._preInitBuffer.length());
237
237
  if (buffered.length > 0) {
238
- __DEV__ && logger.debug(`[ScaleBun] Draining ${buffered.length} pre-init events into persistent queue`);
238
+ logger.debug(`[ScaleBun] Draining ${buffered.length} pre-init events into persistent queue`);
239
239
  for (const envelope of buffered) {
240
240
  realQueue.add(envelope);
241
241
  }
@@ -369,7 +369,9 @@ class ScaleBunFacade {
369
369
  }
370
370
  const clientKey = this._clientKey;
371
371
  const apiUrl = this._apiBaseUrl;
372
- if (!clientKey || !apiUrl) return;
372
+ if (!clientKey || !apiUrl) {
373
+ return;
374
+ }
373
375
 
374
376
  this._otaCheckInFlight = true;
375
377
  try {
@@ -430,7 +432,7 @@ class ScaleBunFacade {
430
432
  if (!dbgConfig) return;
431
433
  (dbgConfig as any).__skipDeprecationWarning = true;
432
434
  this.enableDebug(dbgConfig);
433
- __DEV__ && logger.info('[ScaleBun] Debug auto-connected via init({ debug }).');
435
+ logger.info('[ScaleBun] Debug auto-connected via init({ debug }).');
434
436
  } catch (err: any) {
435
437
  logger.warn('[ScaleBun] Auto debug connection failed:', err?.message);
436
438
  }
@@ -447,7 +449,7 @@ class ScaleBunFacade {
447
449
  const apiBaseUrl = this._apiBaseUrl;
448
450
  if (!enabled || !appId || !clientKey || !apiBaseUrl) {
449
451
  if (enabled && !appId) {
450
- __DEV__ && logger.info('[ScaleBun] Envelope event tracking disabled — no appId in init config.');
452
+ logger.info('[ScaleBun] Envelope event tracking disabled — no appId in init config.');
451
453
  }
452
454
  return;
453
455
  }
@@ -499,7 +501,7 @@ class ScaleBunFacade {
499
501
  onEvent: (eventName, revenue) => this._skanManager?.onEvent(eventName, revenue),
500
502
  });
501
503
  this._eventTracker.start();
502
- __DEV__ && logger.info('[ScaleBun] Envelope event tracking started → /v1/batch.');
504
+ logger.info('[ScaleBun] Envelope event tracking started → /v1/batch.');
503
505
 
504
506
  // Complete the device context from the native bridge.
505
507
  //
@@ -1174,7 +1176,7 @@ class ScaleBunFacade {
1174
1176
  const cfg = await (this._getEngageTransport()?.fetchEngageConfig(context) ?? Promise.resolve(null));
1175
1177
  const messages = cfg?.inAppMessages ?? [];
1176
1178
  const previews = messages.filter((m) => m.preview === true || m.isTest === true);
1177
- __DEV__ && logger.info(
1179
+ logger.info(
1178
1180
  `[Engage] debugFetchInAppMessages → ${messages.length} message(s), ${previews.length} test preview(s)`,
1179
1181
  );
1180
1182
  return messages;
@@ -1485,7 +1487,7 @@ class ScaleBunFacade {
1485
1487
  logger.warn('SDK not initialized. Event dropped:', name);
1486
1488
  return;
1487
1489
  }
1488
- __DEV__ && logger.debug('Tracking event:', name);
1490
+ logger.debug('Tracking event:', name);
1489
1491
 
1490
1492
  // Fan this event out to the Engage trigger engine. Same single funnel,
1491
1493
  // extra observer — lets behavioral triggers (custom_event, failed_action,
@@ -1650,7 +1652,7 @@ class ScaleBunFacade {
1650
1652
  ]);
1651
1653
  return;
1652
1654
  }
1653
- __DEV__ && logger.debug('Flushing events from persistent queue...');
1655
+ logger.debug('Flushing events from persistent queue...');
1654
1656
  const httpClient = coreContainer.get<HttpClient>('HttpClient');
1655
1657
  await flushQueue(this._getQueue(), httpClient, getDeviceId());
1656
1658
  });
@@ -1678,10 +1680,10 @@ class ScaleBunFacade {
1678
1680
  const bootstrapPerfFeature = _getPerformanceFeature();
1679
1681
  if (bootstrapPerfFeature) {
1680
1682
  this._performanceFeature = bootstrapPerfFeature;
1681
- __DEV__ && logger.debug('Performance feature linked from bootstrap');
1683
+ logger.debug('Performance feature linked from bootstrap');
1682
1684
  }
1683
1685
 
1684
- __DEV__ && logger.info('Debug enabled');
1686
+ logger.info('Debug enabled');
1685
1687
  });
1686
1688
  }
1687
1689
 
@@ -1836,14 +1838,14 @@ class ScaleBunFacade {
1836
1838
  return false;
1837
1839
  }
1838
1840
  sm.startSession(metadata as Parameters<SessionManager['startSession']>[0]);
1839
- __DEV__ && logger.info('session.start() ✅ sessionId:', sm.currentSessionId);
1841
+ logger.info('session.start() ✅ sessionId:', sm.currentSessionId);
1840
1842
  return true;
1841
1843
  },
1842
1844
  end: (reason?: 'ended' | 'timeout'): boolean => {
1843
1845
  const sm = SessionManager.getExistingInstance();
1844
1846
  if (!sm || !sm.isActive) return false;
1845
1847
  sm.endSession(reason);
1846
- __DEV__ && logger.info('session.end() ✅');
1848
+ logger.info('session.end() ✅');
1847
1849
  return true;
1848
1850
  },
1849
1851
  emitEvent: (type: string, data?: Record<string, unknown>) => {
@@ -1899,13 +1901,13 @@ class ScaleBunFacade {
1899
1901
  /** Set SDK-wide privacy settings for report generation. */
1900
1902
  setPrivacy: (config: PrivacyConfig): void => {
1901
1903
  this._privacyConfig = { ...this._privacyConfig, ...config };
1902
- __DEV__ && logger.debug('[report] Privacy config updated');
1904
+ logger.debug('[report] Privacy config updated');
1903
1905
  },
1904
1906
 
1905
1907
  /** Set branding for exported reports. */
1906
1908
  setBranding: (config: BrandingConfig): void => {
1907
1909
  this._brandingConfig = { ...this._brandingConfig, ...config };
1908
- __DEV__ && logger.debug('[report] Branding config updated');
1910
+ logger.debug('[report] Branding config updated');
1909
1911
  },
1910
1912
 
1911
1913
  /** Get current privacy config. */
@@ -1956,9 +1958,9 @@ class ScaleBunFacade {
1956
1958
  try {
1957
1959
  const { AutoScreenDetector } = require('../features/navigation/AutoScreenDetector');
1958
1960
  AutoScreenDetector.getInstance().setNavigationRef(ref);
1959
- __DEV__ && logger.info('[ScaleBun] Navigation ref set → auto screen detection active');
1961
+ logger.info('[ScaleBun] Navigation ref set → auto screen detection active');
1960
1962
  } catch (err: any) {
1961
- __DEV__ && logger.debug('[ScaleBun] Navigation ref stashed (detector not ready):', err?.message);
1963
+ logger.debug('[ScaleBun] Navigation ref stashed (detector not ready):', err?.message);
1962
1964
  }
1963
1965
  });
1964
1966
  }
@@ -334,6 +334,16 @@ export interface SimplifiedInitConfig {
334
334
  appId?: string;
335
335
  /** SDK client key (SaaS mode). Also used to authenticate the envelope lane. */
336
336
  clientKey?: string;
337
+ /**
338
+ * Patch React Native's ScrollView export so every scroll view reports its scroll offset,
339
+ * giving taps their content depth without the app swapping to ScaleBunScrollView.
340
+ *
341
+ * OFF BY DEFAULT because it changes what the host renders, not merely what the SDK records:
342
+ * every scroll view in the app — including ones inside libraries you did not write — routes
343
+ * through an SDK wrapper. Turn it on deliberately. With it off, taps on a plain ScrollView
344
+ * report no content depth and say so, rather than reporting a fabricated offset of 0.
345
+ */
346
+ autoInstrumentScrollViews?: boolean;
337
347
  /** Platform OVERRIDE — auto-detected from Platform.OS when omitted (integrations should omit it). */
338
348
  platform?: 'ios' | 'android' | 'web' | 'react_native';
339
349
  /**
@@ -34,6 +34,9 @@ export interface Spec extends TurboModule {
34
34
  */
35
35
  getTrustedKeyIds(): string;
36
36
 
37
+ /** JSON array of root/jailbreak indicator strings; [] when none found. */
38
+ getDeviceIntegrity(): string;
39
+
37
40
  /**
38
41
  * Returns JSON with current slot state:
39
42
  * { current: { bundleId, version, sha256, installedAt } | null,