@sphoro/bugcatch-web-sdk 0.13.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 +21 -0
- package/README.md +717 -0
- package/dist/bugcatch.es.js +4393 -0
- package/dist/bugcatch.es.js.map +1 -0
- package/dist/bugcatch.umd.js +952 -0
- package/dist/bugcatch.umd.js.map +1 -0
- package/package.json +57 -0
- package/src/audio.js +191 -0
- package/src/capture.js +300 -0
- package/src/collectors.js +213 -0
- package/src/incoming-call.js +324 -0
- package/src/index.d.ts +684 -0
- package/src/index.js +1217 -0
- package/src/live-call.js +870 -0
- package/src/redact.js +177 -0
- package/src/session.js +189 -0
- package/src/updates.js +1062 -0
- package/src/voice.js +372 -0
- package/src/widget.js +2481 -0
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sphoro/bugcatch-web-sdk",
|
|
3
|
+
"version": "0.13.1",
|
|
4
|
+
"description": "Drop-in browser SDK — report bugs with console, network and environment context, without the extension.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/bugcatch.es.js",
|
|
7
|
+
"module": "dist/bugcatch.es.js",
|
|
8
|
+
"browser": "dist/bugcatch.umd.js",
|
|
9
|
+
"unpkg": "dist/bugcatch.umd.js",
|
|
10
|
+
"jsdelivr": "dist/bugcatch.umd.js",
|
|
11
|
+
"types": "src/index.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./src/index.d.ts",
|
|
15
|
+
"import": "./dist/bugcatch.es.js",
|
|
16
|
+
"default": "./dist/bugcatch.es.js"
|
|
17
|
+
},
|
|
18
|
+
"./dist/bugcatch.umd.js": "./dist/bugcatch.umd.js",
|
|
19
|
+
"./package.json": "./package.json"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"src"
|
|
24
|
+
],
|
|
25
|
+
"sideEffects": false,
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "vite build",
|
|
28
|
+
"dev": "vite build --watch",
|
|
29
|
+
"prepublishOnly": "vite build"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"bug-report",
|
|
33
|
+
"bug-tracking",
|
|
34
|
+
"error-reporting",
|
|
35
|
+
"console-capture",
|
|
36
|
+
"network-capture",
|
|
37
|
+
"feedback-widget",
|
|
38
|
+
"browser",
|
|
39
|
+
"sdk"
|
|
40
|
+
],
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"homepage": "https://github.com/floki5G/sphoro.bugcatch/tree/main/bugcatch-sdk#readme",
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "git+https://github.com/floki5G/sphoro.bugcatch.git",
|
|
46
|
+
"directory": "bugcatch-sdk"
|
|
47
|
+
},
|
|
48
|
+
"bugs": {
|
|
49
|
+
"url": "https://github.com/floki5G/sphoro.bugcatch/issues"
|
|
50
|
+
},
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"vite": "^5.4.0"
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/audio.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Microphone to µ-law, and back.
|
|
2
|
+
//
|
|
3
|
+
// sphoro.voice speaks G.711 µ-law at 8 kHz in both directions — the format a
|
|
4
|
+
// telephone carrier hands it, which is what makes a browser call and a phone
|
|
5
|
+
// call indistinguishable to everything downstream. A browser has neither: it
|
|
6
|
+
// captures 32-bit floats at whatever rate the sound card runs at. The gap
|
|
7
|
+
// between those two is a codec and a resampler, and neither is available from
|
|
8
|
+
// the platform, so both are here.
|
|
9
|
+
//
|
|
10
|
+
// This file exists rather than the two callers each having a copy. The agent
|
|
11
|
+
// call sends µ-law because that is the whole conversation; a live call sends it
|
|
12
|
+
// because sphoro.voice cannot record or transcribe a conversation it is not in
|
|
13
|
+
// the middle of, and asks each browser for a copy of its own microphone. Two
|
|
14
|
+
// implementations of G.711 in one package would be one implementation and one
|
|
15
|
+
// that is subtly wrong on the day somebody fixes only the first.
|
|
16
|
+
//
|
|
17
|
+
// Nothing is imported to do it. The SDK runs on someone else's page and has no
|
|
18
|
+
// dependencies, and that rule does not bend for audio.
|
|
19
|
+
|
|
20
|
+
/** The wire's sample rate. Not negotiable — see the note above. */
|
|
21
|
+
export const WIRE_RATE = 8000;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* An audio context, at the wire's rate where the browser will give us one.
|
|
25
|
+
*
|
|
26
|
+
* Asking for 8 kHz outright makes the resampler below dead code on most
|
|
27
|
+
* machines. Some browsers refuse a rate their hardware does not like, so the
|
|
28
|
+
* fallback is an ordinary context and the manual path.
|
|
29
|
+
*/
|
|
30
|
+
export function createContext(rate = WIRE_RATE) {
|
|
31
|
+
const Ctor = window.AudioContext || window.webkitAudioContext;
|
|
32
|
+
try {
|
|
33
|
+
return new Ctor({ sampleRate: rate });
|
|
34
|
+
} catch {
|
|
35
|
+
return new Ctor();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Start a context that the browser has parked, if it will let us.
|
|
41
|
+
*
|
|
42
|
+
* Resolves either way. There is nothing useful to do with the rejection: it
|
|
43
|
+
* means this browser wants a user gesture we are not inside, and the call is
|
|
44
|
+
* still worth attempting — a context that stays suspended is a silent call,
|
|
45
|
+
* which is what the caller finds out about from the states it is already being
|
|
46
|
+
* sent.
|
|
47
|
+
*/
|
|
48
|
+
export async function resumeContext(context) {
|
|
49
|
+
if (context.state !== 'suspended') return;
|
|
50
|
+
try {
|
|
51
|
+
await context.resume();
|
|
52
|
+
} catch { /* no activation to spend */ }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Drop a signal to the wire rate by averaging each source window.
|
|
57
|
+
*
|
|
58
|
+
* Exported for its own test: the browsers that refuse an 8 kHz context are the
|
|
59
|
+
* ones that need this, and they are exactly the browsers a developer is least
|
|
60
|
+
* likely to be looking at when they change it.
|
|
61
|
+
*
|
|
62
|
+
* Averaging rather than picking every nth sample: plain decimation aliases
|
|
63
|
+
* everything above 4 kHz down into the speech band, and consonants are exactly
|
|
64
|
+
* what lives up there. This is a crude low-pass, but it is the difference
|
|
65
|
+
* between "sixty" and "sixteen" surviving the trip.
|
|
66
|
+
*/
|
|
67
|
+
export function downsample(input, fromRate, toRate) {
|
|
68
|
+
const ratio = fromRate / toRate;
|
|
69
|
+
const out = new Float32Array(Math.floor(input.length / ratio));
|
|
70
|
+
for (let i = 0; i < out.length; i++) {
|
|
71
|
+
const start = Math.floor(i * ratio);
|
|
72
|
+
const end = Math.min(Math.floor((i + 1) * ratio), input.length);
|
|
73
|
+
let sum = 0;
|
|
74
|
+
for (let j = start; j < end; j++) sum += input[j];
|
|
75
|
+
out[i] = end > start ? sum / (end - start) : 0;
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** G.711 µ-law: the bias and clip the standard specifies. */
|
|
81
|
+
const MU_BIAS = 0x84;
|
|
82
|
+
const MU_CLIP = 32635;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Float samples to µ-law bytes, ready to put on the wire.
|
|
86
|
+
*
|
|
87
|
+
* Exported for the codec test rather than for callers: this file is not the
|
|
88
|
+
* package's entry point, and a hand-rolled G.711 that nothing checks is a
|
|
89
|
+
* silent call nobody can debug from the outside.
|
|
90
|
+
*/
|
|
91
|
+
export function encodeMuLaw(samples) {
|
|
92
|
+
const out = new Uint8Array(samples.length);
|
|
93
|
+
for (let i = 0; i < samples.length; i++) {
|
|
94
|
+
// Clamped before scaling: a float outside [-1, 1] is legal in Web Audio and
|
|
95
|
+
// would wrap round to the opposite sign as an integer.
|
|
96
|
+
const clamped = Math.max(-1, Math.min(1, samples[i]));
|
|
97
|
+
let sample = Math.round(clamped * 32767);
|
|
98
|
+
|
|
99
|
+
const sign = sample < 0 ? 0x80 : 0;
|
|
100
|
+
if (sign) sample = -sample;
|
|
101
|
+
if (sample > MU_CLIP) sample = MU_CLIP;
|
|
102
|
+
sample += MU_BIAS;
|
|
103
|
+
|
|
104
|
+
let exponent = 7;
|
|
105
|
+
for (let mask = 0x4000; (sample & mask) === 0 && exponent > 0; exponent--, mask >>= 1);
|
|
106
|
+
const mantissa = (sample >> (exponent + 3)) & 0x0F;
|
|
107
|
+
out[i] = ~(sign | (exponent << 4) | mantissa) & 0xFF;
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** µ-law bytes back to float samples, ready for an AudioBuffer. */
|
|
113
|
+
export function decodeMuLaw(bytes) {
|
|
114
|
+
const out = new Float32Array(bytes.length);
|
|
115
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
116
|
+
const byte = ~bytes[i] & 0xFF;
|
|
117
|
+
const sign = byte & 0x80;
|
|
118
|
+
const exponent = (byte >> 4) & 0x07;
|
|
119
|
+
const mantissa = byte & 0x0F;
|
|
120
|
+
const magnitude = (((mantissa << 3) + MU_BIAS) << exponent) - MU_BIAS;
|
|
121
|
+
out[i] = (sign ? -magnitude : magnitude) / 32768;
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* How much audio rides in one frame.
|
|
128
|
+
*
|
|
129
|
+
* Measured in samples, and the context above runs at 8 kHz wherever the browser
|
|
130
|
+
* allows it — so this is 64 ms of speech, not the 20-odd it would be at
|
|
131
|
+
* hardware rate. That distinction is the whole reason for the number: at 2048
|
|
132
|
+
* the same code sends a quarter of a second per frame, which is a quarter of a
|
|
133
|
+
* second added to the start of every turn and to every barge-in, and it sounds
|
|
134
|
+
* exactly like an agent that is slow to notice you talking.
|
|
135
|
+
*
|
|
136
|
+
* Not smaller than this. ScriptProcessorNode runs on the main thread, and a
|
|
137
|
+
* block it cannot fill in time is a block it drops — which is a gap in the
|
|
138
|
+
* middle of a word rather than a delay before one. Powers of two only.
|
|
139
|
+
*/
|
|
140
|
+
export const CAPTURE_BLOCK = 512;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Tap a microphone and hand each block over as µ-law bytes.
|
|
144
|
+
*
|
|
145
|
+
* ScriptProcessorNode is deprecated in favour of AudioWorklet, and is used
|
|
146
|
+
* anyway. A worklet's processor has to be loaded from a URL, which on this SDK
|
|
147
|
+
* means a blob: URL — and a blob: worklet is refused outright by the
|
|
148
|
+
* Content-Security-Policy of a great many of the sites this code runs on. A
|
|
149
|
+
* deprecated node that works on a customer's page beats a modern one that is
|
|
150
|
+
* blocked by their headers.
|
|
151
|
+
*
|
|
152
|
+
* Returns a `stop` that lets go of the whole chain. The microphone track is
|
|
153
|
+
* deliberately *not* stopped by it: on a live call the same track is carrying
|
|
154
|
+
* the conversation, and stopping it to end a recording would end the call.
|
|
155
|
+
*
|
|
156
|
+
* @param {AudioContext} context
|
|
157
|
+
* @param {MediaStream} stream
|
|
158
|
+
* @param {(bytes: Uint8Array) => void} onFrame
|
|
159
|
+
* @param {number} [wireRate]
|
|
160
|
+
* @returns {{stop: () => void}}
|
|
161
|
+
*/
|
|
162
|
+
export function tapMicrophone(context, stream, onFrame, wireRate = WIRE_RATE) {
|
|
163
|
+
const source = context.createMediaStreamSource(stream);
|
|
164
|
+
const capture = context.createScriptProcessor(CAPTURE_BLOCK, 1, 1);
|
|
165
|
+
|
|
166
|
+
capture.onaudioprocess = (event) => {
|
|
167
|
+
const input = event.inputBuffer.getChannelData(0);
|
|
168
|
+
const frames = context.sampleRate === wireRate
|
|
169
|
+
? input
|
|
170
|
+
: downsample(input, context.sampleRate, wireRate);
|
|
171
|
+
onFrame(encodeMuLaw(frames));
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
source.connect(capture);
|
|
175
|
+
// A ScriptProcessorNode does not run unless it is connected to a destination,
|
|
176
|
+
// even when nothing is meant to come out of it. Zero gain is what keeps the
|
|
177
|
+
// speaker from hearing their own voice played back at them.
|
|
178
|
+
const mute = context.createGain();
|
|
179
|
+
mute.gain.value = 0;
|
|
180
|
+
capture.connect(mute);
|
|
181
|
+
mute.connect(context.destination);
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
stop() {
|
|
185
|
+
capture.onaudioprocess = null;
|
|
186
|
+
try { source.disconnect(); } catch { /* already gone */ }
|
|
187
|
+
try { capture.disconnect(); } catch { /* already gone */ }
|
|
188
|
+
try { mute.disconnect(); } catch { /* already gone */ }
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
package/src/capture.js
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// Screenshots, screen recordings and file attachments.
|
|
2
|
+
//
|
|
3
|
+
// Capture always goes through getDisplayMedia, so the browser shows its own
|
|
4
|
+
// picker and permission prompt — nothing leaves the page without the person
|
|
5
|
+
// explicitly choosing what to share. There is no DOM-rasterising fallback on
|
|
6
|
+
// purpose: it would need a heavy dependency and would still silently miss
|
|
7
|
+
// cross-origin iframes, canvas and video, which is worse than not offering it.
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Pristine references, taken at module-evaluation time — which happens on
|
|
11
|
+
* import, before init() patches anything.
|
|
12
|
+
*
|
|
13
|
+
* Uploads must not be recorded by our own network capture: a report would
|
|
14
|
+
* otherwise describe its own attachment upload, and a retry would append to a
|
|
15
|
+
* buffer that is already being serialised.
|
|
16
|
+
*/
|
|
17
|
+
const nativeFetch = typeof window !== 'undefined' && window.fetch
|
|
18
|
+
? window.fetch.bind(window)
|
|
19
|
+
: null;
|
|
20
|
+
const nativeXhrOpen = typeof XMLHttpRequest !== 'undefined'
|
|
21
|
+
? XMLHttpRequest.prototype.open
|
|
22
|
+
: null;
|
|
23
|
+
const nativeXhrSend = typeof XMLHttpRequest !== 'undefined'
|
|
24
|
+
? XMLHttpRequest.prototype.send
|
|
25
|
+
: null;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* What the API will presign. Anything else is rejected before we ask.
|
|
29
|
+
*
|
|
30
|
+
* Mirrors UPLOAD_MIMES in the API's storage.service.ts. The two are checked
|
|
31
|
+
* independently — this one to fail early with a message the person can act on,
|
|
32
|
+
* that one because a browser is not a place to enforce anything.
|
|
33
|
+
*/
|
|
34
|
+
export const UPLOAD_MIMES = [
|
|
35
|
+
'image/png', 'image/jpeg', 'image/webp', 'image/gif', 'video/webm', 'video/mp4',
|
|
36
|
+
'audio/webm', 'audio/ogg', 'audio/mpeg', 'audio/mp4', 'audio/wav',
|
|
37
|
+
'application/pdf', 'text/plain', 'text/csv', 'application/json', 'application/zip',
|
|
38
|
+
'application/msword',
|
|
39
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
40
|
+
'application/vnd.ms-excel',
|
|
41
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* What a *bug report* may carry, which is narrower than what may be uploaded.
|
|
46
|
+
*
|
|
47
|
+
* A report's attachments are evidence of the bug — what the screen looked like,
|
|
48
|
+
* what the session did — and the API types them SCREENSHOT or VIDEO. A document
|
|
49
|
+
* has no honest value there: filed as a SCREENSHOT it would render as a broken
|
|
50
|
+
* image in the dashboard's media viewer.
|
|
51
|
+
*
|
|
52
|
+
* Documents and voice notes belong on a *message*, where the type is derived
|
|
53
|
+
* from the mime and the reader gets a player or a download.
|
|
54
|
+
*/
|
|
55
|
+
export const REPORT_MIMES = [
|
|
56
|
+
'image/png', 'image/jpeg', 'image/webp', 'video/webm', 'video/mp4',
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
export const voiceSupported = () =>
|
|
60
|
+
Boolean(navigator?.mediaDevices?.getUserMedia && typeof MediaRecorder !== 'undefined');
|
|
61
|
+
|
|
62
|
+
export const captureSupported = () =>
|
|
63
|
+
Boolean(navigator?.mediaDevices?.getDisplayMedia && typeof MediaRecorder !== 'undefined');
|
|
64
|
+
|
|
65
|
+
export const screenshotSupported = () =>
|
|
66
|
+
Boolean(navigator?.mediaDevices?.getDisplayMedia);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* MediaRecorder reports `video/webm;codecs=vp9`, but the presign signature
|
|
70
|
+
* binds an exact Content-Type and the API only allows the base types. Strip
|
|
71
|
+
* the codec parameter so the type we sign, the header we send and the type the
|
|
72
|
+
* API stored all agree.
|
|
73
|
+
*/
|
|
74
|
+
export function normalizeMime(mime, allowed = UPLOAD_MIMES) {
|
|
75
|
+
const base = String(mime || '').split(';')[0].trim().toLowerCase();
|
|
76
|
+
return allowed.includes(base) ? base : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Grab a single frame of a chosen screen/window/tab as a PNG blob. */
|
|
80
|
+
export async function captureScreenshot() {
|
|
81
|
+
const stream = await navigator.mediaDevices.getDisplayMedia({
|
|
82
|
+
video: { frameRate: 1 },
|
|
83
|
+
audio: false,
|
|
84
|
+
});
|
|
85
|
+
try {
|
|
86
|
+
const track = stream.getVideoTracks()[0];
|
|
87
|
+
const video = document.createElement('video');
|
|
88
|
+
video.srcObject = stream;
|
|
89
|
+
video.muted = true;
|
|
90
|
+
video.playsInline = true;
|
|
91
|
+
await video.play();
|
|
92
|
+
|
|
93
|
+
// Wait one frame so the first paint isn't blank.
|
|
94
|
+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
95
|
+
|
|
96
|
+
const { width, height } = track.getSettings();
|
|
97
|
+
const canvas = document.createElement('canvas');
|
|
98
|
+
canvas.width = width || video.videoWidth;
|
|
99
|
+
canvas.height = height || video.videoHeight;
|
|
100
|
+
canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
101
|
+
|
|
102
|
+
const blob = await new Promise((res) => canvas.toBlob(res, 'image/png'));
|
|
103
|
+
if (!blob) throw new Error('The browser returned an empty screenshot.');
|
|
104
|
+
return { blob, mime: 'image/png', kind: 'SCREENSHOT' };
|
|
105
|
+
} finally {
|
|
106
|
+
// Always release the capture, including on failure — otherwise the browser
|
|
107
|
+
// keeps showing "sharing your screen".
|
|
108
|
+
stream.getTracks().forEach((t) => t.stop());
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Start recording a chosen screen/window/tab. Returns a handle with stop() and
|
|
114
|
+
* cancel(); the caller decides which. Recording ends on its own if the user
|
|
115
|
+
* clicks the browser's "Stop sharing" button or the cap is reached.
|
|
116
|
+
*/
|
|
117
|
+
export async function startRecording({ maxMs = 120_000, onTick, onAutoStop } = {}) {
|
|
118
|
+
const stream = await navigator.mediaDevices.getDisplayMedia({
|
|
119
|
+
video: { frameRate: 15 },
|
|
120
|
+
audio: false,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const mime = ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm']
|
|
124
|
+
.find((t) => MediaRecorder.isTypeSupported(t)) || 'video/webm';
|
|
125
|
+
|
|
126
|
+
const recorder = new MediaRecorder(stream, { mimeType: mime, videoBitsPerSecond: 2_500_000 });
|
|
127
|
+
const chunks = [];
|
|
128
|
+
recorder.ondataavailable = (e) => { if (e.data.size) chunks.push(e.data); };
|
|
129
|
+
recorder.start(1000);
|
|
130
|
+
|
|
131
|
+
const startedAt = Date.now();
|
|
132
|
+
const timer = setInterval(() => onTick?.(Math.floor((Date.now() - startedAt) / 1000)), 500);
|
|
133
|
+
|
|
134
|
+
const cleanup = () => {
|
|
135
|
+
clearInterval(timer);
|
|
136
|
+
clearTimeout(capTimer);
|
|
137
|
+
stream.getTracks().forEach((t) => t.stop());
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const finished = new Promise((resolve) => {
|
|
141
|
+
recorder.onstop = () => {
|
|
142
|
+
cleanup();
|
|
143
|
+
resolve(new Blob(chunks, { type: mime }));
|
|
144
|
+
};
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const endEarly = () => {
|
|
148
|
+
if (recorder.state !== 'inactive') recorder.stop();
|
|
149
|
+
onAutoStop?.();
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// The user can stop sharing from the browser's own UI; treat that as "done".
|
|
153
|
+
stream.getVideoTracks()[0].addEventListener('ended', endEarly);
|
|
154
|
+
|
|
155
|
+
// Hard cap so a forgotten recording can't grow without bound.
|
|
156
|
+
const capTimer = setTimeout(endEarly, maxMs);
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
mime,
|
|
160
|
+
maxMs,
|
|
161
|
+
async stop() {
|
|
162
|
+
clearTimeout(capTimer);
|
|
163
|
+
if (recorder.state !== 'inactive') recorder.stop();
|
|
164
|
+
return { blob: await finished, mime, kind: 'VIDEO' };
|
|
165
|
+
},
|
|
166
|
+
cancel() {
|
|
167
|
+
cleanup();
|
|
168
|
+
if (recorder.state !== 'inactive') recorder.stop();
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Record a voice note from the microphone. Returns a handle with stop() and
|
|
175
|
+
* cancel(), like startRecording() above.
|
|
176
|
+
*
|
|
177
|
+
* Audio only, and from getUserMedia rather than getDisplayMedia: this is
|
|
178
|
+
* somebody talking into their laptop to explain a bug, not a capture of the
|
|
179
|
+
* screen. The browser shows its own microphone prompt, so nothing is recorded
|
|
180
|
+
* without an explicit grant.
|
|
181
|
+
*
|
|
182
|
+
* The container is whichever the browser will give us — Chromium and Firefox
|
|
183
|
+
* produce webm/opus, Safari mp4/aac — because MediaRecorder cannot be made to
|
|
184
|
+
* agree on one, and the API allows both.
|
|
185
|
+
*/
|
|
186
|
+
export async function startVoiceNote({ maxMs = 120_000, onTick, onAutoStop } = {}) {
|
|
187
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
188
|
+
|
|
189
|
+
const mime = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg']
|
|
190
|
+
.find((t) => MediaRecorder.isTypeSupported(t)) || '';
|
|
191
|
+
|
|
192
|
+
const recorder = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
|
|
193
|
+
const chunks = [];
|
|
194
|
+
recorder.ondataavailable = (e) => { if (e.data.size) chunks.push(e.data); };
|
|
195
|
+
recorder.start(1000);
|
|
196
|
+
|
|
197
|
+
const startedAt = Date.now();
|
|
198
|
+
const timer = setInterval(() => onTick?.(Math.floor((Date.now() - startedAt) / 1000)), 500);
|
|
199
|
+
|
|
200
|
+
const cleanup = () => {
|
|
201
|
+
clearInterval(timer);
|
|
202
|
+
clearTimeout(capTimer);
|
|
203
|
+
stream.getTracks().forEach((t) => t.stop());
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const finished = new Promise((resolve) => {
|
|
207
|
+
recorder.onstop = () => {
|
|
208
|
+
cleanup();
|
|
209
|
+
resolve(new Blob(chunks, { type: recorder.mimeType || mime }));
|
|
210
|
+
};
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const endEarly = () => {
|
|
214
|
+
if (recorder.state !== 'inactive') recorder.stop();
|
|
215
|
+
onAutoStop?.();
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
// A hard cap, as for screen recording: a forgotten open microphone is worse
|
|
219
|
+
// than a truncated note.
|
|
220
|
+
const capTimer = setTimeout(endEarly, maxMs);
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
mime: recorder.mimeType || mime,
|
|
224
|
+
maxMs,
|
|
225
|
+
async stop() {
|
|
226
|
+
clearTimeout(capTimer);
|
|
227
|
+
if (recorder.state !== 'inactive') recorder.stop();
|
|
228
|
+
const blob = await finished;
|
|
229
|
+
return { blob, mime: blob.type || recorder.mimeType || mime, kind: 'AUDIO' };
|
|
230
|
+
},
|
|
231
|
+
cancel() {
|
|
232
|
+
cleanup();
|
|
233
|
+
if (recorder.state !== 'inactive') recorder.stop();
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Ask the API for a presigned URL and PUT the blob straight at it, so media
|
|
240
|
+
* never passes through the API request path.
|
|
241
|
+
*
|
|
242
|
+
* Resolves to the storage key, which is what a report references.
|
|
243
|
+
*/
|
|
244
|
+
export async function uploadAttachment({ apiBase, apiKey, blob, mime, onProgress }) {
|
|
245
|
+
const type = normalizeMime(mime || blob.type);
|
|
246
|
+
if (!type) {
|
|
247
|
+
throw new Error(`[BugCatch] ${mime || blob.type || 'that file'} can't be attached.`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const res = await nativeFetch(`${apiBase}/ingest/presign`, {
|
|
251
|
+
method: 'POST',
|
|
252
|
+
headers: { 'Content-Type': 'application/json', 'X-BugCatch-Key': apiKey },
|
|
253
|
+
body: JSON.stringify({ mime: type }),
|
|
254
|
+
});
|
|
255
|
+
if (!res.ok) throw new Error(`[BugCatch] could not start the upload (HTTP ${res.status}).`);
|
|
256
|
+
const { key, uploadUrl } = await res.json();
|
|
257
|
+
|
|
258
|
+
await put(uploadUrl, blob, type, onProgress);
|
|
259
|
+
return { storageKey: key, mime: type };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* XHR rather than fetch purely for upload progress — a recording is megabytes,
|
|
264
|
+
* and a progress bar is the difference between "working" and "frozen".
|
|
265
|
+
*/
|
|
266
|
+
function put(uploadUrl, blob, mime, onProgress) {
|
|
267
|
+
return new Promise((resolve, reject) => {
|
|
268
|
+
const xhr = new XMLHttpRequest();
|
|
269
|
+
nativeXhrOpen.call(xhr, 'PUT', uploadUrl, true);
|
|
270
|
+
xhr.setRequestHeader('Content-Type', mime);
|
|
271
|
+
xhr.upload.onprogress = (e) => {
|
|
272
|
+
if (e.lengthComputable) onProgress?.(e.loaded / e.total);
|
|
273
|
+
};
|
|
274
|
+
xhr.onload = () => {
|
|
275
|
+
if (xhr.status >= 200 && xhr.status < 300) resolve();
|
|
276
|
+
else reject(new Error(`[BugCatch] upload failed (HTTP ${xhr.status}).`));
|
|
277
|
+
};
|
|
278
|
+
// The PUT goes to the bucket, not to the API, so a missing bucket CORS
|
|
279
|
+
// policy arrives here as a bare network error with no status. Reporting it
|
|
280
|
+
// as "network error" sends people to read API logs that are perfectly clean.
|
|
281
|
+
xhr.onerror = () => reject(new Error(
|
|
282
|
+
'[BugCatch] the browser blocked the upload to storage. If this is a new '
|
|
283
|
+
+ 'environment, the media bucket is probably missing its CORS policy.',
|
|
284
|
+
));
|
|
285
|
+
xhr.onabort = () => reject(new Error('[BugCatch] upload cancelled.'));
|
|
286
|
+
nativeXhrSend.call(xhr, blob);
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function humanSize(bytes) {
|
|
291
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
292
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
|
293
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function humanDuration(seconds) {
|
|
297
|
+
const m = Math.floor(seconds / 60);
|
|
298
|
+
const s = seconds % 60;
|
|
299
|
+
return `${m}:${String(s).padStart(2, '0')}`;
|
|
300
|
+
}
|