@bugsession/react-native 0.1.0-beta.3 → 0.1.0-beta.5
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/README.md +11 -1
- package/dist/capture.d.ts +12 -0
- package/dist/core.d.ts +51 -1
- package/dist/core.js +142 -5
- package/dist/index.d.ts +30 -11
- package/dist/index.js +199 -113
- package/dist/tap-label.d.ts +6 -0
- package/dist/tap-label.js +73 -0
- package/dist/touches.d.ts +15 -0
- package/dist/touches.js +35 -0
- package/dist/trigger.d.ts +55 -0
- package/dist/trigger.js +297 -0
- package/package.json +8 -2
- package/src/capture.ts +6 -0
- package/src/core.ts +136 -5
- package/src/index.tsx +205 -125
- package/src/tap-label.ts +69 -0
- package/src/touches.tsx +37 -0
- package/src/trigger.tsx +364 -0
package/dist/trigger.js
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.createTrigger = createTrigger;
|
|
7
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
8
|
+
const react_1 = __importDefault(require("react"));
|
|
9
|
+
const react_native_1 = require("react-native");
|
|
10
|
+
// Native modals (e.g. Expo Router `presentation: 'modal'`) are presented above the app's
|
|
11
|
+
// root view, which would hide the trigger. react-native-screens' FullWindowOverlay renders
|
|
12
|
+
// in a window above them and passes touches through empty areas. Optional dependency.
|
|
13
|
+
let FullWindowOverlay = null;
|
|
14
|
+
if (react_native_1.Platform.OS === 'ios') {
|
|
15
|
+
try {
|
|
16
|
+
FullWindowOverlay = require('react-native-screens').FullWindowOverlay ?? null;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
FullWindowOverlay = null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const tab = { width: 34, height: 76 };
|
|
23
|
+
const panelWidth = 212;
|
|
24
|
+
const autoHideMs = 4000;
|
|
25
|
+
const barShape = [0.55, 0.8, 1, 0.8, 0.55];
|
|
26
|
+
const compactBarShape = [0.8, 1, 0.8];
|
|
27
|
+
const ink = '#FF3B30';
|
|
28
|
+
const strokeWidth = 5;
|
|
29
|
+
// Survives remounts (e.g. the trigger rendered by an error boundary) while the app is open.
|
|
30
|
+
let tabPlacement = null;
|
|
31
|
+
function elapsed(ms) {
|
|
32
|
+
const total = Math.max(0, Math.floor(ms / 1000));
|
|
33
|
+
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;
|
|
34
|
+
}
|
|
35
|
+
function createTrigger(controller) {
|
|
36
|
+
function usePhase() {
|
|
37
|
+
return react_1.default.useSyncExternalStore(controller.subscribe, controller.getPhase, controller.getPhase);
|
|
38
|
+
}
|
|
39
|
+
function useInsets() {
|
|
40
|
+
const [insets, setInsets] = react_1.default.useState(null);
|
|
41
|
+
react_1.default.useEffect(() => { void controller.safeAreaInsets().then(setInsets).catch(() => setInsets(null)); }, []);
|
|
42
|
+
// Without native insets (older native builds, Android), assume a notch and home indicator on iOS.
|
|
43
|
+
return insets ?? { top: react_native_1.Platform.OS === 'ios' ? 54 : 24, bottom: react_native_1.Platform.OS === 'ios' ? 34 : 16 };
|
|
44
|
+
}
|
|
45
|
+
function useElapsed(phase, maxDurationSeconds) {
|
|
46
|
+
const [now, setNow] = react_1.default.useState(Date.now());
|
|
47
|
+
react_1.default.useEffect(() => {
|
|
48
|
+
if (phase.kind !== 'recording')
|
|
49
|
+
return;
|
|
50
|
+
const timer = setInterval(() => setNow(Date.now()), 500);
|
|
51
|
+
return () => clearInterval(timer);
|
|
52
|
+
}, [phase]);
|
|
53
|
+
const ms = phase.kind === 'recording' ? Math.max(0, now - phase.startedAt) : 0;
|
|
54
|
+
return { ms, nearLimit: ms > (maxDurationSeconds - 30) * 1000 };
|
|
55
|
+
}
|
|
56
|
+
function Waveform({ compact }) {
|
|
57
|
+
const shape = compact ? compactBarShape : barShape;
|
|
58
|
+
const bars = react_1.default.useRef(shape.map(() => new react_native_1.Animated.Value(0.25))).current;
|
|
59
|
+
const [live, setLive] = react_1.default.useState(false);
|
|
60
|
+
react_1.default.useEffect(() => {
|
|
61
|
+
const unsubscribe = controller.onAudioLevel(level => {
|
|
62
|
+
setLive(true);
|
|
63
|
+
react_native_1.Animated.parallel(bars.map((bar, i) => react_native_1.Animated.timing(bar, {
|
|
64
|
+
toValue: Math.max(0.18, Math.min(1, level * shape[i] * (0.85 + Math.random() * 0.3))),
|
|
65
|
+
duration: 90, easing: react_native_1.Easing.out(react_native_1.Easing.quad), useNativeDriver: true,
|
|
66
|
+
}))).start();
|
|
67
|
+
});
|
|
68
|
+
return () => unsubscribe?.();
|
|
69
|
+
}, [bars, shape]);
|
|
70
|
+
return (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.wave, compact && styles.waveCompact], accessibilityElementsHidden: true, importantForAccessibility: "no-hide-descendants", children: bars.map((bar, i) => (0, jsx_runtime_1.jsx)(react_native_1.Animated.View, { style: [styles.waveBar, compact && styles.waveBarCompact, !live && styles.waveBarIdle, { transform: [{ scaleY: bar }] }] }, i)) });
|
|
71
|
+
}
|
|
72
|
+
function RecordingDot({ size = 9 }) {
|
|
73
|
+
const pulse = react_1.default.useRef(new react_native_1.Animated.Value(1)).current;
|
|
74
|
+
react_1.default.useEffect(() => {
|
|
75
|
+
const loop = react_native_1.Animated.loop(react_native_1.Animated.sequence([
|
|
76
|
+
react_native_1.Animated.timing(pulse, { toValue: 0.35, duration: 700, useNativeDriver: true }),
|
|
77
|
+
react_native_1.Animated.timing(pulse, { toValue: 1, duration: 700, useNativeDriver: true }),
|
|
78
|
+
]));
|
|
79
|
+
loop.start();
|
|
80
|
+
return () => loop.stop();
|
|
81
|
+
}, [pulse]);
|
|
82
|
+
return (0, jsx_runtime_1.jsx)(react_native_1.Animated.View, { style: { width: size, height: size, borderRadius: size / 2, backgroundColor: ink, opacity: pulse } });
|
|
83
|
+
}
|
|
84
|
+
/** One rotated, rounded rectangle per segment: smooth strokes without an SVG dependency. */
|
|
85
|
+
function Stroke({ points }) {
|
|
86
|
+
if (points.length === 1) {
|
|
87
|
+
const p = points[0];
|
|
88
|
+
return (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.segment, { left: p.x - strokeWidth / 2, top: p.y - strokeWidth / 2, width: strokeWidth }] });
|
|
89
|
+
}
|
|
90
|
+
return (0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: points.slice(1).map((b, i) => {
|
|
91
|
+
const a = points[i];
|
|
92
|
+
const length = Math.hypot(b.x - a.x, b.y - a.y) + strokeWidth;
|
|
93
|
+
return (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.segment, {
|
|
94
|
+
left: (a.x + b.x) / 2 - length / 2, top: (a.y + b.y) / 2 - strokeWidth / 2, width: length,
|
|
95
|
+
transform: [{ rotate: `${Math.atan2(b.y - a.y, b.x - a.x)}rad` }],
|
|
96
|
+
}] }, i);
|
|
97
|
+
}) });
|
|
98
|
+
}
|
|
99
|
+
/** Full-screen pen layer, active only while the tester is drawing. Strokes are part of the screen recording. */
|
|
100
|
+
function DrawingCanvas({ strokes, setStrokes }) {
|
|
101
|
+
const { width, height } = (0, react_native_1.useWindowDimensions)();
|
|
102
|
+
const current = react_1.default.useRef(null);
|
|
103
|
+
const responder = react_1.default.useMemo(() => react_native_1.PanResponder.create({
|
|
104
|
+
onStartShouldSetPanResponder: () => true,
|
|
105
|
+
onMoveShouldSetPanResponder: () => true,
|
|
106
|
+
onPanResponderGrant: event => {
|
|
107
|
+
current.current = [{ x: event.nativeEvent.pageX, y: event.nativeEvent.pageY }];
|
|
108
|
+
setStrokes(all => [...all, current.current]);
|
|
109
|
+
},
|
|
110
|
+
onPanResponderMove: event => {
|
|
111
|
+
const stroke = current.current;
|
|
112
|
+
if (!stroke)
|
|
113
|
+
return;
|
|
114
|
+
const point = { x: event.nativeEvent.pageX, y: event.nativeEvent.pageY };
|
|
115
|
+
const last = stroke[stroke.length - 1];
|
|
116
|
+
if (Math.hypot(point.x - last.x, point.y - last.y) < 3 || stroke.length >= 400)
|
|
117
|
+
return;
|
|
118
|
+
const next = [...stroke, point];
|
|
119
|
+
current.current = next;
|
|
120
|
+
setStrokes(all => [...all.slice(0, -1), next]);
|
|
121
|
+
},
|
|
122
|
+
onPanResponderRelease: () => {
|
|
123
|
+
const stroke = current.current;
|
|
124
|
+
current.current = null;
|
|
125
|
+
if (!stroke)
|
|
126
|
+
return;
|
|
127
|
+
const xs = stroke.map(p => p.x), ys = stroke.map(p => p.y);
|
|
128
|
+
const x = Math.min(...xs), y = Math.min(...ys);
|
|
129
|
+
controller.annotate({
|
|
130
|
+
x: Math.round(x), y: Math.round(y),
|
|
131
|
+
width: Math.round(Math.max(...xs) - x), height: Math.round(Math.max(...ys) - y),
|
|
132
|
+
screenWidth: Math.round(width), screenHeight: Math.round(height), points: stroke.length,
|
|
133
|
+
});
|
|
134
|
+
},
|
|
135
|
+
}), [width, height, setStrokes]);
|
|
136
|
+
return (0, jsx_runtime_1.jsx)(react_native_1.View, { ...responder.panHandlers, style: styles.canvas, accessibilityLabel: "Drawing area. Draw to highlight a problem.", children: strokes.map((points, i) => (0, jsx_runtime_1.jsx)(Stroke, { points: points }, i)) });
|
|
137
|
+
}
|
|
138
|
+
function PanelButton({ label, onPress, disabled, tone, accessibilityLabel }) {
|
|
139
|
+
return (0, jsx_runtime_1.jsx)(react_native_1.Pressable, { accessibilityRole: "button", accessibilityLabel: accessibilityLabel ?? label, accessibilityState: { disabled: !!disabled }, disabled: disabled, onPress: onPress, hitSlop: 4, style: [styles.panelButton, tone === 'primary' && styles.panelButtonPrimary, tone === 'danger' && styles.panelButtonDanger, disabled && styles.panelButtonDisabled], children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.panelButtonText, tone === 'primary' && styles.panelButtonTextPrimary], children: label }) });
|
|
140
|
+
}
|
|
141
|
+
/** After stopping, a short toast at the top; the recording has already ended, so it is not captured. */
|
|
142
|
+
function StatusToast({ phase }) {
|
|
143
|
+
const insets = useInsets();
|
|
144
|
+
const appear = react_1.default.useRef(new react_native_1.Animated.Value(0)).current;
|
|
145
|
+
const visible = phase.kind === 'uploading' || phase.kind === 'sent' || phase.kind === 'failed';
|
|
146
|
+
const [shown, setShown] = react_1.default.useState(phase);
|
|
147
|
+
react_1.default.useEffect(() => { if (visible)
|
|
148
|
+
setShown(phase); }, [phase, visible]);
|
|
149
|
+
react_1.default.useEffect(() => {
|
|
150
|
+
react_native_1.Animated.spring(appear, { toValue: visible ? 1 : 0, useNativeDriver: true, bounciness: 5, speed: 14 }).start();
|
|
151
|
+
}, [visible, appear]);
|
|
152
|
+
let content = null;
|
|
153
|
+
if (shown.kind === 'uploading')
|
|
154
|
+
content = (0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(react_native_1.ActivityIndicator, { size: "small", color: "#FFFFFF" }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.toastText, children: "Sending report\u2026" })] });
|
|
155
|
+
else if (shown.kind === 'sent')
|
|
156
|
+
content = (0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.badge, styles.badgeSuccess], children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.badgeText, children: "\u2713" }) }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.toastText, children: "Report sent" })] });
|
|
157
|
+
else if (shown.kind === 'failed')
|
|
158
|
+
content = (0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.badge, styles.badgeError], children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.badgeText, children: "!" }) }), (0, jsx_runtime_1.jsxs)(react_native_1.Text, { style: styles.toastText, numberOfLines: 2, children: ["Not sent: ", shown.message] })] });
|
|
159
|
+
return (0, jsx_runtime_1.jsx)(react_native_1.Animated.View, { pointerEvents: "none", accessibilityLiveRegion: "polite", style: [styles.toastLayer, {
|
|
160
|
+
top: insets.top + 8, opacity: appear,
|
|
161
|
+
transform: [{ translateY: appear.interpolate({ inputRange: [0, 1], outputRange: [-16, 0] }) }],
|
|
162
|
+
}], children: content && (0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.toast, children: content }) });
|
|
163
|
+
}
|
|
164
|
+
return function Trigger({ position = 'bottom-right', offset = 96, draggable = true, style, accessibilityLabel, children, onError, maxDurationSeconds = 150 }) {
|
|
165
|
+
const phase = usePhase();
|
|
166
|
+
const insets = useInsets();
|
|
167
|
+
const { width, height } = (0, react_native_1.useWindowDimensions)();
|
|
168
|
+
const { ms, nearLimit } = useElapsed(phase, maxDurationSeconds);
|
|
169
|
+
const [expanded, setExpanded] = react_1.default.useState(false);
|
|
170
|
+
const [drawing, setDrawing] = react_1.default.useState(false);
|
|
171
|
+
const [strokes, setStrokes] = react_1.default.useState([]);
|
|
172
|
+
const minTop = insets.top + 8;
|
|
173
|
+
const maxTop = Math.max(minTop, height - insets.bottom - tab.height - 8);
|
|
174
|
+
const clampTop = react_1.default.useCallback((top) => Math.max(minTop, Math.min(top, maxTop)), [minTop, maxTop]);
|
|
175
|
+
const [placement, setPlacement] = react_1.default.useState(() => tabPlacement ?? {
|
|
176
|
+
side: position.includes('left') ? 'left' : 'right',
|
|
177
|
+
top: position.includes('top') ? minTop + offset : height - insets.bottom - tab.height - offset,
|
|
178
|
+
});
|
|
179
|
+
const top = clampTop(placement.top);
|
|
180
|
+
const drag = react_1.default.useRef(new react_native_1.Animated.ValueXY()).current;
|
|
181
|
+
const dragStart = react_1.default.useRef({ side: placement.side, top });
|
|
182
|
+
dragStart.current = { side: placement.side, top };
|
|
183
|
+
const recording = phase.kind === 'recording';
|
|
184
|
+
const starting = phase.kind === 'starting';
|
|
185
|
+
// Auto-stop at the limit, and reset the controls when a recording ends.
|
|
186
|
+
react_1.default.useEffect(() => {
|
|
187
|
+
if (recording && ms >= maxDurationSeconds * 1000)
|
|
188
|
+
void controller.toggle();
|
|
189
|
+
}, [recording, ms, maxDurationSeconds]);
|
|
190
|
+
react_1.default.useEffect(() => {
|
|
191
|
+
if (!recording) {
|
|
192
|
+
setExpanded(false);
|
|
193
|
+
setDrawing(false);
|
|
194
|
+
setStrokes([]);
|
|
195
|
+
}
|
|
196
|
+
}, [recording]);
|
|
197
|
+
// The options panel hides itself so it stays out of the video as much as possible.
|
|
198
|
+
react_1.default.useEffect(() => {
|
|
199
|
+
if (!expanded || drawing)
|
|
200
|
+
return;
|
|
201
|
+
const timer = setTimeout(() => setExpanded(false), autoHideMs);
|
|
202
|
+
return () => clearTimeout(timer);
|
|
203
|
+
}, [expanded, drawing]);
|
|
204
|
+
const responder = react_1.default.useMemo(() => react_native_1.PanResponder.create({
|
|
205
|
+
onStartShouldSetPanResponder: () => false,
|
|
206
|
+
onMoveShouldSetPanResponder: (_, gesture) => draggable && Math.hypot(gesture.dx, gesture.dy) > 7,
|
|
207
|
+
onPanResponderMove: (_, gesture) => drag.setValue({ x: gesture.dx, y: clampTop(dragStart.current.top + gesture.dy) - dragStart.current.top }),
|
|
208
|
+
onPanResponderRelease: (_, gesture) => {
|
|
209
|
+
const startX = dragStart.current.side === 'right' ? width - tab.width : 0;
|
|
210
|
+
const next = { side: (startX + gesture.dx + tab.width / 2 > width / 2 ? 'right' : 'left'), top: clampTop(dragStart.current.top + gesture.dy) };
|
|
211
|
+
tabPlacement = next;
|
|
212
|
+
setPlacement(next);
|
|
213
|
+
drag.setValue({ x: 0, y: 0 });
|
|
214
|
+
},
|
|
215
|
+
onPanResponderTerminate: () => drag.setValue({ x: 0, y: 0 }),
|
|
216
|
+
}), [draggable, drag, width, clampTop]);
|
|
217
|
+
const onTab = async () => {
|
|
218
|
+
if (recording) {
|
|
219
|
+
setExpanded(open => !open || drawing);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
223
|
+
await controller.toggle();
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
controller.fail(error);
|
|
227
|
+
onError?.(error);
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
const stop = () => { setExpanded(false); setDrawing(false); setStrokes([]); void controller.toggle(); };
|
|
231
|
+
const side = placement.side;
|
|
232
|
+
const showTab = phase.kind === 'idle' || starting || recording;
|
|
233
|
+
const panelTop = Math.max(minTop, Math.min(top - 12, height - insets.bottom - 8 - (drawing ? 150 : 170)));
|
|
234
|
+
const panelEdge = { [side]: tab.width + 6 };
|
|
235
|
+
let tabContent;
|
|
236
|
+
if (starting)
|
|
237
|
+
tabContent = (0, jsx_runtime_1.jsx)(react_native_1.ActivityIndicator, { size: "small", color: "#FFFFFF" });
|
|
238
|
+
else if (recording)
|
|
239
|
+
tabContent = (0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(RecordingDot, { size: 8 }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.tabTime, nearLimit && styles.warning], children: elapsed(ms) }), (0, jsx_runtime_1.jsx)(Waveform, { compact: true })] });
|
|
240
|
+
else
|
|
241
|
+
tabContent = children ?? (0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.recordRing, children: (0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.recordDot }) });
|
|
242
|
+
const layer = (0, jsx_runtime_1.jsxs)(react_native_1.View, { pointerEvents: "box-none", style: styles.layer, children: [drawing && recording && (0, jsx_runtime_1.jsx)(DrawingCanvas, { strokes: strokes, setStrokes: setStrokes }), showTab && (0, jsx_runtime_1.jsx)(react_native_1.Animated.View, { ...(draggable ? responder.panHandlers : {}), style: [styles.tabPosition, {
|
|
243
|
+
top, [side]: 0, transform: drag.getTranslateTransform(),
|
|
244
|
+
}], children: (0, jsx_runtime_1.jsx)(react_native_1.Pressable, { accessibilityRole: "button", accessibilityLabel: accessibilityLabel ?? (recording ? `Recording, ${Math.floor(ms / 1000)} seconds. Show recording options.` : 'Record a bug report'), accessibilityHint: recording ? undefined : 'Records the screen and your voice.', accessibilityState: { busy: starting, expanded: recording ? expanded : undefined }, disabled: starting, onPress: () => { void onTab(); }, style: [styles.tab, side === 'right' ? styles.tabRight : styles.tabLeft, recording && styles.tabRecording, style], children: tabContent }) }), recording && expanded && (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [styles.panel, panelEdge, { top: panelTop }], children: [(0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.panelHeader, children: [(0, jsx_runtime_1.jsx)(RecordingDot, {}), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.panelTime, nearLimit && styles.warning], children: elapsed(ms) }), (0, jsx_runtime_1.jsx)(Waveform, {})] }), drawing ? (0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.panelHint, children: "Draw on the screen to highlight the problem." }), (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.panelRow, children: [(0, jsx_runtime_1.jsx)(PanelButton, { label: "Undo", disabled: !strokes.length, onPress: () => setStrokes(all => all.slice(0, -1)) }), (0, jsx_runtime_1.jsx)(PanelButton, { label: "Clear", disabled: !strokes.length, onPress: () => setStrokes([]) })] }), (0, jsx_runtime_1.jsx)(PanelButton, { label: "Done", tone: "primary", onPress: () => { setDrawing(false); setStrokes([]); setExpanded(false); } })] }) : (0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(PanelButton, { label: "\u270E Draw on screen", onPress: () => setDrawing(true) }), (0, jsx_runtime_1.jsx)(PanelButton, { label: "\u25A0 Stop & send", tone: "danger", accessibilityLabel: "Stop recording and send the report", onPress: stop })] })] }), (0, jsx_runtime_1.jsx)(StatusToast, { phase: phase })] });
|
|
245
|
+
return FullWindowOverlay
|
|
246
|
+
? (0, jsx_runtime_1.jsx)(FullWindowOverlay, { unstable_accessibilityContainerViewIsModal: false, children: layer })
|
|
247
|
+
: layer;
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
const styles = react_native_1.StyleSheet.create({
|
|
251
|
+
layer: { ...react_native_1.StyleSheet.absoluteFill, zIndex: 9999, elevation: 9999 },
|
|
252
|
+
tabPosition: { position: 'absolute' },
|
|
253
|
+
tab: {
|
|
254
|
+
width: tab.width, height: tab.height, alignItems: 'center', justifyContent: 'center', gap: 5,
|
|
255
|
+
backgroundColor: 'rgba(16,17,20,0.92)', borderWidth: react_native_1.StyleSheet.hairlineWidth, borderColor: 'rgba(255,255,255,0.16)',
|
|
256
|
+
shadowColor: '#000', shadowOpacity: 0.25, shadowRadius: 8, shadowOffset: { width: 0, height: 3 }, elevation: 10,
|
|
257
|
+
},
|
|
258
|
+
tabRight: { borderTopLeftRadius: 14, borderBottomLeftRadius: 14, borderRightWidth: 0 },
|
|
259
|
+
tabLeft: { borderTopRightRadius: 14, borderBottomRightRadius: 14, borderLeftWidth: 0 },
|
|
260
|
+
tabRecording: { borderColor: 'rgba(255,59,48,0.5)' },
|
|
261
|
+
tabTime: { color: '#FFFFFF', fontSize: 10, fontWeight: '700', fontVariant: ['tabular-nums'] },
|
|
262
|
+
recordRing: { width: 22, height: 22, borderRadius: 11, borderWidth: 2, borderColor: 'rgba(255,255,255,0.9)', alignItems: 'center', justifyContent: 'center' },
|
|
263
|
+
recordDot: { width: 11, height: 11, borderRadius: 5.5, backgroundColor: ink },
|
|
264
|
+
warning: { color: '#FFB340' },
|
|
265
|
+
panel: {
|
|
266
|
+
position: 'absolute', width: panelWidth, gap: 8, padding: 12, borderRadius: 18,
|
|
267
|
+
backgroundColor: 'rgba(16,17,20,0.96)', borderWidth: react_native_1.StyleSheet.hairlineWidth, borderColor: 'rgba(255,255,255,0.14)',
|
|
268
|
+
shadowColor: '#000', shadowOpacity: 0.3, shadowRadius: 14, shadowOffset: { width: 0, height: 6 }, elevation: 14,
|
|
269
|
+
},
|
|
270
|
+
panelHeader: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 2 },
|
|
271
|
+
panelTime: { color: '#FFFFFF', fontSize: 16, fontWeight: '700', fontVariant: ['tabular-nums'], flex: 1 },
|
|
272
|
+
panelHint: { color: 'rgba(255,255,255,0.7)', fontSize: 12, lineHeight: 16 },
|
|
273
|
+
panelRow: { flexDirection: 'row', gap: 8 },
|
|
274
|
+
panelButton: { flex: 1, alignItems: 'center', paddingVertical: 10, paddingHorizontal: 12, borderRadius: 12, backgroundColor: 'rgba(255,255,255,0.12)' },
|
|
275
|
+
panelButtonPrimary: { backgroundColor: '#FFFFFF' },
|
|
276
|
+
panelButtonDanger: { backgroundColor: ink },
|
|
277
|
+
panelButtonDisabled: { opacity: 0.4 },
|
|
278
|
+
panelButtonText: { color: '#FFFFFF', fontSize: 14, fontWeight: '700' },
|
|
279
|
+
panelButtonTextPrimary: { color: '#000000' },
|
|
280
|
+
canvas: { ...react_native_1.StyleSheet.absoluteFill, borderWidth: 3, borderColor: 'rgba(255,59,48,0.55)' },
|
|
281
|
+
segment: { position: 'absolute', height: strokeWidth, borderRadius: strokeWidth / 2, backgroundColor: ink },
|
|
282
|
+
toastLayer: { position: 'absolute', left: 0, right: 0, alignItems: 'center' },
|
|
283
|
+
toast: {
|
|
284
|
+
flexDirection: 'row', alignItems: 'center', gap: 10, maxWidth: 340, paddingHorizontal: 16, paddingVertical: 10, borderRadius: 22,
|
|
285
|
+
backgroundColor: 'rgba(16,17,20,0.96)', shadowColor: '#000', shadowOpacity: 0.3, shadowRadius: 12, shadowOffset: { width: 0, height: 5 }, elevation: 14,
|
|
286
|
+
},
|
|
287
|
+
toastText: { color: '#FFFFFF', fontSize: 15, fontWeight: '600', flexShrink: 1 },
|
|
288
|
+
wave: { flexDirection: 'row', alignItems: 'center', gap: 3, height: 18 },
|
|
289
|
+
waveCompact: { gap: 2, height: 12 },
|
|
290
|
+
waveBar: { width: 3, height: 18, borderRadius: 1.5, backgroundColor: '#FF6B61' },
|
|
291
|
+
waveBarCompact: { width: 2, height: 12, borderRadius: 1 },
|
|
292
|
+
waveBarIdle: { backgroundColor: 'rgba(255,255,255,0.45)' },
|
|
293
|
+
badge: { width: 22, height: 22, borderRadius: 11, alignItems: 'center', justifyContent: 'center' },
|
|
294
|
+
badgeSuccess: { backgroundColor: '#30D158' },
|
|
295
|
+
badgeError: { backgroundColor: '#FF453A' },
|
|
296
|
+
badgeText: { color: '#000000', fontSize: 13, fontWeight: '800' },
|
|
297
|
+
});
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bugsession/react-native",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.5",
|
|
4
4
|
"private": false,
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"peerDependencies": {
|
|
8
8
|
"react": ">=18.3.1 <20",
|
|
9
|
-
"react-native": ">=0.76.0 <0.89"
|
|
9
|
+
"react-native": ">=0.76.0 <0.89",
|
|
10
|
+
"react-native-screens": ">=4.0.0"
|
|
10
11
|
},
|
|
11
12
|
"description": "Opt-in React Native runtime evidence SDK",
|
|
12
13
|
"license": "UNLICENSED",
|
|
@@ -42,5 +43,10 @@
|
|
|
42
43
|
"homepage": "https://github.com/Anis-Ghliss/bugsession/tree/main/packages/react-native#readme",
|
|
43
44
|
"bugs": {
|
|
44
45
|
"url": "https://github.com/Anis-Ghliss/bugsession/issues"
|
|
46
|
+
},
|
|
47
|
+
"peerDependenciesMeta": {
|
|
48
|
+
"react-native-screens": {
|
|
49
|
+
"optional": true
|
|
50
|
+
}
|
|
45
51
|
}
|
|
46
52
|
}
|
package/src/capture.ts
CHANGED
|
@@ -12,6 +12,12 @@ export type NativeCaptureAdapter = {
|
|
|
12
12
|
stop(): Promise<NativeRecording>;
|
|
13
13
|
cancel(): Promise<void>;
|
|
14
14
|
upload(url: string, uri: string, headers?: Record<string, string>): Promise<void>;
|
|
15
|
+
/** Optional: live microphone level (0-1) for the recording indicator. Returns an unsubscribe function. */
|
|
16
|
+
onAudioLevel?(listener: (level: number) => void): () => void;
|
|
17
|
+
/** Optional: safe-area insets in points, used to place the recording controls above the home indicator. */
|
|
18
|
+
safeAreaInsets?(): Promise<{ top: number; bottom: number } | null>;
|
|
19
|
+
/** Optional: native app and device facts merged into each recording's metadata. */
|
|
20
|
+
appContext?(): Promise<{ application: Record<string, unknown>; device: Record<string, unknown> } | null>;
|
|
15
21
|
};
|
|
16
22
|
export function alignNativeRecording(recording: NativeRecording, offsetMs: number, uncertaintyMs: number) {
|
|
17
23
|
const videoStartMs = recording.firstVideoNativeMs + offsetMs;
|
package/src/core.ts
CHANGED
|
@@ -20,24 +20,116 @@ export class SessionClock {
|
|
|
20
20
|
private epoch: number;
|
|
21
21
|
constructor(private source = () => performance.now()) { this.epoch = source(); }
|
|
22
22
|
now() { return Math.max(0, this.source() - this.epoch); }
|
|
23
|
+
/** Monotonic source reading at which this clock reads zero. */
|
|
24
|
+
get origin() { return this.epoch; }
|
|
23
25
|
}
|
|
24
26
|
export class Collector {
|
|
25
27
|
events: Evidence[] = [];
|
|
26
28
|
dropped = 0;
|
|
27
29
|
constructor(public clock = new SessionClock(), private limit = 5000) {}
|
|
28
30
|
add(type: string, data: Record<string, unknown>) {
|
|
31
|
+
this.addAt(type, data, this.clock.now());
|
|
32
|
+
}
|
|
33
|
+
addAt(type: string, data: Record<string, unknown>, timestampMs: number) {
|
|
29
34
|
if (this.events.length >= this.limit) { this.dropped++; return; }
|
|
30
|
-
this.events.push({ ...data, type, timestampMs
|
|
35
|
+
this.events.push({ ...data, type, timestampMs });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type TelemetrySink = { add(type: string, data: Record<string, unknown>): void; clock: { now(): number } };
|
|
40
|
+
export const MAX_PRE_RECORD_MS = 300_000;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Routes app events to the active session, or, between sessions, into a bounded
|
|
44
|
+
* in-memory buffer of the last `preRecordMs`. The buffer never leaves the device
|
|
45
|
+
* unless a recording starts. Timestamps are kept on one app-lifetime clock and
|
|
46
|
+
* converted to the session clock, so requests spanning the start stay consistent.
|
|
47
|
+
*/
|
|
48
|
+
export class Telemetry implements TelemetrySink {
|
|
49
|
+
readonly clock: SessionClock;
|
|
50
|
+
private buffer: Evidence[] = [];
|
|
51
|
+
private session: { collector: Collector; offsetMs: number } | null = null;
|
|
52
|
+
private preRecordMs: number;
|
|
53
|
+
constructor(preRecordMs = 60_000, private maxBuffered = 500, source = () => performance.now()) {
|
|
54
|
+
this.clock = new SessionClock(source);
|
|
55
|
+
this.preRecordMs = Math.max(0, Math.min(preRecordMs, MAX_PRE_RECORD_MS));
|
|
56
|
+
}
|
|
57
|
+
/** False when nothing would be kept, so callers can skip formatting work. */
|
|
58
|
+
get active() { return !!this.session || this.preRecordMs > 0; }
|
|
59
|
+
configure(preRecordMs: number, maxBuffered = this.maxBuffered) {
|
|
60
|
+
this.preRecordMs = Math.max(0, Math.min(preRecordMs, MAX_PRE_RECORD_MS));
|
|
61
|
+
this.maxBuffered = maxBuffered;
|
|
62
|
+
this.prune(this.clock.now());
|
|
63
|
+
}
|
|
64
|
+
add(type: string, data: Record<string, unknown>) {
|
|
65
|
+
const now = this.clock.now();
|
|
66
|
+
if (this.session) {
|
|
67
|
+
const { collector, offsetMs } = this.session;
|
|
68
|
+
collector.addAt(type, shiftRequestTime(data, offsetMs), now - offsetMs);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (this.preRecordMs <= 0) return;
|
|
72
|
+
this.buffer.push({ ...data, type, timestampMs: now });
|
|
73
|
+
this.prune(now);
|
|
31
74
|
}
|
|
75
|
+
/** Routes events to `collector` and returns the buffered events on its clock (negative times). */
|
|
76
|
+
begin(collector: Collector): Evidence[] {
|
|
77
|
+
const offsetMs = collector.clock.origin - this.clock.origin;
|
|
78
|
+
this.prune(this.clock.now());
|
|
79
|
+
const before = this.buffer.map(e => ({ ...shiftRequestTime(e, offsetMs), timestampMs: e.timestampMs - offsetMs, preRecording: true }));
|
|
80
|
+
this.buffer = [];
|
|
81
|
+
this.session = { collector, offsetMs };
|
|
82
|
+
return before;
|
|
83
|
+
}
|
|
84
|
+
end() { this.session = null; }
|
|
85
|
+
private prune(now: number) {
|
|
86
|
+
const cutoff = now - this.preRecordMs;
|
|
87
|
+
let drop = 0;
|
|
88
|
+
while (drop < this.buffer.length && this.buffer[drop].timestampMs < cutoff) drop++;
|
|
89
|
+
drop = Math.max(drop, this.buffer.length - this.maxBuffered);
|
|
90
|
+
if (drop > 0) this.buffer.splice(0, drop);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function shiftRequestTime<T extends Record<string, unknown>>(data: T, offsetMs: number): T {
|
|
94
|
+
return typeof data.requestTimestampMs === 'number' ? { ...data, requestTimestampMs: data.requestTimestampMs - offsetMs } : data;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Response excerpt for a failed request: text only, at most 1000 characters, secrets masked. */
|
|
98
|
+
export function safeBody(text: unknown): string | undefined {
|
|
99
|
+
if (typeof text !== 'string' || !text.trim()) return undefined;
|
|
100
|
+
return safeText(text.slice(0, 4000)
|
|
101
|
+
.replace(/("(?:[\w-]*(?:password|passwd|secret|token|authorization|cookie|api[-_]?key|session)[\w-]*)"\s*:\s*)("(?:[^"\\]|\\.)*"|[^,}\s]+)/gi, '$1"[redacted]"'))
|
|
102
|
+
.slice(0, 1000);
|
|
32
103
|
}
|
|
33
|
-
|
|
104
|
+
const textual = /json|text|xml|html|problem/i;
|
|
105
|
+
const sdkRequests = { ignored: (_url: string) => false };
|
|
106
|
+
/** Requests to these URLs (the recorder itself) are never recorded. */
|
|
107
|
+
export function ignoreRequests(match: (url: string) => boolean) { sdkRequests.ignored = match; }
|
|
108
|
+
// React Native's fetch is built on XMLHttpRequest. While a fetch call creates its
|
|
109
|
+
// request synchronously, the XHR hook skips it so each request is recorded once.
|
|
110
|
+
let insideFetch = 0;
|
|
111
|
+
|
|
112
|
+
export function instrumentFetch(original: typeof fetch, collector: TelemetrySink): typeof fetch {
|
|
34
113
|
return (async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
|
35
|
-
const
|
|
114
|
+
const rawUrl = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
36
115
|
const method = (init?.method ?? (typeof input === 'object' && 'method' in input ? input.method : 'GET')).toUpperCase();
|
|
37
116
|
const start = collector.clock.now();
|
|
117
|
+
let pending: Promise<Response>;
|
|
118
|
+
insideFetch++;
|
|
119
|
+
try { pending = original(input, init); } finally { insideFetch--; }
|
|
120
|
+
if (sdkRequests.ignored(rawUrl)) return pending;
|
|
121
|
+
const url = safeUrl(rawUrl);
|
|
38
122
|
try {
|
|
39
|
-
const response = await
|
|
40
|
-
|
|
123
|
+
const response = await pending;
|
|
124
|
+
const durationMs = collector.clock.now() - start;
|
|
125
|
+
let responseExcerpt: string | undefined;
|
|
126
|
+
if (response.status >= 400 && textual.test(response.headers?.get?.('content-type') ?? 'text')) {
|
|
127
|
+
responseExcerpt = await Promise.race([
|
|
128
|
+
response.clone().text().then(safeBody, () => undefined),
|
|
129
|
+
new Promise<undefined>(resolve => setTimeout(() => resolve(undefined), 500)),
|
|
130
|
+
]);
|
|
131
|
+
}
|
|
132
|
+
collector.add('network', { requestTimestampMs: start, method, url, status: response.status, durationMs, ...(responseExcerpt ? { responseExcerpt } : {}) });
|
|
41
133
|
return response;
|
|
42
134
|
} catch (error) {
|
|
43
135
|
collector.add('network', { requestTimestampMs: start, method, url, status: null, durationMs: collector.clock.now() - start, error: safeText(error) });
|
|
@@ -45,3 +137,42 @@ export function instrumentFetch(original: typeof fetch, collector: Collector): t
|
|
|
45
137
|
}
|
|
46
138
|
}) as typeof fetch;
|
|
47
139
|
}
|
|
140
|
+
|
|
141
|
+
type XhrLike = {
|
|
142
|
+
open(method: string, url: string | URL, ...rest: unknown[]): void;
|
|
143
|
+
send(body?: unknown): void;
|
|
144
|
+
addEventListener(type: string, listener: () => void): void;
|
|
145
|
+
status: number; responseType?: string; responseText?: string;
|
|
146
|
+
getResponseHeader?(name: string): string | null;
|
|
147
|
+
};
|
|
148
|
+
/** Records XMLHttpRequest traffic (axios and most HTTP libraries) that does not go through fetch. */
|
|
149
|
+
export function instrumentXhr(Xhr: { prototype: XhrLike } | undefined, collector: TelemetrySink) {
|
|
150
|
+
if (!Xhr?.prototype?.open || !Xhr.prototype.send) return;
|
|
151
|
+
const proto = Xhr.prototype;
|
|
152
|
+
const open = proto.open, send = proto.send;
|
|
153
|
+
const meta = new WeakMap<object, { method: string; url: string }>();
|
|
154
|
+
proto.open = function (this: XhrLike, method: string, url: string | URL, ...rest: unknown[]) {
|
|
155
|
+
const href = String(url);
|
|
156
|
+
if (!insideFetch && !sdkRequests.ignored(href)) meta.set(this, { method: String(method || 'GET').toUpperCase(), url: safeUrl(href) });
|
|
157
|
+
else meta.delete(this);
|
|
158
|
+
return open.call(this, method, url, ...rest);
|
|
159
|
+
};
|
|
160
|
+
proto.send = function (this: XhrLike, body?: unknown) {
|
|
161
|
+
const request = meta.get(this);
|
|
162
|
+
if (request) {
|
|
163
|
+
const start = collector.clock.now();
|
|
164
|
+
let error: string | undefined;
|
|
165
|
+
for (const type of ['error', 'timeout', 'abort']) this.addEventListener(type, () => { error ??= `Request ${type}`; });
|
|
166
|
+
this.addEventListener('loadend', () => {
|
|
167
|
+
const status = error || !this.status ? null : this.status;
|
|
168
|
+
let responseExcerpt: string | undefined;
|
|
169
|
+
try {
|
|
170
|
+
if (status !== null && status >= 400 && (!this.responseType || this.responseType === 'text') && textual.test(this.getResponseHeader?.('content-type') ?? 'text')) responseExcerpt = safeBody(this.responseText);
|
|
171
|
+
} catch { /* response not readable */ }
|
|
172
|
+
collector.add('network', { requestTimestampMs: start, method: request.method, url: request.url, status, durationMs: collector.clock.now() - start, client: 'xhr',
|
|
173
|
+
...(error ? { error } : {}), ...(responseExcerpt ? { responseExcerpt } : {}) });
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return send.call(this, body);
|
|
177
|
+
};
|
|
178
|
+
}
|