@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
@@ -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 pendingJsEmitRef = useRef(null);
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
- // Cancel any pending JS emission — native event wins
273
- if (pendingJsEmitRef.current !== null) {
274
- clearTimeout(pendingJsEmitRef.current);
275
- pendingJsEmitRef.current = null;
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
- touchStartRef.current = {
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
- ts: Date.now()
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
- x: touch.pageX,
421
- y: touch.pageY,
422
- pageX: touch.pageX,
423
- pageY: touch.pageY,
424
- locationX: touch.locationX,
425
- locationY: touch.locationY,
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.ts;
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,28 +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
- emitAutomaticEvent('element_interacted', {
500
- gesture_type: gestureType,
501
- screen_name: screen,
502
- // testID/nativeID is an author-controlled stable identifier.
503
- // Accessibility labels and rendered text are deliberately omitted.
504
- target_id: targetInfo?.testId,
505
- normalized_x: gestureDetails.normalizedX,
506
- normalized_y: gestureDetails.normalizedY,
507
- direction: gestureDetails.direction,
508
- duration_ms: gestureDetails.duration
509
- });
510
- }
511
539
 
512
540
  // Feed the Engage gesture detector (rage_tap / dead_tap) — only committed
513
541
  // taps, not swipes/scrolls/long-presses. Pure JS, cross-platform; emits the
@@ -517,18 +545,11 @@ export function ScaleBunDebugRoot({
517
545
  const {
518
546
  gestureTriggerDetector
519
547
  } = require('../engage/gestureTriggerDetector');
520
- let screen;
521
- try {
522
- const {
523
- AutoScreenDetector
524
- } = require('../navigation/AutoScreenDetector');
525
- screen = AutoScreenDetector.getInstance().getCurrentScreen() || undefined;
526
- } catch {/* no-throw */}
527
548
  gestureTriggerDetector.recordTap({
528
- x: touch.pageX,
529
- y: touch.pageY,
530
- hasTarget: !!(targetInfo?.testId || targetInfo?.accessibilityLabel),
531
- screen
549
+ x: start?.x ?? touch.pageX,
550
+ y: start?.y ?? touch.pageY,
551
+ hasTarget: !!(start?.targetId || start?.target),
552
+ screen: start?.screenName
532
553
  });
533
554
  } catch {/* no-throw */}
534
555
  }
@@ -543,7 +564,7 @@ export function ScaleBunDebugRoot({
543
564
  // screenshot capture view). Three-tier suppression:
544
565
  // 1. Native confirmed active → skip JS entirely (native handles all touches)
545
566
  // 2. Native event within 300ms → skip (existing dedup)
546
- // 3. First touch (native not yet confirmed) → defer 200ms to let native arrive
567
+ // 3. First touch (native not yet confirmed) → defer 600ms to let native arrive
547
568
  const sinceNative = Date.now() - lastNativeTouchTsRef.current;
548
569
  if (nativeTrackingConfirmedRef.current) {
549
570
  // Native tracker is active — skip JS path entirely
@@ -556,11 +577,9 @@ export function ScaleBunDebugRoot({
556
577
  const capturedDetails = {
557
578
  ...gestureDetails
558
579
  };
559
- if (pendingJsEmitRef.current !== null) {
560
- clearTimeout(pendingJsEmitRef.current);
561
- }
562
- pendingJsEmitRef.current = setTimeout(() => {
563
- pendingJsEmitRef.current = null;
580
+ const interactionId = capturedDetails.interactionId;
581
+ const timer = setTimeout(() => {
582
+ pendingJsEmitsRef.current.delete(interactionId);
564
583
  if (nativeTrackingConfirmedRef.current) return;
565
584
  try {
566
585
  const {
@@ -584,6 +603,7 @@ export function ScaleBunDebugRoot({
584
603
  // devices with no native tracker at all it merely delays a background emission,
585
604
  // which nothing user-visible waits on.
586
605
  }, 600);
606
+ pendingJsEmitsRef.current.set(interactionId, timer);
587
607
  }
588
608
  touchStartRef.current = null;
589
609
  } catch {/* no-throw */}
@@ -698,25 +718,6 @@ export function useScaleBunScreen(name) {
698
718
  }, [name, manager]);
699
719
  }
700
720
 
701
- // ─── Helpers ────────────────────────────────────────────────────────────────
702
-
703
- function _extractTarget(e) {
704
- try {
705
- const target = e?.target;
706
- if (!target) return undefined;
707
-
708
- // React Native nativeEvent target properties
709
- const props = target._internalFiberInstanceHandleDEV?.memoizedProps ?? {};
710
- return {
711
- testId: props.testID || props.nativeID || undefined,
712
- accessibilityLabel: props.accessibilityLabel || undefined,
713
- text: undefined
714
- };
715
- } catch {
716
- return undefined;
717
- }
718
- }
719
-
720
721
  // ─── Styles ─────────────────────────────────────────────────────────────────
721
722
  // IMPORTANT: Do NOT use StyleSheet.create() here.
722
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
@@ -0,0 +1,86 @@
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();
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 => typeof s === 'string' ? s.replace(/[;:|]/g, '').trim().slice(0, 32) : '';
42
+
43
+ /**
44
+ * Declare the state of one UI dimension.
45
+ *
46
+ * Call it when the state CHANGES, not on every render: the value is read at interaction time, so it
47
+ * only has to be correct by the time the next tap lands.
48
+ */
49
+ export function setUiState(name, value) {
50
+ const n = clean(name);
51
+ const v = clean(value);
52
+ if (n && v) declared.set(n, v);
53
+ }
54
+
55
+ /**
56
+ * Stop reporting a dimension — or, with no name, all of them.
57
+ *
58
+ * Later taps carry no value for it, which is not the same as carrying a value. Clearing all is what a
59
+ * screen unmount wants: declarations from the previous screen would otherwise follow the user onto a
60
+ * surface where they mean nothing, and every tap there would be filed under a state that was not on
61
+ * screen.
62
+ */
63
+ export function clearUiState(name) {
64
+ if (name === undefined) declared.clear();else declared.delete(clean(name));
65
+ }
66
+
67
+ /**
68
+ * The signature for the interaction happening RIGHT NOW, or undefined when nothing is declared.
69
+ *
70
+ * undefined rather than '' deliberately — see the header. '' is the web's "we looked and nothing was
71
+ * open"; on RN there is nothing to look at, so the honest answer is silence.
72
+ */
73
+ export function uiStateSignature() {
74
+ if (!declared.size) return undefined;
75
+ /* SORTED, or the same surface produces different signatures depending on the order the host happened
76
+ to declare things in, and every count fragments into several that mean nothing. */
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;
85
+ }
86
+ //# sourceMappingURL=uiState.js.map
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Built-in ed25519 verifier, assembled from OPTIONAL peer dependencies.
3
+ *
4
+ * WHAT THIS REPLACES. Pinning `ota.publicSigningKey` used to require the host
5
+ * app to write its own `verifySignature` — roughly a hundred lines of hex and
6
+ * base64 decoding wrapped around a crypto library. That put the single most
7
+ * security-critical operation in the product, the one deciding whether remote
8
+ * code is authentic before it executes, in code the SDK could neither test nor
9
+ * audit. Three failure modes all landed on the app author:
10
+ *
11
+ * - A verifier that always returns `true` silently disables the feature, and
12
+ * nothing can detect it.
13
+ * - @noble's hash-provider property moved between major versions, so a
14
+ * routine dependency bump made every update fail closed with no signal
15
+ * pointing at the cause.
16
+ * - Hand-written base64 decoders tend to omit base64url, so a signature
17
+ * containing `-` or `_` is rejected as a forgery.
18
+ *
19
+ * All three are now the SDK's problem, which is where they belong. A host that
20
+ * installs `@noble/ed25519` and `@noble/hashes` gets verification by pinning a
21
+ * key and writing no code at all.
22
+ *
23
+ * WHY OPTIONAL AND NOT A HARD DEPENDENCY. Most apps never adopt bundle
24
+ * signing, and they should not carry curve arithmetic they will not run. The
25
+ * `verifySignature` hook remains supported and still WINS over this, for teams
26
+ * with their own crypto policy or a native implementation to delegate to.
27
+ *
28
+ * WHAT THIS DOES NOT FIX. Verification still happens in JS, inside the very
29
+ * bundle it protects. An attacker who lands one malicious bundle by other means
30
+ * can neuter the check for every update after it. Closing that needs native
31
+ * verification — CryptoKit on iOS (13.4+, already the deployment target) and
32
+ * `Signature.getInstance("Ed25519")` on Android API 33+, with a fallback below.
33
+ */
34
+
35
+ import { loadEd25519 } from './loadEd25519';
36
+ import { loadSha512 } from './loadSha512';
37
+ import { logger } from '../../../core/logger/internalLogger';
38
+ const ED25519_SIGNATURE_BYTES = 64;
39
+ const ED25519_PUBLIC_KEY_BYTES = 32;
40
+
41
+ /** Hex → bytes. Returns null rather than throwing; callers treat null as "reject". */
42
+ function hexToBytes(hex) {
43
+ const clean = hex.trim().toLowerCase().replace(/^0x/, '');
44
+ if (clean.length === 0 || clean.length % 2 !== 0 || /[^0-9a-f]/.test(clean)) return null;
45
+ const out = new Uint8Array(clean.length / 2);
46
+ for (let i = 0; i < out.length; i++) {
47
+ out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
48
+ }
49
+ return out;
50
+ }
51
+ const B64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
52
+
53
+ /**
54
+ * Base64 → bytes, accepting base64url as well.
55
+ *
56
+ * The `-`/`_` handling is the point. A verifier that omits it rejects a
57
+ * perfectly valid signature as a forgery the moment the signing side emits
58
+ * base64url, and the resulting symptom — updates silently stop installing —
59
+ * looks nothing like a decoding bug.
60
+ */
61
+ function base64ToBytes(b64) {
62
+ const s = b64.trim().replace(/-/g, '+').replace(/_/g, '/').replace(/=+$/, '');
63
+ if (s.length === 0) return null;
64
+ const out = [];
65
+ let buffer = 0;
66
+ let bits = 0;
67
+ for (const ch of s) {
68
+ const idx = B64_ALPHABET.indexOf(ch);
69
+ if (idx === -1) return null;
70
+ buffer = buffer << 6 | idx;
71
+ bits += 6;
72
+ if (bits >= 8) {
73
+ bits -= 8;
74
+ out.push(buffer >> bits & 0xff);
75
+ }
76
+ }
77
+ return Uint8Array.from(out);
78
+ }
79
+
80
+ /**
81
+ * Decode a detached signature that may arrive hex- or base64-encoded.
82
+ *
83
+ * Tries the unambiguous case first: 128 hex characters is exactly 64 bytes and
84
+ * cannot be anything else. Otherwise base64, then hex as a last resort. Only a
85
+ * result of exactly 64 bytes is accepted, so a string that decodes under the
86
+ * wrong scheme is rejected rather than fed to the curve code as garbage.
87
+ */
88
+ export function decodeSignature(sig) {
89
+ const trimmed = sig.trim();
90
+ if (/^(0x)?[0-9a-fA-F]{128}$/.test(trimmed)) {
91
+ const hex = hexToBytes(trimmed);
92
+ if (hex && hex.length === ED25519_SIGNATURE_BYTES) return hex;
93
+ }
94
+ const b64 = base64ToBytes(trimmed);
95
+ if (b64 && b64.length === ED25519_SIGNATURE_BYTES) return b64;
96
+ const hex = hexToBytes(trimmed);
97
+ if (hex && hex.length === ED25519_SIGNATURE_BYTES) return hex;
98
+ return null;
99
+ }
100
+
101
+ /**
102
+ * Give @noble its SHA-512 provider.
103
+ *
104
+ * The property it reads moved between major versions — v2 wants
105
+ * `etc.sha512Sync`, v3 wants `hashes.sha512` — so both are populated when
106
+ * present. Getting this wrong does not fail loudly: verification simply throws
107
+ * from inside the curve code and the outer catch converts it to "rejected",
108
+ * which is indistinguishable from an actual forgery. That ambiguity is exactly
109
+ * what made this a bad thing to ask app authors to maintain.
110
+ */
111
+ function wireSha512(ed, sha512) {
112
+ // VARIADIC ON PURPOSE. @noble calls its hash provider with several byte
113
+ // arrays to be hashed as one concatenated message. A wrapper taking a
114
+ // single argument hashes only the first, silently produces the wrong
115
+ // digest, and every genuine signature is then reported as INVALID — no
116
+ // error, no exception, just an authenticity check that always says no.
117
+ // Verified against a real CLI-produced signature; the single-argument form
118
+ // failed every positive case while passing every negative one, which is
119
+ // the most misleading shape a bug of this kind can take.
120
+ const concat = parts => {
121
+ if (parts.length === 1) return parts[0];
122
+ let total = 0;
123
+ for (const p of parts) total += p.length;
124
+ const out = new Uint8Array(total);
125
+ let off = 0;
126
+ for (const p of parts) {
127
+ out.set(p, off);
128
+ off += p.length;
129
+ }
130
+ return out;
131
+ };
132
+ const sync = (...msgs) => sha512(concat(msgs));
133
+ const asyncFn = async (...msgs) => sha512(concat(msgs));
134
+
135
+ // EACH BRANCH GETS ITS OWN try. Wrapping both in one is a trap, and it cost
136
+ // an afternoon: under v3 the `hashes` assignment succeeds and the `etc`
137
+ // object is FROZEN, so the v2-shaped assignment throws "object is not
138
+ // extensible" — discarding a verifier that was, by then, already correctly
139
+ // wired. Only "neither shape took" means unavailable.
140
+ let wired = false;
141
+ try {
142
+ if (ed.hashes && typeof ed.hashes === 'object') {
143
+ if (typeof ed.hashes.sha512 !== 'function') ed.hashes.sha512 = sync;
144
+ if (typeof ed.hashes.sha512Async !== 'function') ed.hashes.sha512Async = asyncFn;
145
+ wired = typeof ed.hashes.sha512 === 'function';
146
+ }
147
+ } catch {
148
+ /* frozen or absent — try the other shape */
149
+ }
150
+ try {
151
+ if (ed.etc && typeof ed.etc === 'object') {
152
+ if (typeof ed.etc.sha512Sync !== 'function') ed.etc.sha512Sync = sync;
153
+ if (typeof ed.etc.sha512Async !== 'function') ed.etc.sha512Async = asyncFn;
154
+ wired = wired || typeof ed.etc.sha512Sync === 'function';
155
+ }
156
+ } catch {
157
+ /* frozen or absent — `wired` already records whether the other shape took */
158
+ }
159
+ return wired;
160
+ }
161
+ let resolved;
162
+
163
+ /**
164
+ * The built-in verifier, or null when the optional deps are not installed.
165
+ * Resolution is cached, including the negative result — a missing dependency
166
+ * does not become present at runtime, and retrying the require on every update
167
+ * check would be pure overhead.
168
+ */
169
+ export function getBuiltinVerifier() {
170
+ if (resolved !== undefined) return resolved;
171
+ const ed = loadEd25519();
172
+ const sha512 = loadSha512();
173
+ if (!ed || !sha512) {
174
+ resolved = null;
175
+ return null;
176
+ }
177
+ if (!wireSha512(ed, sha512)) {
178
+ // A shape neither branch matched — a @noble major this SDK predates.
179
+ // Report "no verifier", which is actionable, rather than letting every
180
+ // update fail deep inside the curve code with an opaque error.
181
+ logger.error('[OTA] @noble/ed25519 is installed but its SHA-512 provider could not be ' + 'wired (unrecognised version shape). Supply `ota.verifySignature` ' + 'yourself, or pin a @noble/ed25519 version this SDK supports.');
182
+ resolved = null;
183
+ return null;
184
+ }
185
+ resolved = async ({
186
+ messageHex,
187
+ signature,
188
+ publicKey
189
+ }) => {
190
+ const message = hexToBytes(messageHex);
191
+ const pub = hexToBytes(publicKey);
192
+ const sig = decodeSignature(signature);
193
+ if (!message) {
194
+ logger.error('[OTA] Bundle SHA-256 is not valid hex — refusing to stage.');
195
+ return false;
196
+ }
197
+ if (!pub || pub.length !== ED25519_PUBLIC_KEY_BYTES) {
198
+ // Worth naming precisely: a mistyped or wrongly-encoded key would
199
+ // otherwise present as "every update is a forgery".
200
+ logger.error(`[OTA] ota.publicSigningKey must be ${ED25519_PUBLIC_KEY_BYTES} bytes of hex ` + `(${ED25519_PUBLIC_KEY_BYTES * 2} characters) — got ` + `${pub ? `${pub.length} bytes` : 'a value that is not hex'}.`);
201
+ return false;
202
+ }
203
+ if (!sig) {
204
+ logger.error('[OTA] Bundle signature is not a 64-byte hex/base64 value.');
205
+ return false;
206
+ }
207
+
208
+ // @noble THROWS rather than returning false for bytes that are not a
209
+ // well-formed point — which is most forgeries, since a tampered
210
+ // signature usually decodes to garbage rather than to a valid-but-wrong
211
+ // point. Swallowing that here is deliberate: from the SDK's side a
212
+ // malformed signature IS a rejection, and letting it escape would
213
+ // surface as `verifier_threw`, the outcome reserved for a HOST verifier
214
+ // misbehaving. Conflating "this bundle is forged" with "your verifier
215
+ // is broken" would point every investigation in the wrong direction.
216
+ try {
217
+ // SYNC FIRST, and this is not a style preference. @noble's
218
+ // `verifyAsync` reaches for `crypto.subtle` — "crypto.subtle must be
219
+ // defined, consider polyfill" — which React Native does not provide
220
+ // and neither does a plain Node test environment. The sync `verify`
221
+ // uses the SHA-512 provider wired above and needs no WebCrypto, so
222
+ // it is the path that actually works on a phone. Verification is a
223
+ // few hundred microseconds over a 32-byte digest, once per update
224
+ // check, so there is nothing to gain from the async form anyway.
225
+ if (typeof ed.verify === 'function') return ed.verify(sig, message, pub);
226
+ if (typeof ed.verifyAsync === 'function') return await ed.verifyAsync(sig, message, pub);
227
+ return false;
228
+ } catch {
229
+ return false;
230
+ }
231
+ };
232
+ __DEV__ && logger.debug('[OTA] Using built-in ed25519 verifier (@noble/ed25519 detected).');
233
+ return resolved;
234
+ }
235
+
236
+ /** Test seam — clears the cached resolution. */
237
+ export function _resetBuiltinVerifier() {
238
+ resolved = undefined;
239
+ }
240
+ //# sourceMappingURL=builtinVerifier.js.map
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Isolated lazy loader for the OPTIONAL `@noble/ed25519` dep.
3
+ *
4
+ * WHY ITS OWN FILE: Metro's dependency collector emits a dependency map one
5
+ * entry short for a module containing TWO OR MORE different-string inline
6
+ * `require()` calls, so the require indices desync and `_dependencyMap[N]`
7
+ * reads `undefined` ("Requiring unknown module 'undefined'"). Exactly ONE
8
+ * inline require per module avoids the miscount. `loadSha512` is a sibling file
9
+ * for the same reason — see `push/adapters/loadNotifee.ts`, which hit this first.
10
+ *
11
+ * The string must still be STATICALLY resolvable at bundle time; hosts that do
12
+ * not install it stub it via `withScaleBun`.
13
+ */
14
+
15
+ /** The subset of the @noble/ed25519 surface this SDK uses. */
16
+
17
+ export function loadEd25519() {
18
+ try {
19
+ const mod = require('@noble/ed25519');
20
+ const ed = mod?.default ?? mod;
21
+ // Validate the API actually used, not mere truthiness: when the package
22
+ // is absent `withScaleBun` resolves it to an EMPTY STUB MODULE, which is
23
+ // a perfectly truthy object. A stub must read as "unavailable", because
24
+ // treating it as available would make every signed update fail with a
25
+ // crypto error instead of the actionable "no verifier" message.
26
+ if (typeof ed?.verifyAsync === 'function' || typeof ed?.verify === 'function') {
27
+ return ed;
28
+ }
29
+ return null;
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+ //# sourceMappingURL=loadEd25519.js.map