@bugsession/react-native 0.1.0-beta.1 → 0.1.0-beta.3
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 +18 -8
- package/dist/capture.d.ts +4 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +87 -16
- package/package.json +3 -3
- package/src/capture.ts +2 -1
- package/src/index.tsx +98 -27
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Opt-in screen recording, microphone narration and bounded runtime telemetry for
|
|
|
4
4
|
|
|
5
5
|
## Compatibility
|
|
6
6
|
|
|
7
|
-
The
|
|
7
|
+
The SDK supports React 18.3.1–19.x and React Native 0.76–0.88. Expo SDK 54–58 are supported; SDK 52–53 are best effort. CI builds Android against SDK 52–58 and iOS simulators against SDK 52 and 54–58. Each Expo SDK selects its matching React and React Native versions; do not mix versions manually. iOS and Android require a native build and `@bugsession/capture-native`; Expo Go cannot capture screen video. Android uses MediaProjection and asks for screen-capture consent every session. The hosted recorder admits one session at a time.
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
@@ -12,10 +12,10 @@ After publication:
|
|
|
12
12
|
|
|
13
13
|
```sh
|
|
14
14
|
npm install @bugsession/react-native@beta @bugsession/capture-native@beta
|
|
15
|
-
npx expo install expo-
|
|
15
|
+
npx expo install expo-audio expo-file-system
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
For local evaluation, install the generated `.tgz` files instead.
|
|
18
|
+
For local evaluation, install the generated `.tgz` files instead. Configure microphone permission through your audio library and rebuild the native app. Explain recording to the tester and start only after their explicit action.
|
|
19
19
|
|
|
20
20
|
## iOS example
|
|
21
21
|
|
|
@@ -25,8 +25,8 @@ import { IOSCapture } from '@bugsession/capture-native';
|
|
|
25
25
|
import * as FileSystem from 'expo-file-system/legacy';
|
|
26
26
|
|
|
27
27
|
BugSession.configure({
|
|
28
|
-
recorderUrl: 'https://beta.bugsession.dev',
|
|
29
|
-
recorderToken:
|
|
28
|
+
recorderUrl: 'https://beta.bugsession.dev',
|
|
29
|
+
recorderToken: projectIngestionKey, // Generate in dashboard → project → Connect your SDK.
|
|
30
30
|
device: { platform: 'ios', model: 'tester-device' },
|
|
31
31
|
application: { version: '1.0.0', gitCommit: appSourceCommit },
|
|
32
32
|
capture: {
|
|
@@ -56,8 +56,18 @@ Configure once while idle. Wire an automatic stop timer into your recording UI,
|
|
|
56
56
|
|
|
57
57
|
React Navigation integration: call the function returned by `BugSession.navigationListener(navigationRef)` from both `NavigationContainer.onReady` and `onStateChange`. Expo Router apps can call `trackNavigation` when the committed pathname changes. Use `BugSession.Touchable` to instrument a Pressable, or `trackAction` directly. Send caught errors through `BugSession.captureError(error)`.
|
|
58
58
|
|
|
59
|
-
`<BugSession.Trigger />` renders a floating logo button that starts and stops sessions.
|
|
59
|
+
`<BugSession.Trigger />` renders a floating logo button that starts and stops sessions. It is draggable by default, stays within screen bounds, and remembers its moved position across screen changes and trigger remounts while the app remains open. Render it once above the app navigator so it stays visible during navigation. Configure its initial corner with `position` (`bottom-right` by default; also `bottom-left`, `top-right`, or `top-left`) and adjust `offset` in points. Set `draggable={false}` to lock it. Pass `onError` to surface permission, recorder, and upload failures. Supply `children` to replace the default mark.
|
|
60
60
|
|
|
61
|
-
Android
|
|
61
|
+
Android uses `AndroidCapture` from `@bugsession/capture-native` plus an `audio` adapter implementing `prepare`, `start`, `stop` (returns a local URI), and `upload`. `prepare` must request microphone permission. `expo-audio` can provide this adapter. Android shows a system screen-capture prompt each session and a persistent capture notification; keep the app foregrounded through upload.
|
|
62
62
|
|
|
63
|
-
Fetch instrumentation omits request/response bodies, headers, URL credentials and query strings. Free-text redaction is best effort. Video, audio, screen labels, URL paths and arbitrary logs can contain private information. Record only test data and stop before entering credentials. Native crashes and process termination can lose in-memory telemetry. A maximum of 5,000 events is retained.
|
|
63
|
+
Fetch instrumentation omits request/response bodies, headers, URL credentials and query strings. Free-text redaction is best effort. Video, audio, screen labels, URL paths and arbitrary logs can contain private information. Record only test data and stop before entering credentials. Native crashes and process termination can lose in-memory telemetry. A maximum of 5,000 events is retained. Project SDK keys are extractable from an app build, so share them only with invited testers. Each key scopes uploads to one project; it cannot read recordings. Revoke unused keys. Keep recorder operator and Clerk secrets server-side.
|
|
64
|
+
|
|
65
|
+
## Project-scoped beta uploads
|
|
66
|
+
|
|
67
|
+
Generate an SDK key from your dashboard project. Copy it into `recorderToken`; the backend derives project scope from the key. No SDK `projectId` option is needed. A project ID alone never authorizes uploads. Never distribute recorder operator or Clerk secret keys.
|
|
68
|
+
|
|
69
|
+
Keys allow recording creation, media/telemetry submission and processing status only for their project. They cannot list or download recordings. Revoking a key rejects subsequent requests, including an unfinished upload sequence; an already accepted upload or processing job can finish. Replace the app configuration before revoking a key still used by testers.
|
|
70
|
+
|
|
71
|
+
Hosted beta accepts native iOS ReplayKit and Android MediaProjection uploads. Limits are 3 active keys per project, 20 accepted recording starts per project per recorder hour, 600 requests per project per minute, 128 MiB per video, 32 MiB per audio upload, and 4 MiB telemetry bodies. The recorder still runs one recording or processing job at a time. Rate counters reset on restart. Mobile keys are extractable; treat them as limited ingestion capabilities, not account credentials.
|
|
72
|
+
|
|
73
|
+
After stopping, open the same project in the dashboard and refresh its recordings list to see status. Status polling using an SDK key returns a warning count, not raw processing warnings; inspect the dashboard/operator tools for processing details.
|
package/dist/capture.d.ts
CHANGED
|
@@ -6,8 +6,11 @@ export type NativeRecording = {
|
|
|
6
6
|
droppedAudioBuffers: number;
|
|
7
7
|
};
|
|
8
8
|
export type NativeCaptureAdapter = {
|
|
9
|
+
prepare?(): Promise<void>;
|
|
9
10
|
clock(): Promise<number>;
|
|
10
|
-
start(): Promise<void
|
|
11
|
+
start(): Promise<void | {
|
|
12
|
+
firstVideoNativeMs: number;
|
|
13
|
+
}>;
|
|
11
14
|
stop(): Promise<NativeRecording>;
|
|
12
15
|
cancel(): Promise<void>;
|
|
13
16
|
upload(url: string, uri: string, headers?: Record<string, string>): Promise<void>;
|
package/dist/index.d.ts
CHANGED
|
@@ -20,6 +20,7 @@ export type BugSessionOptions = {
|
|
|
20
20
|
export type BugSessionTriggerProps = {
|
|
21
21
|
position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
|
|
22
22
|
offset?: number;
|
|
23
|
+
draggable?: boolean;
|
|
23
24
|
style?: StyleProp<ViewStyle>;
|
|
24
25
|
accessibilityLabel?: string;
|
|
25
26
|
children?: React.ReactNode;
|
|
@@ -47,5 +48,5 @@ export declare const BugSession: {
|
|
|
47
48
|
name: string;
|
|
48
49
|
screen?: string;
|
|
49
50
|
}): React.JSX.Element;
|
|
50
|
-
Trigger({ position, offset, style, accessibilityLabel, children, onError }: BugSessionTriggerProps): React.JSX.Element;
|
|
51
|
+
Trigger({ position, offset, draggable, style, accessibilityLabel, children, onError }: BugSessionTriggerProps): React.JSX.Element;
|
|
51
52
|
};
|
package/dist/index.js
CHANGED
|
@@ -22,6 +22,14 @@ let state = 'idle';
|
|
|
22
22
|
let restore = null;
|
|
23
23
|
let currentScreen = 'Home';
|
|
24
24
|
let rawFetch;
|
|
25
|
+
let triggerPosition = null;
|
|
26
|
+
const triggerSize = 58;
|
|
27
|
+
function clampTriggerPosition(point, width, height) {
|
|
28
|
+
return {
|
|
29
|
+
left: Math.max(0, Math.min(point.left, Math.max(0, width - triggerSize))),
|
|
30
|
+
top: Math.max(0, Math.min(point.top, Math.max(0, height - triggerSize))),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
25
33
|
function authHeaders() {
|
|
26
34
|
return options.recorderToken ? { Authorization: `Bearer ${options.recorderToken}` } : {};
|
|
27
35
|
}
|
|
@@ -99,27 +107,33 @@ exports.BugSession = {
|
|
|
99
107
|
rawFetch = globalThis.fetch.bind(globalThis);
|
|
100
108
|
let createdId = null;
|
|
101
109
|
let audioStarted = false;
|
|
110
|
+
const isIOS = react_native_1.Platform.OS === 'ios';
|
|
111
|
+
const useNativeCapture = isIOS || (react_native_1.Platform.OS === 'android' && !!options.capture);
|
|
102
112
|
try {
|
|
103
|
-
|
|
113
|
+
if (react_native_1.Platform.OS === 'android' && options.capture?.prepare)
|
|
114
|
+
await options.capture.prepare();
|
|
104
115
|
if (!isIOS)
|
|
105
116
|
await options.audio.prepare();
|
|
106
117
|
const clock = new core_1.SessionClock();
|
|
107
|
-
const nativeClock =
|
|
118
|
+
const nativeClock = useNativeCapture ? await calibrate(clock, () => options.capture.clock()) : null;
|
|
108
119
|
const best = await calibrate(clock, async () => (await request('/clock')).monoMs);
|
|
109
120
|
collector = new core_1.Collector(clock);
|
|
110
|
-
const
|
|
121
|
+
const captureBackend = isIOS ? 'replaykit' : useNativeCapture ? 'mediaprojection' : 'adb';
|
|
122
|
+
const result = await request('/sessions/start', { device: options.device, application: options.application, captureBackend, startedAt: new Date(Date.now() - clock.now()).toISOString() });
|
|
111
123
|
createdId = result.id;
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
else {
|
|
124
|
+
const nativeStart = useNativeCapture ? await options.capture.start() : undefined;
|
|
125
|
+
const audioStartMs = clock.now();
|
|
126
|
+
if (!isIOS) {
|
|
116
127
|
options.audio.start();
|
|
117
128
|
audioStarted = true;
|
|
118
129
|
}
|
|
130
|
+
const nativeVideoStartMs = nativeStart && 'firstVideoNativeMs' in nativeStart
|
|
131
|
+
? nativeStart.firstVideoNativeMs + nativeClock.offset
|
|
132
|
+
: undefined;
|
|
119
133
|
session = {
|
|
120
134
|
id: result.id, audioStartMs,
|
|
121
|
-
videoStartMs: isIOS ? 0 : Math.max(0, result.videoHostStartMs + best.offset),
|
|
122
|
-
uncertaintyMs: best.rtt / 2, captureBackend
|
|
135
|
+
videoStartMs: isIOS ? 0 : Math.max(0, nativeVideoStartMs ?? result.videoHostStartMs + best.offset),
|
|
136
|
+
uncertaintyMs: best.rtt / 2, captureBackend,
|
|
123
137
|
...(nativeClock ? { nativeOffsetMs: nativeClock.offset, nativeUncertaintyMs: nativeClock.rtt / 2 } : {}),
|
|
124
138
|
};
|
|
125
139
|
restore = install(collector);
|
|
@@ -128,7 +142,7 @@ exports.BugSession = {
|
|
|
128
142
|
return session.id;
|
|
129
143
|
}
|
|
130
144
|
catch (error) {
|
|
131
|
-
if (
|
|
145
|
+
if (useNativeCapture)
|
|
132
146
|
await options.capture?.cancel().catch(() => { });
|
|
133
147
|
if (audioStarted)
|
|
134
148
|
await options.audio.stop().catch(() => null);
|
|
@@ -156,6 +170,20 @@ exports.BugSession = {
|
|
|
156
170
|
media = { ...active, ...(0, capture_1.alignNativeRecording)(recording, active.nativeOffsetMs, active.nativeUncertaintyMs) };
|
|
157
171
|
await options.capture.upload(`${options.recorderUrl}/sessions/${active.id}/video`, recording.uri, authHeaders());
|
|
158
172
|
}
|
|
173
|
+
else if (active.captureBackend === 'mediaprojection') {
|
|
174
|
+
const recording = await options.capture.stop();
|
|
175
|
+
const uri = await options.audio.stop();
|
|
176
|
+
const audioEndMs = c.clock.now();
|
|
177
|
+
if (!uri)
|
|
178
|
+
throw new Error('Microphone recording has no file');
|
|
179
|
+
await options.capture.upload(`${options.recorderUrl}/sessions/${active.id}/video`, recording.uri, authHeaders());
|
|
180
|
+
await options.audio.upload(`${options.recorderUrl}/sessions/${active.id}/audio`, uri, authHeaders());
|
|
181
|
+
media = {
|
|
182
|
+
...active, audioEndMs, alignment: 'mediaprojection-host-clock',
|
|
183
|
+
videoAlignmentUncertaintyMs: Math.max(250, active.nativeUncertaintyMs ?? 0),
|
|
184
|
+
microphoneAvailable: true, droppedVideoFrames: recording.droppedVideoFrames,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
159
187
|
else {
|
|
160
188
|
const uri = await options.audio.stop();
|
|
161
189
|
const audioEndMs = c.clock.now();
|
|
@@ -170,7 +198,7 @@ exports.BugSession = {
|
|
|
170
198
|
});
|
|
171
199
|
}
|
|
172
200
|
catch (error) {
|
|
173
|
-
if (active.captureBackend
|
|
201
|
+
if (active.captureBackend !== 'adb')
|
|
174
202
|
await options.capture.cancel().catch(() => { });
|
|
175
203
|
// Release the device recorder, but preserve partial files for recovery.
|
|
176
204
|
await request(`/sessions/${active.id}/abort`, { reason: (0, core_1.safeText)(error), events: c.events, media: active, durationMs: c.clock.now() }).catch(() => { });
|
|
@@ -199,11 +227,53 @@ exports.BugSession = {
|
|
|
199
227
|
Touchable({ name, screen, onPress, ...props }) {
|
|
200
228
|
return (0, jsx_runtime_1.jsx)(react_native_1.Pressable, { ...props, onPress: e => { exports.BugSession.trackAction({ name, screen }); onPress?.(e); } });
|
|
201
229
|
},
|
|
202
|
-
Trigger({ position = 'bottom-right', offset = 24, style, accessibilityLabel, children, onError }) {
|
|
230
|
+
Trigger({ position = 'bottom-right', offset = 24, draggable = true, style, accessibilityLabel, children, onError }) {
|
|
231
|
+
const { width, height } = (0, react_native_1.useWindowDimensions)();
|
|
232
|
+
const [point, setPoint] = react_1.default.useState(() => triggerPosition
|
|
233
|
+
? clampTriggerPosition(triggerPosition, width, height)
|
|
234
|
+
: clampTriggerPosition({
|
|
235
|
+
left: position.includes('right') ? width - triggerSize - offset : offset,
|
|
236
|
+
top: position.includes('bottom') ? height - triggerSize - offset : offset,
|
|
237
|
+
}, width, height));
|
|
238
|
+
const pointRef = react_1.default.useRef(point);
|
|
239
|
+
pointRef.current = point;
|
|
240
|
+
const drag = react_1.default.useRef(null);
|
|
241
|
+
if (!drag.current)
|
|
242
|
+
drag.current = new react_native_1.Animated.ValueXY();
|
|
243
|
+
const dragStart = react_1.default.useRef(point);
|
|
244
|
+
react_1.default.useEffect(() => {
|
|
245
|
+
const next = clampTriggerPosition(pointRef.current, width, height);
|
|
246
|
+
pointRef.current = next;
|
|
247
|
+
setPoint(next);
|
|
248
|
+
if (triggerPosition)
|
|
249
|
+
triggerPosition = next;
|
|
250
|
+
drag.current?.setValue({ x: 0, y: 0 });
|
|
251
|
+
}, [width, height]);
|
|
252
|
+
const responder = react_1.default.useMemo(() => react_native_1.PanResponder.create({
|
|
253
|
+
onStartShouldSetPanResponder: () => false,
|
|
254
|
+
onMoveShouldSetPanResponder: (_, gesture) => draggable && Math.hypot(gesture.dx, gesture.dy) > 7,
|
|
255
|
+
onPanResponderGrant: () => { dragStart.current = pointRef.current; },
|
|
256
|
+
onPanResponderMove: (_, gesture) => {
|
|
257
|
+
const next = clampTriggerPosition({ left: dragStart.current.left + gesture.dx, top: dragStart.current.top + gesture.dy }, width, height);
|
|
258
|
+
drag.current?.setValue({ x: next.left - dragStart.current.left, y: next.top - dragStart.current.top });
|
|
259
|
+
},
|
|
260
|
+
onPanResponderRelease: (_, gesture) => {
|
|
261
|
+
const next = clampTriggerPosition({ left: dragStart.current.left + gesture.dx, top: dragStart.current.top + gesture.dy }, width, height);
|
|
262
|
+
triggerPosition = next;
|
|
263
|
+
pointRef.current = next;
|
|
264
|
+
setPoint(next);
|
|
265
|
+
drag.current?.setValue({ x: 0, y: 0 });
|
|
266
|
+
},
|
|
267
|
+
onPanResponderTerminate: (_, gesture) => {
|
|
268
|
+
const next = clampTriggerPosition({ left: dragStart.current.left + gesture.dx, top: dragStart.current.top + gesture.dy }, width, height);
|
|
269
|
+
triggerPosition = next;
|
|
270
|
+
pointRef.current = next;
|
|
271
|
+
setPoint(next);
|
|
272
|
+
drag.current?.setValue({ x: 0, y: 0 });
|
|
273
|
+
},
|
|
274
|
+
}), [draggable, width, height]);
|
|
203
275
|
const [busy, setBusy] = react_1.default.useState(false);
|
|
204
276
|
const recording = state === 'recording';
|
|
205
|
-
const edge = position.includes('right') ? { right: offset } : { left: offset };
|
|
206
|
-
const vertical = position.includes('bottom') ? { bottom: offset } : { top: offset };
|
|
207
277
|
const toggle = async () => {
|
|
208
278
|
if (busy)
|
|
209
279
|
return;
|
|
@@ -221,11 +291,12 @@ exports.BugSession = {
|
|
|
221
291
|
setBusy(false);
|
|
222
292
|
}
|
|
223
293
|
};
|
|
224
|
-
return (0, jsx_runtime_1.jsx)(react_native_1.View, { pointerEvents: "box-none", style:
|
|
294
|
+
return (0, jsx_runtime_1.jsx)(react_native_1.View, { pointerEvents: "box-none", style: styles.triggerLayer, children: (0, jsx_runtime_1.jsx)(react_native_1.Animated.View, { ...(draggable ? responder.panHandlers : {}), style: [styles.triggerPosition, { left: point.left, top: point.top, transform: drag.current?.getTranslateTransform() }], children: (0, jsx_runtime_1.jsx)(react_native_1.Pressable, { accessibilityRole: "button", accessibilityLabel: accessibilityLabel ?? (recording ? 'Stop BugSession recording' : 'Start BugSession recording'), accessibilityHint: draggable ? 'Drag to move the BugSession button' : undefined, accessibilityState: { busy, selected: recording }, disabled: busy, onPress: () => { void toggle(); }, style: [styles.trigger, recording && styles.triggerRecording, style], children: children ?? (0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.triggerMark, children: "b" }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.triggerDot, recording && styles.triggerDotRecording] })] }) }) }) });
|
|
225
295
|
},
|
|
226
296
|
};
|
|
227
297
|
const styles = react_native_1.StyleSheet.create({
|
|
228
|
-
triggerLayer: {
|
|
298
|
+
triggerLayer: { ...react_native_1.StyleSheet.absoluteFill, zIndex: 9999 },
|
|
299
|
+
triggerPosition: { position: 'absolute' },
|
|
229
300
|
trigger: { width: 58, height: 58, borderRadius: 29, backgroundColor: '#173B34', alignItems: 'center', justifyContent: 'center', elevation: 8, shadowColor: '#000', shadowOpacity: 0.24, shadowRadius: 8, shadowOffset: { width: 0, height: 4 } },
|
|
230
301
|
triggerRecording: { backgroundColor: '#B9382F' },
|
|
231
302
|
triggerMark: { color: '#F4F4EC', fontSize: 31, fontWeight: '800', lineHeight: 38 },
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bugsession/react-native",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.3",
|
|
4
4
|
"private": false,
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"peerDependencies": {
|
|
8
|
-
"react": ">=
|
|
9
|
-
"react-native": ">=0.
|
|
8
|
+
"react": ">=18.3.1 <20",
|
|
9
|
+
"react-native": ">=0.76.0 <0.89"
|
|
10
10
|
},
|
|
11
11
|
"description": "Opt-in React Native runtime evidence SDK",
|
|
12
12
|
"license": "UNLICENSED",
|
package/src/capture.ts
CHANGED
|
@@ -6,8 +6,9 @@ export type NativeRecording = {
|
|
|
6
6
|
droppedAudioBuffers: number;
|
|
7
7
|
};
|
|
8
8
|
export type NativeCaptureAdapter = {
|
|
9
|
+
prepare?(): Promise<void>;
|
|
9
10
|
clock(): Promise<number>;
|
|
10
|
-
start(): Promise<void>;
|
|
11
|
+
start(): Promise<void | { firstVideoNativeMs: number }>;
|
|
11
12
|
stop(): Promise<NativeRecording>;
|
|
12
13
|
cancel(): Promise<void>;
|
|
13
14
|
upload(url: string, uri: string, headers?: Record<string, string>): Promise<void>;
|
package/src/index.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import { Pressable, StyleSheet, Text, View, type PressableProps, type StyleProp, type ViewStyle, Platform } from 'react-native';
|
|
2
|
+
import { Animated, PanResponder, Pressable, StyleSheet, Text, View, useWindowDimensions, type PressableProps, type StyleProp, type ViewStyle, Platform } from 'react-native';
|
|
3
3
|
import { Collector, instrumentFetch, safeText, safeUrl, SessionClock } from './core';
|
|
4
4
|
import { alignNativeRecording, type NativeCaptureAdapter } from './capture';
|
|
5
5
|
export type { NativeCaptureAdapter } from './capture';
|
|
@@ -15,6 +15,7 @@ export type BugSessionOptions = { recorderUrl: string; recorderToken?: string; a
|
|
|
15
15
|
export type BugSessionTriggerProps = {
|
|
16
16
|
position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
|
|
17
17
|
offset?: number;
|
|
18
|
+
draggable?: boolean;
|
|
18
19
|
style?: StyleProp<ViewStyle>;
|
|
19
20
|
accessibilityLabel?: string;
|
|
20
21
|
children?: React.ReactNode;
|
|
@@ -24,11 +25,19 @@ type ErrorUtilsType = { getGlobalHandler(): (e: Error, fatal?: boolean) => void;
|
|
|
24
25
|
const globals = globalThis as typeof globalThis & { ErrorUtils?: ErrorUtilsType };
|
|
25
26
|
let options: BugSessionOptions;
|
|
26
27
|
let collector: Collector | null = null;
|
|
27
|
-
let session: { id: string; videoStartMs: number; audioStartMs: number; uncertaintyMs: number; captureBackend: 'adb' | 'replaykit'; nativeOffsetMs?: number; nativeUncertaintyMs?: number } | null = null;
|
|
28
|
+
let session: { id: string; videoStartMs: number; audioStartMs: number; uncertaintyMs: number; captureBackend: 'adb' | 'replaykit' | 'mediaprojection'; nativeOffsetMs?: number; nativeUncertaintyMs?: number } | null = null;
|
|
28
29
|
let state: 'idle' | 'starting' | 'recording' | 'stopping' = 'idle';
|
|
29
30
|
let restore: (() => void) | null = null;
|
|
30
31
|
let currentScreen = 'Home';
|
|
31
32
|
let rawFetch: typeof fetch;
|
|
33
|
+
let triggerPosition: { left: number; top: number } | null = null;
|
|
34
|
+
const triggerSize = 58;
|
|
35
|
+
function clampTriggerPosition(point: { left: number; top: number }, width: number, height: number) {
|
|
36
|
+
return {
|
|
37
|
+
left: Math.max(0, Math.min(point.left, Math.max(0, width - triggerSize))),
|
|
38
|
+
top: Math.max(0, Math.min(point.top, Math.max(0, height - triggerSize))),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
32
41
|
function authHeaders(): Record<string, string> {
|
|
33
42
|
return options.recorderToken ? { Authorization: `Bearer ${options.recorderToken}` } : {};
|
|
34
43
|
}
|
|
@@ -93,22 +102,28 @@ export const BugSession = {
|
|
|
93
102
|
rawFetch = globalThis.fetch.bind(globalThis);
|
|
94
103
|
let createdId: string | null = null;
|
|
95
104
|
let audioStarted = false;
|
|
105
|
+
const isIOS = Platform.OS === 'ios';
|
|
106
|
+
const useNativeCapture = isIOS || (Platform.OS === 'android' && !!options.capture);
|
|
96
107
|
try {
|
|
97
|
-
|
|
108
|
+
if (Platform.OS === 'android' && options.capture?.prepare) await options.capture.prepare();
|
|
98
109
|
if (!isIOS) await options.audio!.prepare();
|
|
99
110
|
const clock = new SessionClock();
|
|
100
|
-
const nativeClock =
|
|
111
|
+
const nativeClock = useNativeCapture ? await calibrate(clock, () => options.capture!.clock()) : null;
|
|
101
112
|
const best = await calibrate(clock, async () => (await request('/clock')).monoMs);
|
|
102
113
|
collector = new Collector(clock);
|
|
103
|
-
const
|
|
114
|
+
const captureBackend = isIOS ? 'replaykit' : useNativeCapture ? 'mediaprojection' : 'adb';
|
|
115
|
+
const result = await request('/sessions/start', { device: options.device, application: options.application, captureBackend, startedAt: new Date(Date.now() - clock.now()).toISOString() });
|
|
104
116
|
createdId = result.id;
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
117
|
+
const nativeStart = useNativeCapture ? await options.capture!.start() : undefined;
|
|
118
|
+
const audioStartMs = clock.now();
|
|
119
|
+
if (!isIOS) { options.audio!.start(); audioStarted = true; }
|
|
120
|
+
const nativeVideoStartMs = nativeStart && 'firstVideoNativeMs' in nativeStart
|
|
121
|
+
? nativeStart.firstVideoNativeMs + nativeClock!.offset
|
|
122
|
+
: undefined;
|
|
108
123
|
session = {
|
|
109
124
|
id: result.id, audioStartMs,
|
|
110
|
-
videoStartMs: isIOS ? 0 : Math.max(0, result.videoHostStartMs + best.offset),
|
|
111
|
-
uncertaintyMs: best.rtt / 2, captureBackend
|
|
125
|
+
videoStartMs: isIOS ? 0 : Math.max(0, nativeVideoStartMs ?? result.videoHostStartMs + best.offset),
|
|
126
|
+
uncertaintyMs: best.rtt / 2, captureBackend,
|
|
112
127
|
...(nativeClock ? { nativeOffsetMs: nativeClock.offset, nativeUncertaintyMs: nativeClock.rtt / 2 } : {}),
|
|
113
128
|
};
|
|
114
129
|
restore = install(collector);
|
|
@@ -116,7 +131,7 @@ export const BugSession = {
|
|
|
116
131
|
collector.add('navigation', { from: null, to: currentScreen });
|
|
117
132
|
return session.id;
|
|
118
133
|
} catch (error) {
|
|
119
|
-
if (
|
|
134
|
+
if (useNativeCapture) await options.capture?.cancel().catch(() => {});
|
|
120
135
|
if (audioStarted) await options.audio!.stop().catch(() => null);
|
|
121
136
|
if (createdId) await request(`/sessions/${createdId}/abort`, {}).catch(() => {});
|
|
122
137
|
collector = null; session = null; state = 'idle'; throw error;
|
|
@@ -135,6 +150,18 @@ export const BugSession = {
|
|
|
135
150
|
const recording = await options.capture!.stop();
|
|
136
151
|
media = { ...active, ...alignNativeRecording(recording, active.nativeOffsetMs!, active.nativeUncertaintyMs!) };
|
|
137
152
|
await options.capture!.upload(`${options.recorderUrl}/sessions/${active.id}/video`, recording.uri, authHeaders());
|
|
153
|
+
} else if (active.captureBackend === 'mediaprojection') {
|
|
154
|
+
const recording = await options.capture!.stop();
|
|
155
|
+
const uri = await options.audio!.stop();
|
|
156
|
+
const audioEndMs = c.clock.now();
|
|
157
|
+
if (!uri) throw new Error('Microphone recording has no file');
|
|
158
|
+
await options.capture!.upload(`${options.recorderUrl}/sessions/${active.id}/video`, recording.uri, authHeaders());
|
|
159
|
+
await options.audio!.upload(`${options.recorderUrl}/sessions/${active.id}/audio`, uri, authHeaders());
|
|
160
|
+
media = {
|
|
161
|
+
...active, audioEndMs, alignment: 'mediaprojection-host-clock',
|
|
162
|
+
videoAlignmentUncertaintyMs: Math.max(250, active.nativeUncertaintyMs ?? 0),
|
|
163
|
+
microphoneAvailable: true, droppedVideoFrames: recording.droppedVideoFrames,
|
|
164
|
+
};
|
|
138
165
|
} else {
|
|
139
166
|
const uri = await options.audio!.stop();
|
|
140
167
|
const audioEndMs = c.clock.now();
|
|
@@ -147,7 +174,7 @@ export const BugSession = {
|
|
|
147
174
|
media,
|
|
148
175
|
});
|
|
149
176
|
} catch (error) {
|
|
150
|
-
if (active.captureBackend
|
|
177
|
+
if (active.captureBackend !== 'adb') await options.capture!.cancel().catch(() => {});
|
|
151
178
|
// Release the device recorder, but preserve partial files for recovery.
|
|
152
179
|
await request(`/sessions/${active.id}/abort`, { reason: safeText(error), events: c.events, media: active, durationMs: c.clock.now() }).catch(() => {});
|
|
153
180
|
throw error;
|
|
@@ -168,11 +195,51 @@ export const BugSession = {
|
|
|
168
195
|
Touchable({ name, screen, onPress, ...props }: PressableProps & { name: string; screen?: string }) {
|
|
169
196
|
return <Pressable {...props} onPress={e => { BugSession.trackAction({ name, screen }); onPress?.(e); }} />;
|
|
170
197
|
},
|
|
171
|
-
Trigger({ position = 'bottom-right', offset = 24, style, accessibilityLabel, children, onError }: BugSessionTriggerProps) {
|
|
198
|
+
Trigger({ position = 'bottom-right', offset = 24, draggable = true, style, accessibilityLabel, children, onError }: BugSessionTriggerProps) {
|
|
199
|
+
const { width, height } = useWindowDimensions();
|
|
200
|
+
const [point, setPoint] = React.useState(() => triggerPosition
|
|
201
|
+
? clampTriggerPosition(triggerPosition, width, height)
|
|
202
|
+
: clampTriggerPosition({
|
|
203
|
+
left: position.includes('right') ? width - triggerSize - offset : offset,
|
|
204
|
+
top: position.includes('bottom') ? height - triggerSize - offset : offset,
|
|
205
|
+
}, width, height));
|
|
206
|
+
const pointRef = React.useRef(point);
|
|
207
|
+
pointRef.current = point;
|
|
208
|
+
const drag = React.useRef<Animated.ValueXY | null>(null);
|
|
209
|
+
if (!drag.current) drag.current = new Animated.ValueXY();
|
|
210
|
+
const dragStart = React.useRef(point);
|
|
211
|
+
React.useEffect(() => {
|
|
212
|
+
const next = clampTriggerPosition(pointRef.current, width, height);
|
|
213
|
+
pointRef.current = next;
|
|
214
|
+
setPoint(next);
|
|
215
|
+
if (triggerPosition) triggerPosition = next;
|
|
216
|
+
drag.current?.setValue({ x: 0, y: 0 });
|
|
217
|
+
}, [width, height]);
|
|
218
|
+
const responder = React.useMemo(() => PanResponder.create({
|
|
219
|
+
onStartShouldSetPanResponder: () => false,
|
|
220
|
+
onMoveShouldSetPanResponder: (_, gesture) => draggable && Math.hypot(gesture.dx, gesture.dy) > 7,
|
|
221
|
+
onPanResponderGrant: () => { dragStart.current = pointRef.current; },
|
|
222
|
+
onPanResponderMove: (_, gesture) => {
|
|
223
|
+
const next = clampTriggerPosition({ left: dragStart.current.left + gesture.dx, top: dragStart.current.top + gesture.dy }, width, height);
|
|
224
|
+
drag.current?.setValue({ x: next.left - dragStart.current.left, y: next.top - dragStart.current.top });
|
|
225
|
+
},
|
|
226
|
+
onPanResponderRelease: (_, gesture) => {
|
|
227
|
+
const next = clampTriggerPosition({ left: dragStart.current.left + gesture.dx, top: dragStart.current.top + gesture.dy }, width, height);
|
|
228
|
+
triggerPosition = next;
|
|
229
|
+
pointRef.current = next;
|
|
230
|
+
setPoint(next);
|
|
231
|
+
drag.current?.setValue({ x: 0, y: 0 });
|
|
232
|
+
},
|
|
233
|
+
onPanResponderTerminate: (_, gesture) => {
|
|
234
|
+
const next = clampTriggerPosition({ left: dragStart.current.left + gesture.dx, top: dragStart.current.top + gesture.dy }, width, height);
|
|
235
|
+
triggerPosition = next;
|
|
236
|
+
pointRef.current = next;
|
|
237
|
+
setPoint(next);
|
|
238
|
+
drag.current?.setValue({ x: 0, y: 0 });
|
|
239
|
+
},
|
|
240
|
+
}), [draggable, width, height]);
|
|
172
241
|
const [busy, setBusy] = React.useState(false);
|
|
173
242
|
const recording = state === 'recording';
|
|
174
|
-
const edge = position.includes('right') ? { right: offset } : { left: offset };
|
|
175
|
-
const vertical = position.includes('bottom') ? { bottom: offset } : { top: offset };
|
|
176
243
|
const toggle = async () => {
|
|
177
244
|
if (busy) return;
|
|
178
245
|
setBusy(true);
|
|
@@ -182,23 +249,27 @@ export const BugSession = {
|
|
|
182
249
|
} catch (error) { onError?.(error); }
|
|
183
250
|
finally { setBusy(false); }
|
|
184
251
|
};
|
|
185
|
-
return <View pointerEvents="box-none" style={
|
|
186
|
-
<
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
252
|
+
return <View pointerEvents="box-none" style={styles.triggerLayer}>
|
|
253
|
+
<Animated.View {...(draggable ? responder.panHandlers : {})} style={[styles.triggerPosition, { left: point.left, top: point.top, transform: drag.current?.getTranslateTransform() }]}>
|
|
254
|
+
<Pressable
|
|
255
|
+
accessibilityRole="button"
|
|
256
|
+
accessibilityLabel={accessibilityLabel ?? (recording ? 'Stop BugSession recording' : 'Start BugSession recording')}
|
|
257
|
+
accessibilityHint={draggable ? 'Drag to move the BugSession button' : undefined}
|
|
258
|
+
accessibilityState={{ busy, selected: recording }}
|
|
259
|
+
disabled={busy}
|
|
260
|
+
onPress={() => { void toggle(); }}
|
|
261
|
+
style={[styles.trigger, recording && styles.triggerRecording, style]}
|
|
262
|
+
>
|
|
263
|
+
{children ?? <><Text style={styles.triggerMark}>b</Text><View style={[styles.triggerDot, recording && styles.triggerDotRecording]} /></>}
|
|
264
|
+
</Pressable>
|
|
265
|
+
</Animated.View>
|
|
196
266
|
</View>;
|
|
197
267
|
},
|
|
198
268
|
};
|
|
199
269
|
|
|
200
270
|
const styles = StyleSheet.create({
|
|
201
|
-
triggerLayer: {
|
|
271
|
+
triggerLayer: { ...StyleSheet.absoluteFill, zIndex: 9999 },
|
|
272
|
+
triggerPosition: { position: 'absolute' },
|
|
202
273
|
trigger: { width: 58, height: 58, borderRadius: 29, backgroundColor: '#173B34', alignItems: 'center', justifyContent: 'center', elevation: 8, shadowColor: '#000', shadowOpacity: 0.24, shadowRadius: 8, shadowOffset: { width: 0, height: 4 } },
|
|
203
274
|
triggerRecording: { backgroundColor: '#B9382F' },
|
|
204
275
|
triggerMark: { color: '#F4F4EC', fontSize: 31, fontWeight: '800', lineHeight: 38 },
|