@bugsession/react-native 0.0.0-stage → 0.1.0-beta.1
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/LICENSE +4 -0
- package/README.md +62 -2
- package/dist/capture.d.ts +23 -0
- package/dist/capture.js +17 -0
- package/dist/core.d.ts +22 -0
- package/dist/core.js +65 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.js +234 -0
- package/package.json +44 -4
- package/src/capture.ts +28 -0
- package/src/core.ts +47 -0
- package/src/index.tsx +207 -0
package/LICENSE
ADDED
package/README.md
CHANGED
|
@@ -1,3 +1,63 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @bugsession/react-native — beta
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Opt-in screen recording, microphone narration and bounded runtime telemetry for React Native. This beta is for invited testers using non-sensitive test data. It is not ready for general production use. This SDK is proprietary (`UNLICENSED`), with all rights reserved. Beta use requires authorization from the package owner; see LICENSE.
|
|
4
|
+
|
|
5
|
+
## Compatibility
|
|
6
|
+
|
|
7
|
+
The prepared beta targets React 19.3, React Native 0.88 and Expo SDK 58. Other versions have not been validated. iOS requires a native build on a physical iPhone, iOS 16.4+, and the optional `@bugsession/capture-native` package. Expo Go cannot record iOS video. Android currently needs a local recorder with an ADB-connected device; a VPS cannot capture Android screens. The recorder admits one session at a time.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
After publication:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npm install @bugsession/react-native@beta @bugsession/capture-native@beta
|
|
15
|
+
npx expo install expo-file-system expo-modules-core
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
For local evaluation, install the generated `.tgz` files instead. Add `ios.infoPlist.NSMicrophoneUsageDescription` to your Expo app config and rebuild the native app. Explain recording to the tester and start only after their explicit action.
|
|
19
|
+
|
|
20
|
+
## iOS example
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import { BugSession } from '@bugsession/react-native';
|
|
24
|
+
import { IOSCapture } from '@bugsession/capture-native';
|
|
25
|
+
import * as FileSystem from 'expo-file-system/legacy';
|
|
26
|
+
|
|
27
|
+
BugSession.configure({
|
|
28
|
+
recorderUrl: 'https://beta.bugsession.dev', // available after DNS + deployment
|
|
29
|
+
recorderToken: betaRecorderToken,
|
|
30
|
+
device: { platform: 'ios', model: 'tester-device' },
|
|
31
|
+
application: { version: '1.0.0', gitCommit: appSourceCommit },
|
|
32
|
+
capture: {
|
|
33
|
+
...IOSCapture,
|
|
34
|
+
async upload(url, uri, headers = {}) {
|
|
35
|
+
const response = await FileSystem.uploadAsync(url, uri, {
|
|
36
|
+
httpMethod: 'POST',
|
|
37
|
+
uploadType: FileSystem.FileSystemUploadType.BINARY_CONTENT,
|
|
38
|
+
headers,
|
|
39
|
+
});
|
|
40
|
+
if (response.status !== 200) throw new Error(`Upload failed: ${response.status}`);
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Or render <BugSession.Trigger /> for a floating bottom-right record button.
|
|
46
|
+
// Set position to bottom-left, top-right, or top-left; offset defaults to 24.
|
|
47
|
+
// Render it above your navigator, and provide onError to display failures.
|
|
48
|
+
// Stop within 160 seconds.
|
|
49
|
+
await BugSession.start();
|
|
50
|
+
BugSession.trackAction({ name: 'save-profile', screen: 'Profile' });
|
|
51
|
+
BugSession.trackNavigation('Settings');
|
|
52
|
+
const { id } = await BugSession.stop();
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Configure once while idle. Wire an automatic stop timer into your recording UI, keep the app foregrounded through upload, and display recording/upload failures. `BugSession.stop()` acknowledges queued processing; it does not promise completed analysis. Poll authenticated `GET /sessions/{id}/status` until `status` is `complete`, `processing-failed`, or `interrupted`, and inspect `processing.warnings`. `complete` with partial processing can mean missing narration.
|
|
56
|
+
|
|
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
|
+
|
|
59
|
+
`<BugSession.Trigger />` renders a floating logo button that starts and stops sessions. Its default position is `bottom-right`; configure `position` as `bottom-left`, `top-right`, or `top-left`, and adjust `offset` in points. Render it as an overlay above the app navigator. Pass `onError` to surface permission, recorder, and upload failures. Supply `children` to replace the default mark.
|
|
60
|
+
|
|
61
|
+
Android requires an `audio` adapter implementing `prepare`, `start`, `stop` (returns a local URI), and `upload`; prepare must request microphone permission. `expo-audio` can provide this adapter. Point Android at the local recorder, not the hosted URL.
|
|
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. The shared beta token is extractable from an app build: use it only in invited builds, rotate it, and isolate each tester group into its own recorder and storage.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type NativeRecording = {
|
|
2
|
+
uri: string;
|
|
3
|
+
firstVideoNativeMs: number;
|
|
4
|
+
firstAudioNativeMs: number | null;
|
|
5
|
+
droppedVideoFrames: number;
|
|
6
|
+
droppedAudioBuffers: number;
|
|
7
|
+
};
|
|
8
|
+
export type NativeCaptureAdapter = {
|
|
9
|
+
clock(): Promise<number>;
|
|
10
|
+
start(): Promise<void>;
|
|
11
|
+
stop(): Promise<NativeRecording>;
|
|
12
|
+
cancel(): Promise<void>;
|
|
13
|
+
upload(url: string, uri: string, headers?: Record<string, string>): Promise<void>;
|
|
14
|
+
};
|
|
15
|
+
export declare function alignNativeRecording(recording: NativeRecording, offsetMs: number, uncertaintyMs: number): {
|
|
16
|
+
videoStartMs: number;
|
|
17
|
+
audioStartMs: number;
|
|
18
|
+
alignment: string;
|
|
19
|
+
videoAlignmentUncertaintyMs: number;
|
|
20
|
+
microphoneAvailable: boolean;
|
|
21
|
+
droppedVideoFrames: number;
|
|
22
|
+
droppedAudioBuffers: number;
|
|
23
|
+
};
|
package/dist/capture.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.alignNativeRecording = alignNativeRecording;
|
|
4
|
+
function alignNativeRecording(recording, offsetMs, uncertaintyMs) {
|
|
5
|
+
const videoStartMs = recording.firstVideoNativeMs + offsetMs;
|
|
6
|
+
const audioStartMs = recording.firstAudioNativeMs === null ? videoStartMs : recording.firstAudioNativeMs + offsetMs;
|
|
7
|
+
if (![videoStartMs, audioStartMs, uncertaintyMs].every(Number.isFinite) || videoStartMs < 0 || audioStartMs < videoStartMs || uncertaintyMs < 0) {
|
|
8
|
+
throw new Error('Invalid ReplayKit clock mapping');
|
|
9
|
+
}
|
|
10
|
+
return {
|
|
11
|
+
videoStartMs, audioStartMs, alignment: 'replaykit-host-clock',
|
|
12
|
+
videoAlignmentUncertaintyMs: uncertaintyMs,
|
|
13
|
+
microphoneAvailable: recording.firstAudioNativeMs !== null,
|
|
14
|
+
droppedVideoFrames: recording.droppedVideoFrames,
|
|
15
|
+
droppedAudioBuffers: recording.droppedAudioBuffers,
|
|
16
|
+
};
|
|
17
|
+
}
|
package/dist/core.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type Evidence = {
|
|
2
|
+
timestampMs: number;
|
|
3
|
+
type: string;
|
|
4
|
+
[key: string]: unknown;
|
|
5
|
+
};
|
|
6
|
+
export declare function safeUrl(input: string): string;
|
|
7
|
+
export declare function safeText(value: unknown): string;
|
|
8
|
+
export declare class SessionClock {
|
|
9
|
+
private source;
|
|
10
|
+
private epoch;
|
|
11
|
+
constructor(source?: () => number);
|
|
12
|
+
now(): number;
|
|
13
|
+
}
|
|
14
|
+
export declare class Collector {
|
|
15
|
+
clock: SessionClock;
|
|
16
|
+
private limit;
|
|
17
|
+
events: Evidence[];
|
|
18
|
+
dropped: number;
|
|
19
|
+
constructor(clock?: SessionClock, limit?: number);
|
|
20
|
+
add(type: string, data: Record<string, unknown>): void;
|
|
21
|
+
}
|
|
22
|
+
export declare function instrumentFetch(original: typeof fetch, collector: Collector): typeof fetch;
|
package/dist/core.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Collector = exports.SessionClock = void 0;
|
|
4
|
+
exports.safeUrl = safeUrl;
|
|
5
|
+
exports.safeText = safeText;
|
|
6
|
+
exports.instrumentFetch = instrumentFetch;
|
|
7
|
+
function safeUrl(input) {
|
|
8
|
+
try {
|
|
9
|
+
const u = new URL(input, 'http://relative.invalid');
|
|
10
|
+
// Paths can contain credentials too; redact common key/value path segments.
|
|
11
|
+
const path = u.pathname.replace(/\/(token|password|secret|api[-_]?key)\/[^/]+/gi, '/$1/[redacted]');
|
|
12
|
+
return (u.hostname === 'relative.invalid' ? '' : `${u.protocol}//${u.host}`) + path;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return '[invalid URL]';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function safeText(value) {
|
|
19
|
+
const text = value instanceof Error ? value.message : typeof value === 'string' ? value : '[non-string value omitted]';
|
|
20
|
+
return text
|
|
21
|
+
.replace(/https?:\/\/[^\s)"']+/g, (url) => safeUrl(url))
|
|
22
|
+
.replace(/\bBearer\s+[^\s,;]+/gi, 'Bearer [redacted]')
|
|
23
|
+
.replace(/((?:authorization|cookie|api[-_]?key|password|secret|token)\s*[=:]\s*)[^\s,;]+/gi, '$1[redacted]')
|
|
24
|
+
.slice(0, 4000);
|
|
25
|
+
}
|
|
26
|
+
class SessionClock {
|
|
27
|
+
constructor(source = () => performance.now()) {
|
|
28
|
+
this.source = source;
|
|
29
|
+
this.epoch = source();
|
|
30
|
+
}
|
|
31
|
+
now() { return Math.max(0, this.source() - this.epoch); }
|
|
32
|
+
}
|
|
33
|
+
exports.SessionClock = SessionClock;
|
|
34
|
+
class Collector {
|
|
35
|
+
constructor(clock = new SessionClock(), limit = 5000) {
|
|
36
|
+
this.clock = clock;
|
|
37
|
+
this.limit = limit;
|
|
38
|
+
this.events = [];
|
|
39
|
+
this.dropped = 0;
|
|
40
|
+
}
|
|
41
|
+
add(type, data) {
|
|
42
|
+
if (this.events.length >= this.limit) {
|
|
43
|
+
this.dropped++;
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
this.events.push({ ...data, type, timestampMs: this.clock.now() });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
exports.Collector = Collector;
|
|
50
|
+
function instrumentFetch(original, collector) {
|
|
51
|
+
return (async (input, init) => {
|
|
52
|
+
const url = safeUrl(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url);
|
|
53
|
+
const method = (init?.method ?? (typeof input === 'object' && 'method' in input ? input.method : 'GET')).toUpperCase();
|
|
54
|
+
const start = collector.clock.now();
|
|
55
|
+
try {
|
|
56
|
+
const response = await original(input, init);
|
|
57
|
+
collector.add('network', { requestTimestampMs: start, method, url, status: response.status, durationMs: collector.clock.now() - start });
|
|
58
|
+
return response;
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
collector.add('network', { requestTimestampMs: start, method, url, status: null, durationMs: collector.clock.now() - start, error: safeText(error) });
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { type PressableProps, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
import { type NativeCaptureAdapter } from './capture';
|
|
4
|
+
export type { NativeCaptureAdapter } from './capture';
|
|
5
|
+
export { Collector, SessionClock, safeText, safeUrl } from './core';
|
|
6
|
+
export type AudioAdapter = {
|
|
7
|
+
prepare(): Promise<void>;
|
|
8
|
+
start(): void;
|
|
9
|
+
stop(): Promise<string | null>;
|
|
10
|
+
upload(url: string, uri: string, headers?: Record<string, string>): Promise<void>;
|
|
11
|
+
};
|
|
12
|
+
export type BugSessionOptions = {
|
|
13
|
+
recorderUrl: string;
|
|
14
|
+
recorderToken?: string;
|
|
15
|
+
audio?: AudioAdapter;
|
|
16
|
+
capture?: NativeCaptureAdapter;
|
|
17
|
+
device: Record<string, unknown>;
|
|
18
|
+
application: Record<string, unknown>;
|
|
19
|
+
};
|
|
20
|
+
export type BugSessionTriggerProps = {
|
|
21
|
+
position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
|
|
22
|
+
offset?: number;
|
|
23
|
+
style?: StyleProp<ViewStyle>;
|
|
24
|
+
accessibilityLabel?: string;
|
|
25
|
+
children?: React.ReactNode;
|
|
26
|
+
onError?: (error: unknown) => void;
|
|
27
|
+
};
|
|
28
|
+
declare function captureError(error: unknown, source?: string): void;
|
|
29
|
+
export declare const BugSession: {
|
|
30
|
+
configure(config: BugSessionOptions): void;
|
|
31
|
+
readonly status: "idle" | "starting" | "recording" | "stopping";
|
|
32
|
+
start(): Promise<string>;
|
|
33
|
+
stop(): Promise<any>;
|
|
34
|
+
trackAction(action: {
|
|
35
|
+
name: string;
|
|
36
|
+
screen?: string;
|
|
37
|
+
}): void;
|
|
38
|
+
trackNavigation(screen: string): void;
|
|
39
|
+
captureError: typeof captureError;
|
|
40
|
+
/** Use with NavigationContainer.onStateChange for non-Expo React Navigation apps. */
|
|
41
|
+
navigationListener(ref: {
|
|
42
|
+
getCurrentRoute(): {
|
|
43
|
+
name: string;
|
|
44
|
+
} | undefined;
|
|
45
|
+
}): () => void;
|
|
46
|
+
Touchable({ name, screen, onPress, ...props }: PressableProps & {
|
|
47
|
+
name: string;
|
|
48
|
+
screen?: string;
|
|
49
|
+
}): React.JSX.Element;
|
|
50
|
+
Trigger({ position, offset, style, accessibilityLabel, children, onError }: BugSessionTriggerProps): React.JSX.Element;
|
|
51
|
+
};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
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.BugSession = exports.safeUrl = exports.safeText = exports.SessionClock = exports.Collector = void 0;
|
|
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
|
+
const core_1 = require("./core");
|
|
11
|
+
const capture_1 = require("./capture");
|
|
12
|
+
var core_2 = require("./core");
|
|
13
|
+
Object.defineProperty(exports, "Collector", { enumerable: true, get: function () { return core_2.Collector; } });
|
|
14
|
+
Object.defineProperty(exports, "SessionClock", { enumerable: true, get: function () { return core_2.SessionClock; } });
|
|
15
|
+
Object.defineProperty(exports, "safeText", { enumerable: true, get: function () { return core_2.safeText; } });
|
|
16
|
+
Object.defineProperty(exports, "safeUrl", { enumerable: true, get: function () { return core_2.safeUrl; } });
|
|
17
|
+
const globals = globalThis;
|
|
18
|
+
let options;
|
|
19
|
+
let collector = null;
|
|
20
|
+
let session = null;
|
|
21
|
+
let state = 'idle';
|
|
22
|
+
let restore = null;
|
|
23
|
+
let currentScreen = 'Home';
|
|
24
|
+
let rawFetch;
|
|
25
|
+
function authHeaders() {
|
|
26
|
+
return options.recorderToken ? { Authorization: `Bearer ${options.recorderToken}` } : {};
|
|
27
|
+
}
|
|
28
|
+
async function calibrate(clock, source) {
|
|
29
|
+
const samples = [];
|
|
30
|
+
for (let i = 0; i < 5; i++) {
|
|
31
|
+
const sent = clock.now();
|
|
32
|
+
const remote = await source();
|
|
33
|
+
const received = clock.now();
|
|
34
|
+
if (!Number.isFinite(remote))
|
|
35
|
+
throw new Error('Invalid capture clock');
|
|
36
|
+
samples.push({ rtt: received - sent, offset: (sent + received) / 2 - remote });
|
|
37
|
+
}
|
|
38
|
+
return samples.sort((a, b) => a.rtt - b.rtt)[0];
|
|
39
|
+
}
|
|
40
|
+
async function request(path, data) {
|
|
41
|
+
const response = await rawFetch(`${options.recorderUrl}${path}`, {
|
|
42
|
+
...(data === undefined ? {} : { method: 'POST', body: JSON.stringify(data) }),
|
|
43
|
+
headers: { ...authHeaders(), ...(data === undefined ? {} : { 'Content-Type': 'application/json' }) },
|
|
44
|
+
});
|
|
45
|
+
if (!response.ok)
|
|
46
|
+
throw new Error(`Recorder ${response.status}: ${await response.text()}`);
|
|
47
|
+
return response.json();
|
|
48
|
+
}
|
|
49
|
+
function install(c) {
|
|
50
|
+
const originalFetch = globalThis.fetch;
|
|
51
|
+
const wrappedFetch = (0, core_1.instrumentFetch)(rawFetch, c);
|
|
52
|
+
globalThis.fetch = wrappedFetch;
|
|
53
|
+
const originals = { log: console.log, warn: console.warn, error: console.error };
|
|
54
|
+
for (const level of ['log', 'warn', 'error']) {
|
|
55
|
+
console[level] = (...args) => {
|
|
56
|
+
c.add(level === 'error' ? 'error' : 'log', { level, message: args.map(core_1.safeText).join(' '), source: 'console', stack: args.find(a => a instanceof Error)?.stack ? (0, core_1.safeText)(args.find(a => a instanceof Error).stack) : undefined });
|
|
57
|
+
originals[level](...args);
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const utils = globals.ErrorUtils;
|
|
61
|
+
const handler = utils?.getGlobalHandler();
|
|
62
|
+
const onError = (error, fatal) => {
|
|
63
|
+
captureError(error, fatal ? 'fatal' : 'uncaught');
|
|
64
|
+
handler?.(error, fatal);
|
|
65
|
+
};
|
|
66
|
+
if (utils && handler)
|
|
67
|
+
utils.setGlobalHandler(onError);
|
|
68
|
+
return () => {
|
|
69
|
+
if (globalThis.fetch === wrappedFetch)
|
|
70
|
+
globalThis.fetch = originalFetch;
|
|
71
|
+
for (const level of ['log', 'warn', 'error'])
|
|
72
|
+
console[level] = originals[level];
|
|
73
|
+
if (utils?.getGlobalHandler() === onError && handler)
|
|
74
|
+
utils.setGlobalHandler(handler);
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function captureError(error, source = 'boundary') {
|
|
78
|
+
collector?.add('error', { source, message: (0, core_1.safeText)(error), stack: error instanceof Error ? (0, core_1.safeText)(error.stack ?? '') : undefined, screen: currentScreen });
|
|
79
|
+
}
|
|
80
|
+
exports.BugSession = {
|
|
81
|
+
configure(config) {
|
|
82
|
+
if (state !== 'idle')
|
|
83
|
+
throw new Error('Cannot configure an active BugSession');
|
|
84
|
+
options = { ...config, recorderUrl: config.recorderUrl.replace(/\/$/, '') };
|
|
85
|
+
},
|
|
86
|
+
get status() { return state; },
|
|
87
|
+
async start() {
|
|
88
|
+
if (!options)
|
|
89
|
+
throw new Error('Configure BugSession first');
|
|
90
|
+
if (react_native_1.Platform.OS !== 'android' && react_native_1.Platform.OS !== 'ios')
|
|
91
|
+
throw new Error('Recording supports iOS and Android only');
|
|
92
|
+
if (react_native_1.Platform.OS === 'ios' && !options.capture)
|
|
93
|
+
throw new Error('iOS needs the ReplayKit capture adapter and a native development build');
|
|
94
|
+
if (react_native_1.Platform.OS === 'android' && !options.audio)
|
|
95
|
+
throw new Error('Android needs a microphone adapter');
|
|
96
|
+
if (state !== 'idle')
|
|
97
|
+
throw new Error(`BugSession is ${state}`);
|
|
98
|
+
state = 'starting';
|
|
99
|
+
rawFetch = globalThis.fetch.bind(globalThis);
|
|
100
|
+
let createdId = null;
|
|
101
|
+
let audioStarted = false;
|
|
102
|
+
try {
|
|
103
|
+
const isIOS = react_native_1.Platform.OS === 'ios';
|
|
104
|
+
if (!isIOS)
|
|
105
|
+
await options.audio.prepare();
|
|
106
|
+
const clock = new core_1.SessionClock();
|
|
107
|
+
const nativeClock = isIOS ? await calibrate(clock, () => options.capture.clock()) : null;
|
|
108
|
+
const best = await calibrate(clock, async () => (await request('/clock')).monoMs);
|
|
109
|
+
collector = new core_1.Collector(clock);
|
|
110
|
+
const result = await request('/sessions/start', { device: options.device, application: options.application, captureBackend: isIOS ? 'replaykit' : 'adb', startedAt: new Date(Date.now() - clock.now()).toISOString() });
|
|
111
|
+
createdId = result.id;
|
|
112
|
+
let audioStartMs = clock.now();
|
|
113
|
+
if (isIOS)
|
|
114
|
+
await options.capture.start();
|
|
115
|
+
else {
|
|
116
|
+
options.audio.start();
|
|
117
|
+
audioStarted = true;
|
|
118
|
+
}
|
|
119
|
+
session = {
|
|
120
|
+
id: result.id, audioStartMs,
|
|
121
|
+
videoStartMs: isIOS ? 0 : Math.max(0, result.videoHostStartMs + best.offset),
|
|
122
|
+
uncertaintyMs: best.rtt / 2, captureBackend: isIOS ? 'replaykit' : 'adb',
|
|
123
|
+
...(nativeClock ? { nativeOffsetMs: nativeClock.offset, nativeUncertaintyMs: nativeClock.rtt / 2 } : {}),
|
|
124
|
+
};
|
|
125
|
+
restore = install(collector);
|
|
126
|
+
state = 'recording';
|
|
127
|
+
collector.add('navigation', { from: null, to: currentScreen });
|
|
128
|
+
return session.id;
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
if (react_native_1.Platform.OS === 'ios')
|
|
132
|
+
await options.capture?.cancel().catch(() => { });
|
|
133
|
+
if (audioStarted)
|
|
134
|
+
await options.audio.stop().catch(() => null);
|
|
135
|
+
if (createdId)
|
|
136
|
+
await request(`/sessions/${createdId}/abort`, {}).catch(() => { });
|
|
137
|
+
collector = null;
|
|
138
|
+
session = null;
|
|
139
|
+
state = 'idle';
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
async stop() {
|
|
144
|
+
if (state !== 'recording' || !session || !collector)
|
|
145
|
+
throw new Error('No recording is active');
|
|
146
|
+
state = 'stopping';
|
|
147
|
+
const c = collector;
|
|
148
|
+
const active = session;
|
|
149
|
+
c.add('session-stop', {});
|
|
150
|
+
restore?.();
|
|
151
|
+
restore = null;
|
|
152
|
+
try {
|
|
153
|
+
let media;
|
|
154
|
+
if (active.captureBackend === 'replaykit') {
|
|
155
|
+
const recording = await options.capture.stop();
|
|
156
|
+
media = { ...active, ...(0, capture_1.alignNativeRecording)(recording, active.nativeOffsetMs, active.nativeUncertaintyMs) };
|
|
157
|
+
await options.capture.upload(`${options.recorderUrl}/sessions/${active.id}/video`, recording.uri, authHeaders());
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
const uri = await options.audio.stop();
|
|
161
|
+
const audioEndMs = c.clock.now();
|
|
162
|
+
if (!uri)
|
|
163
|
+
throw new Error('Microphone recording has no file');
|
|
164
|
+
await options.audio.upload(`${options.recorderUrl}/sessions/${active.id}/audio`, uri, authHeaders());
|
|
165
|
+
media = { ...active, audioEndMs };
|
|
166
|
+
}
|
|
167
|
+
return await request(`/sessions/${active.id}/stop`, {
|
|
168
|
+
durationMs: c.clock.now(), events: c.events, droppedEvents: c.dropped,
|
|
169
|
+
media,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
if (active.captureBackend === 'replaykit')
|
|
174
|
+
await options.capture.cancel().catch(() => { });
|
|
175
|
+
// Release the device recorder, but preserve partial files for recovery.
|
|
176
|
+
await request(`/sessions/${active.id}/abort`, { reason: (0, core_1.safeText)(error), events: c.events, media: active, durationMs: c.clock.now() }).catch(() => { });
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
finally {
|
|
180
|
+
collector = null;
|
|
181
|
+
session = null;
|
|
182
|
+
state = 'idle';
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
trackAction(action) {
|
|
186
|
+
collector?.add('action', { target: (0, core_1.safeText)(action.name), screen: (0, core_1.safeText)(action.screen ?? currentScreen) });
|
|
187
|
+
},
|
|
188
|
+
trackNavigation(screen) {
|
|
189
|
+
if (currentScreen !== screen)
|
|
190
|
+
collector?.add('navigation', { from: currentScreen, to: screen });
|
|
191
|
+
currentScreen = screen;
|
|
192
|
+
},
|
|
193
|
+
captureError,
|
|
194
|
+
/** Use with NavigationContainer.onStateChange for non-Expo React Navigation apps. */
|
|
195
|
+
navigationListener(ref) {
|
|
196
|
+
return () => { const route = ref.getCurrentRoute(); if (route)
|
|
197
|
+
exports.BugSession.trackNavigation(route.name); };
|
|
198
|
+
},
|
|
199
|
+
Touchable({ name, screen, onPress, ...props }) {
|
|
200
|
+
return (0, jsx_runtime_1.jsx)(react_native_1.Pressable, { ...props, onPress: e => { exports.BugSession.trackAction({ name, screen }); onPress?.(e); } });
|
|
201
|
+
},
|
|
202
|
+
Trigger({ position = 'bottom-right', offset = 24, style, accessibilityLabel, children, onError }) {
|
|
203
|
+
const [busy, setBusy] = react_1.default.useState(false);
|
|
204
|
+
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
|
+
const toggle = async () => {
|
|
208
|
+
if (busy)
|
|
209
|
+
return;
|
|
210
|
+
setBusy(true);
|
|
211
|
+
try {
|
|
212
|
+
if (state === 'recording')
|
|
213
|
+
await exports.BugSession.stop();
|
|
214
|
+
else
|
|
215
|
+
await exports.BugSession.start();
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
onError?.(error);
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
setBusy(false);
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
return (0, jsx_runtime_1.jsx)(react_native_1.View, { pointerEvents: "box-none", style: [styles.triggerLayer, edge, vertical], children: (0, jsx_runtime_1.jsx)(react_native_1.Pressable, { accessibilityRole: "button", accessibilityLabel: accessibilityLabel ?? (recording ? 'Stop BugSession recording' : 'Start BugSession recording'), 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
|
+
},
|
|
226
|
+
};
|
|
227
|
+
const styles = react_native_1.StyleSheet.create({
|
|
228
|
+
triggerLayer: { position: 'absolute', zIndex: 9999 },
|
|
229
|
+
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
|
+
triggerRecording: { backgroundColor: '#B9382F' },
|
|
231
|
+
triggerMark: { color: '#F4F4EC', fontSize: 31, fontWeight: '800', lineHeight: 38 },
|
|
232
|
+
triggerDot: { position: 'absolute', right: 9, bottom: 9, width: 10, height: 10, borderRadius: 5, backgroundColor: '#D8F36A', borderWidth: 1, borderColor: '#173B34' },
|
|
233
|
+
triggerDotRecording: { backgroundColor: '#FFF', borderColor: '#B9382F' },
|
|
234
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bugsession/react-native",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-beta.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"peerDependencies": {
|
|
8
|
+
"react": ">=19.3.0 <20",
|
|
9
|
+
"react-native": ">=0.88.0 <0.89"
|
|
10
|
+
},
|
|
11
|
+
"description": "Opt-in React Native runtime evidence SDK",
|
|
12
|
+
"license": "UNLICENSED",
|
|
13
|
+
"react-native": "src/index.tsx",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"react-native": "./src/index.tsx",
|
|
18
|
+
"default": "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./package.json": "./package.json"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"src",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsc -p tsconfig.build.json",
|
|
30
|
+
"prepack": "npm run build"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public",
|
|
34
|
+
"tag": "beta",
|
|
35
|
+
"registry": "https://registry.npmjs.org/"
|
|
36
|
+
},
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/Anis-Ghliss/bugsession.git",
|
|
40
|
+
"directory": "packages/react-native"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://github.com/Anis-Ghliss/bugsession/tree/main/packages/react-native#readme",
|
|
43
|
+
"bugs": {
|
|
44
|
+
"url": "https://github.com/Anis-Ghliss/bugsession/issues"
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/capture.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type NativeRecording = {
|
|
2
|
+
uri: string;
|
|
3
|
+
firstVideoNativeMs: number;
|
|
4
|
+
firstAudioNativeMs: number | null;
|
|
5
|
+
droppedVideoFrames: number;
|
|
6
|
+
droppedAudioBuffers: number;
|
|
7
|
+
};
|
|
8
|
+
export type NativeCaptureAdapter = {
|
|
9
|
+
clock(): Promise<number>;
|
|
10
|
+
start(): Promise<void>;
|
|
11
|
+
stop(): Promise<NativeRecording>;
|
|
12
|
+
cancel(): Promise<void>;
|
|
13
|
+
upload(url: string, uri: string, headers?: Record<string, string>): Promise<void>;
|
|
14
|
+
};
|
|
15
|
+
export function alignNativeRecording(recording: NativeRecording, offsetMs: number, uncertaintyMs: number) {
|
|
16
|
+
const videoStartMs = recording.firstVideoNativeMs + offsetMs;
|
|
17
|
+
const audioStartMs = recording.firstAudioNativeMs === null ? videoStartMs : recording.firstAudioNativeMs + offsetMs;
|
|
18
|
+
if (![videoStartMs, audioStartMs, uncertaintyMs].every(Number.isFinite) || videoStartMs < 0 || audioStartMs < videoStartMs || uncertaintyMs < 0) {
|
|
19
|
+
throw new Error('Invalid ReplayKit clock mapping');
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
videoStartMs, audioStartMs, alignment: 'replaykit-host-clock',
|
|
23
|
+
videoAlignmentUncertaintyMs: uncertaintyMs,
|
|
24
|
+
microphoneAvailable: recording.firstAudioNativeMs !== null,
|
|
25
|
+
droppedVideoFrames: recording.droppedVideoFrames,
|
|
26
|
+
droppedAudioBuffers: recording.droppedAudioBuffers,
|
|
27
|
+
};
|
|
28
|
+
}
|
package/src/core.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export type Evidence = { timestampMs: number; type: string; [key: string]: unknown };
|
|
2
|
+
|
|
3
|
+
export function safeUrl(input: string): string {
|
|
4
|
+
try {
|
|
5
|
+
const u = new URL(input, 'http://relative.invalid');
|
|
6
|
+
// Paths can contain credentials too; redact common key/value path segments.
|
|
7
|
+
const path = u.pathname.replace(/\/(token|password|secret|api[-_]?key)\/[^/]+/gi, '/$1/[redacted]');
|
|
8
|
+
return (u.hostname === 'relative.invalid' ? '' : `${u.protocol}//${u.host}`) + path;
|
|
9
|
+
} catch { return '[invalid URL]'; }
|
|
10
|
+
}
|
|
11
|
+
export function safeText(value: unknown): string {
|
|
12
|
+
const text = value instanceof Error ? value.message : typeof value === 'string' ? value : '[non-string value omitted]';
|
|
13
|
+
return text
|
|
14
|
+
.replace(/https?:\/\/[^\s)"']+/g, (url) => safeUrl(url))
|
|
15
|
+
.replace(/\bBearer\s+[^\s,;]+/gi, 'Bearer [redacted]')
|
|
16
|
+
.replace(/((?:authorization|cookie|api[-_]?key|password|secret|token)\s*[=:]\s*)[^\s,;]+/gi, '$1[redacted]')
|
|
17
|
+
.slice(0, 4000);
|
|
18
|
+
}
|
|
19
|
+
export class SessionClock {
|
|
20
|
+
private epoch: number;
|
|
21
|
+
constructor(private source = () => performance.now()) { this.epoch = source(); }
|
|
22
|
+
now() { return Math.max(0, this.source() - this.epoch); }
|
|
23
|
+
}
|
|
24
|
+
export class Collector {
|
|
25
|
+
events: Evidence[] = [];
|
|
26
|
+
dropped = 0;
|
|
27
|
+
constructor(public clock = new SessionClock(), private limit = 5000) {}
|
|
28
|
+
add(type: string, data: Record<string, unknown>) {
|
|
29
|
+
if (this.events.length >= this.limit) { this.dropped++; return; }
|
|
30
|
+
this.events.push({ ...data, type, timestampMs: this.clock.now() });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function instrumentFetch(original: typeof fetch, collector: Collector): typeof fetch {
|
|
34
|
+
return (async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
|
35
|
+
const url = safeUrl(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url);
|
|
36
|
+
const method = (init?.method ?? (typeof input === 'object' && 'method' in input ? input.method : 'GET')).toUpperCase();
|
|
37
|
+
const start = collector.clock.now();
|
|
38
|
+
try {
|
|
39
|
+
const response = await original(input, init);
|
|
40
|
+
collector.add('network', { requestTimestampMs: start, method, url, status: response.status, durationMs: collector.clock.now() - start });
|
|
41
|
+
return response;
|
|
42
|
+
} catch (error) {
|
|
43
|
+
collector.add('network', { requestTimestampMs: start, method, url, status: null, durationMs: collector.clock.now() - start, error: safeText(error) });
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
}) as typeof fetch;
|
|
47
|
+
}
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Pressable, StyleSheet, Text, View, type PressableProps, type StyleProp, type ViewStyle, Platform } from 'react-native';
|
|
3
|
+
import { Collector, instrumentFetch, safeText, safeUrl, SessionClock } from './core';
|
|
4
|
+
import { alignNativeRecording, type NativeCaptureAdapter } from './capture';
|
|
5
|
+
export type { NativeCaptureAdapter } from './capture';
|
|
6
|
+
export { Collector, SessionClock, safeText, safeUrl } from './core';
|
|
7
|
+
|
|
8
|
+
export type AudioAdapter = {
|
|
9
|
+
prepare(): Promise<void>;
|
|
10
|
+
start(): void;
|
|
11
|
+
stop(): Promise<string | null>;
|
|
12
|
+
upload(url: string, uri: string, headers?: Record<string, string>): Promise<void>;
|
|
13
|
+
};
|
|
14
|
+
export type BugSessionOptions = { recorderUrl: string; recorderToken?: string; audio?: AudioAdapter; capture?: NativeCaptureAdapter; device: Record<string, unknown>; application: Record<string, unknown> };
|
|
15
|
+
export type BugSessionTriggerProps = {
|
|
16
|
+
position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
|
|
17
|
+
offset?: number;
|
|
18
|
+
style?: StyleProp<ViewStyle>;
|
|
19
|
+
accessibilityLabel?: string;
|
|
20
|
+
children?: React.ReactNode;
|
|
21
|
+
onError?: (error: unknown) => void;
|
|
22
|
+
};
|
|
23
|
+
type ErrorUtilsType = { getGlobalHandler(): (e: Error, fatal?: boolean) => void; setGlobalHandler(handler: (e: Error, fatal?: boolean) => void): void };
|
|
24
|
+
const globals = globalThis as typeof globalThis & { ErrorUtils?: ErrorUtilsType };
|
|
25
|
+
let options: BugSessionOptions;
|
|
26
|
+
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 state: 'idle' | 'starting' | 'recording' | 'stopping' = 'idle';
|
|
29
|
+
let restore: (() => void) | null = null;
|
|
30
|
+
let currentScreen = 'Home';
|
|
31
|
+
let rawFetch: typeof fetch;
|
|
32
|
+
function authHeaders(): Record<string, string> {
|
|
33
|
+
return options.recorderToken ? { Authorization: `Bearer ${options.recorderToken}` } : {};
|
|
34
|
+
}
|
|
35
|
+
async function calibrate(clock: SessionClock, source: () => Promise<number>) {
|
|
36
|
+
const samples: { rtt: number; offset: number }[] = [];
|
|
37
|
+
for (let i = 0; i < 5; i++) {
|
|
38
|
+
const sent = clock.now(); const remote = await source(); const received = clock.now();
|
|
39
|
+
if (!Number.isFinite(remote)) throw new Error('Invalid capture clock');
|
|
40
|
+
samples.push({ rtt: received - sent, offset: (sent + received) / 2 - remote });
|
|
41
|
+
}
|
|
42
|
+
return samples.sort((a, b) => a.rtt - b.rtt)[0];
|
|
43
|
+
}
|
|
44
|
+
async function request(path: string, data?: unknown) {
|
|
45
|
+
const response = await rawFetch(`${options.recorderUrl}${path}`, {
|
|
46
|
+
...(data === undefined ? {} : { method: 'POST', body: JSON.stringify(data) }),
|
|
47
|
+
headers: { ...authHeaders(), ...(data === undefined ? {} : { 'Content-Type': 'application/json' }) },
|
|
48
|
+
});
|
|
49
|
+
if (!response.ok) throw new Error(`Recorder ${response.status}: ${await response.text()}`);
|
|
50
|
+
return response.json();
|
|
51
|
+
}
|
|
52
|
+
function install(c: Collector) {
|
|
53
|
+
const originalFetch = globalThis.fetch;
|
|
54
|
+
const wrappedFetch = instrumentFetch(rawFetch, c);
|
|
55
|
+
globalThis.fetch = wrappedFetch;
|
|
56
|
+
const originals = { log: console.log, warn: console.warn, error: console.error };
|
|
57
|
+
for (const level of ['log', 'warn', 'error'] as const) {
|
|
58
|
+
console[level] = (...args: unknown[]) => {
|
|
59
|
+
c.add(level === 'error' ? 'error' : 'log', { level, message: args.map(safeText).join(' '), source: 'console', stack: args.find(a => a instanceof Error)?.stack ? safeText((args.find(a => a instanceof Error) as Error).stack) : undefined });
|
|
60
|
+
originals[level](...args);
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const utils = globals.ErrorUtils;
|
|
64
|
+
const handler = utils?.getGlobalHandler();
|
|
65
|
+
const onError = (error: Error, fatal?: boolean) => {
|
|
66
|
+
captureError(error, fatal ? 'fatal' : 'uncaught');
|
|
67
|
+
handler?.(error, fatal);
|
|
68
|
+
};
|
|
69
|
+
if (utils && handler) utils.setGlobalHandler(onError);
|
|
70
|
+
return () => {
|
|
71
|
+
if (globalThis.fetch === wrappedFetch) globalThis.fetch = originalFetch;
|
|
72
|
+
for (const level of ['log', 'warn', 'error'] as const) console[level] = originals[level];
|
|
73
|
+
if (utils?.getGlobalHandler() === onError && handler) utils.setGlobalHandler(handler);
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function captureError(error: unknown, source = 'boundary') {
|
|
77
|
+
collector?.add('error', { source, message: safeText(error), stack: error instanceof Error ? safeText(error.stack ?? '') : undefined, screen: currentScreen });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const BugSession = {
|
|
81
|
+
configure(config: BugSessionOptions) {
|
|
82
|
+
if (state !== 'idle') throw new Error('Cannot configure an active BugSession');
|
|
83
|
+
options = { ...config, recorderUrl: config.recorderUrl.replace(/\/$/, '') };
|
|
84
|
+
},
|
|
85
|
+
get status() { return state; },
|
|
86
|
+
async start() {
|
|
87
|
+
if (!options) throw new Error('Configure BugSession first');
|
|
88
|
+
if (Platform.OS !== 'android' && Platform.OS !== 'ios') throw new Error('Recording supports iOS and Android only');
|
|
89
|
+
if (Platform.OS === 'ios' && !options.capture) throw new Error('iOS needs the ReplayKit capture adapter and a native development build');
|
|
90
|
+
if (Platform.OS === 'android' && !options.audio) throw new Error('Android needs a microphone adapter');
|
|
91
|
+
if (state !== 'idle') throw new Error(`BugSession is ${state}`);
|
|
92
|
+
state = 'starting';
|
|
93
|
+
rawFetch = globalThis.fetch.bind(globalThis);
|
|
94
|
+
let createdId: string | null = null;
|
|
95
|
+
let audioStarted = false;
|
|
96
|
+
try {
|
|
97
|
+
const isIOS = Platform.OS === 'ios';
|
|
98
|
+
if (!isIOS) await options.audio!.prepare();
|
|
99
|
+
const clock = new SessionClock();
|
|
100
|
+
const nativeClock = isIOS ? await calibrate(clock, () => options.capture!.clock()) : null;
|
|
101
|
+
const best = await calibrate(clock, async () => (await request('/clock')).monoMs);
|
|
102
|
+
collector = new Collector(clock);
|
|
103
|
+
const result = await request('/sessions/start', { device: options.device, application: options.application, captureBackend: isIOS ? 'replaykit' : 'adb', startedAt: new Date(Date.now() - clock.now()).toISOString() });
|
|
104
|
+
createdId = result.id;
|
|
105
|
+
let audioStartMs = clock.now();
|
|
106
|
+
if (isIOS) await options.capture!.start();
|
|
107
|
+
else { options.audio!.start(); audioStarted = true; }
|
|
108
|
+
session = {
|
|
109
|
+
id: result.id, audioStartMs,
|
|
110
|
+
videoStartMs: isIOS ? 0 : Math.max(0, result.videoHostStartMs + best.offset),
|
|
111
|
+
uncertaintyMs: best.rtt / 2, captureBackend: isIOS ? 'replaykit' : 'adb',
|
|
112
|
+
...(nativeClock ? { nativeOffsetMs: nativeClock.offset, nativeUncertaintyMs: nativeClock.rtt / 2 } : {}),
|
|
113
|
+
};
|
|
114
|
+
restore = install(collector);
|
|
115
|
+
state = 'recording';
|
|
116
|
+
collector.add('navigation', { from: null, to: currentScreen });
|
|
117
|
+
return session.id;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (Platform.OS === 'ios') await options.capture?.cancel().catch(() => {});
|
|
120
|
+
if (audioStarted) await options.audio!.stop().catch(() => null);
|
|
121
|
+
if (createdId) await request(`/sessions/${createdId}/abort`, {}).catch(() => {});
|
|
122
|
+
collector = null; session = null; state = 'idle'; throw error;
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
async stop() {
|
|
126
|
+
if (state !== 'recording' || !session || !collector) throw new Error('No recording is active');
|
|
127
|
+
state = 'stopping';
|
|
128
|
+
const c = collector;
|
|
129
|
+
const active = session;
|
|
130
|
+
c.add('session-stop', {});
|
|
131
|
+
restore?.(); restore = null;
|
|
132
|
+
try {
|
|
133
|
+
let media: Record<string, unknown>;
|
|
134
|
+
if (active.captureBackend === 'replaykit') {
|
|
135
|
+
const recording = await options.capture!.stop();
|
|
136
|
+
media = { ...active, ...alignNativeRecording(recording, active.nativeOffsetMs!, active.nativeUncertaintyMs!) };
|
|
137
|
+
await options.capture!.upload(`${options.recorderUrl}/sessions/${active.id}/video`, recording.uri, authHeaders());
|
|
138
|
+
} else {
|
|
139
|
+
const uri = await options.audio!.stop();
|
|
140
|
+
const audioEndMs = c.clock.now();
|
|
141
|
+
if (!uri) throw new Error('Microphone recording has no file');
|
|
142
|
+
await options.audio!.upload(`${options.recorderUrl}/sessions/${active.id}/audio`, uri, authHeaders());
|
|
143
|
+
media = { ...active, audioEndMs };
|
|
144
|
+
}
|
|
145
|
+
return await request(`/sessions/${active.id}/stop`, {
|
|
146
|
+
durationMs: c.clock.now(), events: c.events, droppedEvents: c.dropped,
|
|
147
|
+
media,
|
|
148
|
+
});
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if (active.captureBackend === 'replaykit') await options.capture!.cancel().catch(() => {});
|
|
151
|
+
// Release the device recorder, but preserve partial files for recovery.
|
|
152
|
+
await request(`/sessions/${active.id}/abort`, { reason: safeText(error), events: c.events, media: active, durationMs: c.clock.now() }).catch(() => {});
|
|
153
|
+
throw error;
|
|
154
|
+
} finally { collector = null; session = null; state = 'idle'; }
|
|
155
|
+
},
|
|
156
|
+
trackAction(action: { name: string; screen?: string }) {
|
|
157
|
+
collector?.add('action', { target: safeText(action.name), screen: safeText(action.screen ?? currentScreen) });
|
|
158
|
+
},
|
|
159
|
+
trackNavigation(screen: string) {
|
|
160
|
+
if (currentScreen !== screen) collector?.add('navigation', { from: currentScreen, to: screen });
|
|
161
|
+
currentScreen = screen;
|
|
162
|
+
},
|
|
163
|
+
captureError,
|
|
164
|
+
/** Use with NavigationContainer.onStateChange for non-Expo React Navigation apps. */
|
|
165
|
+
navigationListener(ref: { getCurrentRoute(): { name: string } | undefined }) {
|
|
166
|
+
return () => { const route = ref.getCurrentRoute(); if (route) BugSession.trackNavigation(route.name); };
|
|
167
|
+
},
|
|
168
|
+
Touchable({ name, screen, onPress, ...props }: PressableProps & { name: string; screen?: string }) {
|
|
169
|
+
return <Pressable {...props} onPress={e => { BugSession.trackAction({ name, screen }); onPress?.(e); }} />;
|
|
170
|
+
},
|
|
171
|
+
Trigger({ position = 'bottom-right', offset = 24, style, accessibilityLabel, children, onError }: BugSessionTriggerProps) {
|
|
172
|
+
const [busy, setBusy] = React.useState(false);
|
|
173
|
+
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
|
+
const toggle = async () => {
|
|
177
|
+
if (busy) return;
|
|
178
|
+
setBusy(true);
|
|
179
|
+
try {
|
|
180
|
+
if (state === 'recording') await BugSession.stop();
|
|
181
|
+
else await BugSession.start();
|
|
182
|
+
} catch (error) { onError?.(error); }
|
|
183
|
+
finally { setBusy(false); }
|
|
184
|
+
};
|
|
185
|
+
return <View pointerEvents="box-none" style={[styles.triggerLayer, edge, vertical]}>
|
|
186
|
+
<Pressable
|
|
187
|
+
accessibilityRole="button"
|
|
188
|
+
accessibilityLabel={accessibilityLabel ?? (recording ? 'Stop BugSession recording' : 'Start BugSession recording')}
|
|
189
|
+
accessibilityState={{ busy, selected: recording }}
|
|
190
|
+
disabled={busy}
|
|
191
|
+
onPress={() => { void toggle(); }}
|
|
192
|
+
style={[styles.trigger, recording && styles.triggerRecording, style]}
|
|
193
|
+
>
|
|
194
|
+
{children ?? <><Text style={styles.triggerMark}>b</Text><View style={[styles.triggerDot, recording && styles.triggerDotRecording]} /></>}
|
|
195
|
+
</Pressable>
|
|
196
|
+
</View>;
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const styles = StyleSheet.create({
|
|
201
|
+
triggerLayer: { position: 'absolute', zIndex: 9999 },
|
|
202
|
+
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
|
+
triggerRecording: { backgroundColor: '#B9382F' },
|
|
204
|
+
triggerMark: { color: '#F4F4EC', fontSize: 31, fontWeight: '800', lineHeight: 38 },
|
|
205
|
+
triggerDot: { position: 'absolute', right: 9, bottom: 9, width: 10, height: 10, borderRadius: 5, backgroundColor: '#D8F36A', borderWidth: 1, borderColor: '#173B34' },
|
|
206
|
+
triggerDotRecording: { backgroundColor: '#FFF', borderColor: '#B9382F' },
|
|
207
|
+
});
|