@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
|
@@ -93,31 +93,33 @@ export class JourneyEventPipeline {
|
|
|
93
93
|
journeyId?: string;
|
|
94
94
|
/** Frame that was on screen when this happened. Resolved by the caller; see sessionTypes. */
|
|
95
95
|
frameId?: string;
|
|
96
|
+
timestamp?: number;
|
|
96
97
|
},
|
|
97
98
|
): JourneyEvent | null {
|
|
98
99
|
try {
|
|
99
100
|
const key = `${type}:${opts?.subtype ?? ''}`;
|
|
100
|
-
const
|
|
101
|
-
|
|
101
|
+
const receivedAt = Date.now();
|
|
102
|
+
const occurredAt = opts?.timestamp ?? receivedAt;
|
|
103
|
+
if (key === this.lastEventKey && (receivedAt - this.lastEventTs) < this.config.dedupeWindowMs) {
|
|
102
104
|
// Within dedup window — allow high-confidence native events to
|
|
103
105
|
// REPLACE a prior low-confidence JS event for the same gesture.
|
|
104
106
|
// This prevents the race where JS fires first and the pipeline
|
|
105
107
|
// drops the native event that has more accurate coordinates.
|
|
106
108
|
const incomingConfidence = (opts?.payload as any)?.confidence;
|
|
107
109
|
if (incomingConfidence === 'high' && this.lastEventConfidence !== 'high') {
|
|
108
|
-
this._replaceLastEvent(key,
|
|
110
|
+
this._replaceLastEvent(key, receivedAt, opts);
|
|
109
111
|
}
|
|
110
112
|
return null;
|
|
111
113
|
}
|
|
112
114
|
this.lastEventKey = key;
|
|
113
|
-
this.lastEventTs =
|
|
115
|
+
this.lastEventTs = receivedAt;
|
|
114
116
|
this.lastEventConfidence = (opts?.payload as any)?.confidence ?? null;
|
|
115
117
|
|
|
116
118
|
const event: JourneyEvent = {
|
|
117
119
|
eventId: generateEventId(),
|
|
118
120
|
sessionId: this.sessionId,
|
|
119
121
|
journeyId: opts?.journeyId,
|
|
120
|
-
ts:
|
|
122
|
+
ts: occurredAt,
|
|
121
123
|
type,
|
|
122
124
|
subtype: opts?.subtype,
|
|
123
125
|
severity: opts?.severity ?? inferSeverity(type),
|
|
@@ -41,16 +41,16 @@ import { bridgeAdapter } from '../replay/bridge/adapters/bridgeAdapter';
|
|
|
41
41
|
import { redactBody } from '../../debug/redaction';
|
|
42
42
|
import { consumeCalibrationTarget } from '../journey/calibrationContext';
|
|
43
43
|
import { resolvePlatformOS } from '../../core/context/device';
|
|
44
|
+
import { emitAutomaticEvent } from '../../analytics/automaticEvents';
|
|
45
|
+
import {
|
|
46
|
+
INTERACTION_PROTOCOL_VERSION,
|
|
47
|
+
automaticInteractionProperties,
|
|
48
|
+
generateInteractionId,
|
|
49
|
+
type InteractionStateStatus,
|
|
50
|
+
} from '../journey/interactionProtocol';
|
|
44
51
|
|
|
45
52
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
46
53
|
|
|
47
|
-
/**
|
|
48
|
-
* Max analytics-lane heatmap interactions emitted per foreground (analytics
|
|
49
|
-
* session) window. Bounds ingest volume now that capture defaults ON; enough to
|
|
50
|
-
* resolve hotspot density, the long tail is dropped (drop-newest beyond cap).
|
|
51
|
-
*/
|
|
52
|
-
const HEATMAP_MAX_INTERACTIONS_PER_WINDOW = 200;
|
|
53
|
-
|
|
54
54
|
export interface SessionManagerConfig {
|
|
55
55
|
sessionReplay?: Partial<SessionReplayConfig>;
|
|
56
56
|
syncPolicy?: SyncDecisionCallback;
|
|
@@ -84,16 +84,6 @@ export class SessionManager {
|
|
|
84
84
|
* lane even when no replay recording is active. Additive, opt-in (default off).
|
|
85
85
|
*/
|
|
86
86
|
private _captureInteractionHeatmap = false;
|
|
87
|
-
/**
|
|
88
|
-
* Sampling cap for the analytics-lane heatmap emission. Now that capture is
|
|
89
|
-
* ON by default, an unbounded one-event-per-gesture stream could materially
|
|
90
|
-
* inflate ingest volume. We cap emitted interactions per analytics-session
|
|
91
|
-
* window (finalize-scoped per foreground): the first N gestures define the
|
|
92
|
-
* hotspot shape; the long tail is dropped. Resets when the window changes.
|
|
93
|
-
*/
|
|
94
|
-
private _heatmapWindowSessionId: string | null = null;
|
|
95
|
-
private _heatmapWindowCount = 0;
|
|
96
|
-
|
|
97
87
|
private session: Session | null = null;
|
|
98
88
|
private active = false;
|
|
99
89
|
private timeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -740,21 +730,16 @@ export class SessionManager {
|
|
|
740
730
|
* injected sensitive keys (e.g. a label) are stripped before transport.
|
|
741
731
|
* - Never throws.
|
|
742
732
|
*/
|
|
743
|
-
private _emitInteractionToAnalytics(
|
|
744
|
-
|
|
745
|
-
|
|
733
|
+
private _emitInteractionToAnalytics(
|
|
734
|
+
gestureType: string,
|
|
735
|
+
payload: Record<string, unknown>,
|
|
736
|
+
occurredAt: number,
|
|
737
|
+
screenName?: string,
|
|
738
|
+
): boolean {
|
|
739
|
+
if (!this._captureInteractionHeatmap) return false;
|
|
740
|
+
if (this.active) return false; // recording lane already carries this tap
|
|
746
741
|
const adapter = this._backendTransport;
|
|
747
|
-
if (!adapter) return;
|
|
748
|
-
|
|
749
|
-
// Per-foreground sampling cap. Reset the counter when the analytics
|
|
750
|
-
// session window rolls over, then drop anything past the cap.
|
|
751
|
-
const windowId = adapter.analyticsSessionId ?? '';
|
|
752
|
-
if (windowId !== this._heatmapWindowSessionId) {
|
|
753
|
-
this._heatmapWindowSessionId = windowId;
|
|
754
|
-
this._heatmapWindowCount = 0;
|
|
755
|
-
}
|
|
756
|
-
if (this._heatmapWindowCount >= HEATMAP_MAX_INTERACTIONS_PER_WINDOW) return;
|
|
757
|
-
this._heatmapWindowCount++;
|
|
742
|
+
if (!adapter) return false;
|
|
758
743
|
|
|
759
744
|
try {
|
|
760
745
|
// Normalize coords in place (same logic as the recording lane).
|
|
@@ -782,16 +767,18 @@ export class SessionManager {
|
|
|
782
767
|
eventId: generateEventId(),
|
|
783
768
|
// sessionId is (re)stamped by the analytics lane at flush time.
|
|
784
769
|
sessionId: adapter.analyticsSessionId ?? '',
|
|
785
|
-
ts:
|
|
770
|
+
ts: occurredAt,
|
|
786
771
|
type: 'USER_ACTION',
|
|
787
772
|
subtype: `gesture:${gestureType}`,
|
|
788
|
-
screen: this._lastKnownScreen ?? undefined,
|
|
773
|
+
screen: screenName ?? this._lastKnownScreen ?? undefined,
|
|
789
774
|
payload: safePayload,
|
|
790
775
|
source: 'user',
|
|
791
776
|
};
|
|
792
777
|
adapter.trackEvent(event);
|
|
778
|
+
return true;
|
|
793
779
|
} catch (err) {
|
|
794
780
|
logger.error('[SessionManager] heatmap analytics emit failed:', err);
|
|
781
|
+
return false;
|
|
795
782
|
}
|
|
796
783
|
}
|
|
797
784
|
|
|
@@ -810,6 +797,8 @@ export class SessionManager {
|
|
|
810
797
|
traceId?: string;
|
|
811
798
|
source?: JourneyEventSource;
|
|
812
799
|
journeyId?: string;
|
|
800
|
+
/** Original observation time; interaction capture may resolve asynchronously. */
|
|
801
|
+
timestamp?: number;
|
|
813
802
|
},
|
|
814
803
|
): void {
|
|
815
804
|
if (!this.active) return;
|
|
@@ -1046,13 +1035,18 @@ export class SessionManager {
|
|
|
1046
1035
|
* Notify of a user interaction. Called by ScaleBunDebugRoot touch handlers.
|
|
1047
1036
|
* Also triggers frame capture for desktop-initiated recordings.
|
|
1048
1037
|
*/
|
|
1049
|
-
onUserAction(
|
|
1038
|
+
onUserAction(
|
|
1039
|
+
subtype: string,
|
|
1040
|
+
payload?: Record<string, unknown>,
|
|
1041
|
+
context?: { screen?: string; timestamp?: number },
|
|
1042
|
+
): void {
|
|
1050
1043
|
if (!this.active) return; // Defense-in-depth: no emission when recording is OFF
|
|
1051
1044
|
this.emitEvent('USER_ACTION', {
|
|
1052
1045
|
subtype,
|
|
1053
|
-
screen: this._lastKnownScreen ?? undefined,
|
|
1046
|
+
screen: context?.screen ?? this._lastKnownScreen ?? undefined,
|
|
1054
1047
|
payload,
|
|
1055
1048
|
source: 'user',
|
|
1049
|
+
timestamp: context?.timestamp,
|
|
1056
1050
|
});
|
|
1057
1051
|
// NOTE: Do NOT call captureManager.onInteraction() here.
|
|
1058
1052
|
// emitEvent() already triggers onInteraction() for USER_ACTION events (line ~405).
|
|
@@ -1103,11 +1097,22 @@ export class SessionManager {
|
|
|
1103
1097
|
screenWidth?: number;
|
|
1104
1098
|
screenHeight?: number;
|
|
1105
1099
|
platform?: string;
|
|
1100
|
+
interactionId?: string;
|
|
1101
|
+
interactionProtocol?: number;
|
|
1102
|
+
occurredAt?: number;
|
|
1103
|
+
ui?: string;
|
|
1104
|
+
stateStatus?: InteractionStateStatus;
|
|
1105
|
+
targetId?: string;
|
|
1106
|
+
screenName?: string;
|
|
1107
|
+
emitAutomaticAnalytics?: boolean;
|
|
1106
1108
|
}): void {
|
|
1107
1109
|
// Exact-duplicate suppression — see _lastGestureSig. Signature is the gesture type plus
|
|
1108
1110
|
// raw coordinates verbatim; String() keeps undefined coords distinct from 0 (a payload
|
|
1109
1111
|
// with no coords never collides with a real origin tap).
|
|
1110
|
-
const
|
|
1112
|
+
const suppliedInteractionId = details?.interactionId;
|
|
1113
|
+
const dedupSig = suppliedInteractionId
|
|
1114
|
+
? `id:${suppliedInteractionId}`
|
|
1115
|
+
: `${gestureType}|${String(details?.x)}|${String(details?.y)}|${String(details?.endX)}|${String(details?.endY)}`;
|
|
1111
1116
|
const nowTs = Date.now();
|
|
1112
1117
|
if (
|
|
1113
1118
|
dedupSig === this._lastGestureSig &&
|
|
@@ -1122,7 +1127,15 @@ export class SessionManager {
|
|
|
1122
1127
|
// Build the canonical gesture payload ONCE so the recording lane and the
|
|
1123
1128
|
// (additive) analytics lane carry byte-identical keys (normalizedX/Y,
|
|
1124
1129
|
// gestureType, etc.). Same object shape that was previously inlined.
|
|
1130
|
+
const interactionId = suppliedInteractionId ?? generateInteractionId();
|
|
1131
|
+
const occurredAt = details?.occurredAt ?? Date.now();
|
|
1132
|
+
const stateStatus = details?.stateStatus ?? (details?.ui ? 'captured_nonempty' : 'not_captured');
|
|
1125
1133
|
const payload: Record<string, unknown> = {
|
|
1134
|
+
interaction_id: interactionId,
|
|
1135
|
+
interaction_protocol: details?.interactionProtocol ?? INTERACTION_PROTOCOL_VERSION,
|
|
1136
|
+
state_status: stateStatus,
|
|
1137
|
+
ui: details?.ui,
|
|
1138
|
+
target_id: details?.targetId,
|
|
1126
1139
|
gestureType,
|
|
1127
1140
|
x: details?.x,
|
|
1128
1141
|
y: details?.y,
|
|
@@ -1167,7 +1180,27 @@ export class SessionManager {
|
|
|
1167
1180
|
// when the flag is OFF, no backend transport is attached, or recording is
|
|
1168
1181
|
// active (the recording path below already carries this tap). Uses a fresh
|
|
1169
1182
|
// payload clone so analytics normalization never mutates the recording one.
|
|
1170
|
-
this._emitInteractionToAnalytics(
|
|
1183
|
+
const analyticsReplayCarrier = this._emitInteractionToAnalytics(
|
|
1184
|
+
gestureType,
|
|
1185
|
+
{ ...payload },
|
|
1186
|
+
occurredAt,
|
|
1187
|
+
details?.screenName,
|
|
1188
|
+
);
|
|
1189
|
+
const replayCarrier = !!this._backendTransport && (this.active || analyticsReplayCarrier);
|
|
1190
|
+
if (details?.emitAutomaticAnalytics) {
|
|
1191
|
+
try {
|
|
1192
|
+
const safePayload = redactBody(payload) as Record<string, unknown>;
|
|
1193
|
+
emitAutomaticEvent(
|
|
1194
|
+
'element_interacted',
|
|
1195
|
+
automaticInteractionProperties(
|
|
1196
|
+
safePayload,
|
|
1197
|
+
details?.screenName ?? this._lastKnownScreen ?? undefined,
|
|
1198
|
+
replayCarrier,
|
|
1199
|
+
),
|
|
1200
|
+
occurredAt,
|
|
1201
|
+
);
|
|
1202
|
+
} catch { /* automatic projection must never affect interaction capture */ }
|
|
1203
|
+
}
|
|
1171
1204
|
|
|
1172
1205
|
if (!this.active) return; // Defense-in-depth: no emission when recording is OFF
|
|
1173
1206
|
// The subtype becomes `ReplayEvent.label`, which is what the Events explorer GROUPS BY. Naming
|
|
@@ -1178,7 +1211,11 @@ export class SessionManager {
|
|
|
1178
1211
|
// ⚠️ `gestureType` also travels in the payload, and the backend's `resolveGesture` reads THAT
|
|
1179
1212
|
// first — so enriching the label cannot change heatmap gesture classification.
|
|
1180
1213
|
const target = typeof details?.target === 'string' ? details.target.trim() : '';
|
|
1181
|
-
this.onUserAction(
|
|
1214
|
+
this.onUserAction(
|
|
1215
|
+
target ? `gesture:${gestureType} · ${target}` : `gesture:${gestureType}`,
|
|
1216
|
+
payload,
|
|
1217
|
+
{ screen: details?.screenName, timestamp: occurredAt },
|
|
1218
|
+
);
|
|
1182
1219
|
}
|
|
1183
1220
|
|
|
1184
1221
|
/**
|
|
@@ -106,6 +106,9 @@ export function discoverArtifacts(packageRoot: string): ArtifactMapping[] {
|
|
|
106
106
|
return out;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/** One-shot guard so an Expo build does not print the pass-through notice per bundle. */
|
|
110
|
+
let warnedUnknownShape = false;
|
|
111
|
+
|
|
109
112
|
/**
|
|
110
113
|
* Build the composing serializer. `hostSerializer` is the app's own customSerializer, if any.
|
|
111
114
|
*/
|
|
@@ -122,10 +125,43 @@ export function createComposingSerializer(
|
|
|
122
125
|
const hostResult = await hostSerializer(entryPoint, preModules, graph, options);
|
|
123
126
|
if (typeof hostResult === 'string') {
|
|
124
127
|
code = hostResult;
|
|
128
|
+
} else if (hostResult && typeof (hostResult as any).code !== 'string') {
|
|
129
|
+
/**
|
|
130
|
+
* A shape this wrapper does not understand — pass it back UNTOUCHED.
|
|
131
|
+
*
|
|
132
|
+
* Expo is the case that matters. `expo/metro-config` installs a
|
|
133
|
+
* serializer returning `{ artifacts: [...] }`, a multi-artifact
|
|
134
|
+
* shape with no top-level `code`. Reading `.code` off it yielded
|
|
135
|
+
* undefined, and returning `{ code: undefined, map }` made every
|
|
136
|
+
* `expo export:embed` die with:
|
|
137
|
+
*
|
|
138
|
+
* Serializer did not return expected format. The project copy
|
|
139
|
+
* of `expo/metro-config` may be out of date.
|
|
140
|
+
*
|
|
141
|
+
* — a message that sends you to upgrade Expo, which is not the
|
|
142
|
+
* problem. The result: `withScaleBun` broke PRODUCTION BUNDLING
|
|
143
|
+
* FOR EVERY EXPO APP, and the SDK's own marketing test app could
|
|
144
|
+
* not build. Found by bundling it.
|
|
145
|
+
*
|
|
146
|
+
* Passing it through costs ScaleBun's source-map composition on
|
|
147
|
+
* Expo (OTA stack traces stay mapped to the bundle rather than to
|
|
148
|
+
* original sources). That is a real loss and strictly better than
|
|
149
|
+
* a build that cannot run at all. Composing INTO an artifact
|
|
150
|
+
* array is the follow-up; it must not be guessed at here.
|
|
151
|
+
*/
|
|
152
|
+
if (!warnedUnknownShape) {
|
|
153
|
+
warnedUnknownShape = true;
|
|
154
|
+
console.warn(
|
|
155
|
+
'[ScaleBun/metro] The host serializer returned a shape this SDK does not ' +
|
|
156
|
+
'compose (Expo returns { artifacts }). Passing it through unchanged — ' +
|
|
157
|
+
'the build is correct, but ScaleBun source-map composition is skipped.',
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
return hostResult as never;
|
|
125
161
|
} else {
|
|
126
|
-
code = hostResult.code;
|
|
162
|
+
code = (hostResult as { code: string; map: string }).code;
|
|
127
163
|
try {
|
|
128
|
-
mapObj = JSON.parse(hostResult.map);
|
|
164
|
+
mapObj = JSON.parse((hostResult as { code: string; map: string }).map);
|
|
129
165
|
} catch {
|
|
130
166
|
// Host emitted an unparsable map; fall through and rebuild from the graph so
|
|
131
167
|
// the build still succeeds with a correct map rather than failing here.
|
|
@@ -275,12 +275,17 @@ class ScaleBunFacade {
|
|
|
275
275
|
/**
|
|
276
276
|
* Boot the OTA orchestrator when the init config asks for it.
|
|
277
277
|
*
|
|
278
|
-
* `publicSigningKey`
|
|
279
|
-
* from configuration alone. The
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
278
|
+
* `publicSigningKey` / `publicSigningKeys` are honoured here so signature
|
|
279
|
+
* enforcement is reachable from configuration alone. The list form exists
|
|
280
|
+
* for key ROTATION: a build pinning [old, new] keeps verifying while the
|
|
281
|
+
* server moves to the new key, so replacing a key never needs an emergency
|
|
282
|
+
* store release. Both fields merge (deduplicated) into one pinned set.
|
|
283
|
+
*
|
|
284
|
+
* Verification comes from `ota.verifySignature` when supplied, else the
|
|
285
|
+
* built-in @noble-based verifier (optional peers). Pinning keys with
|
|
286
|
+
* NEITHER available is fail-CLOSED by design (signature.ts) — an update
|
|
287
|
+
* that cannot be verified is not installed. We say that out loud rather
|
|
288
|
+
* than letting the app discover it as a silent no-update condition.
|
|
284
289
|
*/
|
|
285
290
|
private _maybeStartOta(rawConfig: any): void {
|
|
286
291
|
const ota = rawConfig?.ota;
|
|
@@ -290,16 +295,49 @@ class ScaleBunFacade {
|
|
|
290
295
|
// module (or touch the native spec) at import time.
|
|
291
296
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
292
297
|
const { otaOrchestrator } = require('../features/ota/OtaOrchestrator');
|
|
293
|
-
const
|
|
298
|
+
const rawSingle = ota.publicSigningKey;
|
|
299
|
+
const rawList = ota.publicSigningKeys;
|
|
300
|
+
const publicKeys = Array.from(
|
|
301
|
+
new Set(
|
|
302
|
+
[
|
|
303
|
+
...(typeof rawSingle === 'string' && rawSingle ? [rawSingle] : []),
|
|
304
|
+
...(Array.isArray(rawList) ? rawList : []),
|
|
305
|
+
].filter((k: unknown): k is string => typeof k === 'string' && k.trim().length > 0),
|
|
306
|
+
),
|
|
307
|
+
);
|
|
308
|
+
// Signing was REQUESTED if a usable key exists, or the host passed a
|
|
309
|
+
// keys array at all — an empty one included. `publicSigningKeys: []`
|
|
310
|
+
// is a broken attempt to enable signing, and it must flow through to
|
|
311
|
+
// signature.ts's fail-closed handling, not silently disable signing.
|
|
312
|
+
const signingRequested = publicKeys.length > 0 || Array.isArray(rawList);
|
|
294
313
|
const verifier = ota.verifySignature;
|
|
295
|
-
if (
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
314
|
+
if (signingRequested && typeof verifier !== 'function') {
|
|
315
|
+
// A host verifier is optional since the built-in one landed — the
|
|
316
|
+
// old unconditional warning here told every correctly-configured
|
|
317
|
+
// app that its updates would be rejected. Warn only when neither
|
|
318
|
+
// verifier can actually be resolved.
|
|
319
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
320
|
+
const { getBuiltinVerifier } = require('../features/ota/crypto/builtinVerifier');
|
|
321
|
+
if (!getBuiltinVerifier()) {
|
|
322
|
+
logger.warn(
|
|
323
|
+
'[ScaleBun] A signing key is pinned but no signature verifier is available. ' +
|
|
324
|
+
'Signature checking is fail-closed: updates will be REJECTED until one exists. ' +
|
|
325
|
+
'Install the optional peers `@noble/ed25519` + `@noble/hashes` (no further ' +
|
|
326
|
+
'code needed), or supply `ota.verifySignature`.',
|
|
327
|
+
);
|
|
328
|
+
}
|
|
300
329
|
}
|
|
301
330
|
otaOrchestrator.init({
|
|
302
|
-
...(
|
|
331
|
+
...(signingRequested
|
|
332
|
+
? {
|
|
333
|
+
signature: {
|
|
334
|
+
// Single key stays a plain string — the shape every
|
|
335
|
+
// existing consumer and test already handles.
|
|
336
|
+
publicKey: publicKeys.length === 1 ? publicKeys[0] : publicKeys,
|
|
337
|
+
verifier,
|
|
338
|
+
},
|
|
339
|
+
}
|
|
340
|
+
: {}),
|
|
303
341
|
...(typeof ota.healthyTimeoutMs === 'number'
|
|
304
342
|
? { healthyTimeoutMs: ota.healthyTimeoutMs }
|
|
305
343
|
: {}),
|
|
@@ -526,6 +564,42 @@ class ScaleBunFacade {
|
|
|
526
564
|
}
|
|
527
565
|
|
|
528
566
|
/** Convenience: emit a Phase 1 purchase event (revenue + currency + transaction_id). */
|
|
567
|
+
/**
|
|
568
|
+
* Declare which UI state the user is looking at, so taps are attributed to the surface they
|
|
569
|
+
* happened on rather than averaged across every variant of the screen.
|
|
570
|
+
*
|
|
571
|
+
* ScaleBun.setUiState('filter-sheet', 'open');
|
|
572
|
+
* ScaleBun.setUiState('checkout-step', 'payment');
|
|
573
|
+
*
|
|
574
|
+
* Call it when the state CHANGES — the value is read at tap time, so it only has to be right by
|
|
575
|
+
* the time the next tap lands. On React Native this is the ONLY source of UI state: unlike the web,
|
|
576
|
+
* there is no queryable accessibility tree that says a sheet is up, and guessing would produce a
|
|
577
|
+
* state key that is right on some apps and silently wrong on others.
|
|
578
|
+
*
|
|
579
|
+
* Names and values are identifiers, not content: they become query keys in the dashboard, so a
|
|
580
|
+
* person's name or a cart total does not belong in one. `;`, `:` and `|` are stripped and both
|
|
581
|
+
* halves are capped at 32 characters.
|
|
582
|
+
*/
|
|
583
|
+
public setUiState(name: string, value: string): void {
|
|
584
|
+
try {
|
|
585
|
+
const { setUiState } = require('../features/journey/uiState');
|
|
586
|
+
setUiState(name, value);
|
|
587
|
+
} catch { /* no-throw: a state declaration must never break the host */ }
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Stop reporting a UI state dimension — or, with no argument, all of them.
|
|
592
|
+
*
|
|
593
|
+
* Clear on screen unmount. A declaration left behind follows the user onto a surface where it means
|
|
594
|
+
* nothing, and every tap there is filed under a state that was not on screen.
|
|
595
|
+
*/
|
|
596
|
+
public clearUiState(name?: string): void {
|
|
597
|
+
try {
|
|
598
|
+
const { clearUiState } = require('../features/journey/uiState');
|
|
599
|
+
clearUiState(name);
|
|
600
|
+
} catch { /* no-throw */ }
|
|
601
|
+
}
|
|
602
|
+
|
|
529
603
|
public trackPurchase(input: { revenue: number; currency: string; transactionId: string; [k: string]: any }): void {
|
|
530
604
|
noThrow(() => this._eventTracker?.trackPurchase(input));
|
|
531
605
|
}
|
|
@@ -85,6 +85,30 @@ export interface Spec extends TurboModule {
|
|
|
85
85
|
* `recreateReactContextInBackground()` (bridge). iOS: RCTReloadCommand.
|
|
86
86
|
*/
|
|
87
87
|
restartApp(): void;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Verify a detached ed25519 signature in NATIVE code.
|
|
91
|
+
*
|
|
92
|
+
* Exists because the JS verifier runs inside the very bundle it protects —
|
|
93
|
+
* an attacker who lands one malicious bundle can neuter a JS check for every
|
|
94
|
+
* update after it. Platform crypto is outside the bundle's reach, and it also
|
|
95
|
+
* removes the runtime dependency on the optional @noble peers wherever the OS
|
|
96
|
+
* provides ed25519 (iOS 13+ CryptoKit everywhere; Android API 33+).
|
|
97
|
+
*
|
|
98
|
+
* All three arguments are lowercase hex, pre-validated by the JS caller:
|
|
99
|
+
* the 32-byte bundle SHA-256 (the signature is over its RAW BYTES, matching
|
|
100
|
+
* the CLI's signing contract), the 64-byte signature, the 32-byte public key.
|
|
101
|
+
*
|
|
102
|
+
* Resolves a verdict STRING, not a boolean, because "the signature is wrong"
|
|
103
|
+
* and "this OS cannot check" must never collapse into one value:
|
|
104
|
+
* 'valid' — signature verifies under the key
|
|
105
|
+
* 'invalid' — it does not; the caller must refuse the bundle
|
|
106
|
+
* 'unavailable' — this OS level has no ed25519 (Android < 33); the caller
|
|
107
|
+
* falls back to the JS verifier
|
|
108
|
+
* A rejected promise is machinery failure and the caller treats it as
|
|
109
|
+
* 'unavailable' — never as 'valid'.
|
|
110
|
+
*/
|
|
111
|
+
verifyEd25519(messageHex: string, signatureHex: string, publicKeyHex: string): Promise<string>;
|
|
88
112
|
}
|
|
89
113
|
|
|
90
114
|
/**
|