@bugsession/react-native 0.1.0-beta.1 → 0.1.0-beta.2

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 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 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.
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, 53, 54, 57 and 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-file-system expo-modules-core
15
+ npx expo install expo-audio expo-file-system
16
16
  ```
17
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.
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', // available after DNS + deployment
29
- recorderToken: betaRecorderToken,
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: {
@@ -58,6 +58,16 @@ React Navigation integration: call the function returned by `BugSession.navigati
58
58
 
59
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
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.
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. 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.
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.js CHANGED
@@ -99,27 +99,33 @@ exports.BugSession = {
99
99
  rawFetch = globalThis.fetch.bind(globalThis);
100
100
  let createdId = null;
101
101
  let audioStarted = false;
102
+ const isIOS = react_native_1.Platform.OS === 'ios';
103
+ const useNativeCapture = isIOS || (react_native_1.Platform.OS === 'android' && !!options.capture);
102
104
  try {
103
- const isIOS = react_native_1.Platform.OS === 'ios';
105
+ if (react_native_1.Platform.OS === 'android' && options.capture?.prepare)
106
+ await options.capture.prepare();
104
107
  if (!isIOS)
105
108
  await options.audio.prepare();
106
109
  const clock = new core_1.SessionClock();
107
- const nativeClock = isIOS ? await calibrate(clock, () => options.capture.clock()) : null;
110
+ const nativeClock = useNativeCapture ? await calibrate(clock, () => options.capture.clock()) : null;
108
111
  const best = await calibrate(clock, async () => (await request('/clock')).monoMs);
109
112
  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() });
113
+ const captureBackend = isIOS ? 'replaykit' : useNativeCapture ? 'mediaprojection' : 'adb';
114
+ const result = await request('/sessions/start', { device: options.device, application: options.application, captureBackend, startedAt: new Date(Date.now() - clock.now()).toISOString() });
111
115
  createdId = result.id;
