@scalebun/react-native 1.10.7 → 1.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/src/main/java/com/scalebun/replaysdk/tracking/InteractionTracker.kt +25 -25
- package/android/src/main/java/com/scalebun/rn/ota/SlotManager.kt +15 -3
- package/dist/scalebun.full.js +467 -255
- package/dist/scalebun.slim.js +466 -254
- package/ios/Capture/InteractionTracker.swift +8 -4
- package/ios/Ota/OtaSlotManager.swift +19 -5
- package/lib/commonjs/analytics/EventTracker.js +5 -5
- package/lib/commonjs/analytics/automaticEvents.js +3 -2
- package/lib/commonjs/core/constants/version.js +7 -2
- package/lib/commonjs/features/journey/ScaleBunDebugRoot.js +80 -103
- package/lib/commonjs/features/journey/interactionProtocol.js +47 -0
- package/lib/commonjs/features/journey/uiState.js +8 -1
- package/lib/commonjs/features/ota/OtaOrchestrator.js +174 -48
- package/lib/commonjs/features/ota/OtaTypes.js +4 -0
- package/lib/commonjs/features/ota/useOtaUpdate.js +11 -2
- package/lib/commonjs/features/session/JourneyEventPipeline.js +6 -5
- package/lib/commonjs/features/session/SessionManager.js +37 -38
- package/lib/commonjs/public/ScaleBunFacade.js +115 -2
- package/lib/module/analytics/EventTracker.js +5 -5
- package/lib/module/analytics/automaticEvents.js +3 -2
- package/lib/module/core/constants/version.js +7 -2
- package/lib/module/features/journey/ScaleBunDebugRoot.js +80 -103
- package/lib/module/features/journey/interactionProtocol.js +38 -0
- package/lib/module/features/journey/uiState.js +8 -1
- package/lib/module/features/ota/OtaOrchestrator.js +174 -48
- package/lib/module/features/ota/OtaTypes.js +1 -1
- package/lib/module/features/ota/useOtaUpdate.js +11 -2
- package/lib/module/features/session/JourneyEventPipeline.js +6 -5
- package/lib/module/features/session/SessionManager.js +37 -38
- package/lib/module/public/ScaleBunFacade.js +115 -2
- package/lib/typescript/analytics/EventTracker.d.ts +1 -1
- package/lib/typescript/analytics/automaticEvents.d.ts +3 -1
- package/lib/typescript/core/constants/version.d.ts +7 -2
- package/lib/typescript/features/journey/interactionProtocol.d.ts +21 -0
- package/lib/typescript/features/ota/OtaEventEmitter.d.ts +15 -1
- package/lib/typescript/features/ota/OtaOrchestrator.d.ts +22 -3
- package/lib/typescript/features/ota/OtaTypes.d.ts +29 -32
- package/lib/typescript/features/session/JourneyEventPipeline.d.ts +1 -0
- package/lib/typescript/features/session/SessionManager.d.ts +15 -10
- package/lib/typescript/public/ScaleBunFacade.d.ts +27 -0
- package/package.json +4 -3
- package/src/analytics/EventTracker.ts +5 -5
- package/src/analytics/automaticEvents.ts +4 -0
- package/src/core/constants/version.ts +7 -2
- package/src/features/journey/ScaleBunDebugRoot.tsx +96 -97
- package/src/features/journey/interactionProtocol.ts +65 -0
- package/src/features/journey/uiState.ts +9 -4
- package/src/features/ota/OtaEventEmitter.ts +12 -0
- package/src/features/ota/OtaOrchestrator.ts +209 -62
- package/src/features/ota/OtaTypes.ts +37 -39
- package/src/features/ota/useOtaUpdate.ts +11 -2
- package/src/features/session/JourneyEventPipeline.ts +7 -5
- package/src/features/session/SessionManager.ts +75 -38
- package/src/public/ScaleBunFacade.ts +127 -3
|
@@ -259,6 +259,11 @@ class ScaleBunFacade {
|
|
|
259
259
|
_internalLogger.logger.warn('[ScaleBun] A signing key is pinned but no signature verifier is available. ' + 'Signature checking is fail-closed: updates will be REJECTED until one exists. ' + 'Install the optional peers `@noble/ed25519` + `@noble/hashes` (no further ' + 'code needed), or supply `ota.verifySignature`.');
|
|
260
260
|
}
|
|
261
261
|
}
|
|
262
|
+
// `healthyAfterMs` is the field BootGuardConfig actually declares.
|
|
263
|
+
// This passed `healthyTimeoutMs`, which nothing reads, so a host that
|
|
264
|
+
// tuned the boot-guard window silently got the 10s default instead.
|
|
265
|
+
// Both spellings are accepted so the older one keeps working.
|
|
266
|
+
const healthyAfterMs = typeof ota.healthyAfterMs === 'number' ? ota.healthyAfterMs : typeof ota.healthyTimeoutMs === 'number' ? ota.healthyTimeoutMs : undefined;
|
|
262
267
|
otaOrchestrator.init({
|
|
263
268
|
...(signingRequested ? {
|
|
264
269
|
signature: {
|
|
@@ -268,15 +273,123 @@ class ScaleBunFacade {
|
|
|
268
273
|
verifier
|
|
269
274
|
}
|
|
270
275
|
} : {}),
|
|
271
|
-
...(
|
|
272
|
-
|
|
276
|
+
...(healthyAfterMs !== undefined ? {
|
|
277
|
+
healthyAfterMs
|
|
273
278
|
} : {})
|
|
274
279
|
});
|
|
275
280
|
_internalLogger.logger.info('[ScaleBun] OTA enabled from init config.');
|
|
281
|
+
|
|
282
|
+
// Run the checks the config asked for. Until this existed, `ota.enabled`
|
|
283
|
+
// initialised the orchestrator and then never checked anything: the
|
|
284
|
+
// documented `checkOnForeground` and `channelOverride` options were read
|
|
285
|
+
// by no code at all, and an app following the documented config received
|
|
286
|
+
// updates only if it ALSO drove `useOtaUpdate` or the CodePush shim by
|
|
287
|
+
// hand. Nothing logged, because "no update available" and "never asked"
|
|
288
|
+
// look identical from the outside.
|
|
289
|
+
this._startOtaChecks(ota);
|
|
276
290
|
} catch (err) {
|
|
277
291
|
_internalLogger.logger.warn(`[ScaleBun] OTA init failed: ${err?.message ?? err}`);
|
|
278
292
|
}
|
|
279
293
|
}
|
|
294
|
+
|
|
295
|
+
/** Guards against overlapping config-driven OTA checks. */
|
|
296
|
+
_otaCheckInFlight = false;
|
|
297
|
+
/** Wall clock of the last config-driven check, for the foreground floor. */
|
|
298
|
+
_otaLastCheckAt = 0;
|
|
299
|
+
_otaForegroundListener = null;
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Minimum gap between config-driven checks.
|
|
303
|
+
*
|
|
304
|
+
* A foreground transition is cheap to trigger — app switchers, permission
|
|
305
|
+
* dialogs and share sheets all produce one — so an unthrottled check would
|
|
306
|
+
* put a request on the hot path every time the user glanced away. Ten
|
|
307
|
+
* minutes is well below any realistic release cadence and well above that
|
|
308
|
+
* noise. A host that wants a check on demand calls `useOtaUpdate().sync()`,
|
|
309
|
+
* which is never throttled.
|
|
310
|
+
*/
|
|
311
|
+
static OTA_MIN_CHECK_INTERVAL_MS = 10 * 60 * 1000;
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Drive OTA checks from init config: once at startup, then on each
|
|
315
|
+
* foreground when `checkOnForeground` is on (the schema default).
|
|
316
|
+
*
|
|
317
|
+
* `appVersion` is resolved from the native bridge rather than asked of the
|
|
318
|
+
* integrator, because it gates the server's `targetAppVersion` semver check
|
|
319
|
+
* — sending a wrong or invented value is worse than sending none, and there
|
|
320
|
+
* is no honest default. If it cannot be resolved, the check is skipped with
|
|
321
|
+
* a warning instead of guessing.
|
|
322
|
+
*/
|
|
323
|
+
_startOtaChecks(ota) {
|
|
324
|
+
// Never in a debug build. `ScaleBunOtaModule.getJSBundleFile()` returns null
|
|
325
|
+
// there on purpose so Metro keeps ownership of the bundle — so a bundle
|
|
326
|
+
// downloaded in dev is installed into a slot that will never be loaded, and
|
|
327
|
+
// the identity check on the next launch then correctly observes that the
|
|
328
|
+
// running code is not what was installed and reports APPLY_FAILED. Checking
|
|
329
|
+
// at all in dev buys nothing and manufactures that false alarm. A developer
|
|
330
|
+
// testing the OTA path drives `useOtaUpdate().sync()` explicitly.
|
|
331
|
+
if (__DEV__) {
|
|
332
|
+
_internalLogger.logger.info('[ScaleBun] OTA checks are skipped in debug builds (Metro owns the bundle).');
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const runCheck = async trigger => {
|
|
336
|
+
if (this._otaCheckInFlight) return;
|
|
337
|
+
if (trigger === 'foreground' && Date.now() - this._otaLastCheckAt < ScaleBunFacade.OTA_MIN_CHECK_INTERVAL_MS) {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const clientKey = this._clientKey;
|
|
341
|
+
const apiUrl = this._apiBaseUrl;
|
|
342
|
+
if (!clientKey || !apiUrl) return;
|
|
343
|
+
this._otaCheckInFlight = true;
|
|
344
|
+
try {
|
|
345
|
+
const info = await _bridgeAdapter.bridgeAdapter.getDeviceInfo();
|
|
346
|
+
const appVersion = info?.appVersion;
|
|
347
|
+
if (!appVersion) {
|
|
348
|
+
_internalLogger.logger.warn('[ScaleBun] OTA check skipped — the app version could not be read from the ' + 'native bridge. Rebuild the native app, or drive checks yourself with ' + 'useOtaUpdate({ appVersion }).');
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
this._otaLastCheckAt = Date.now();
|
|
352
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
353
|
+
const {
|
|
354
|
+
otaOrchestrator
|
|
355
|
+
} = require('../features/ota/OtaOrchestrator');
|
|
356
|
+
await otaOrchestrator.sync({
|
|
357
|
+
apiUrl,
|
|
358
|
+
clientKey,
|
|
359
|
+
appVersion,
|
|
360
|
+
// The documented option, finally connected. Omitted means the
|
|
361
|
+
// server's `default` channel, exactly as before.
|
|
362
|
+
channelName: typeof ota.channelOverride === 'string' ? ota.channelOverride : undefined
|
|
363
|
+
// Never forced from config: the release's own installMode
|
|
364
|
+
// decides when the app restarts, and yanking the screen out
|
|
365
|
+
// from under a user is not a decision this switch should make.
|
|
366
|
+
});
|
|
367
|
+
} catch (err) {
|
|
368
|
+
_internalLogger.logger.warn(`[ScaleBun] OTA check failed: ${err?.message ?? err}`);
|
|
369
|
+
} finally {
|
|
370
|
+
this._otaCheckInFlight = false;
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
void runCheck('startup');
|
|
374
|
+
if (ota.checkOnForeground === false) return;
|
|
375
|
+
if (this._otaForegroundListener) return; // idempotent across repeated init()
|
|
376
|
+
try {
|
|
377
|
+
// Through `appLifecycle`, not a second AppState subscription: the SDK
|
|
378
|
+
// already owns one and fanning out from it keeps every consumer on the
|
|
379
|
+
// same transition sequence.
|
|
380
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
381
|
+
const {
|
|
382
|
+
appLifecycle
|
|
383
|
+
} = require('../core/lifecycle/appLifecycle');
|
|
384
|
+
this._otaForegroundListener = state => {
|
|
385
|
+
if (state === 'active') void runCheck('foreground');
|
|
386
|
+
};
|
|
387
|
+
appLifecycle.addListener(this._otaForegroundListener);
|
|
388
|
+
} catch {
|
|
389
|
+
// Lifecycle unavailable (tests, exotic hosts) — the startup check stands.
|
|
390
|
+
this._otaForegroundListener = null;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
280
393
|
_autoEnableDebug(debugConfig) {
|
|
281
394
|
try {
|
|
282
395
|
const dbgConfig = (0, _debug.buildDebugConfigFromInitConfig)(debugConfig);
|
|
@@ -98,7 +98,7 @@ export class EventTracker {
|
|
|
98
98
|
this.started = true;
|
|
99
99
|
if (this.cfg.automaticEventTracking) {
|
|
100
100
|
this.automaticEventsUnsubscribe = subscribeAutomaticEvents(event => {
|
|
101
|
-
this.track(event.name, event.properties);
|
|
101
|
+
this.track(event.name, event.properties, event.timestamp);
|
|
102
102
|
});
|
|
103
103
|
}
|
|
104
104
|
if (this.cfg.autoLifecycleEvents) {
|
|
@@ -221,9 +221,9 @@ export class EventTracker {
|
|
|
221
221
|
|
|
222
222
|
// ─── tracking ────────────────────────────────────────────────────────────
|
|
223
223
|
|
|
224
|
-
track(eventName, properties) {
|
|
224
|
+
track(eventName, properties, timestamp) {
|
|
225
225
|
try {
|
|
226
|
-
this.enqueue(this.buildEnvelope(eventName, properties));
|
|
226
|
+
this.enqueue(this.buildEnvelope(eventName, properties, timestamp));
|
|
227
227
|
try {
|
|
228
228
|
this.cfg.onEvent?.(eventName);
|
|
229
229
|
} catch {/* no-throw */}
|
|
@@ -360,7 +360,7 @@ export class EventTracker {
|
|
|
360
360
|
|
|
361
361
|
// ─── internals ─────────────────────────────────────────────────────────────
|
|
362
362
|
|
|
363
|
-
buildEnvelope(eventName, properties) {
|
|
363
|
+
buildEnvelope(eventName, properties, timestamp) {
|
|
364
364
|
const ctx = this.cfg.context ?? {};
|
|
365
365
|
let canonicalSessionId;
|
|
366
366
|
try {
|
|
@@ -371,7 +371,7 @@ export class EventTracker {
|
|
|
371
371
|
const env = {
|
|
372
372
|
event_id: uuid(),
|
|
373
373
|
event_name: eventName,
|
|
374
|
-
event_time: Date.now(),
|
|
374
|
+
event_time: timestamp ?? Date.now(),
|
|
375
375
|
app_id: this.cfg.appId,
|
|
376
376
|
platform: this.cfg.platform ?? resolveEventPlatform(),
|
|
377
377
|
installation_id: this.installationId,
|
|
@@ -19,10 +19,11 @@ function compactProperties(properties) {
|
|
|
19
19
|
}
|
|
20
20
|
return out;
|
|
21
21
|
}
|
|
22
|
-
export function emitAutomaticEvent(name, properties) {
|
|
22
|
+
export function emitAutomaticEvent(name, properties, timestamp) {
|
|
23
23
|
const event = {
|
|
24
24
|
name,
|
|
25
|
-
properties: compactProperties(properties)
|
|
25
|
+
properties: compactProperties(properties),
|
|
26
|
+
timestamp
|
|
26
27
|
};
|
|
27
28
|
if (listeners.size === 0) {
|
|
28
29
|
pending.push(event);
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ScaleBun SDK version. Sent with the session-start envelope so the dashboard
|
|
3
3
|
* can attribute telemetry to the SDK build that produced it.
|
|
4
|
-
* Keep in sync with package.json "version".
|
|
4
|
+
* Keep in sync with package.json "version" — `version.test.ts` fails when they drift.
|
|
5
|
+
*
|
|
6
|
+
* Why the test matters: the 1.11.0 bump missed this line, so the build would have reported itself as
|
|
7
|
+
* 1.10.6. Every "is the release live, and on what share of traffic" question is answered from this
|
|
8
|
+
* value, so a stale one makes a rollout unobservable — which is the exact problem sending an SDK
|
|
9
|
+
* version was introduced to solve.
|
|
5
10
|
*/
|
|
6
|
-
export const SDK_VERSION = '1.
|
|
11
|
+
export const SDK_VERSION = '1.11.1';
|
|
7
12
|
//# sourceMappingURL=version.js.map
|
|
@@ -22,9 +22,9 @@
|
|
|
22
22
|
|
|
23
23
|
import React, { useEffect, useRef, createContext, useContext } from 'react';
|
|
24
24
|
import { View, NativeEventEmitter, NativeModules } from 'react-native';
|
|
25
|
-
import { emitAutomaticEvent } from '../../analytics/automaticEvents';
|
|
26
25
|
import { resolveTouchTarget, describeTouchTarget } from './touchTarget';
|
|
27
26
|
import { resolvePlatformOS } from '../../core/context/device';
|
|
27
|
+
import { INTERACTION_PROTOCOL_VERSION, generateInteractionId, nearestInteractionStart } from './interactionProtocol';
|
|
28
28
|
|
|
29
29
|
// ─── Lazy imports to avoid native module cascade at load time ────────────────
|
|
30
30
|
// We only import TYPE references at the top level; actual modules are require()'d
|
|
@@ -256,7 +256,9 @@ export function ScaleBunDebugRoot({
|
|
|
256
256
|
// and forwards them to SessionManager as USER_ACTION events.
|
|
257
257
|
const lastNativeTouchTsRef = useRef(0);
|
|
258
258
|
const nativeTrackingConfirmedRef = useRef(false);
|
|
259
|
-
const
|
|
259
|
+
const touchStartRef = useRef(null);
|
|
260
|
+
const interactionStartsRef = useRef([]);
|
|
261
|
+
const pendingJsEmitsRef = useRef(new Map());
|
|
260
262
|
useEffect(() => {
|
|
261
263
|
let subscription = null;
|
|
262
264
|
try {
|
|
@@ -268,11 +270,15 @@ export function ScaleBunDebugRoot({
|
|
|
268
270
|
// Record timestamp to suppress duplicate JS touch events
|
|
269
271
|
lastNativeTouchTsRef.current = Date.now();
|
|
270
272
|
nativeTrackingConfirmedRef.current = true;
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
273
|
+
const nativeOccurredAt = typeof event.occurredAt === 'number' ? event.occurredAt : (typeof event.timestamp === 'number' ? event.timestamp : Date.now()) - (typeof event.durationMs === 'number' ? event.durationMs : 0);
|
|
274
|
+
const start = nearestInteractionStart(interactionStartsRef.current, nativeOccurredAt);
|
|
275
|
+
|
|
276
|
+
// Cancel only this physical touch's fallback. A single timer used to cancel a
|
|
277
|
+
// previous rapid tap and silently lose it on devices without native tracking.
|
|
278
|
+
if (start) {
|
|
279
|
+
const pending = pendingJsEmitsRef.current.get(start.interactionId);
|
|
280
|
+
if (pending !== undefined) clearTimeout(pending);
|
|
281
|
+
pendingJsEmitsRef.current.delete(start.interactionId);
|
|
276
282
|
}
|
|
277
283
|
const {
|
|
278
284
|
SessionManager
|
|
@@ -294,6 +300,15 @@ export function ScaleBunDebugRoot({
|
|
|
294
300
|
_diagWinH = _d.height;
|
|
295
301
|
} catch {/* no-throw */}
|
|
296
302
|
sm.onGestureDetected(event.gestureType || 'tap', {
|
|
303
|
+
interactionId: event.interactionId || start?.interactionId || generateInteractionId(),
|
|
304
|
+
interactionProtocol: event.interactionProtocol || INTERACTION_PROTOCOL_VERSION,
|
|
305
|
+
occurredAt: nativeOccurredAt,
|
|
306
|
+
ui: start?.ui,
|
|
307
|
+
stateStatus: start?.stateStatus ?? 'not_captured',
|
|
308
|
+
target: start?.target,
|
|
309
|
+
targetId: start?.targetId,
|
|
310
|
+
screenName: start?.screenName,
|
|
311
|
+
emitAutomaticAnalytics: start?.emitAutomaticAnalytics ?? captureAutomaticInteractions,
|
|
297
312
|
x: event.rawX,
|
|
298
313
|
y: event.rawY,
|
|
299
314
|
// Use the actual native end coords when present, not the down
|
|
@@ -323,11 +338,12 @@ export function ScaleBunDebugRoot({
|
|
|
323
338
|
try {
|
|
324
339
|
subscription?.remove();
|
|
325
340
|
} catch {/* no-throw */}
|
|
341
|
+
for (const timer of pendingJsEmitsRef.current.values()) clearTimeout(timer);
|
|
342
|
+
pendingJsEmitsRef.current.clear();
|
|
326
343
|
};
|
|
327
|
-
}, []);
|
|
344
|
+
}, [captureAutomaticInteractions]);
|
|
328
345
|
|
|
329
346
|
// ─── Touch Tracking State ───────────────────────────────────────────
|
|
330
|
-
const touchStartRef = useRef(null);
|
|
331
347
|
|
|
332
348
|
// On-screen rect of the root view — the EXACT view the native screenshot
|
|
333
349
|
// captures. Normalizing pageX/pageY against this rect makes JS-fallback taps
|
|
@@ -371,11 +387,37 @@ export function ScaleBunDebugRoot({
|
|
|
371
387
|
const handleTouchStart = e => {
|
|
372
388
|
try {
|
|
373
389
|
const touch = e.nativeEvent;
|
|
374
|
-
|
|
390
|
+
const target = resolveTouchTarget(e);
|
|
391
|
+
let ui;
|
|
392
|
+
let screenName;
|
|
393
|
+
try {
|
|
394
|
+
const {
|
|
395
|
+
uiStateSignature
|
|
396
|
+
} = require('./uiState');
|
|
397
|
+
ui = uiStateSignature();
|
|
398
|
+
} catch {/* no-throw */}
|
|
399
|
+
try {
|
|
400
|
+
const {
|
|
401
|
+
AutoScreenDetector
|
|
402
|
+
} = require('../navigation/AutoScreenDetector');
|
|
403
|
+
screenName = AutoScreenDetector.getInstance().getCurrentScreen() || undefined;
|
|
404
|
+
} catch {/* no-throw */}
|
|
405
|
+
const start = {
|
|
406
|
+
interactionId: generateInteractionId(),
|
|
407
|
+
occurredAt: Date.now(),
|
|
375
408
|
x: touch.pageX,
|
|
376
409
|
y: touch.pageY,
|
|
377
|
-
|
|
410
|
+
locationX: touch.locationX,
|
|
411
|
+
locationY: touch.locationY,
|
|
412
|
+
target: describeTouchTarget(target),
|
|
413
|
+
targetId: target?.testID,
|
|
414
|
+
screenName,
|
|
415
|
+
ui,
|
|
416
|
+
stateStatus: ui ? 'captured_nonempty' : 'not_instrumented',
|
|
417
|
+
emitAutomaticAnalytics: captureAutomaticInteractions
|
|
378
418
|
};
|
|
419
|
+
touchStartRef.current = start;
|
|
420
|
+
interactionStartsRef.current = interactionStartsRef.current.filter(candidate => start.occurredAt - candidate.occurredAt < 5000).concat(start).slice(-8);
|
|
379
421
|
} catch {/* no-throw */}
|
|
380
422
|
};
|
|
381
423
|
const handleTouchEnd = e => {
|
|
@@ -414,15 +456,23 @@ export function ScaleBunDebugRoot({
|
|
|
414
456
|
// WHAT was tapped, resolved from the React fiber on the touch event. Without this the tap
|
|
415
457
|
// carries only coordinates, so every tap in the app groups into one row per gesture type.
|
|
416
458
|
// Defensive by construction — returns undefined rather than throwing (see touchTarget.ts).
|
|
417
|
-
const tapped = describeTouchTarget(resolveTouchTarget(e));
|
|
459
|
+
const tapped = start?.target ?? describeTouchTarget(resolveTouchTarget(e));
|
|
418
460
|
const gestureDetails = {
|
|
419
461
|
target: tapped,
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
462
|
+
interactionId: start?.interactionId ?? generateInteractionId(),
|
|
463
|
+
interactionProtocol: INTERACTION_PROTOCOL_VERSION,
|
|
464
|
+
occurredAt: start?.occurredAt ?? Date.now(),
|
|
465
|
+
ui: start?.ui,
|
|
466
|
+
stateStatus: start?.stateStatus ?? 'not_captured',
|
|
467
|
+
targetId: start?.targetId,
|
|
468
|
+
screenName: start?.screenName,
|
|
469
|
+
emitAutomaticAnalytics: start?.emitAutomaticAnalytics ?? captureAutomaticInteractions,
|
|
470
|
+
x: start?.x ?? touch.pageX,
|
|
471
|
+
y: start?.y ?? touch.pageY,
|
|
472
|
+
pageX: start?.x ?? touch.pageX,
|
|
473
|
+
pageY: start?.y ?? touch.pageY,
|
|
474
|
+
locationX: start?.locationX ?? touch.locationX,
|
|
475
|
+
locationY: start?.locationY ?? touch.locationY,
|
|
426
476
|
viewportWidth: vpW > 0 ? vpW : undefined,
|
|
427
477
|
viewportHeight: vpH > 0 ? vpH : undefined
|
|
428
478
|
};
|
|
@@ -434,8 +484,8 @@ export function ScaleBunDebugRoot({
|
|
|
434
484
|
// measureInWindow returns rootRect.y = statusBarHeight, so subtracting it
|
|
435
485
|
// double-counts the status bar and pushes every marker upward.
|
|
436
486
|
if (rootRect && rootRect.w > 0 && rootRect.h > 0) {
|
|
437
|
-
const nx = Math.max(0, Math.min(1, touch.pageX / rootRect.w));
|
|
438
|
-
const ny = Math.max(0, Math.min(1, touch.pageY / rootRect.h));
|
|
487
|
+
const nx = Math.max(0, Math.min(1, (start?.x ?? touch.pageX) / rootRect.w));
|
|
488
|
+
const ny = Math.max(0, Math.min(1, (start?.y ?? touch.pageY) / rootRect.h));
|
|
439
489
|
gestureDetails.normalizedX = nx;
|
|
440
490
|
gestureDetails.normalizedY = ny;
|
|
441
491
|
gestureDetails.normalizedPrecomputed = true;
|
|
@@ -467,7 +517,7 @@ export function ScaleBunDebugRoot({
|
|
|
467
517
|
const dx = touch.pageX - start.x;
|
|
468
518
|
const dy = touch.pageY - start.y;
|
|
469
519
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
470
|
-
const duration = Date.now() - start.
|
|
520
|
+
const duration = Date.now() - start.occurredAt;
|
|
471
521
|
gestureDetails.duration = duration;
|
|
472
522
|
if (dist >= 15) {
|
|
473
523
|
// Movement gesture: swipe or scroll
|
|
@@ -486,52 +536,6 @@ export function ScaleBunDebugRoot({
|
|
|
486
536
|
gestureType = 'long_press';
|
|
487
537
|
}
|
|
488
538
|
}
|
|
489
|
-
const targetInfo = _extractTarget(e);
|
|
490
|
-
gestureDetails.target = targetInfo?.testId || targetInfo?.accessibilityLabel;
|
|
491
|
-
if (captureAutomaticInteractions) {
|
|
492
|
-
let screen;
|
|
493
|
-
try {
|
|
494
|
-
const {
|
|
495
|
-
AutoScreenDetector
|
|
496
|
-
} = require('../navigation/AutoScreenDetector');
|
|
497
|
-
screen = AutoScreenDetector.getInstance().getCurrentScreen() || undefined;
|
|
498
|
-
} catch {/* no-throw */}
|
|
499
|
-
/**
|
|
500
|
-
* UI STATE, read HERE and not later.
|
|
501
|
-
*
|
|
502
|
-
* A tap belongs to the surface that was on screen when the finger landed: tapping the
|
|
503
|
-
* filter button while the sheet is DOWN belongs to `closed`, because that is what the
|
|
504
|
-
* user was looking at when they reached for it. Reading it after the handler has run
|
|
505
|
-
* moves every "open the thing" tap into the state it created — the one state it
|
|
506
|
-
* certainly does not belong to.
|
|
507
|
-
*
|
|
508
|
-
* Declared-only on this platform (see uiState.ts): absent means not captured, which
|
|
509
|
-
* the dashboard keeps distinct from "nothing was open".
|
|
510
|
-
*/
|
|
511
|
-
let ui;
|
|
512
|
-
try {
|
|
513
|
-
const {
|
|
514
|
-
uiStateSignature
|
|
515
|
-
} = require('./uiState');
|
|
516
|
-
ui = uiStateSignature();
|
|
517
|
-
} catch {/* no-throw: a tap must never be lost to state capture */}
|
|
518
|
-
emitAutomaticEvent('element_interacted', {
|
|
519
|
-
gesture_type: gestureType,
|
|
520
|
-
screen_name: screen,
|
|
521
|
-
/* THIS MAP ENUMERATES. A field added to the payload and forgotten here reaches the
|
|
522
|
-
backend as undefined with no error anywhere — the recurring defect class in this
|
|
523
|
-
codebase. `ui` is the key the grid aggregate reads for state, byte-identical to
|
|
524
|
-
the web SDK's, so one dashboard control queries both platforms. */
|
|
525
|
-
ui,
|
|
526
|
-
// testID/nativeID is an author-controlled stable identifier.
|
|
527
|
-
// Accessibility labels and rendered text are deliberately omitted.
|
|
528
|
-
target_id: targetInfo?.testId,
|
|
529
|
-
normalized_x: gestureDetails.normalizedX,
|
|
530
|
-
normalized_y: gestureDetails.normalizedY,
|
|
531
|
-
direction: gestureDetails.direction,
|
|
532
|
-
duration_ms: gestureDetails.duration
|
|
533
|
-
});
|
|
534
|
-
}
|
|
535
539
|
|
|
536
540
|
// Feed the Engage gesture detector (rage_tap / dead_tap) — only committed
|
|
537
541
|
// taps, not swipes/scrolls/long-presses. Pure JS, cross-platform; emits the
|
|
@@ -541,18 +545,11 @@ export function ScaleBunDebugRoot({
|
|
|
541
545
|
const {
|
|
542
546
|
gestureTriggerDetector
|
|
543
547
|
} = require('../engage/gestureTriggerDetector');
|
|
544
|
-
let screen;
|
|
545
|
-
try {
|
|
546
|
-
const {
|
|
547
|
-
AutoScreenDetector
|
|
548
|
-
} = require('../navigation/AutoScreenDetector');
|
|
549
|
-
screen = AutoScreenDetector.getInstance().getCurrentScreen() || undefined;
|
|
550
|
-
} catch {/* no-throw */}
|
|
551
548
|
gestureTriggerDetector.recordTap({
|
|
552
|
-
x: touch.pageX,
|
|
553
|
-
y: touch.pageY,
|
|
554
|
-
hasTarget: !!(
|
|
555
|
-
screen
|
|
549
|
+
x: start?.x ?? touch.pageX,
|
|
550
|
+
y: start?.y ?? touch.pageY,
|
|
551
|
+
hasTarget: !!(start?.targetId || start?.target),
|
|
552
|
+
screen: start?.screenName
|
|
556
553
|
});
|
|
557
554
|
} catch {/* no-throw */}
|
|
558
555
|
}
|
|
@@ -567,7 +564,7 @@ export function ScaleBunDebugRoot({
|
|
|
567
564
|
// screenshot capture view). Three-tier suppression:
|
|
568
565
|
// 1. Native confirmed active → skip JS entirely (native handles all touches)
|
|
569
566
|
// 2. Native event within 300ms → skip (existing dedup)
|
|
570
|
-
// 3. First touch (native not yet confirmed) → defer
|
|
567
|
+
// 3. First touch (native not yet confirmed) → defer 600ms to let native arrive
|
|
571
568
|
const sinceNative = Date.now() - lastNativeTouchTsRef.current;
|
|
572
569
|
if (nativeTrackingConfirmedRef.current) {
|
|
573
570
|
// Native tracker is active — skip JS path entirely
|
|
@@ -580,11 +577,9 @@ export function ScaleBunDebugRoot({
|
|
|
580
577
|
const capturedDetails = {
|
|
581
578
|
...gestureDetails
|
|
582
579
|
};
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
pendingJsEmitRef.current = setTimeout(() => {
|
|
587
|
-
pendingJsEmitRef.current = null;
|
|
580
|
+
const interactionId = capturedDetails.interactionId;
|
|
581
|
+
const timer = setTimeout(() => {
|
|
582
|
+
pendingJsEmitsRef.current.delete(interactionId);
|
|
588
583
|
if (nativeTrackingConfirmedRef.current) return;
|
|
589
584
|
try {
|
|
590
585
|
const {
|
|
@@ -608,6 +603,7 @@ export function ScaleBunDebugRoot({
|
|
|
608
603
|
// devices with no native tracker at all it merely delays a background emission,
|
|
609
604
|
// which nothing user-visible waits on.
|
|
610
605
|
}, 600);
|
|
606
|
+
pendingJsEmitsRef.current.set(interactionId, timer);
|
|
611
607
|
}
|
|
612
608
|
touchStartRef.current = null;
|
|
613
609
|
} catch {/* no-throw */}
|
|
@@ -722,25 +718,6 @@ export function useScaleBunScreen(name) {
|
|
|
722
718
|
}, [name, manager]);
|
|
723
719
|
}
|
|
724
720
|
|
|
725
|
-
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
726
|
-
|
|
727
|
-
function _extractTarget(e) {
|
|
728
|
-
try {
|
|
729
|
-
const target = e?.target;
|
|
730
|
-
if (!target) return undefined;
|
|
731
|
-
|
|
732
|
-
// React Native nativeEvent target properties
|
|
733
|
-
const props = target._internalFiberInstanceHandleDEV?.memoizedProps ?? {};
|
|
734
|
-
return {
|
|
735
|
-
testId: props.testID || props.nativeID || undefined,
|
|
736
|
-
accessibilityLabel: props.accessibilityLabel || undefined,
|
|
737
|
-
text: undefined
|
|
738
|
-
};
|
|
739
|
-
} catch {
|
|
740
|
-
return undefined;
|
|
741
|
-
}
|
|
742
|
-
}
|
|
743
|
-
|
|
744
721
|
// ─── Styles ─────────────────────────────────────────────────────────────────
|
|
745
722
|
// IMPORTANT: Do NOT use StyleSheet.create() here.
|
|
746
723
|
// In RN 0.84 bridgeless mode, StyleSheet.create() calls
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** One physical interaction, shared by replay and analytics projections. */
|
|
2
|
+
export const INTERACTION_PROTOCOL_VERSION = 1;
|
|
3
|
+
export function generateInteractionId() {
|
|
4
|
+
return `ixj-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** Match a native bridge event to the JS evidence sampled at the same finger-down. */
|
|
8
|
+
export function nearestInteractionStart(starts, occurredAt, toleranceMs = 1500) {
|
|
9
|
+
let best;
|
|
10
|
+
let bestDelta = toleranceMs + 1;
|
|
11
|
+
for (const start of starts) {
|
|
12
|
+
const delta = Math.abs(start.occurredAt - occurredAt);
|
|
13
|
+
if (delta < bestDelta) {
|
|
14
|
+
best = start;
|
|
15
|
+
bestDelta = delta;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return bestDelta <= toleranceMs ? best : undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Analytics is a projection of the same evidence; no second click is invented. */
|
|
22
|
+
export function automaticInteractionProperties(payload, screenName, canonicalMirror) {
|
|
23
|
+
return {
|
|
24
|
+
gesture_type: payload.gestureType,
|
|
25
|
+
screen_name: screenName,
|
|
26
|
+
interaction_id: payload.interaction_id,
|
|
27
|
+
interaction_protocol: payload.interaction_protocol,
|
|
28
|
+
state_status: payload.state_status,
|
|
29
|
+
ui: payload.ui,
|
|
30
|
+
target_id: payload.target_id,
|
|
31
|
+
normalized_x: payload.normalizedX,
|
|
32
|
+
normalized_y: payload.normalizedY,
|
|
33
|
+
direction: payload.direction,
|
|
34
|
+
duration_ms: payload.durationMs,
|
|
35
|
+
canonical_mirror: canonicalMirror || undefined
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
//# sourceMappingURL=interactionProtocol.js.map
|
|
@@ -74,6 +74,13 @@ export function uiStateSignature() {
|
|
|
74
74
|
if (!declared.size) return undefined;
|
|
75
75
|
/* SORTED, or the same surface produces different signatures depending on the order the host happened
|
|
76
76
|
to declare things in, and every count fragments into several that mean nothing. */
|
|
77
|
-
|
|
77
|
+
const pairs = [...declared.keys()].sort().map(k => `${k}:${declared.get(k)}`);
|
|
78
|
+
let signature = '';
|
|
79
|
+
for (const pair of pairs) {
|
|
80
|
+
const next = signature ? `${signature};${pair}` : pair;
|
|
81
|
+
if (next.length > 96) break;
|
|
82
|
+
signature = next;
|
|
83
|
+
}
|
|
84
|
+
return signature || undefined;
|
|
78
85
|
}
|
|
79
86
|
//# sourceMappingURL=uiState.js.map
|