@scalebun/react-native 1.10.6 → 1.11.0

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 (75) hide show
  1. package/android/src/main/java/com/scalebun/replaysdk/tracking/InteractionTracker.kt +25 -24
  2. package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaModule.kt +75 -0
  3. package/android/src/oldarch/java/com/scalebun/rn/ota/ScaleBunOtaSpec.kt +12 -0
  4. package/dist/scalebun.full.js +653 -223
  5. package/dist/scalebun.slim.js +652 -222
  6. package/ios/Capture/InteractionTracker.swift +8 -4
  7. package/ios/Ota/ScaleBunOtaBridge.mm +6 -0
  8. package/ios/Ota/ScaleBunOtaModule.swift +67 -0
  9. package/lib/commonjs/analytics/EventTracker.js +5 -5
  10. package/lib/commonjs/analytics/automaticEvents.js +3 -2
  11. package/lib/commonjs/core/config/schema.js +34 -1
  12. package/lib/commonjs/core/constants/version.js +7 -2
  13. package/lib/commonjs/features/journey/ScaleBunDebugRoot.js +80 -79
  14. package/lib/commonjs/features/journey/interactionProtocol.js +47 -0
  15. package/lib/commonjs/features/journey/uiState.js +94 -0
  16. package/lib/commonjs/features/ota/crypto/builtinVerifier.js +248 -0
  17. package/lib/commonjs/features/ota/crypto/loadEd25519.js +40 -0
  18. package/lib/commonjs/features/ota/crypto/loadSha512.js +40 -0
  19. package/lib/commonjs/features/ota/crypto/nativeVerifier.js +121 -0
  20. package/lib/commonjs/features/ota/signature.js +87 -27
  21. package/lib/commonjs/features/session/JourneyEventPipeline.js +6 -5
  22. package/lib/commonjs/features/session/SessionManager.js +37 -38
  23. package/lib/commonjs/metro/serializerCompose.js +32 -0
  24. package/lib/commonjs/public/ScaleBunFacade.js +74 -11
  25. package/lib/module/analytics/EventTracker.js +5 -5
  26. package/lib/module/analytics/automaticEvents.js +3 -2
  27. package/lib/module/core/config/schema.js +34 -1
  28. package/lib/module/core/constants/version.js +7 -2
  29. package/lib/module/features/journey/ScaleBunDebugRoot.js +80 -79
  30. package/lib/module/features/journey/interactionProtocol.js +38 -0
  31. package/lib/module/features/journey/uiState.js +86 -0
  32. package/lib/module/features/ota/crypto/builtinVerifier.js +240 -0
  33. package/lib/module/features/ota/crypto/loadEd25519.js +34 -0
  34. package/lib/module/features/ota/crypto/loadSha512.js +34 -0
  35. package/lib/module/features/ota/crypto/nativeVerifier.js +113 -0
  36. package/lib/module/features/ota/signature.js +87 -27
  37. package/lib/module/features/session/JourneyEventPipeline.js +6 -5
  38. package/lib/module/features/session/SessionManager.js +37 -38
  39. package/lib/module/metro/serializerCompose.js +32 -0
  40. package/lib/module/public/ScaleBunFacade.js +74 -11
  41. package/lib/typescript/analytics/EventTracker.d.ts +1 -1
  42. package/lib/typescript/analytics/automaticEvents.d.ts +3 -1
  43. package/lib/typescript/core/config/schema.d.ts +2 -0
  44. package/lib/typescript/core/constants/version.d.ts +7 -2
  45. package/lib/typescript/features/journey/interactionProtocol.d.ts +21 -0
  46. package/lib/typescript/features/journey/uiState.d.ts +53 -0
  47. package/lib/typescript/features/ota/OtaTypes.d.ts +50 -0
  48. package/lib/typescript/features/ota/crypto/builtinVerifier.d.ts +53 -0
  49. package/lib/typescript/features/ota/crypto/loadEd25519.d.ts +30 -0
  50. package/lib/typescript/features/ota/crypto/loadSha512.d.ts +15 -0
  51. package/lib/typescript/features/ota/crypto/nativeVerifier.d.ts +35 -0
  52. package/lib/typescript/features/ota/signature.d.ts +22 -7
  53. package/lib/typescript/features/session/JourneyEventPipeline.d.ts +1 -0
  54. package/lib/typescript/features/session/SessionManager.d.ts +15 -10
  55. package/lib/typescript/public/ScaleBunFacade.d.ts +35 -6
  56. package/lib/typescript/specs/NativeScaleBunOta.d.ts +23 -0
  57. package/package.json +19 -3
  58. package/src/analytics/EventTracker.ts +5 -5
  59. package/src/analytics/automaticEvents.ts +4 -0
  60. package/src/core/config/schema.ts +30 -3
  61. package/src/core/constants/version.ts +7 -2
  62. package/src/features/journey/ScaleBunDebugRoot.tsx +96 -75
  63. package/src/features/journey/interactionProtocol.ts +65 -0
  64. package/src/features/journey/uiState.ts +89 -0
  65. package/src/features/ota/OtaTypes.ts +51 -0
  66. package/src/features/ota/crypto/builtinVerifier.ts +257 -0
  67. package/src/features/ota/crypto/loadEd25519.ts +41 -0
  68. package/src/features/ota/crypto/loadSha512.ts +35 -0
  69. package/src/features/ota/crypto/nativeVerifier.ts +117 -0
  70. package/src/features/ota/signature.ts +108 -25
  71. package/src/features/session/JourneyEventPipeline.ts +7 -5
  72. package/src/features/session/SessionManager.ts +75 -38
  73. package/src/metro/serializerCompose.ts +38 -2
  74. package/src/public/ScaleBunFacade.ts +87 -13
  75. package/src/specs/NativeScaleBunOta.ts +24 -0