112
- let audioStartMs = clock.now();
113
- if (isIOS)
114
- await options.capture.start();
115
- else {
116
+ const nativeStart = useNativeCapture ? await options.capture.start() : undefined;
117
+ const audioStartMs = clock.now();
118
+ if (!isIOS) {
116
119
  options.audio.start();
117
120
  audioStarted = true;
118
121
  }
122
+ const nativeVideoStartMs = nativeStart && 'firstVideoNativeMs' in nativeStart
123
+ ? nativeStart.firstVideoNativeMs + nativeClock.offset
124
+ : undefined;
119
125
  session = {
120
126
  id: result.id, audioStartMs,
121
- videoStartMs: isIOS ? 0 : Math.max(0, result.videoHostStartMs + best.offset),
122
- uncertaintyMs: best.rtt / 2, captureBackend: isIOS ? 'replaykit' : 'adb',
127
+ videoStartMs: isIOS ? 0 : Math.max(0, nativeVideoStartMs ?? result.videoHostStartMs + best.offset),
128
+ uncertaintyMs: best.rtt / 2, captureBackend,
123
129
  ...(nativeClock ? { nativeOffsetMs: nativeClock.offset, nativeUncertaintyMs: nativeClock.rtt / 2 } : {}),
124
130
  };
125
131
  restore = install(collector);
@@ -128,7 +134,7 @@ exports.BugSession = {
128
134
  return session.id;
129
135
  }
130
136
  catch (error) {
131
- if (react_native_1.Platform.OS === 'ios')
137
+ if (useNativeCapture)
132
138
  await options.capture?.cancel().catch(() => { });
133
139
  if (audioStarted)
134
140
  await options.audio.stop().catch(() => null);
@@ -156,6 +162,20 @@ exports.BugSession = {
156
162
  media = { ...active, ...(0, capture_1.alignNativeRecording)(recording, active.nativeOffsetMs, active.nativeUncertaintyMs) };
157
163
  await options.capture.upload(`${options.recorderUrl}/sessions/${active.id}/video`, recording.uri, authHeaders());
158
164
  }
165
+ else if (active.captureBackend === 'mediaprojection') {
166
+ const recording = await options.capture.stop();
167
+ const uri = await options.audio.stop();
168
+ const audioEndMs = c.clock.now();
169
+ if (!uri)
170
+ throw new Error('Microphone recording has no file');
171
+ await options.capture.upload(`${options.recorderUrl}/sessions/${active.id}/video`, recording.uri, authHeaders());
172
+ await options.audio.upload(`${options.recorderUrl}/sessions/${active.id}/audio`, uri, authHeaders());
173
+ media = {
174
+ ...active, audioEndMs, alignment: 'mediaprojection-host-clock',
175
+ videoAlignmentUncertaintyMs: Math.max(250, active.nativeUncertaintyMs ?? 0),
176
+ microphoneAvailable: true, droppedVideoFrames: recording.droppedVideoFrames,
177
+ };
178
+ }
159
179
  else {
160
180
  const uri = await options.audio.stop();
161
181
  const audioEndMs = c.clock.now();
@@ -170,7 +190,7 @@ exports.BugSession = {
170
190
  });
171
191
  }
172
192
  catch (error) {
173
- if (active.captureBackend === 'replaykit')
193
+ if (active.captureBackend !== 'adb')
174
194
  await options.capture.cancel().catch(() => { });
175
195
  // Release the device recorder, but preserve partial files for recovery.
176
196
  await request(`/sessions/${active.id}/abort`, { reason: (0, core_1.safeText)(error), events: c.events, media: active, durationMs: c.clock.now() }).catch(() => { });
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bugsession/react-native",
3
- "version": "0.1.0-beta.1",
3
+ "version": "0.1.0-beta.2",
4
4
  "private": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "peerDependencies": {
8
- "react": ">=19.3.0 <20",
9
- "react-native": ">=0.88.0 <0.89"
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
@@ -24,7 +24,7 @@ type ErrorUtilsType = { getGlobalHandler(): (e: Error, fatal?: boolean) => void;
24
24
  const globals = globalThis as typeof globalThis & { ErrorUtils?: ErrorUtilsType };
25
25
  let options: BugSessionOptions;
26
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;
27
+ let session: { id: string; videoStartMs: number; audioStartMs: number; uncertaintyMs: number; captureBackend: 'adb' | 'replaykit' | 'mediaprojection'; nativeOffsetMs?: number; nativeUncertaintyMs?: number } | null = null;
28
28
  let state: 'idle' | 'starting' | 'recording' | 'stopping' = 'idle';
29
29
  let restore: (() => void) | null = null;
30
30
  let currentScreen = 'Home';
@@ -93,22 +93,28 @@ export const BugSession = {
93
93
  rawFetch = globalThis.fetch.bind(globalThis);
94
94
  let createdId: string | null = null;
95
95
  let audioStarted = false;
96
+ const isIOS = Platform.OS === 'ios';
97
+ const useNativeCapture = isIOS || (Platform.OS === 'android' && !!options.capture);
96
98
  try {
97
- const isIOS = Platform.OS === 'ios';
99
+ if (Platform.OS === 'android' && options.capture?.prepare) await options.capture.prepare();
98
100
  if (!isIOS) await options.audio!.prepare();
99
101
  const clock = new SessionClock();
100
- const nativeClock = isIOS ? await calibrate(clock, () => options.capture!.clock()) : null;
102
+ const nativeClock = useNativeCapture ? await calibrate(clock, () => options.capture!.clock()) : null;
101
103
  const best = await calibrate(clock, async () => (await request('/clock')).monoMs);
102
104
  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() });
105
+ const captureBackend = isIOS ? 'replaykit' : useNativeCapture ? 'mediaprojection' : 'adb';
106
+ const result = await request('/sessions/start', { device: options.device, application: options.application, captureBackend, startedAt: new Date(Date.now() - clock.now()).toISOString() });
104
107
  createdId = result.id;
105
- let audioStartMs = clock.now();
106
- if (isIOS) await options.capture!.start();
107
- else { options.audio!.start(); audioStarted = true; }
108
+ const nativeStart = useNativeCapture ? await options.capture!.start() : undefined;
109
+ const audioStartMs = clock.now();
110
+ if (!isIOS) { options.audio!.start(); audioStarted = true; }
111
+ const nativeVideoStartMs = nativeStart && 'firstVideoNativeMs' in nativeStart
112
+ ? nativeStart.firstVideoNativeMs + nativeClock!.offset
113
+ : undefined;
108
114
  session = {
109
115
  id: result.id, audioStartMs,
110
- videoStartMs: isIOS ? 0 : Math.max(0, result.videoHostStartMs + best.offset),
111
- uncertaintyMs: best.rtt / 2, captureBackend: isIOS ? 'replaykit' : 'adb',
116
+ videoStartMs: isIOS ? 0 : Math.max(0, nativeVideoStartMs ?? result.videoHostStartMs + best.offset),
117
+ uncertaintyMs: best.rtt / 2, captureBackend,
112
118
  ...(nativeClock ? { nativeOffsetMs: nativeClock.offset, nativeUncertaintyMs: nativeClock.rtt / 2 } : {}),
113
119
  };
114
120
  restore = install(collector);
@@ -116,7 +122,7 @@ export const BugSession = {
116
122
  collector.add('navigation', { from: null, to: currentScreen });
117
123
  return session.id;
118
124
  } catch (error) {
119
- if (Platform.OS === 'ios') await options.capture?.cancel().catch(() => {});
125
+ if (useNativeCapture) await options.capture?.cancel().catch(() => {});
120
126
  if (audioStarted) await options.audio!.stop().catch(() => null);
121
127
  if (createdId) await request(`/sessions/${createdId}/abort`, {}).catch(() => {});
122
128
  collector = null; session = null; state = 'idle'; throw error;
@@ -135,6 +141,18 @@ export const BugSession = {
135
141
  const recording = await options.capture!.stop();
136
142
  media = { ...active, ...alignNativeRecording(recording, active.nativeOffsetMs!, active.nativeUncertaintyMs!) };
137
143
  await options.capture!.upload(`${options.recorderUrl}/sessions/${active.id}/video`, recording.uri, authHeaders());
144
+ } else if (active.captureBackend === 'mediaprojection') {
145
+ const recording = await options.capture!.stop();
146
+ const uri = await options.audio!.stop();
147
+ const audioEndMs = c.clock.now();
148
+ if (!uri) throw new Error('Microphone recording has no file');
149
+ await options.capture!.upload(`${options.recorderUrl}/sessions/${active.id}/video`, recording.uri, authHeaders());
150
+ await options.audio!.upload(`${options.recorderUrl}/sessions/${active.id}/audio`, uri, authHeaders());
151
+ media = {
152
+ ...active, audioEndMs, alignment: 'mediaprojection-host-clock',
153
+ videoAlignmentUncertaintyMs: Math.max(250, active.nativeUncertaintyMs ?? 0),
154
+ microphoneAvailable: true, droppedVideoFrames: recording.droppedVideoFrames,
155
+ };
138
156
  } else {
139
157
  const uri = await options.audio!.stop();
140
158
  const audioEndMs = c.clock.now();
@@ -147,7 +165,7 @@ export const BugSession = {
147
165
  media,
148
166
  });
149
167
  } catch (error) {
150
- if (active.captureBackend === 'replaykit') await options.capture!.cancel().catch(() => {});
168
+ if (active.captureBackend !== 'adb') await options.capture!.cancel().catch(() => {});
151
169
  // Release the device recorder, but preserve partial files for recovery.
152
170
  await request(`/sessions/${active.id}/abort`, { reason: safeText(error), events: c.events, media: active, durationMs: c.clock.now() }).catch(() => {});
153
171
  throw error;