@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.
- package/android/src/main/java/com/scalebun/replaysdk/tracking/InteractionTracker.kt +25 -24
- package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaModule.kt +75 -0
- package/android/src/oldarch/java/com/scalebun/rn/ota/ScaleBunOtaSpec.kt +12 -0
- package/dist/scalebun.full.js +653 -223
- package/dist/scalebun.slim.js +652 -222
- package/ios/Capture/InteractionTracker.swift +8 -4
- package/ios/Ota/ScaleBunOtaBridge.mm +6 -0
- package/ios/Ota/ScaleBunOtaModule.swift +67 -0
- package/lib/commonjs/analytics/EventTracker.js +5 -5
- package/lib/commonjs/analytics/automaticEvents.js +3 -2
- package/lib/commonjs/core/config/schema.js +34 -1
- package/lib/commonjs/core/constants/version.js +7 -2
- package/lib/commonjs/features/journey/ScaleBunDebugRoot.js +80 -79
- package/lib/commonjs/features/journey/interactionProtocol.js +47 -0
- package/lib/commonjs/features/journey/uiState.js +94 -0
- package/lib/commonjs/features/ota/crypto/builtinVerifier.js +248 -0
- package/lib/commonjs/features/ota/crypto/loadEd25519.js +40 -0
- package/lib/commonjs/features/ota/crypto/loadSha512.js +40 -0
- package/lib/commonjs/features/ota/crypto/nativeVerifier.js +121 -0
- package/lib/commonjs/features/ota/signature.js +87 -27
- package/lib/commonjs/features/session/JourneyEventPipeline.js +6 -5
- package/lib/commonjs/features/session/SessionManager.js +37 -38
- package/lib/commonjs/metro/serializerCompose.js +32 -0
- package/lib/commonjs/public/ScaleBunFacade.js +74 -11
- package/lib/module/analytics/EventTracker.js +5 -5
- package/lib/module/analytics/automaticEvents.js +3 -2
- package/lib/module/core/config/schema.js +34 -1
- package/lib/module/core/constants/version.js +7 -2
- package/lib/module/features/journey/ScaleBunDebugRoot.js +80 -79
- package/lib/module/features/journey/interactionProtocol.js +38 -0
- package/lib/module/features/journey/uiState.js +86 -0
- package/lib/module/features/ota/crypto/builtinVerifier.js +240 -0
- package/lib/module/features/ota/crypto/loadEd25519.js +34 -0
- package/lib/module/features/ota/crypto/loadSha512.js +34 -0
- package/lib/module/features/ota/crypto/nativeVerifier.js +113 -0
- package/lib/module/features/ota/signature.js +87 -27
- package/lib/module/features/session/JourneyEventPipeline.js +6 -5
- package/lib/module/features/session/SessionManager.js +37 -38
- package/lib/module/metro/serializerCompose.js +32 -0
- package/lib/module/public/ScaleBunFacade.js +74 -11
- package/lib/typescript/analytics/EventTracker.d.ts +1 -1
- package/lib/typescript/analytics/automaticEvents.d.ts +3 -1
- package/lib/typescript/core/config/schema.d.ts +2 -0
- package/lib/typescript/core/constants/version.d.ts +7 -2
- package/lib/typescript/features/journey/interactionProtocol.d.ts +21 -0
- package/lib/typescript/features/journey/uiState.d.ts +53 -0
- package/lib/typescript/features/ota/OtaTypes.d.ts +50 -0
- package/lib/typescript/features/ota/crypto/builtinVerifier.d.ts +53 -0
- package/lib/typescript/features/ota/crypto/loadEd25519.d.ts +30 -0
- package/lib/typescript/features/ota/crypto/loadSha512.d.ts +15 -0
- package/lib/typescript/features/ota/crypto/nativeVerifier.d.ts +35 -0
- package/lib/typescript/features/ota/signature.d.ts +22 -7
- 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 +35 -6
- package/lib/typescript/specs/NativeScaleBunOta.d.ts +23 -0
- package/package.json +19 -3
- package/src/analytics/EventTracker.ts +5 -5
- package/src/analytics/automaticEvents.ts +4 -0
- package/src/core/config/schema.ts +30 -3
- package/src/core/constants/version.ts +7 -2
- package/src/features/journey/ScaleBunDebugRoot.tsx +96 -75
- package/src/features/journey/interactionProtocol.ts +65 -0
- package/src/features/journey/uiState.ts +89 -0
- package/src/features/ota/OtaTypes.ts +51 -0
- package/src/features/ota/crypto/builtinVerifier.ts +257 -0
- package/src/features/ota/crypto/loadEd25519.ts +41 -0
- package/src/features/ota/crypto/loadSha512.ts +35 -0
- package/src/features/ota/crypto/nativeVerifier.ts +117 -0
- package/src/features/ota/signature.ts +108 -25
- package/src/features/session/JourneyEventPipeline.ts +7 -5
- package/src/features/session/SessionManager.ts +75 -38
- package/src/metro/serializerCompose.ts +38 -2
- package/src/public/ScaleBunFacade.ts +87 -13
- package/src/specs/NativeScaleBunOta.ts +24 -0
|
@@ -39,6 +39,8 @@ export type AutomaticEventName =
|
|
|
39
39
|
export interface AutomaticEvent {
|
|
40
40
|
name: AutomaticEventName;
|
|
41
41
|
properties: Record<string, unknown>;
|
|
42
|
+
/** Original observation time. Delivery can be delayed while native coordinates resolve. */
|
|
43
|
+
timestamp?: number;
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
type AutomaticEventListener = (event: AutomaticEvent) => void;
|
|
@@ -60,10 +62,12 @@ function compactProperties(
|
|
|
60
62
|
export function emitAutomaticEvent(
|
|
61
63
|
name: AutomaticEventName,
|
|
62
64
|
properties?: Record<string, unknown>,
|
|
65
|
+
timestamp?: number,
|
|
63
66
|
): void {
|
|
64
67
|
const event: AutomaticEvent = {
|
|
65
68
|
name,
|
|
66
69
|
properties: compactProperties(properties),
|
|
70
|
+
timestamp,
|
|
67
71
|
};
|
|
68
72
|
if (listeners.size === 0) {
|
|
69
73
|
pending.push(event);
|
|
@@ -85,6 +85,10 @@ type Field =
|
|
|
85
85
|
| { kind: 'bool'; def?: boolean; opt?: true }
|
|
86
86
|
| { kind: 'num'; min?: number; max?: number; int?: true; def?: number; opt?: true }
|
|
87
87
|
| { kind: 'str'; min?: number; opt?: true }
|
|
88
|
+
// Array of strings. Message parity target is the vendored z's
|
|
89
|
+
// `z.array(z.string())`: "Expected array, received X" at the field path,
|
|
90
|
+
// "Expected string, received X" at [...path, index] per bad element.
|
|
91
|
+
| { kind: 'strArr'; opt?: true }
|
|
88
92
|
| { kind: 'enum'; values: readonly string[]; opt?: true }
|
|
89
93
|
| { kind: 'any'; def: () => unknown }
|
|
90
94
|
| { kind: 'obj'; shape: Record<string, Field>; def?: true; opt?: true };
|
|
@@ -198,6 +202,11 @@ const SHAPE: Record<string, Field> = {
|
|
|
198
202
|
checkOnForeground: bool(true),
|
|
199
203
|
channelOverride: { kind: 'str', opt: true },
|
|
200
204
|
publicSigningKey: { kind: 'str', opt: true },
|
|
205
|
+
// Rotation support: several pinned keys, any of which may verify a
|
|
206
|
+
// bundle. Ship a build pinning [old, new], re-sign server-side with
|
|
207
|
+
// new, drop old next release — no emergency store submission when a
|
|
208
|
+
// key must be replaced. Merged with publicSigningKey by the facade.
|
|
209
|
+
publicSigningKeys: { kind: 'strArr', opt: true },
|
|
201
210
|
mandatoryBlocksUi: bool(false),
|
|
202
211
|
},
|
|
203
212
|
},
|
|
@@ -232,9 +241,11 @@ function parseField(
|
|
|
232
241
|
? 'Expected number, received undefined'
|
|
233
242
|
: field.kind === 'str'
|
|
234
243
|
? 'Expected string, received undefined'
|
|
235
|
-
: field.kind === '
|
|
236
|
-
?
|
|
237
|
-
:
|
|
244
|
+
: field.kind === 'strArr'
|
|
245
|
+
? 'Expected array, received undefined'
|
|
246
|
+
: field.kind === 'enum'
|
|
247
|
+
? "Invalid enum value. Expected " + field.values.map((v) => `'${v}'`).join(' | ')
|
|
248
|
+
: 'Expected object, received undefined',
|
|
238
249
|
);
|
|
239
250
|
}
|
|
240
251
|
|
|
@@ -250,6 +261,20 @@ function parseField(
|
|
|
250
261
|
return fail(`String must contain at least ${field.min} character(s)`);
|
|
251
262
|
}
|
|
252
263
|
return { ok: true, value: input, present: true };
|
|
264
|
+
case 'strArr': {
|
|
265
|
+
// Mirrors the vendored ArraySchema exactly: element failures are
|
|
266
|
+
// reported at their index and fail the whole field (no partial
|
|
267
|
+
// arrays reach the output).
|
|
268
|
+
if (!Array.isArray(input)) return fail(`Expected array, received ${typeofName(input)}`);
|
|
269
|
+
let allOk = true;
|
|
270
|
+
for (let i = 0; i < input.length; i++) {
|
|
271
|
+
if (typeof input[i] !== 'string') {
|
|
272
|
+
issues.push({ path: [...path, i], message: `Expected string, received ${typeofName(input[i])}` });
|
|
273
|
+
allOk = false;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return allOk ? { ok: true, value: [...input], present: true } : { ok: false, present: true };
|
|
277
|
+
}
|
|
253
278
|
case 'num':
|
|
254
279
|
if (typeof input !== 'number' || Number.isNaN(input)) {
|
|
255
280
|
return fail(`Expected number, received ${typeofName(input)}`);
|
|
@@ -381,6 +406,8 @@ export interface ScaleBunConfig {
|
|
|
381
406
|
checkOnForeground: boolean;
|
|
382
407
|
channelOverride?: string;
|
|
383
408
|
publicSigningKey?: string;
|
|
409
|
+
/** Additional pinned keys for rotation; unioned with publicSigningKey. */
|
|
410
|
+
publicSigningKeys?: string[];
|
|
384
411
|
mandatoryBlocksUi: boolean;
|
|
385
412
|
};
|
|
386
413
|
}
|
|
@@ -1,6 +1,11 @@
|
|
|
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.0';
|
|
@@ -28,9 +28,14 @@ import React, {
|
|
|
28
28
|
type ReactNode,
|
|
29
29
|
} from 'react';
|
|
30
30
|
import { View, NativeEventEmitter, NativeModules } from 'react-native';
|
|
31
|
-
import { emitAutomaticEvent } from '../../analytics/automaticEvents';
|
|
32
31
|
import { resolveTouchTarget, describeTouchTarget } from './touchTarget';
|
|
33
32
|
import { resolvePlatformOS } from '../../core/context/device';
|
|
33
|
+
import {
|
|
34
|
+
INTERACTION_PROTOCOL_VERSION,
|
|
35
|
+
generateInteractionId,
|
|
36
|
+
nearestInteractionStart,
|
|
37
|
+
type InteractionStartContext,
|
|
38
|
+
} from './interactionProtocol';
|
|
34
39
|
|
|
35
40
|
// ─── Lazy imports to avoid native module cascade at load time ────────────────
|
|
36
41
|
// We only import TYPE references at the top level; actual modules are require()'d
|
|
@@ -60,6 +65,11 @@ interface ScaleBunDebugRootProps {
|
|
|
60
65
|
suppressDeprecationWarning?: boolean;
|
|
61
66
|
}
|
|
62
67
|
|
|
68
|
+
interface TouchStartContext extends InteractionStartContext {
|
|
69
|
+
locationX?: number;
|
|
70
|
+
locationY?: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
63
73
|
export function ScaleBunDebugRoot({
|
|
64
74
|
children,
|
|
65
75
|
navigationRef,
|
|
@@ -270,7 +280,9 @@ export function ScaleBunDebugRoot({
|
|
|
270
280
|
// and forwards them to SessionManager as USER_ACTION events.
|
|
271
281
|
const lastNativeTouchTsRef = useRef<number>(0);
|
|
272
282
|
const nativeTrackingConfirmedRef = useRef<boolean>(false);
|
|
273
|
-
const
|
|
283
|
+
const touchStartRef = useRef<TouchStartContext | null>(null);
|
|
284
|
+
const interactionStartsRef = useRef<TouchStartContext[]>([]);
|
|
285
|
+
const pendingJsEmitsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
|
274
286
|
|
|
275
287
|
useEffect(() => {
|
|
276
288
|
let subscription: any = null;
|
|
@@ -284,10 +296,19 @@ export function ScaleBunDebugRoot({
|
|
|
284
296
|
lastNativeTouchTsRef.current = Date.now();
|
|
285
297
|
nativeTrackingConfirmedRef.current = true;
|
|
286
298
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
299
|
+
const nativeOccurredAt =
|
|
300
|
+
typeof event.occurredAt === 'number'
|
|
301
|
+
? event.occurredAt
|
|
302
|
+
: (typeof event.timestamp === 'number' ? event.timestamp : Date.now()) -
|
|
303
|
+
(typeof event.durationMs === 'number' ? event.durationMs : 0);
|
|
304
|
+
const start = nearestInteractionStart(interactionStartsRef.current, nativeOccurredAt);
|
|
305
|
+
|
|
306
|
+
// Cancel only this physical touch's fallback. A single timer used to cancel a
|
|
307
|
+
// previous rapid tap and silently lose it on devices without native tracking.
|
|
308
|
+
if (start) {
|
|
309
|
+
const pending = pendingJsEmitsRef.current.get(start.interactionId);
|
|
310
|
+
if (pending !== undefined) clearTimeout(pending);
|
|
311
|
+
pendingJsEmitsRef.current.delete(start.interactionId);
|
|
291
312
|
}
|
|
292
313
|
|
|
293
314
|
const { SessionManager } = require('../session/SessionManager');
|
|
@@ -306,6 +327,15 @@ export function ScaleBunDebugRoot({
|
|
|
306
327
|
} catch { /* no-throw */ }
|
|
307
328
|
|
|
308
329
|
(sm as any).onGestureDetected(event.gestureType || 'tap', {
|
|
330
|
+
interactionId: event.interactionId || start?.interactionId || generateInteractionId(),
|
|
331
|
+
interactionProtocol: event.interactionProtocol || INTERACTION_PROTOCOL_VERSION,
|
|
332
|
+
occurredAt: nativeOccurredAt,
|
|
333
|
+
ui: start?.ui,
|
|
334
|
+
stateStatus: start?.stateStatus ?? 'not_captured',
|
|
335
|
+
target: start?.target,
|
|
336
|
+
targetId: start?.targetId,
|
|
337
|
+
screenName: start?.screenName,
|
|
338
|
+
emitAutomaticAnalytics: start?.emitAutomaticAnalytics ?? captureAutomaticInteractions,
|
|
309
339
|
x: event.rawX,
|
|
310
340
|
y: event.rawY,
|
|
311
341
|
// Use the actual native end coords when present, not the down
|
|
@@ -334,11 +364,12 @@ export function ScaleBunDebugRoot({
|
|
|
334
364
|
|
|
335
365
|
return () => {
|
|
336
366
|
try { subscription?.remove(); } catch { /* no-throw */ }
|
|
367
|
+
for (const timer of pendingJsEmitsRef.current.values()) clearTimeout(timer);
|
|
368
|
+
pendingJsEmitsRef.current.clear();
|
|
337
369
|
};
|
|
338
|
-
}, []);
|
|
370
|
+
}, [captureAutomaticInteractions]);
|
|
339
371
|
|
|
340
372
|
// ─── Touch Tracking State ───────────────────────────────────────────
|
|
341
|
-
const touchStartRef = useRef<{ x: number; y: number; ts: number } | null>(null);
|
|
342
373
|
|
|
343
374
|
// On-screen rect of the root view — the EXACT view the native screenshot
|
|
344
375
|
// captures. Normalizing pageX/pageY against this rect makes JS-fallback taps
|
|
@@ -377,7 +408,36 @@ export function ScaleBunDebugRoot({
|
|
|
377
408
|
const handleTouchStart = (e: any) => {
|
|
378
409
|
try {
|
|
379
410
|
const touch = e.nativeEvent;
|
|
380
|
-
|
|
411
|
+
const target = resolveTouchTarget(e);
|
|
412
|
+
let ui: string | undefined;
|
|
413
|
+
let screenName: string | undefined;
|
|
414
|
+
try {
|
|
415
|
+
const { uiStateSignature } = require('./uiState');
|
|
416
|
+
ui = uiStateSignature();
|
|
417
|
+
} catch { /* no-throw */ }
|
|
418
|
+
try {
|
|
419
|
+
const { AutoScreenDetector } = require('../navigation/AutoScreenDetector');
|
|
420
|
+
screenName = AutoScreenDetector.getInstance().getCurrentScreen() || undefined;
|
|
421
|
+
} catch { /* no-throw */ }
|
|
422
|
+
const start: TouchStartContext = {
|
|
423
|
+
interactionId: generateInteractionId(),
|
|
424
|
+
occurredAt: Date.now(),
|
|
425
|
+
x: touch.pageX,
|
|
426
|
+
y: touch.pageY,
|
|
427
|
+
locationX: touch.locationX,
|
|
428
|
+
locationY: touch.locationY,
|
|
429
|
+
target: describeTouchTarget(target),
|
|
430
|
+
targetId: target?.testID,
|
|
431
|
+
screenName,
|
|
432
|
+
ui,
|
|
433
|
+
stateStatus: ui ? 'captured_nonempty' : 'not_instrumented',
|
|
434
|
+
emitAutomaticAnalytics: captureAutomaticInteractions,
|
|
435
|
+
};
|
|
436
|
+
touchStartRef.current = start;
|
|
437
|
+
interactionStartsRef.current = interactionStartsRef.current
|
|
438
|
+
.filter((candidate) => start.occurredAt - candidate.occurredAt < 5000)
|
|
439
|
+
.concat(start)
|
|
440
|
+
.slice(-8);
|
|
381
441
|
} catch { /* no-throw */ }
|
|
382
442
|
};
|
|
383
443
|
|
|
@@ -413,16 +473,24 @@ export function ScaleBunDebugRoot({
|
|
|
413
473
|
// WHAT was tapped, resolved from the React fiber on the touch event. Without this the tap
|
|
414
474
|
// carries only coordinates, so every tap in the app groups into one row per gesture type.
|
|
415
475
|
// Defensive by construction — returns undefined rather than throwing (see touchTarget.ts).
|
|
416
|
-
const tapped = describeTouchTarget(resolveTouchTarget(e));
|
|
476
|
+
const tapped = start?.target ?? describeTouchTarget(resolveTouchTarget(e));
|
|
417
477
|
|
|
418
478
|
const gestureDetails: any = {
|
|
419
479
|
target: tapped,
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
480
|
+
interactionId: start?.interactionId ?? generateInteractionId(),
|
|
481
|
+
interactionProtocol: INTERACTION_PROTOCOL_VERSION,
|
|
482
|
+
occurredAt: start?.occurredAt ?? Date.now(),
|
|
483
|
+
ui: start?.ui,
|
|
484
|
+
stateStatus: start?.stateStatus ?? 'not_captured',
|
|
485
|
+
targetId: start?.targetId,
|
|
486
|
+
screenName: start?.screenName,
|
|
487
|
+
emitAutomaticAnalytics: start?.emitAutomaticAnalytics ?? captureAutomaticInteractions,
|
|
488
|
+
x: start?.x ?? touch.pageX,
|
|
489
|
+
y: start?.y ?? touch.pageY,
|
|
490
|
+
pageX: start?.x ?? touch.pageX,
|
|
491
|
+
pageY: start?.y ?? touch.pageY,
|
|
492
|
+
locationX: start?.locationX ?? touch.locationX,
|
|
493
|
+
locationY: start?.locationY ?? touch.locationY,
|
|
426
494
|
viewportWidth: vpW > 0 ? vpW : undefined,
|
|
427
495
|
viewportHeight: vpH > 0 ? vpH : undefined,
|
|
428
496
|
};
|
|
@@ -434,8 +502,8 @@ export function ScaleBunDebugRoot({
|
|
|
434
502
|
// measureInWindow returns rootRect.y = statusBarHeight, so subtracting it
|
|
435
503
|
// double-counts the status bar and pushes every marker upward.
|
|
436
504
|
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));
|
|
505
|
+
const nx = Math.max(0, Math.min(1, (start?.x ?? touch.pageX) / rootRect.w));
|
|
506
|
+
const ny = Math.max(0, Math.min(1, (start?.y ?? touch.pageY) / rootRect.h));
|
|
439
507
|
gestureDetails.normalizedX = nx;
|
|
440
508
|
gestureDetails.normalizedY = ny;
|
|
441
509
|
gestureDetails.normalizedPrecomputed = true;
|
|
@@ -472,7 +540,7 @@ export function ScaleBunDebugRoot({
|
|
|
472
540
|
const dx = touch.pageX - start.x;
|
|
473
541
|
const dy = touch.pageY - start.y;
|
|
474
542
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
475
|
-
const duration = Date.now() - start.
|
|
543
|
+
const duration = Date.now() - start.occurredAt;
|
|
476
544
|
gestureDetails.duration = duration;
|
|
477
545
|
|
|
478
546
|
if (dist >= 15) {
|
|
@@ -494,44 +562,17 @@ export function ScaleBunDebugRoot({
|
|
|
494
562
|
}
|
|
495
563
|
}
|
|
496
564
|
|
|
497
|
-
const targetInfo = _extractTarget(e);
|
|
498
|
-
gestureDetails.target = targetInfo?.testId || targetInfo?.accessibilityLabel;
|
|
499
|
-
|
|
500
|
-
if (captureAutomaticInteractions) {
|
|
501
|
-
let screen: string | undefined;
|
|
502
|
-
try {
|
|
503
|
-
const { AutoScreenDetector } = require('../navigation/AutoScreenDetector');
|
|
504
|
-
screen = AutoScreenDetector.getInstance().getCurrentScreen() || undefined;
|
|
505
|
-
} catch { /* no-throw */ }
|
|
506
|
-
emitAutomaticEvent('element_interacted', {
|
|
507
|
-
gesture_type: gestureType,
|
|
508
|
-
screen_name: screen,
|
|
509
|
-
// testID/nativeID is an author-controlled stable identifier.
|
|
510
|
-
// Accessibility labels and rendered text are deliberately omitted.
|
|
511
|
-
target_id: targetInfo?.testId,
|
|
512
|
-
normalized_x: gestureDetails.normalizedX,
|
|
513
|
-
normalized_y: gestureDetails.normalizedY,
|
|
514
|
-
direction: gestureDetails.direction,
|
|
515
|
-
duration_ms: gestureDetails.duration,
|
|
516
|
-
});
|
|
517
|
-
}
|
|
518
|
-
|
|
519
565
|
// Feed the Engage gesture detector (rage_tap / dead_tap) — only committed
|
|
520
566
|
// taps, not swipes/scrolls/long-presses. Pure JS, cross-platform; emits the
|
|
521
567
|
// unified `$gesture` signal the engage trigger engine listens for.
|
|
522
568
|
if (gestureType === 'tap') {
|
|
523
569
|
try {
|
|
524
570
|
const { gestureTriggerDetector } = require('../engage/gestureTriggerDetector');
|
|
525
|
-
let screen: string | undefined;
|
|
526
|
-
try {
|
|
527
|
-
const { AutoScreenDetector } = require('../navigation/AutoScreenDetector');
|
|
528
|
-
screen = AutoScreenDetector.getInstance().getCurrentScreen() || undefined;
|
|
529
|
-
} catch { /* no-throw */ }
|
|
530
571
|
gestureTriggerDetector.recordTap({
|
|
531
|
-
x: touch.pageX,
|
|
532
|
-
y: touch.pageY,
|
|
533
|
-
hasTarget: !!(
|
|
534
|
-
screen,
|
|
572
|
+
x: start?.x ?? touch.pageX,
|
|
573
|
+
y: start?.y ?? touch.pageY,
|
|
574
|
+
hasTarget: !!(start?.targetId || start?.target),
|
|
575
|
+
screen: start?.screenName,
|
|
535
576
|
});
|
|
536
577
|
} catch { /* no-throw */ }
|
|
537
578
|
}
|
|
@@ -546,7 +587,7 @@ export function ScaleBunDebugRoot({
|
|
|
546
587
|
// screenshot capture view). Three-tier suppression:
|
|
547
588
|
// 1. Native confirmed active → skip JS entirely (native handles all touches)
|
|
548
589
|
// 2. Native event within 300ms → skip (existing dedup)
|
|
549
|
-
// 3. First touch (native not yet confirmed) → defer
|
|
590
|
+
// 3. First touch (native not yet confirmed) → defer 600ms to let native arrive
|
|
550
591
|
const sinceNative = Date.now() - lastNativeTouchTsRef.current;
|
|
551
592
|
if (nativeTrackingConfirmedRef.current) {
|
|
552
593
|
// Native tracker is active — skip JS path entirely
|
|
@@ -557,11 +598,9 @@ export function ScaleBunDebugRoot({
|
|
|
557
598
|
// If native fires during the delay, it cancels this pending emit.
|
|
558
599
|
const capturedType = gestureType;
|
|
559
600
|
const capturedDetails = { ...gestureDetails };
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
pendingJsEmitRef.current = setTimeout(() => {
|
|
564
|
-
pendingJsEmitRef.current = null;
|
|
601
|
+
const interactionId = capturedDetails.interactionId as string;
|
|
602
|
+
const timer = setTimeout(() => {
|
|
603
|
+
pendingJsEmitsRef.current.delete(interactionId);
|
|
565
604
|
if (nativeTrackingConfirmedRef.current) return;
|
|
566
605
|
try {
|
|
567
606
|
const { SessionManager } = require('../session/SessionManager');
|
|
@@ -583,6 +622,7 @@ export function ScaleBunDebugRoot({
|
|
|
583
622
|
// devices with no native tracker at all it merely delays a background emission,
|
|
584
623
|
// which nothing user-visible waits on.
|
|
585
624
|
}, 600);
|
|
625
|
+
pendingJsEmitsRef.current.set(interactionId, timer);
|
|
586
626
|
}
|
|
587
627
|
|
|
588
628
|
touchStartRef.current = null;
|
|
@@ -702,25 +742,6 @@ export function useScaleBunScreen(name: string): void {
|
|
|
702
742
|
}, [name, manager]);
|
|
703
743
|
}
|
|
704
744
|
|
|
705
|
-
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
706
|
-
|
|
707
|
-
function _extractTarget(e: any): { testId?: string; accessibilityLabel?: string; text?: string } | undefined {
|
|
708
|
-
try {
|
|
709
|
-
const target = e?.target;
|
|
710
|
-
if (!target) return undefined;
|
|
711
|
-
|
|
712
|
-
// React Native nativeEvent target properties
|
|
713
|
-
const props = target._internalFiberInstanceHandleDEV?.memoizedProps ?? {};
|
|
714
|
-
return {
|
|
715
|
-
testId: props.testID || props.nativeID || undefined,
|
|
716
|
-
accessibilityLabel: props.accessibilityLabel || undefined,
|
|
717
|
-
text: undefined,
|
|
718
|
-
};
|
|
719
|
-
} catch {
|
|
720
|
-
return undefined;
|
|
721
|
-
}
|
|
722
|
-
}
|
|
723
|
-
|
|
724
745
|
// ─── Styles ─────────────────────────────────────────────────────────────────
|
|
725
746
|
// IMPORTANT: Do NOT use StyleSheet.create() here.
|
|
726
747
|
// In RN 0.84 bridgeless mode, StyleSheet.create() calls
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** One physical interaction, shared by replay and analytics projections. */
|
|
2
|
+
export const INTERACTION_PROTOCOL_VERSION = 1;
|
|
3
|
+
|
|
4
|
+
export type InteractionStateStatus =
|
|
5
|
+
| 'captured_empty'
|
|
6
|
+
| 'captured_nonempty'
|
|
7
|
+
| 'not_captured'
|
|
8
|
+
| 'not_instrumented';
|
|
9
|
+
|
|
10
|
+
export interface InteractionStartContext {
|
|
11
|
+
interactionId: string;
|
|
12
|
+
occurredAt: number;
|
|
13
|
+
x: number;
|
|
14
|
+
y: number;
|
|
15
|
+
target?: string;
|
|
16
|
+
targetId?: string;
|
|
17
|
+
screenName?: string;
|
|
18
|
+
ui?: string;
|
|
19
|
+
stateStatus: InteractionStateStatus;
|
|
20
|
+
emitAutomaticAnalytics: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function generateInteractionId(): string {
|
|
24
|
+
return `ixj-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Match a native bridge event to the JS evidence sampled at the same finger-down. */
|
|
28
|
+
export function nearestInteractionStart<T extends InteractionStartContext>(
|
|
29
|
+
starts: readonly T[],
|
|
30
|
+
occurredAt: number,
|
|
31
|
+
toleranceMs = 1500,
|
|
32
|
+
): T | undefined {
|
|
33
|
+
let best: T | undefined;
|
|
34
|
+
let bestDelta = toleranceMs + 1;
|
|
35
|
+
for (const start of starts) {
|
|
36
|
+
const delta = Math.abs(start.occurredAt - occurredAt);
|
|
37
|
+
if (delta < bestDelta) {
|
|
38
|
+
best = start;
|
|
39
|
+
bestDelta = delta;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return bestDelta <= toleranceMs ? best : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Analytics is a projection of the same evidence; no second click is invented. */
|
|
46
|
+
export function automaticInteractionProperties(
|
|
47
|
+
payload: Record<string, unknown>,
|
|
48
|
+
screenName: string | undefined,
|
|
49
|
+
canonicalMirror: boolean,
|
|
50
|
+
): Record<string, unknown> {
|
|
51
|
+
return {
|
|
52
|
+
gesture_type: payload.gestureType,
|
|
53
|
+
screen_name: screenName,
|
|
54
|
+
interaction_id: payload.interaction_id,
|
|
55
|
+
interaction_protocol: payload.interaction_protocol,
|
|
56
|
+
state_status: payload.state_status,
|
|
57
|
+
ui: payload.ui,
|
|
58
|
+
target_id: payload.target_id,
|
|
59
|
+
normalized_x: payload.normalizedX,
|
|
60
|
+
normalized_y: payload.normalizedY,
|
|
61
|
+
direction: payload.direction,
|
|
62
|
+
duration_ms: payload.durationMs,
|
|
63
|
+
canonical_mirror: canonicalMirror || undefined,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UI STATE AT INTERACTION TIME — React Native.
|
|
3
|
+
*
|
|
4
|
+
* A screen is not just `Cart`. It is `Cart` with the filter sheet down, or up; with the address form
|
|
5
|
+
* expanded, or collapsed. Those are different interaction surfaces and a tap on one must never be
|
|
6
|
+
* counted on another — heat from the open sheet painted over the closed layout is a picture of
|
|
7
|
+
* something that never existed.
|
|
8
|
+
*
|
|
9
|
+
* HOW THIS DIFFERS FROM THE WEB, and the difference is not an omission:
|
|
10
|
+
*
|
|
11
|
+
* The web SDK ALSO detects state automatically, by reading `aria-expanded` and open `<dialog>`
|
|
12
|
+
* elements out of the live DOM. Those are platform-defined, so reading them is a measurement.
|
|
13
|
+
*
|
|
14
|
+
* React Native has no equivalent. There is no queryable tree of accessibility state that says a
|
|
15
|
+
* bottom sheet is up; a sheet is a component with a boolean in someone's store, and the only place
|
|
16
|
+
* that boolean exists is the host's own code. Guessing at it — from a modal's presence in the tree, or
|
|
17
|
+
* from a component name — would produce a state key that is right on some apps and silently wrong on
|
|
18
|
+
* others, and a wrong state key partitions taps into buckets corresponding to nothing.
|
|
19
|
+
*
|
|
20
|
+
* So on RN, state is DECLARED and never inferred. `ScaleBun.setUiState('filter-sheet', 'open')` is the
|
|
21
|
+
* whole mechanism, and its absence means "not captured" rather than "nothing was open". That
|
|
22
|
+
* distinction is carried all the way to the dashboard: a tap with no declared state is NULL, not '',
|
|
23
|
+
* and cannot be selected as a state or counted as one. Reporting it as "nothing was open" would be
|
|
24
|
+
* asserting a measurement nobody took.
|
|
25
|
+
*
|
|
26
|
+
* PAIRED WITH: whatzbug-web-sdk/packages/web/src/features/journey/uiState.ts — same wire format
|
|
27
|
+
* (`name:value` pairs, sorted, `;`-joined, <= 96 chars), same separator stripping, same explicit-wins
|
|
28
|
+
* precedence, so one dashboard control queries both platforms.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** Host-declared state dimensions. The only source on this platform. */
|
|
32
|
+
const declared = new Map<string, string>();
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `;` and `:` are the wire separators and cannot appear inside a name or value; the length is bounded.
|
|
36
|
+
*
|
|
37
|
+
* Names and values are IDENTIFIERS, not content. They become aggregation keys in the dashboard, so a
|
|
38
|
+
* value carrying a person's name or a cart total would put user data into a query key — and into every
|
|
39
|
+
* chart legend built from it.
|
|
40
|
+
*/
|
|
41
|
+
const clean = (s: string): string =>
|
|
42
|
+
typeof s === 'string' ? s.replace(/[;:|]/g, '').trim().slice(0, 32) : '';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Declare the state of one UI dimension.
|
|
46
|
+
*
|
|
47
|
+
* Call it when the state CHANGES, not on every render: the value is read at interaction time, so it
|
|
48
|
+
* only has to be correct by the time the next tap lands.
|
|
49
|
+
*/
|
|
50
|
+
export function setUiState(name: string, value: string): void {
|
|
51
|
+
const n = clean(name);
|
|
52
|
+
const v = clean(value);
|
|
53
|
+
if (n && v) declared.set(n, v);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Stop reporting a dimension — or, with no name, all of them.
|
|
58
|
+
*
|
|
59
|
+
* Later taps carry no value for it, which is not the same as carrying a value. Clearing all is what a
|
|
60
|
+
* screen unmount wants: declarations from the previous screen would otherwise follow the user onto a
|
|
61
|
+
* surface where they mean nothing, and every tap there would be filed under a state that was not on
|
|
62
|
+
* screen.
|
|
63
|
+
*/
|
|
64
|
+
export function clearUiState(name?: string): void {
|
|
65
|
+
if (name === undefined) declared.clear();
|
|
66
|
+
else declared.delete(clean(name));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The signature for the interaction happening RIGHT NOW, or undefined when nothing is declared.
|
|
71
|
+
*
|
|
72
|
+
* undefined rather than '' deliberately — see the header. '' is the web's "we looked and nothing was
|
|
73
|
+
* open"; on RN there is nothing to look at, so the honest answer is silence.
|
|
74
|
+
*/
|
|
75
|
+
export function uiStateSignature(): string | undefined {
|
|
76
|
+
if (!declared.size) return undefined;
|
|
77
|
+
/* SORTED, or the same surface produces different signatures depending on the order the host happened
|
|
78
|
+
to declare things in, and every count fragments into several that mean nothing. */
|
|
79
|
+
const pairs = [...declared.keys()]
|
|
80
|
+
.sort()
|
|
81
|
+
.map((k) => `${k}:${declared.get(k)}`);
|
|
82
|
+
let signature = '';
|
|
83
|
+
for (const pair of pairs) {
|
|
84
|
+
const next = signature ? `${signature};${pair}` : pair;
|
|
85
|
+
if (next.length > 96) break;
|
|
86
|
+
signature = next;
|
|
87
|
+
}
|
|
88
|
+
return signature || undefined;
|
|
89
|
+
}
|
|
@@ -6,6 +6,50 @@
|
|
|
6
6
|
* of truth; conformance tests in both repos assert against them to prevent drift.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
11
|
+
* OTA-TELEMETRY-SPEC — remaining telemetry fixes (from the 2026-08 audit)
|
|
12
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
13
|
+
* These close the SDK half of spec §84-86. All are ADDITIVE + backward-compatible
|
|
14
|
+
* (new optional fields, new enum members, new no-throw emit calls); old installed
|
|
15
|
+
* clients omit them and the backend ingests nullable/unknown fields. Each ALTERS
|
|
16
|
+
* DEVICE RUNTIME BEHAVIOR, so land them behind a real RN build + device/kill-test
|
|
17
|
+
* (Metro cannot exercise the boot-guard) — this file only carries the contract
|
|
18
|
+
* types + this spec, not the runtime wiring.
|
|
19
|
+
*
|
|
20
|
+
* 1. releaseId end-to-end (CRITICAL — root of the dashboard funnel mismatch)
|
|
21
|
+
* Backend already serves it: OtaBundlePayload.releaseId (done, this file +
|
|
22
|
+
* ota-check.service.ts). SDK TODO:
|
|
23
|
+
* - store payload.releaseId on the downloaded/installed bundle state, and
|
|
24
|
+
* - set `releaseId: bundle.releaseId` in the delivery mapper
|
|
25
|
+
* OtaOrchestrator.ts deliverOtaEvents (~:105) — currently only bundleId.
|
|
26
|
+
* Then ota_events carry releaseId and the funnel keys by release.
|
|
27
|
+
*
|
|
28
|
+
* 2. Emit CHECK + OFFERED (funnel top is currently unmeasurable)
|
|
29
|
+
* - CHECK: emit at the start of checkForUpdate (OtaOrchestrator.ts ~:480),
|
|
30
|
+
* before the fetch. ('CHECK' type already exists — no emit site today.)
|
|
31
|
+
* - OFFERED: add 'OFFERED' to OtaEventType, emit when checkRes.action ===
|
|
32
|
+
* 'DOWNLOAD' (~:619) before download begins.
|
|
33
|
+
*
|
|
34
|
+
* 3. Emit BOOT_SUCCESS (honest activation signal)
|
|
35
|
+
* Add 'BOOT_SUCCESS' to OtaEventType and emit it from the boot-guard
|
|
36
|
+
* markHealthy path (OtaOrchestrator.ts ~:863). INSTALLED (~:794) is emitted
|
|
37
|
+
* optimistically BEFORE the bundle boots; keep it (it means "staged+swapped")
|
|
38
|
+
* but let the dashboard measure real activation on BOOT_SUCCESS. Optionally
|
|
39
|
+
* add 'VERIFIED' after signature check (~:635).
|
|
40
|
+
*
|
|
41
|
+
* 4. Stamp the running OTA bundle onto session/crash telemetry
|
|
42
|
+
* SessionMetadata.bundleId is the NATIVE app package id, not the OTA bundle.
|
|
43
|
+
* Add optional otaBundleId?/otaBundleVersion? (distinct fields — do NOT
|
|
44
|
+
* overload bundleId) sourced from otaOrchestrator.getCurrentBundle() at
|
|
45
|
+
* session start (SessionManager.ts ~:451), so crashes attribute to the
|
|
46
|
+
* running bundle/version (release-health crash impact).
|
|
47
|
+
*
|
|
48
|
+
* Do NOT repurpose errorCode (it already collapses failure-error vs rollback-
|
|
49
|
+
* reason); add a new optional field if the two must be distinguished.
|
|
50
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
51
|
+
*/
|
|
52
|
+
|
|
9
53
|
// ── SDK → backend (POST /sdk/ota/check) ──────────────────────────────────────
|
|
10
54
|
export interface OtaCheckRequest {
|
|
11
55
|
appVersion: string;
|
|
@@ -62,6 +106,13 @@ export interface OtaCheckResponse {
|
|
|
62
106
|
export interface OtaBundlePayload {
|
|
63
107
|
id: string;
|
|
64
108
|
version: number;
|
|
109
|
+
/**
|
|
110
|
+
* The release this bundle is served AS (the backend now includes it — see
|
|
111
|
+
* ota.contracts.ts / ota-check.service.ts). Optional, for backward-compat with
|
|
112
|
+
* older backends. Carry it onto the emitted OtaEventItem.releaseId so telemetry
|
|
113
|
+
* is attributable by RELEASE, not just bundle. See OTA-TELEMETRY-SPEC below.
|
|
114
|
+
*/
|
|
115
|
+
releaseId?: string;
|
|
65
116
|
url: string; // presigned GET — short-TTL
|
|
66
117
|
size: number;
|
|
67
118
|
sha256: string;
|