@@ -17,8 +17,8 @@ class InteractionTracker: NSObject, UIGestureRecognizerDelegate {
17
17
 
18
18
  private var touchDownLocation: CGPoint = .zero
19
19
  private var touchDownTime: TimeInterval = 0
20
- private var lastEmitTime: TimeInterval = 0
21
- private let throttleInterval: TimeInterval = 0.15
20
+ private var touchInteractionId = ""
21
+ private var lastEmittedInteractionId = ""
22
22
 
23
23
  func attach(to window: UIWindow, sendEvent: @escaping (String, [String: Any]) -> Void) {
24
24
  guard !isActive else { return }
@@ -29,6 +29,7 @@ class InteractionTracker: NSObject, UIGestureRecognizerDelegate {
29
29
  onBegan: { [weak self] location in
30
30
  self?.touchDownLocation = location
31
31
  self?.touchDownTime = Date().timeIntervalSince1970
32
+ self?.touchInteractionId = "ixn-i-\(UUID().uuidString.lowercased())"
32
33
  },
33
34
  onEnded: { [weak self] location in
34
35
  self?.handleTouchEnded(at: location, in: window)
@@ -55,8 +56,8 @@ class InteractionTracker: NSObject, UIGestureRecognizerDelegate {
55
56
 
56
57
  private func handleTouchEnded(at location: CGPoint, in window: UIWindow) {
57
58
  let now = Date().timeIntervalSince1970
58
- guard now - lastEmitTime >= throttleInterval else { return }
59
- lastEmitTime = now
59
+ guard touchInteractionId != lastEmittedInteractionId else { return }
60
+ lastEmittedInteractionId = touchInteractionId
60
61
 
61
62
  let durationMs = (now - touchDownTime) * 1000
62
63
  let dx = location.x - touchDownLocation.x
@@ -104,6 +105,9 @@ class InteractionTracker: NSObject, UIGestureRecognizerDelegate {
104
105
 
105
106
  let event: [String: Any] = [
106
107
  "gestureType": gestureType,
108
+ "interactionId": touchInteractionId,
109
+ "interactionProtocol": 1,
110
+ "occurredAt": touchDownTime * 1000,
107
111
  "rawX": downInCapture.x,
108
112
  "rawY": downInCapture.y,
109
113
  "normalizedX": nX,
@@ -40,6 +40,12 @@ RCT_EXTERN_METHOD(revertToPrevious:(RCTPromiseResolveBlock)resolve
40
40
 
41
41
  RCT_EXTERN_METHOD(restartApp)
42
42
 
43
+ RCT_EXTERN_METHOD(verifyEd25519:(NSString *)messageHex
44
+ signatureHex:(NSString *)signatureHex
45
+ publicKeyHex:(NSString *)publicKeyHex
46
+ resolve:(RCTPromiseResolveBlock)resolve
47
+ reject:(RCTPromiseRejectBlock)reject)
48
+
43
49
  @end
44
50
 
45
51
  // Download-progress event channel. Swift subclass of RCTEventEmitter; only
@@ -1,6 +1,7 @@
1
1
  import Foundation
2
2
  import React
3
3
  import CommonCrypto
4
+ import CryptoKit
4
5
 
5
6
  /**
6
7
  * React Native bridge for OTA bundle management (`ScaleBunOta`) on iOS.
@@ -252,4 +253,70 @@ class ScaleBunOtaModule: NSObject, RCTBridgeModule {
252
253
  RCTTriggerReloadCommandListeners("ScaleBun OTA update")
253
254
  }
254
255
  }
256
+
257
+ /**
258
+ * Verify a detached ed25519 signature with PLATFORM crypto (CryptoKit).
259
+ *
260
+ * Why native: the JS verifier lives inside the very bundle it protects —
261
+ * one malicious bundle can neuter it for every update after. CryptoKit is
262
+ * outside the bundle's reach, and unlike Android there is no OS floor to
263
+ * worry about: Curve25519 signing shipped in iOS 13 and the podspec
264
+ * already requires 13.4.
265
+ *
266
+ * Contract (mirrors `src/specs/NativeScaleBunOta.ts`): inputs are
267
+ * lowercase hex pre-validated by the JS caller; the message is the RAW 32
268
+ * BYTES of the bundle SHA-256 (the CLI signs digest bytes, not hex text);
269
+ * resolves 'valid' / 'invalid' / 'unavailable'. A wrong signature is
270
+ * 'invalid', never a rejection; anything unexpected is 'unavailable' so
271
+ * machinery failure can never read as a pass.
272
+ *
273
+ * ⚪ UNCOMPILED — no macOS toolchain has been available in this workspace
274
+ * (the standing gap: BsPatch.swift shipped the same way). The algorithm is
275
+ * pinned by `tools/ed25519-parity` against the live production fixture;
276
+ * this file still needs one Xcode build before an iOS consumer ships it.
277
+ */
278
+ @objc(verifyEd25519:signatureHex:publicKeyHex:resolve:reject:)
279
+ func verifyEd25519(_ messageHex: NSString,
280
+ signatureHex: NSString,
281
+ publicKeyHex: NSString,
282
+ resolve: @escaping RCTPromiseResolveBlock,
283
+ reject: @escaping RCTPromiseRejectBlock) {
284
+ guard let message = ScaleBunOtaModule.bytesFromHex(messageHex as String),
285
+ let signature = ScaleBunOtaModule.bytesFromHex(signatureHex as String),
286
+ let rawKey = ScaleBunOtaModule.bytesFromHex(publicKeyHex as String),
287
+ rawKey.count == 32 else {
288
+ // Malformed input from our own caller is not a forgery verdict.
289
+ resolve("unavailable")
290
+ return
291
+ }
292
+ do {
293
+ let key = try Curve25519.Signing.PublicKey(rawRepresentation: rawKey)
294
+ resolve(key.isValidSignature(signature, for: message) ? "valid" : "invalid")
295
+ } catch {
296
+ // Bytes that are not a curve point cannot have signed anything.
297
+ resolve("invalid")
298
+ }
299
+ }
300
+
301
+ /// Hex → Data; nil for odd length or a non-hex character.
302
+ private static func bytesFromHex(_ hex: String) -> Data? {
303
+ let chars = Array(hex.utf8)
304
+ guard !chars.isEmpty, chars.count % 2 == 0 else { return nil }
305
+ var out = Data(capacity: chars.count / 2)
306
+ for i in stride(from: 0, to: chars.count, by: 2) {
307
+ guard let hi = ScaleBunOtaModule.hexNibble(chars[i]),
308
+ let lo = ScaleBunOtaModule.hexNibble(chars[i + 1]) else { return nil }
309
+ out.append((hi << 4) | lo)
310
+ }
311
+ return out
312
+ }
313
+
314
+ private static func hexNibble(_ c: UInt8) -> UInt8? {
315
+ switch c {
316
+ case 0x30...0x39: return c - 0x30 // 0-9
317
+ case 0x61...0x66: return c - 0x61 + 10 // a-f
318
+ case 0x41...0x46: return c - 0x41 + 10 // A-F
319
+ default: return nil
320
+ }
321
+ }
255
322
  }
@@ -103,7 +103,7 @@ class EventTracker {
103
103
  this.started = true;
104
104
  if (this.cfg.automaticEventTracking) {
105
105
  this.automaticEventsUnsubscribe = (0, _automaticEvents.subscribeAutomaticEvents)(event => {
106
- this.track(event.name, event.properties);
106
+ this.track(event.name, event.properties, event.timestamp);
107
107
  });
108
108
  }
109
109
  if (this.cfg.autoLifecycleEvents) {
@@ -226,9 +226,9 @@ class EventTracker {
226
226
 
227
227
  // ─── tracking ────────────────────────────────────────────────────────────
228
228
 
229
- track(eventName, properties) {
229
+ track(eventName, properties, timestamp) {
230
230
  try {
231
- this.enqueue(this.buildEnvelope(eventName, properties));
231
+ this.enqueue(this.buildEnvelope(eventName, properties, timestamp));
232
232
  try {
233
233
  this.cfg.onEvent?.(eventName);
234
234
  } catch {/* no-throw */}
@@ -365,7 +365,7 @@ class EventTracker {
365
365
 
366
366
  // ─── internals ─────────────────────────────────────────────────────────────
367
367
 
368
- buildEnvelope(eventName, properties) {
368
+ buildEnvelope(eventName, properties, timestamp) {
369
369
  const ctx = this.cfg.context ?? {};
370
370
  let canonicalSessionId;
371
371
  try {
@@ -376,7 +376,7 @@ class EventTracker {
376
376
  const env = {
377
377
  event_id: uuid(),
378
378
  event_name: eventName,
379
- event_time: Date.now(),
379
+ event_time: timestamp ?? Date.now(),
380
380
  app_id: this.cfg.appId,
381
381
  platform: this.cfg.platform ?? (0, _device.resolveEventPlatform)(),
382
382
  installation_id: this.installationId,
@@ -27,10 +27,11 @@ function compactProperties(properties) {
27
27
  }
28
28
  return out;
29
29
  }
30
- function emitAutomaticEvent(name, properties) {
30
+ function emitAutomaticEvent(name, properties, timestamp) {
31
31
  const event = {
32
32
  name,
33
- properties: compactProperties(properties)
33
+ properties: compactProperties(properties),
34
+ timestamp
34
35
  };
35
36
  if (listeners.size === 0) {
36
37
  pending.push(event);
@@ -295,6 +295,14 @@ const SHAPE = {
295
295
  kind: 'str',
296
296
  opt: true
297
297
  },
298
+ // Rotation support: several pinned keys, any of which may verify a
299
+ // bundle. Ship a build pinning [old, new], re-sign server-side with
300
+ // new, drop old next release — no emergency store submission when a
301
+ // key must be replaced. Merged with publicSigningKey by the facade.
302
+ publicSigningKeys: {
303
+ kind: 'strArr',
304
+ opt: true
305
+ },
298
306
  mandatoryBlocksUi: bool(false)
299
307
  }
300
308
  }
@@ -334,7 +342,7 @@ function parseField(field, input, path, issues) {
334
342
  present: true
335
343
  };
336
344
  // Required with no default (matches the base schemas' type errors on undefined).
337
- return fail(field.kind === 'bool' ? 'Expected boolean, received undefined' : field.kind === 'num' ? 'Expected number, received undefined' : field.kind === 'str' ? 'Expected string, received undefined' : field.kind === 'enum' ? "Invalid enum value. Expected " + field.values.map(v => `'${v}'`).join(' | ') : 'Expected object, received undefined');
345
+ return fail(field.kind === 'bool' ? 'Expected boolean, received undefined' : field.kind === 'num' ? 'Expected number, received undefined' : field.kind === 'str' ? 'Expected string, received undefined' : field.kind === 'strArr' ? 'Expected array, received undefined' : field.kind === 'enum' ? "Invalid enum value. Expected " + field.values.map(v => `'${v}'`).join(' | ') : 'Expected object, received undefined');
338
346
  }
339
347
  switch (field.kind) {
340
348
  case 'any':
@@ -360,6 +368,31 @@ function parseField(field, input, path, issues) {
360
368
  value: input,
361
369
  present: true
362
370
  };
371
+ case 'strArr':
372
+ {
373
+ // Mirrors the vendored ArraySchema exactly: element failures are
374
+ // reported at their index and fail the whole field (no partial
375
+ // arrays reach the output).
376
+ if (!Array.isArray(input)) return fail(`Expected array, received ${typeofName(input)}`);
377
+ let allOk = true;
378
+ for (let i = 0; i < input.length; i++) {
379
+ if (typeof input[i] !== 'string') {
380
+ issues.push({
381
+ path: [...path, i],
382
+ message: `Expected string, received ${typeofName(input[i])}`
383
+ });
384
+ allOk = false;
385
+ }
386
+ }
387
+ return allOk ? {
388
+ ok: true,
389
+ value: [...input],
390
+ present: true
391
+ } : {
392
+ ok: false,
393
+ present: true
394
+ };
395
+ }
363
396
  case 'num':
364
397
  if (typeof input !== 'number' || Number.isNaN(input)) {
365
398
  return fail(`Expected number, received ${typeofName(input)}`);
@@ -7,7 +7,12 @@ exports.SDK_VERSION = void 0;
7
7
  /**
8
8
  * ScaleBun SDK version. Sent with the session-start envelope so the dashboard
9
9
  * can attribute telemetry to the SDK build that produced it.
10
- * Keep in sync with package.json "version".
10
+ * Keep in sync with package.json "version" — `version.test.ts` fails when they drift.
11
+ *
12
+ * Why the test matters: the 1.11.0 bump missed this line, so the build would have reported itself as
13
+ * 1.10.6. Every "is the release live, and on what share of traffic" question is answered from this
14
+ * value, so a stale one makes a rollout unobservable — which is the exact problem sending an SDK
15
+ * version was introduced to solve.
11
16
  */
12
- const SDK_VERSION = exports.SDK_VERSION = '1.10.6';
17
+ const SDK_VERSION = exports.SDK_VERSION = '1.11.0';
13
18
  //# sourceMappingURL=version.js.map
@@ -8,9 +8,9 @@ exports.ScaleBunScreen = ScaleBunScreen;
8
8
  exports.useScaleBunScreen = useScaleBunScreen;
9
9
  var _react = _interopRequireWildcard(require("react"));
10
10
  var _reactNative = require("react-native");
11
- var _automaticEvents = require("../../analytics/automaticEvents");
12
11
  var _touchTarget = require("./touchTarget");
13
12
  var _device = require("../../core/context/device");
13
+ var _interactionProtocol = require("./interactionProtocol");
14
14
  var _jsxRuntime = require("react/jsx-runtime");
15
15
  function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
16
16
  /**
@@ -264,7 +264,9 @@ function ScaleBunDebugRoot({
264
264
  // and forwards them to SessionManager as USER_ACTION events.
265
265
  const lastNativeTouchTsRef = (0, _react.useRef)(0);
266
266
  const nativeTrackingConfirmedRef = (0, _react.useRef)(false);
267
- const pendingJsEmitRef = (0, _react.useRef)(null);
267
+ const touchStartRef = (0, _react.useRef)(null);
268
+ const interactionStartsRef = (0, _react.useRef)([]);
269
+ const pendingJsEmitsRef = (0, _react.useRef)(new Map());
268
270
  (0, _react.useEffect)(() => {
269
271
  let subscription = null;
270
272
  try {
@@ -276,11 +278,15 @@ function ScaleBunDebugRoot({
276
278
  // Record timestamp to suppress duplicate JS touch events
277
279
  lastNativeTouchTsRef.current = Date.now();
278
280
  nativeTrackingConfirmedRef.current = true;
279
-
280
- // Cancel any pending JS emission — native event wins
281
- if (pendingJsEmitRef.current !== null) {
282
- clearTimeout(pendingJsEmitRef.current);
283
- pendingJsEmitRef.current = null;
281
+ const nativeOccurredAt = typeof event.occurredAt === 'number' ? event.occurredAt : (typeof event.timestamp === 'number' ? event.timestamp : Date.now()) - (typeof event.durationMs === 'number' ? event.durationMs : 0);
282
+ const start = (0, _interactionProtocol.nearestInteractionStart)(interactionStartsRef.current, nativeOccurredAt);
283
+
284
+ // Cancel only this physical touch's fallback. A single timer used to cancel a
285
+ // previous rapid tap and silently lose it on devices without native tracking.
286
+ if (start) {
287
+ const pending = pendingJsEmitsRef.current.get(start.interactionId);
288
+ if (pending !== undefined) clearTimeout(pending);
289
+ pendingJsEmitsRef.current.delete(start.interactionId);
284
290
  }
285
291
  const {
286
292
  SessionManager
@@ -302,6 +308,15 @@ function ScaleBunDebugRoot({
302
308
  _diagWinH = _d.height;
303
309
  } catch {/* no-throw */}
304
310
  sm.onGestureDetected(event.gestureType || 'tap', {
311
+ interactionId: event.interactionId || start?.interactionId || (0, _interactionProtocol.generateInteractionId)(),
312
+ interactionProtocol: event.interactionProtocol || _interactionProtocol.INTERACTION_PROTOCOL_VERSION,
313
+ occurredAt: nativeOccurredAt,
314
+ ui: start?.ui,
315
+ stateStatus: start?.stateStatus ?? 'not_captured',
316
+ target: start?.target,
317
+ targetId: start?.targetId,
318
+ screenName: start?.screenName,
319
+ emitAutomaticAnalytics: start?.emitAutomaticAnalytics ?? captureAutomaticInteractions,
305
320
  x: event.rawX,
306
321
  y: event.rawY,
307
322
  // Use the actual native end coords when present, not the down
@@ -331,11 +346,12 @@ function ScaleBunDebugRoot({
331
346
  try {
332
347
  subscription?.remove();
333
348
  } catch {/* no-throw */}
349
+ for (const timer of pendingJsEmitsRef.current.values()) clearTimeout(timer);
350
+ pendingJsEmitsRef.current.clear();
334
351
  };
335
- }, []);
352
+ }, [captureAutomaticInteractions]);
336
353
 
337
354
  // ─── Touch Tracking State ───────────────────────────────────────────
338
- const touchStartRef = (0, _react.useRef)(null);
339
355
 
340
356
  // On-screen rect of the root view — the EXACT view the native screenshot
341
357
  // captures. Normalizing pageX/pageY against this rect makes JS-fallback taps
@@ -379,11 +395,37 @@ function ScaleBunDebugRoot({
379
395
  const handleTouchStart = e => {
380
396
  try {
381
397
  const touch = e.nativeEvent;
382
- touchStartRef.current = {
398
+ const target = (0, _touchTarget.resolveTouchTarget)(e);
399
+ let ui;
400
+ let screenName;
401
+ try {
402
+ const {
403
+ uiStateSignature
404
+ } = require('./uiState');
405
+ ui = uiStateSignature();
406
+ } catch {/* no-throw */}
407
+ try {
408
+ const {
409
+ AutoScreenDetector
410
+ } = require('../navigation/AutoScreenDetector');
411
+ screenName = AutoScreenDetector.getInstance().getCurrentScreen() || undefined;
412
+ } catch {/* no-throw */}
413
+ const start = {
414
+ interactionId: (0, _interactionProtocol.generateInteractionId)(),
415
+ occurredAt: Date.now(),
383
416
  x: touch.pageX,
384
417
  y: touch.pageY,
385
- ts: Date.now()
418
+ locationX: touch.locationX,
419
+ locationY: touch.locationY,
420
+ target: (0, _touchTarget.describeTouchTarget)(target),
421
+ targetId: target?.testID,
422
+ screenName,
423
+ ui,
424
+ stateStatus: ui ? 'captured_nonempty' : 'not_instrumented',
425
+ emitAutomaticAnalytics: captureAutomaticInteractions
386
426
  };
427
+ touchStartRef.current = start;
428
+ interactionStartsRef.current = interactionStartsRef.current.filter(candidate => start.occurredAt - candidate.occurredAt < 5000).concat(start).slice(-8);
387
429
  } catch {/* no-throw */}
388
430
  };
389
431
  const handleTouchEnd = e => {
@@ -422,15 +464,23 @@ function ScaleBunDebugRoot({
422
464
  // WHAT was tapped, resolved from the React fiber on the touch event. Without this the tap
423
465
  // carries only coordinates, so every tap in the app groups into one row per gesture type.
424
466
  // Defensive by construction — returns undefined rather than throwing (see touchTarget.ts).
425
- const tapped = (0, _touchTarget.describeTouchTarget)((0, _touchTarget.resolveTouchTarget)(e));
467
+ const tapped = start?.target ?? (0, _touchTarget.describeTouchTarget)((0, _touchTarget.resolveTouchTarget)(e));
426
468
  const gestureDetails = {
427
469
  target: tapped,
428
- x: touch.pageX,
429
- y: touch.pageY,
430
- pageX: touch.pageX,
431
- pageY: touch.pageY,
432
- locationX: touch.locationX,
433
- locationY: touch.locationY,
470
+ interactionId: start?.interactionId ?? (0, _interactionProtocol.generateInteractionId)(),
471
+ interactionProtocol: _interactionProtocol.INTERACTION_PROTOCOL_VERSION,
472
+ occurredAt: start?.occurredAt ?? Date.now(),
473
+ ui: start?.ui,
474
+ stateStatus: start?.stateStatus ?? 'not_captured',
475
+ targetId: start?.targetId,
476
+ screenName: start?.screenName,
477
+ emitAutomaticAnalytics: start?.emitAutomaticAnalytics ?? captureAutomaticInteractions,
478
+ x: start?.x ?? touch.pageX,
479
+ y: start?.y ?? touch.pageY,
480
+ pageX: start?.x ?? touch.pageX,
481
+ pageY: start?.y ?? touch.pageY,
482
+ locationX: start?.locationX ?? touch.locationX,
483
+ locationY: start?.locationY ?? touch.locationY,
434
484
  viewportWidth: vpW > 0 ? vpW : undefined,
435
485
  viewportHeight: vpH > 0 ? vpH : undefined
436
486
  };
@@ -442,8 +492,8 @@ function ScaleBunDebugRoot({
442
492
  // measureInWindow returns rootRect.y = statusBarHeight, so subtracting it
443
493
  // double-counts the status bar and pushes every marker upward.
444
494
  if (rootRect && rootRect.w > 0 && rootRect.h > 0) {
445
- const nx = Math.max(0, Math.min(1, touch.pageX / rootRect.w));
446
- const ny = Math.max(0, Math.min(1, touch.pageY / rootRect.h));
495
+ const nx = Math.max(0, Math.min(1, (start?.x ?? touch.pageX) / rootRect.w));
496
+ const ny = Math.max(0, Math.min(1, (start?.y ?? touch.pageY) / rootRect.h));
447
497
  gestureDetails.normalizedX = nx;
448
498
  gestureDetails.normalizedY = ny;
449
499
  gestureDetails.normalizedPrecomputed = true;
@@ -475,7 +525,7 @@ function ScaleBunDebugRoot({
475
525
  const dx = touch.pageX - start.x;
476
526
  const dy = touch.pageY - start.y;
477
527
  const dist = Math.sqrt(dx * dx + dy * dy);
478
- const duration = Date.now() - start.ts;
528
+ const duration = Date.now() - start.occurredAt;
479
529
  gestureDetails.duration = duration;
480
530
  if (dist >= 15) {
481
531
  // Movement gesture: swipe or scroll
@@ -494,28 +544,6 @@ function ScaleBunDebugRoot({
494
544
  gestureType = 'long_press';
495
545
  }
496
546
  }
497
- const targetInfo = _extractTarget(e);
498
- gestureDetails.target = targetInfo?.testId || targetInfo?.accessibilityLabel;
499
- if (captureAutomaticInteractions) {
500
- let screen;
501
- try {
502
- const {
503
- AutoScreenDetector
504
- } = require('../navigation/AutoScreenDetector');
505
- screen = AutoScreenDetector.getInstance().getCurrentScreen() || undefined;
506
- } catch {/* no-throw */}
507
- (0, _automaticEvents.emitAutomaticEvent)('element_interacted', {
508
- gesture_type: gestureType,
509
- screen_name: screen,
510
- // testID/nativeID is an author-controlled stable identifier.
511
- // Accessibility labels and rendered text are deliberately omitted.
512
- target_id: targetInfo?.testId,
513
- normalized_x: gestureDetails.normalizedX,
514
- normalized_y: gestureDetails.normalizedY,
515
- direction: gestureDetails.direction,
516
- duration_ms: gestureDetails.duration
517
- });
518
- }
519
547
 
520
548
  // Feed the Engage gesture detector (rage_tap / dead_tap) — only committed
521
549
  // taps, not swipes/scrolls/long-presses. Pure JS, cross-platform; emits the
@@ -525,18 +553,11 @@ function ScaleBunDebugRoot({
525
553
  const {
526
554
  gestureTriggerDetector
527
555
  } = require('../engage/gestureTriggerDetector');
528
- let screen;
529
- try {
530
- const {
531
- AutoScreenDetector
532
- } = require('../navigation/AutoScreenDetector');
533
- screen = AutoScreenDetector.getInstance().getCurrentScreen() || undefined;
534
- } catch {/* no-throw */}
535
556
  gestureTriggerDetector.recordTap({
536
- x: touch.pageX,
537
- y: touch.pageY,
538
- hasTarget: !!(targetInfo?.testId || targetInfo?.accessibilityLabel),
539
- screen
557
+ x: start?.x ?? touch.pageX,
558
+ y: start?.y ?? touch.pageY,
559
+ hasTarget: !!(start?.targetId || start?.target),
560
+ screen: start?.screenName
540
561
  });
541
562
  } catch {/* no-throw */}
542
563
  }
@@ -551,7 +572,7 @@ function ScaleBunDebugRoot({
551
572
  // screenshot capture view). Three-tier suppression:
552
573
  // 1. Native confirmed active → skip JS entirely (native handles all touches)
553
574
  // 2. Native event within 300ms → skip (existing dedup)
554
- // 3. First touch (native not yet confirmed) → defer 200ms to let native arrive
575
+ // 3. First touch (native not yet confirmed) → defer 600ms to let native arrive
555
576
  const sinceNative = Date.now() - lastNativeTouchTsRef.current;
556
577
  if (nativeTrackingConfirmedRef.current) {
557
578
  // Native tracker is active — skip JS path entirely
@@ -564,11 +585,9 @@ function ScaleBunDebugRoot({
564
585
  const capturedDetails = {
565
586
  ...gestureDetails
566
587
  };
567
- if (pendingJsEmitRef.current !== null) {
568
- clearTimeout(pendingJsEmitRef.current);
569
- }
570
- pendingJsEmitRef.current = setTimeout(() => {
571
- pendingJsEmitRef.current = null;
588
+ const interactionId = capturedDetails.interactionId;
589
+ const timer = setTimeout(() => {
590
+ pendingJsEmitsRef.current.delete(interactionId);
572
591
  if (nativeTrackingConfirmedRef.current) return;
573
592
  try {
574
593
  const {
@@ -592,6 +611,7 @@ function ScaleBunDebugRoot({
592
611
  // devices with no native tracker at all it merely delays a background emission,
593
612
  // which nothing user-visible waits on.
594
613
  }, 600);
614
+ pendingJsEmitsRef.current.set(interactionId, timer);
595
615
  }
596
616
  touchStartRef.current = null;
597
617
  } catch {/* no-throw */}
@@ -706,25 +726,6 @@ function useScaleBunScreen(name) {
706
726
  }, [name, manager]);
707
727
  }
708
728
 
709
- // ─── Helpers ────────────────────────────────────────────────────────────────
710
-
711
- function _extractTarget(e) {
712
- try {
713
- const target = e?.target;
714
- if (!target) return undefined;
715
-
716
- // React Native nativeEvent target properties
717
- const props = target._internalFiberInstanceHandleDEV?.memoizedProps ?? {};
718
- return {
719
- testId: props.testID || props.nativeID || undefined,
720
- accessibilityLabel: props.accessibilityLabel || undefined,
721
- text: undefined
722
- };
723
- } catch {
724
- return undefined;
725
- }
726
- }
727
-
728
729
  // ─── Styles ─────────────────────────────────────────────────────────────────
729
730
  // IMPORTANT: Do NOT use StyleSheet.create() here.
730
731
  // In RN 0.84 bridgeless mode, StyleSheet.create() calls
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.INTERACTION_PROTOCOL_VERSION = void 0;
7
+ exports.automaticInteractionProperties = automaticInteractionProperties;
8
+ exports.generateInteractionId = generateInteractionId;
9
+ exports.nearestInteractionStart = nearestInteractionStart;
10
+ /** One physical interaction, shared by replay and analytics projections. */
11
+ const INTERACTION_PROTOCOL_VERSION = exports.INTERACTION_PROTOCOL_VERSION = 1;
12
+ function generateInteractionId() {
13
+ return `ixj-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
14
+ }
15
+
16
+ /** Match a native bridge event to the JS evidence sampled at the same finger-down. */
17
+ function nearestInteractionStart(starts, occurredAt, toleranceMs = 1500) {
18
+ let best;
19
+ let bestDelta = toleranceMs + 1;
20
+ for (const start of starts) {
21
+ const delta = Math.abs(start.occurredAt - occurredAt);
22
+ if (delta < bestDelta) {
23
+ best = start;
24
+ bestDelta = delta;
25
+ }
26
+ }
27
+ return bestDelta <= toleranceMs ? best : undefined;
28
+ }
29
+
30
+ /** Analytics is a projection of the same evidence; no second click is invented. */
31
+ function automaticInteractionProperties(payload, screenName, canonicalMirror) {
32
+ return {
33
+ gesture_type: payload.gestureType,
34
+ screen_name: screenName,
35
+ interaction_id: payload.interaction_id,
36
+ interaction_protocol: payload.interaction_protocol,
37
+ state_status: payload.state_status,
38
+ ui: payload.ui,
39
+ target_id: payload.target_id,
40
+ normalized_x: payload.normalizedX,
41
+ normalized_y: payload.normalizedY,
42
+ direction: payload.direction,
43
+ duration_ms: payload.durationMs,
44
+ canonical_mirror: canonicalMirror || undefined
45
+ };
46
+ }
47
+ //# sourceMappingURL=interactionProtocol.js.map