From 0e3c500325208a2e0bc7c1167390f89a250cdb88 Mon Sep 17 00:00:00 2001 From: Lukas Boehler Date: Wed, 30 Sep 2026 21:49:05 -0400 Subject: [PATCH 01/11] fix: classic screen recording stops cleanly on "Stop sharing" and is not uploaded twice - "Stop sharing" in the browser ended only the screen track: the timer kept running and the microphone stayed open (the recorder went on recording audio). The track's end now stops the recording like the Stop button, releasing the timer and every track. - The recording stayed in the global data after the report that carried it was sent, so a later silent crash report uploaded it again. It is cleared once a report with it was sent, and silent reports never take the feedback form's recording along. Co-Authored-By: Claude Opus 5.5 --- src/GleapFeedback.js | 20 ++++++++++++++++++-- src/GleapScreenRecorder.js | 23 ++++++++++++++++++++--- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/GleapFeedback.js b/src/GleapFeedback.js index 791f1f0c..6d714e76 100644 --- a/src/GleapFeedback.js +++ b/src/GleapFeedback.js @@ -73,6 +73,8 @@ export default class GleapFeedback { screenshotData = undefined; webReplay = undefined; screenRecordingUrl = undefined; + // The form's recording this report uploads (see forgetSentScreenRecording). + screenRecordingData = undefined; spamToken = undefined; constructor(type, priority, formData, isSilent, excludeData, outboundId, spamToken) { @@ -102,9 +104,11 @@ export default class GleapFeedback { this.webReplay = webReplay; } - // Prepare screen recording - var screenRecordingData = gleapInstance.getGlobalDataItem('screenRecordingData'); + // Prepare screen recording. It was made in the feedback form, so a silent (crash) report never + // takes it along. + var screenRecordingData = this.isSilent ? null : gleapInstance.getGlobalDataItem('screenRecordingData'); if (screenRecordingData != null) { + this.screenRecordingData = screenRecordingData; var recordingUrlPromise = GleapScreenRecorder.uploadScreenRecording(screenRecordingData).then((recordingUrl) => { if (recordingUrl) { this.screenRecordingUrl = recordingUrl; @@ -199,7 +203,18 @@ export default class GleapFeedback { }); } + // The recording went out with this report: a later report must not upload it again. + forgetSentScreenRecording() { + try { + const gleapInstance = Gleap.getInstance(); + if (this.screenRecordingData && gleapInstance.getGlobalDataItem('screenRecordingData') === this.screenRecordingData) { + gleapInstance.setGlobalDataItem('screenRecordingData', null); + } + } catch (exp) {} + } + sendFeedback() { + const self = this; return new Promise((resolve, reject) => { this.takeSnapshot() .then(() => { @@ -216,6 +231,7 @@ export default class GleapFeedback { http.onreadystatechange = function (e) { if (http.readyState === 4) { if (http.status === 200 || http.status === 201) { + self.forgetSentScreenRecording(); try { const feedback = JSON.parse(http.responseText); resolve(feedback); diff --git a/src/GleapScreenRecorder.js b/src/GleapScreenRecorder.js index 52170bc3..d331966a 100644 --- a/src/GleapScreenRecorder.js +++ b/src/GleapScreenRecorder.js @@ -158,6 +158,19 @@ export class GleapScreenRecorder { this.mediaRecorder.stop(); } catch (_) {} + this.releaseCapture(); + + this.rerender(); + } + + // Stops the timer and every captured track: the screen (what Chrome uses for the recording + // indicator), the composed stream and the microphone. + releaseCapture() { + if (this.recordingTimer) { + clearInterval(this.recordingTimer); + this.recordingTimer = null; + } + // Stop composed stream tracks this.stopStreamTracks(this.stream); @@ -174,8 +187,6 @@ export class GleapScreenRecorder { } this._displayVideoTrack = null; - - this.rerender(); } async startAudioRecording() { @@ -309,8 +320,14 @@ export class GleapScreenRecorder { const videoTrack = this.getFirstVideoTrack(stream); if (videoTrack) { + // The browser's "Stop sharing" ends the screen track: stop like the Stop button does, so the + // timer and the microphone stop too (the recorder's onstop prepares the recording). videoTrack.onended = function () { - self.prepareRecording(recordedChunks); + if (self.isRecording) { + self.stopScreenRecording(); + } else { + self.releaseCapture(); + } }; } From b30b0d41f7e04fb322f9c776f45783af30b4895d Mon Sep 17 00:00:00 2001 From: Lukas Boehler Date: Wed, 30 Sep 2026 21:49:10 -0400 Subject: [PATCH 02/11] feat: session replay can pause while a capture request records the page rrweb records one session per page. A capture request's page recording (phones and tablets, where there is no screen capture) pauses the running replay, keeping its buffer, and resumes it in a new checkpoint afterwards. A config apply meanwhile doesn't restart it; stop() ends the pause. Co-Authored-By: Claude Opus 5.5 --- src/GleapReplayRecorder.js | 64 +++++++++++++++++++++++++++++++++++--- 1 file changed, 59 insertions(+), 5 deletions(-) diff --git a/src/GleapReplayRecorder.js b/src/GleapReplayRecorder.js index 058357e1..94a29b9b 100644 --- a/src/GleapReplayRecorder.js +++ b/src/GleapReplayRecorder.js @@ -66,6 +66,8 @@ export default class GleapReplayRecorder { forcedCheckoutTimeout = undefined; stopFunction = undefined; customOptions = {}; + // A capture request's page recording runs rrweb itself, and rrweb records one session per page. + pausedForCapture = false; // GleapReplayRecorder singleton static instance; @@ -93,13 +95,33 @@ export default class GleapReplayRecorder { /** * Start replays + * @param {boolean} keepBuffer Continue the current buffer in a new checkpoint (after a capture + * request's page recording) instead of starting over. * @returns */ - start() { - this.stop(); + start(keepBuffer = false) { + if (keepBuffer && this.startDate) { + if (this.stopFunction) { + try { + this.stopFunction(); + } catch (e) {} + this.stopFunction = undefined; + } + // The new recording opens with its own full snapshot, so it is a checkpoint of its own. + if (this.segments[this.segments.length - 1].length > 0) { + this.segments.push([]); + this.segmentSizes.push(0); + this.incrementalSize = 0; + while (this.segments.length > MAX_CHECKPOINTS) { + this.dropOldestSegment(); + } + } + } else { + this.stop(); - this.startDate = Date.now(); - this.resetBuffer(); + this.startDate = Date.now(); + this.resetBuffer(); + } var options = { inlineStylesheet: true, @@ -190,7 +212,7 @@ export default class GleapReplayRecorder { * far. */ startIfNotRunning() { - if (this.stopFunction) { + if (this.stopFunction || this.pausedForCapture) { return; } @@ -207,10 +229,42 @@ export default class GleapReplayRecorder { this.stopFunction = undefined; } + this.pausedForCapture = false; this.startDate = undefined; this.resetBuffer(); } + /** + * Pauses a running replay while a capture request records the page (see pausedForCapture). The + * buffer is kept. + * @returns {boolean} whether it was running (and so must be resumed) + */ + pauseForCapture() { + if (!this.stopFunction) { + return false; + } + try { + this.stopFunction(); + } catch (e) {} + this.stopFunction = undefined; + clearTimeout(this.forcedCheckoutTimeout); + this.forcedCheckoutTimeout = undefined; + this.pausedForCapture = true; + return true; + } + + /** + * Continues a replay paused by pauseForCapture, in a new checkpoint. Nothing when it was stopped + * in the meantime. + */ + resumeAfterCapture() { + if (!this.pausedForCapture) { + return; + } + this.pausedForCapture = false; + this.start(true); + } + resetBuffer() { clearTimeout(this.forcedCheckoutTimeout); this.forcedCheckoutTimeout = undefined; From 493114eefec1f9deb5de09f049f525db14e5b00d Mon Sep 17 00:00:00 2001 From: Lukas Boehler Date: Wed, 30 Sep 2026 21:49:24 -0400 Subject: [PATCH 03/11] =?UTF-8?q?feat:=20capture=20requests=20=E2=80=94=20?= =?UTF-8?q?screenshots,=20screen=20recordings=20and=20log=20requests=20on?= =?UTF-8?q?=20the=20web?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host side of the capture-requests contract (Messenger bridge §7, web capture §8, logs §9, APIs §10): - capture-capabilities after every Messenger ping (screenshot, recording via getDisplayMedia or rrweb, microphone per flowConfig.capture.allowMicrophone); everything false with Gleap.setCaptureEnabled(false). The SDK websocket announces &caps=capture.screenshot, capture.recording,capture.logs (only what is enabled). - capture-start hides the widget without closing it and shows a capture bar in the page: shadow DOM under one 0x0 rr-block/gl-block host, max z-index, draggable on desktop, a bottom sheet with safe-area padding on phones, keyboard accessible, reduced-motion aware, labels from the Messenger with English fallbacks. capture-state reports every step; Cancel releases the request. - Screenshots: desktop Chromium grabs one frame of the current tab (preferCurrentTab; ImageCapture, MediaStreamTrackProcessor,