diff --git a/CHANGELOG.md b/CHANGELOG.md index fa05889..ca3ccad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased +Capture requests ("show me the issue"): when a workflow, an AI agent or a teammate asks the customer for a screenshot or a screen recording in a conversation, the customer answers from the card in the widget and the SDK captures the page. On desktop Chrome and Edge the screenshot is taken from the tab itself after one "Share this tab" confirmation (exact pixels); everywhere else, and when sharing is declined, the page is rendered in the browser. Screen recordings use the browser's screen sharing (MP4 where supported, otherwise WebM with its duration written in so players can seek), with the microphone if the project allows it; while recording, everything a screenshot masks is blurred on the page. When screen sharing doesn't start, and on phones and tablets, the customer can record the page instead (a page recording the dashboard plays like a session replay). Password, one-time-code and card fields, the `gl-mask` / `gl-block` / `rr-mask` / `rr-block` classes, the `setReplayOptions` privacy options, payment frames and the project's masked elements are left out. The capture bar is a small dark pill the customer can drag; all its texts come from the widget's translations. +Logs requests: workflows, AI agents and teammates can collect the page's console logs, network logs, custom data, environment data and events in the background, the same data a bug report carries. They arrive over the SDK's websocket and are uploaded once, gzipped. +New: `Gleap.setCaptureEnabled(enabled)` (off: the widget only offers a file upload) and `Gleap.setRemoteLogCollectionEnabled(enabled)` (off: logs requests are answered as unsupported and captures are sent without logs). +The classic screen recording now stops cleanly when the customer clicks the browser's "Stop sharing" and is no longer uploaded twice. The browser bundle grows by about 34 KB gzipped. + ## 19.0.1 Fixed: the SDK's injected styles made the host page slower. The product tour rule `.gleap-tour-popover-navigation-btns button + button` ends in a bare `button`, so Chrome restyled sibling elements across the whole page whenever a node was inserted anywhere, not only inside Gleap's UI (on a page with ~19k nodes, ~2,000 extra elements per popover open or close). The tour buttons are now spaced with `gap` on their flex container, which looks the same; when the Previous button is hidden, the Next button no longer keeps a stray 4px margin. diff --git a/babel.config.js b/babel.config.js index 6f13ce9..b3b20b8 100644 --- a/babel.config.js +++ b/babel.config.js @@ -4,5 +4,19 @@ module.exports = { ], plugins: [ ["@babel/plugin-transform-class-properties"] + ], + overrides: [ + { + // The capture request modules only run where the rest of the bundle does: its untranspiled + // dependencies (modern-screenshot, rrweb) already need ES2020 (?. and ??). Compiled for those + // browsers they stay smaller (no ES5 classes, arrows, spread or destructuring helpers). + test: /[\\/]src[\\/](GleapCapture(Api|Manager|Recorder|Screenshot|Settings|Tasks|UI|Veil)|GleapWebmDuration)\.js$/, + presets: [ + [ + "@babel/env", + { targets: { chrome: "80", edge: "80", firefox: "74", safari: "13.1", ios: "13.4", samsung: "13" } } + ] + ] + } ] -}; \ No newline at end of file +}; diff --git a/index.d.ts b/index.d.ts index e7cdfab..cdcddd4 100644 --- a/index.d.ts +++ b/index.d.ts @@ -409,6 +409,19 @@ export namespace Gleap { }[]; errorHandler?: (error: Error) => void; }): void; + /** + * Enables or disables screenshots and screen recordings that teammates, AI agents or workflows + * ask the customer for in the widget (default: enabled). While disabled, the widget offers + * uploading a file instead. Can be called before initialize. + */ + function setCaptureEnabled(enabled: boolean): void; + /** + * Enables or disables sending logs when teammates, AI agents or workflows request them, on their + * own or along with a screenshot or screen recording (default: enabled). Only data a bug report + * carries is sent: console and network logs, custom data, environment data and events. Can be + * called before initialize. + */ + function setRemoteLogCollectionEnabled(enabled: boolean): void; } export default Gleap; diff --git a/package-lock.json b/package-lock.json index 2b2d866..f5b6bfe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@floating-ui/dom": "^1.7.5", "@rrweb/packer": "^2.1.0", "@rrweb/record": "^2.1.0", + "modern-screenshot": "4.7.0", "unique-selector": "^0.5.0" }, "devDependencies": { @@ -10711,6 +10712,12 @@ "node": ">=10" } }, + "node_modules/modern-screenshot": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/modern-screenshot/-/modern-screenshot-4.7.0.tgz", + "integrity": "sha512-9YxN+ddPSMMlhylOv25VHzXrl9u67QRxoh7+SEewGtgUw7t6hHTrjptSDJUSne9oG4Xk/h2cwG15nIt4Hc9ujg==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", diff --git a/package.json b/package.json index 9e62cc0..2ecd8cf 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "@floating-ui/dom": "^1.7.5", "@rrweb/packer": "^2.1.0", "@rrweb/record": "^2.1.0", + "modern-screenshot": "4.7.0", "unique-selector": "^0.5.0" } } diff --git a/src/Gleap.js b/src/Gleap.js index 99d7328..f7c36e2 100755 --- a/src/Gleap.js +++ b/src/Gleap.js @@ -7,6 +7,7 @@ import GleapAgentToolManager from './GleapAgentToolManager'; import GleapAiChatbarManager from './GleapAiChatbarManager'; import GleapAudioManager from './GleapAudioManager'; import GleapBannerManager from './GleapBannerManager'; +import GleapCaptureManager from './GleapCaptureManager'; import { registerGleapChecklist } from './GleapChecklist'; import GleapClickListener from './GleapClickListener'; import GleapConfigManager from './GleapConfigManager'; @@ -279,6 +280,11 @@ class Gleap { // Check for URL params. Gleap.checkForUrlParams(); + // A capture request that was in progress before this page load. + try { + GleapCaptureManager.getInstance().resumeFromStorage(); + } catch (exp) {} + // Notify event. GleapEventManager.notifyEvent('initialized'); }); @@ -354,6 +360,9 @@ class Gleap { * @returns */ static destroy() { + try { + GleapCaptureManager.getInstance().destroy(); + } catch (exp) {} GleapReplayRecorder.getInstance().stop(); GleapStreamedEvent.getInstance().stop(); GleapFrameManager.getInstance().destroy(); @@ -541,6 +550,27 @@ class Gleap { GleapReplayRecorder.getInstance().setOptions(options); } + /** + * Enables or disables screenshots and screen recordings that teammates, AI agents or workflows + * ask the customer for in the widget (default: enabled). While disabled, the widget offers + * uploading a file instead. Can be called before initialize. + * @param {boolean} enabled + */ + static setCaptureEnabled(enabled) { + GleapCaptureManager.getInstance().setCaptureEnabled(enabled); + } + + /** + * Enables or disables sending logs when teammates, AI agents or workflows request them, on their + * own or along with a screenshot or screen recording (default: enabled). Only data a bug report + * carries is sent (console and network logs, custom data, environment data, events). Can be + * called before initialize. + * @param {boolean} enabled + */ + static setRemoteLogCollectionEnabled(enabled) { + GleapCaptureManager.getInstance().setRemoteLogCollectionEnabled(enabled); + } + /** * Closes any open Gleap dialogs. * @param {boolean} resetRoutes @@ -1667,6 +1697,7 @@ export { GleapAiChatbarManager, GleapAudioManager, GleapBannerManager, + GleapCaptureManager, GleapClickListener, GleapConfigManager, GleapConsoleLogManager, diff --git a/src/GleapBannerManager.js b/src/GleapBannerManager.js index 87f6a65..870f81a 100644 --- a/src/GleapBannerManager.js +++ b/src/GleapBannerManager.js @@ -1,4 +1,5 @@ import Gleap, { GleapFrameManager } from './Gleap'; +import { isOutboundActionBlocked } from './GleapCaptureSettings'; import { bootstrapGleapFrame } from './GleapHelper'; export default class GleapBannerManager { @@ -65,6 +66,10 @@ export default class GleapBannerManager { if (data.name === 'banner-close') { this.removeBannerUI(); } + // While the widget is hidden for a capture (screenshot or recording), actions open nothing. + if (isOutboundActionBlocked(data.name)) { + return; + } if (data.name === 'start-conversation') { Gleap.startBot(data.data?.botId); } diff --git a/src/GleapCaptureApi.js b/src/GleapCaptureApi.js new file mode 100644 index 0000000..c9a93e6 --- /dev/null +++ b/src/GleapCaptureApi.js @@ -0,0 +1,332 @@ +import { + GleapConfigManager, + GleapConsoleLogManager, + GleapCustomDataManager, + GleapMetaDataManager, + GleapNetworkIntercepter, + GleapReplayRecorder, + GleapSession, + GleapStreamedEvent, +} from './Gleap'; +import { packEvents } from './GleapCaptureRecorder'; +import { CAPTURE_PLATFORM, CAPTURE_SDK_TYPE, getSdkVersion } from './GleapCaptureSettings'; +import { toJsonParts } from './GleapCaptureTasks'; + +// Server calls and the log bundle of capture requests (contract §4 and §9). Every call resolves +// (never rejects) with { status, data }; status 0 means no answer. + +// The /logs body limit is 20 MB; stay below it. +const MAX_LOGS_BODY_BYTES = 19 * 1024 * 1024; +// Log entries this long before a recording started still count as part of it. +const RECORDING_LOG_LEAD_MS = 10 * 1000; +const JSON_TIMEOUT_MS = 30 * 1000; +// A large body gets more time: 30 s plus a second per 64 KB. +const timeoutForSize = (bytes) => JSON_TIMEOUT_MS + Math.ceil((bytes || 0) / (64 * 1024)) * 1000; + +const DEFAULT_INCLUDE = { + consoleLog: true, + networkLogs: true, + customData: true, + metaData: true, + customEventLog: true, + replays: false, +}; + +const capturePath = (requestId, action) => + '/v3/shared/capture-requests/' + encodeURIComponent(String(requestId)) + '/' + action; + +const parseJson = (text) => { + try { + return text ? JSON.parse(text) : null; + } catch (exp) { + return null; + } +}; + +/** + * Sends a JSON (or pre-encoded) body to the API with the session headers. + * @returns {Promise<{status: number, data: any}>} + */ +export const sendCaptureApiRequest = (method, path, body, options = {}) => + new Promise((resolve) => { + let settled = false; + const done = (status, data) => { + if (!settled) { + settled = true; + resolve({ status, data }); + } + }; + + try { + const session = GleapSession.getInstance(); + const xhr = new XMLHttpRequest(); + xhr.open(method, session.apiUrl + path); + xhr.setRequestHeader('Content-Type', 'application/json;charset=UTF-8'); + if (options.gzip) { + xhr.setRequestHeader('Content-Encoding', 'gzip'); + } + session.injectSession(xhr); + xhr.timeout = options.timeoutMs || JSON_TIMEOUT_MS; + xhr.onreadystatechange = () => { + if (xhr.readyState === 4) { + done(xhr.status, parseJson(xhr.responseText)); + } + }; + xhr.onerror = () => done(0, null); + xhr.ontimeout = () => done(0, null); + xhr.send(body === undefined ? null : body); + } catch (exp) { + done(0, null); + } + }); + +/** + * The request as the server has it ({ status, expiresAt, ... }). + */ +export const getCaptureRequest = (requestId) => + sendCaptureApiRequest('GET', '/v3/shared/capture-requests/' + encodeURIComponent(String(requestId))); + +const postJson = (requestId, action, payload) => + sendCaptureApiRequest('POST', capturePath(requestId, action), JSON.stringify(payload || {})); + +export const claimCaptureRequest = (requestId, payload) => postJson(requestId, 'claim', payload); + +/** + * Reports a non-final outcome ('declined' | 'unsupported' | 'failed' | 'released'); releases the claim. + */ +export const reportCaptureEvent = (requestId, type, reason) => { + const payload = { type }; + if (reason) { + payload.reason = String(reason).slice(0, 200); + } + return postJson(requestId, 'event', payload); +}; + +export const completeCaptureRequest = (requestId, payload) => postJson(requestId, 'complete', payload); + +const gzipBlob = (blob) => { + try { + if (typeof CompressionStream === 'function' && typeof Response === 'function' && typeof blob.stream === 'function') { + return new Response(blob.stream().pipeThrough(new CompressionStream('gzip'))).blob().catch(() => null); + } + } catch (exp) {} + return Promise.resolve(null); +}; + +// The replay's events packed (in slices) if they aren't yet. +const withPackedReplay = (bundle) => { + const replay = bundle.webReplay; + if (!replay || replay.packed || !Array.isArray(replay.events)) { + return Promise.resolve(bundle); + } + return packEvents(replay.events).then((packed) => { + if (!packed) { + return bundle; + } + return Object.assign({}, bundle, { webReplay: Object.assign({}, replay, { events: packed, packed: true }) }); + }); +}; + +// Serialized in slices into a Blob (no single huge string), then gzipped when the browser can. +const encodeJson = (value) => + toJsonParts(value).then((parts) => { + const blob = new Blob(parts, { type: 'application/json' }); + return gzipBlob(blob).then((gzipped) => + gzipped ? { body: gzipped, gzip: true, size: gzipped.size } : { body: blob, gzip: false, size: blob.size } + ); + }); + +/** + * The request body for a log bundle: gzip when the browser can (CompressionStream), plain JSON + * otherwise. A bundle over the size limit goes without its replay; null when it is still too large. + * The main thread is never blocked for long: packing and serializing run in slices. + * @returns {Promise<{body: Blob, gzip: boolean, size: number}|null>} + */ +export const encodeLogsBundle = (bundle) => + withPackedReplay(bundle) + .then((ready) => encodeJson(ready)) + .then((encoded) => { + if (encoded.size <= MAX_LOGS_BODY_BYTES) { + return encoded; + } + if (!bundle.webReplay) { + return null; + } + const withoutReplay = Object.assign({}, bundle); + delete withoutReplay.webReplay; + return encodeJson(withoutReplay).then((smaller) => (smaller.size <= MAX_LOGS_BODY_BYTES ? smaller : null)); + }); + +export const postCaptureLogs = (requestId, bundle) => + encodeLogsBundle(bundle).then((encoded) => + encoded + ? sendCaptureApiRequest('POST', capturePath(requestId, 'logs'), encoded.body, { + gzip: encoded.gzip, + timeoutMs: timeoutForSize(encoded.size), + }) + : { status: 413, data: null } + ); + +/** + * Uploads one file to /uploads/attachments (the endpoint the Messenger uses for attachments). + * @param {File|Blob} file + * @param {function(number)} onProgress 0..1 + * @returns {{promise: Promise, abort: function}} resolves with the file URL + */ +export const uploadCaptureFile = (file, fileName, onProgress) => { + let xhr = null; + const promise = new Promise((resolve, reject) => { + try { + const session = GleapSession.getInstance(); + const formData = new FormData(); + formData.append('file', file, fileName); + xhr = new XMLHttpRequest(); + xhr.open('POST', session.apiUrl + '/uploads/attachments'); + session.injectSession(xhr); + if (xhr.upload && onProgress) { + xhr.upload.onprogress = (event) => { + if (event.lengthComputable && event.total > 0) { + onProgress(Math.min(1, event.loaded / event.total)); + } + }; + } + xhr.onreadystatechange = () => { + if (xhr.readyState !== 4) { + return; + } + const data = parseJson(xhr.responseText); + if (xhr.status === 200 && data && Array.isArray(data.fileUrls) && typeof data.fileUrls[0] === 'string') { + resolve(data.fileUrls[0]); + } else { + reject(new Error(xhr.status === 413 ? 'file-too-large' : 'upload-failed-' + xhr.status)); + } + }; + xhr.onerror = () => reject(new Error('upload-failed')); + xhr.onabort = () => reject(new Error('upload-aborted')); + xhr.send(formData); + } catch (exp) { + reject(exp); + } + }); + return { + promise, + abort: () => { + try { + if (xhr) { + xhr.abort(); + } + } catch (exp) {} + }, + }; +}; + +export const normalizeInclude = (include) => { + const result = Object.assign({}, DEFAULT_INCLUDE); + if (include && typeof include === 'object') { + Object.keys(DEFAULT_INCLUDE).forEach((key) => { + if (typeof include[key] === 'boolean') { + result[key] = include[key]; + } + }); + } + return result; +}; + +const entryTime = (entry) => { + if (!entry) { + return NaN; + } + const value = entry.date; + if (value instanceof Date) { + return value.getTime(); + } + return typeof value === 'string' || typeof value === 'number' ? new Date(value).getTime() : NaN; +}; + +const withinWindow = (entries, from, to) => { + if (!Array.isArray(entries)) { + return []; + } + if (!from) { + return entries.slice(); + } + return entries.filter((entry) => { + const time = entryTime(entry); + // Entries without a usable date stay in. + return isNaN(time) || (time >= from && time <= to); + }); +}; + +const webReplaysEnabled = () => { + try { + const flowConfig = GleapConfigManager.getInstance().getFlowConfig(); + return !!(flowConfig && flowConfig.enableWebReplays); + } catch (exp) { + return false; + } +}; + +/** + * The log bundle for /logs (contract §9): the data a bug report carries, from the same collectors, + * limited to what `include` asks for. Never takes a screenshot. With a recording window only the + * entries of that window (and a short lead-in) are included. + * @param {{include?: object, windowStart?: number, windowEnd?: number, deviceId?: string}} options + */ +export const buildLogsBundle = (options = {}) => { + const include = normalizeInclude(options.include); + const now = Date.now(); + const windowEnd = options.windowEnd || now; + const filterFrom = options.windowStart ? options.windowStart - RECORDING_LOG_LEAD_MS : 0; + const bundle = {}; + + const collect = (key, read) => { + try { + bundle[key] = read(); + } catch (exp) {} + }; + + if (include.consoleLog) { + collect('consoleLog', () => withinWindow(GleapConsoleLogManager.getInstance().getLogs(), filterFrom, windowEnd)); + } + if (include.networkLogs) { + collect('networkLogs', () => withinWindow(GleapNetworkIntercepter.getInstance().getRequests(), filterFrom, windowEnd)); + } + if (include.customData) { + collect('customData', () => GleapCustomDataManager.getInstance().getCustomData()); + } + if (include.metaData) { + collect('metaData', () => GleapMetaDataManager.getInstance().getMetaData()); + } + if (include.customEventLog) { + collect('customEventLog', () => withinWindow(GleapStreamedEvent.getInstance().getEventArray(), filterFrom, windowEnd)); + } + if (include.replays && webReplaysEnabled()) { + try { + // Unpacked here (cheap); encodeLogsBundle packs it in slices. + const replay = GleapReplayRecorder.getInstance().getReplaySnapshot(); + if (replay && replay.startDate && Array.isArray(replay.events) && replay.events.length > 0) { + bundle.webReplay = replay; + } + } catch (exp) {} + } + + let windowStart = options.windowStart; + if (!windowStart) { + try { + windowStart = GleapMetaDataManager.getInstance().sessionStart.getTime(); + } catch (exp) { + windowStart = now; + } + } + + bundle.capturedAt = new Date(now).toISOString(); + bundle.windowStart = new Date(windowStart).toISOString(); + bundle.windowEnd = new Date(windowEnd).toISOString(); + bundle.platform = CAPTURE_PLATFORM; + bundle.sdkType = CAPTURE_SDK_TYPE; + bundle.sdkVersion = getSdkVersion(); + if (options.deviceId) { + bundle.deviceId = options.deviceId; + } + return bundle; +}; diff --git a/src/GleapCaptureLogs.test.js b/src/GleapCaptureLogs.test.js new file mode 100644 index 0000000..9af3a33 --- /dev/null +++ b/src/GleapCaptureLogs.test.js @@ -0,0 +1,220 @@ +/** + * @jest-environment jsdom + */ + +// Background log collection sends only what a bug report carries, only what the request asks for, +// and nothing when the app switched it off (Gleap.setRemoteLogCollectionEnabled(false)). + +const mockFlowConfig = { enableWebReplays: true, capture: {} }; +const mockReplay = { startDate: 1, events: [{ type: 4, data: { href: 'https://app.test' } }], packed: false, type: 'rrweb' }; +const iso = (ms) => new Date(ms).toISOString(); + +jest.mock('./Gleap', () => ({ + __esModule: true, + default: { openConversation: jest.fn(), open: jest.fn() }, + GleapConfigManager: { + getInstance: () => ({ getFlowConfig: () => mockFlowConfig, onConfigLoaded: (callback) => callback() }), + }, + GleapConsoleLogManager: { + getInstance: () => ({ + getLogs: () => [ + { log: 'old', date: iso(1000), priority: 'INFO' }, + { log: 'during', date: iso(50000), priority: 'ERROR' }, + ], + }), + }, + GleapNetworkIntercepter: { + getInstance: () => ({ + getRequests: () => [ + { url: 'https://app.test/a', date: iso(1000) }, + { url: 'https://app.test/b', date: iso(52000) }, + ], + }), + }, + GleapCustomDataManager: { getInstance: () => ({ getCustomData: () => ({ plan: 'pro' }) }) }, + GleapMetaDataManager: { getInstance: () => ({ getMetaData: () => ({ browser: 'Chrome' }), sessionStart: new Date(500) }) }, + GleapStreamedEvent: { getInstance: () => ({ getEventArray: () => [{ name: 'pageView', date: new Date(51000) }] }) }, + GleapReplayRecorder: { getInstance: () => ({ getReplaySnapshot: () => mockReplay, customOptions: {} }) }, + GleapSession: { + getInstance: () => ({ session: { gleapId: 'g1' }, sdkKey: 'key', apiUrl: 'https://api.test', injectSession: () => {} }), + }, + GleapFrameManager: { + getInstance: () => ({ sendMessage: jest.fn(), setCaptureHidden: jest.fn(), setCaptureEditorOpen: jest.fn() }), + }, + GleapTranslationManager: { getInstance: () => ({ isRTLLayout: false }) }, +})); + +import { buildLogsBundle, encodeLogsBundle, normalizeInclude } from './GleapCaptureApi'; +import { unpack } from '@rrweb/packer'; + +describe('log bundle', () => { + const allKeys = ['consoleLog', 'networkLogs', 'customData', 'metaData', 'customEventLog']; + + test('the bug report data, without a replay unless asked for', () => { + const bundle = buildLogsBundle({ deviceId: 'web-1' }); + allKeys.forEach((key) => expect(bundle).toHaveProperty(key)); + expect(bundle.webReplay).toBeUndefined(); + expect(bundle).toEqual(expect.objectContaining({ platform: 'web', sdkType: 'JAVASCRIPT', deviceId: 'web-1' })); + expect(bundle.windowStart).toBe(iso(500)); + }); + + test('only what include asks for (absent keys = not collected)', () => { + const bundle = buildLogsBundle({ + include: { consoleLog: true, networkLogs: false, customData: false, metaData: true, customEventLog: false }, + }); + expect( + Object.keys(bundle) + .filter((key) => allKeys.indexOf(key) !== -1) + .sort() + ).toEqual(['consoleLog', 'metaData']); + }); + + test('the replay only when asked for and web replays are on', () => { + expect(buildLogsBundle({ include: { replays: true } }).webReplay).toEqual(mockReplay); + mockFlowConfig.enableWebReplays = false; + expect(buildLogsBundle({ include: { replays: true } }).webReplay).toBeUndefined(); + mockFlowConfig.enableWebReplays = true; + }); + + test('a recording window keeps the entries of the window (with a short lead-in)', () => { + const bundle = buildLogsBundle({ windowStart: 50000, windowEnd: 60000 }); + expect(bundle.consoleLog.map((entry) => entry.log)).toEqual(['during']); + expect(bundle.networkLogs.map((entry) => entry.url)).toEqual(['https://app.test/b']); + expect(bundle.windowStart).toBe(iso(50000)); + expect(bundle.windowEnd).toBe(iso(60000)); + }); + + test('encoded in slices to valid JSON: the replay packed, data that cannot be serialized left out', async () => { + const bundle = buildLogsBundle({ include: { replays: true } }); + const cyclic = { plan: 'pro' }; + cyclic.self = cyclic; + bundle.customData = cyclic; + bundle.consoleLog = Array.from({ length: 120 }, (value, index) => ({ log: 'line ' + index })); + + const encoded = await encodeLogsBundle(bundle); + const text = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = reject; + reader.readAsText(encoded.body); + }); + const parsed = JSON.parse(text); + + expect(parsed.customData).toBeUndefined(); + expect(parsed.consoleLog).toHaveLength(120); + expect(parsed.consoleLog[119]).toEqual({ log: 'line 119' }); + expect(parsed.webReplay.packed).toBe(true); + expect(unpack(parsed.webReplay.events[0])).toEqual(expect.objectContaining(mockReplay.events[0])); + expect(parsed).toEqual(expect.objectContaining({ platform: 'web', sdkType: 'JAVASCRIPT' })); + }); + + test('include defaults: everything but replays', () => { + expect(normalizeInclude(undefined)).toEqual({ + consoleLog: true, + networkLogs: true, + customData: true, + metaData: true, + customEventLog: true, + replays: false, + }); + expect(normalizeInclude({ replays: true, consoleLog: 'yes' }).replays).toBe(true); + expect(normalizeInclude({ consoleLog: 'yes' }).consoleLog).toBe(true); + }); +}); + +describe('log requests from the websocket', () => { + let api; + let manager; + + const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + + beforeEach(() => { + jest.resetModules(); + jest.doMock('./GleapCaptureApi', () => { + const actual = jest.requireActual('./GleapCaptureApi'); + return { + ...actual, + claimCaptureRequest: jest.fn(() => Promise.resolve({ status: 200, data: { claimedElsewhere: false } })), + postCaptureLogs: jest.fn(() => Promise.resolve({ status: 200, data: {} })), + reportCaptureEvent: jest.fn(() => Promise.resolve({ status: 200, data: {} })), + }; + }); + api = require('./GleapCaptureApi'); + const settings = require('./GleapCaptureSettings'); + settings.setRemoteLogCollectionEnabled(true); + manager = new (require('./GleapCaptureManager').default)(); + mockFlowConfig.capture = {}; + }); + + const request = (id, options) => ({ id, kind: 'logs', options: options || {}, expiresAt: iso(Date.now() + 60000) }); + + test('claims, then posts the bundle once per request id', async () => { + manager.handleServerRequest(request('cr-1', { include: { replays: false } })); + manager.handleServerRequest(request('cr-1')); + await flush(); + + expect(api.claimCaptureRequest).toHaveBeenCalledTimes(1); + expect(api.claimCaptureRequest.mock.calls[0][1]).toEqual( + expect.objectContaining({ platform: 'web', sdkType: 'JAVASCRIPT' }) + ); + expect(api.postCaptureLogs).toHaveBeenCalledTimes(1); + expect(api.postCaptureLogs.mock.calls[0][0]).toBe('cr-1'); + expect(api.reportCaptureEvent).not.toHaveBeenCalled(); + }); + + test('switched off by the app: answered "unsupported", nothing collected', async () => { + require('./GleapCaptureSettings').setRemoteLogCollectionEnabled(false); + manager.handleServerRequest(request('cr-2')); + await flush(); + + expect(api.reportCaptureEvent).toHaveBeenCalledWith('cr-2', 'unsupported', 'disabled-by-app'); + expect(api.claimCaptureRequest).not.toHaveBeenCalled(); + expect(api.postCaptureLogs).not.toHaveBeenCalled(); + }); + + test('switched off in the project: answered "unsupported", nothing collected', async () => { + mockFlowConfig.capture = { backgroundLogs: false }; + manager.handleServerRequest(request('cr-3')); + await flush(); + + expect(api.reportCaptureEvent).toHaveBeenCalledWith('cr-3', 'unsupported', 'disabled-by-project'); + expect(api.postCaptureLogs).not.toHaveBeenCalled(); + }); + + test('claimed by another tab or device a moment ago: that one answers', async () => { + api.claimCaptureRequest.mockReturnValueOnce(Promise.resolve({ status: 200, data: { claimedElsewhere: true } })); + manager.handleServerRequest(request('cr-4')); + await flush(); + + expect(api.postCaptureLogs).not.toHaveBeenCalled(); + }); + + test('logs along with a screenshot or recording: none when the app switched log collection off', async () => { + const settings = require('./GleapCaptureSettings'); + const session = { options: { attachLogs: true } }; + + settings.setRemoteLogCollectionEnabled(false); + expect(manager.attachesLogs(session)).toBe(false); + manager.postLogs('cr-8', { consoleLog: [] }); + await flush(); + expect(api.postCaptureLogs).not.toHaveBeenCalled(); + + settings.setRemoteLogCollectionEnabled(true); + expect(manager.attachesLogs(session)).toBe(true); + expect(manager.attachesLogs({ options: { attachLogs: false } })).toBe(false); + manager.postLogs('cr-8', { consoleLog: [] }); + await flush(); + expect(api.postCaptureLogs).toHaveBeenCalledTimes(1); + }); + + test('ignores other kinds, expired requests and requests that are already final', async () => { + manager.handleServerRequest({ id: 'cr-5', kind: 'screenshot' }); + manager.handleServerRequest({ id: 'cr-6', kind: 'logs', expiresAt: iso(Date.now() - 1000) }); + api.claimCaptureRequest.mockReturnValueOnce(Promise.resolve({ status: 410, data: null })); + manager.handleServerRequest(request('cr-7')); + await flush(); + + expect(api.claimCaptureRequest).toHaveBeenCalledTimes(1); + expect(api.postCaptureLogs).not.toHaveBeenCalled(); + }); +}); diff --git a/src/GleapCaptureManager.js b/src/GleapCaptureManager.js new file mode 100644 index 0000000..251644d --- /dev/null +++ b/src/GleapCaptureManager.js @@ -0,0 +1,1220 @@ +import Gleap, { + GleapConfigManager, + GleapFrameManager, + GleapReplayRecorder, + GleapSession, + GleapTranslationManager, +} from './Gleap'; +import { + buildLogsBundle, + claimCaptureRequest, + completeCaptureRequest, + getCaptureRequest, + normalizeInclude, + postCaptureLogs, + reportCaptureEvent, + uploadCaptureFile, +} from './GleapCaptureApi'; +import { CaptureTimeline, DisplayRecording, PageRecording, requestDisplayStream } from './GleapCaptureRecorder'; +import { captureScreenshot, requestTabStream, stopStream } from './GleapCaptureScreenshot'; +import { + CAPTURE_MAX_RECORDING_SEC, + CAPTURE_PLATFORM, + CAPTURE_SDK_TYPE, + getCaptureConfig, + getRecordingMethod, + getSdkVersion, + hasDisplayMedia, + hasUserMedia, + isCaptureSupported, + isDesktopChromium, + isRemoteLogCollectionEnabled, + setCaptureEnabled, + setRemoteLogCollectionEnabled, +} from './GleapCaptureSettings'; +import GleapCaptureUI, { resolveCaptureLabels } from './GleapCaptureUI'; +import { applyPrivacyVeil } from './GleapCaptureVeil'; + +// Capture requests on the web (contract §7): the Messenger asks for a screenshot or a recording +// (capture-start), this page shows the capture bar, captures, and hands the result back (screenshot: +// capture-image, annotated and sent by the Messenger) or uploads and completes it itself +// (recordings). Log requests arrive on the SDK websocket and are answered without the customer. +// Idle until a request arrives: no timers, listeners or UI. + +const STORAGE_KEY = 'gleap-capture-session'; +const DEVICE_KEY = 'gleap-capture-device'; +// A bar restored after a page load is dropped when the request is older than this. +const RESUME_MAX_AGE_MS = 30 * 60 * 1000; +const WATCHDOG_MS = 1000; +const MAX_HANDLED_LOG_REQUESTS = 100; +// Statuses after which a request takes no capture any more (contract §1). +const FINAL_STATUSES = ['completed', 'skipped', 'cancelled', 'expired']; +// Gleap UI hidden while the customer captures the page (the widget itself is hidden by the frame +// manager): launcher, notifications, banners, modals, tooltips, tours, checklists, the admin helper. +const HIDDEN_GLEAP_UI_SELECTOR = [ + '.bb-feedback-button', + '.gleap-notification-container', + '.gleap-chatbar', + '.gleap-b', + '.gleap-modal-wrapper', + '.gleap-tooltip', + '.gleap-tooltip-anchor', + '.gleap-tour-popover', + '.gleap-tour-overlay', + '#copilot-pointer-container', + '#copilot-joined-container', + '.gleap-audio-unmute-modal-overlay', + '.gleap-admin-frame-container', + '.gleap-admin-collapse-ui', + 'gleap-checklist', +].join(', '); + +const isTopWindow = () => { + try { + return window.top === window.self; + } catch (exp) { + return false; + } +}; + +const readStorage = (key) => { + try { + return window.sessionStorage.getItem(key); + } catch (exp) { + return null; + } +}; + +const writeStorage = (key, value) => { + try { + if (value === null) { + window.sessionStorage.removeItem(key); + } else { + window.sessionStorage.setItem(key, value); + } + } catch (exp) {} +}; + +const randomHex = (bytes) => { + try { + const values = new Uint8Array(bytes); + window.crypto.getRandomValues(values); + return Array.prototype.map.call(values, (value) => (value < 16 ? '0' : '') + value.toString(16)).join(''); + } catch (exp) { + let text = ''; + for (let i = 0; i < bytes * 2; i++) { + text += Math.floor(Math.random() * 16).toString(16); + } + return text; + } +}; + +const clampDuration = (value) => { + const seconds = parseInt(value, 10); + if (isNaN(seconds)) { + return 60; + } + return Math.max(5, Math.min(CAPTURE_MAX_RECORDING_SEC, seconds)); +}; + +// A short reason for capture-state / the event log: "NotAllowedError", "upload-failed-500", ... +const errorReason = (error) => { + if (!error) { + return 'unknown'; + } + const name = error.name && error.name !== 'Error' ? error.name : ''; + const message = error.message ? String(error.message) : name ? '' : String(error); + return (name && message ? name + ': ' + message : name || message).slice(0, 120); +}; + +const isOk = (status) => status >= 200 && status < 300; + +let sessionCounter = 0; + +export default class GleapCaptureManager { + session = null; + ui = null; + watchdog = null; + hiddenElements = []; + // Escape while the bar is hidden for a tab frame. + hiddenEscape = null; + handledLogRequests = []; + logsPostedFor = []; + deviceId = null; + + // GleapCaptureManager singleton + static instance; + static getInstance() { + if (!this.instance) { + this.instance = new GleapCaptureManager(); + } + return this.instance; + } + + /** + * Gleap.setCaptureEnabled: false reports no capture capabilities (the Messenger offers uploads + * only) and ends a capture in progress. + */ + setCaptureEnabled(enabled) { + setCaptureEnabled(enabled); + if (!enabled) { + writeStorage(STORAGE_KEY, null); + if (this.session) { + this.cancelSession(this.session, 'disabled'); + } + } + // Only a Messenger that is already connected needs to hear it; it asks again on every load. + try { + if (GleapFrameManager.instance) { + GleapFrameManager.instance.sendCaptureCapabilities(); + } + } catch (exp) {} + } + + /** + * Gleap.setRemoteLogCollectionEnabled: false answers log requests with "unsupported". + */ + setRemoteLogCollectionEnabled(enabled) { + setRemoteLogCollectionEnabled(enabled); + } + + getDeviceId() { + if (this.deviceId) { + return this.deviceId; + } + let deviceId = readStorage(DEVICE_KEY); + if (!deviceId || !/^web-[0-9a-f]{32}$/.test(deviceId)) { + deviceId = 'web-' + randomHex(16); + writeStorage(DEVICE_KEY, deviceId); + } + this.deviceId = deviceId; + return deviceId; + } + + privacyOptions() { + try { + return GleapReplayRecorder.getInstance().customOptions || {}; + } catch (exp) { + return {}; + } + } + + maskSelectors() { + const selectors = getCaptureConfig().maskSelectors; + return Array.isArray(selectors) ? selectors.filter((selector) => typeof selector === 'string') : []; + } + + // Logs go along with a screenshot or recording unless the request says otherwise or the app + // switched remote log collection off (Gleap.setRemoteLogCollectionEnabled(false)). + attachesLogs(session) { + return !!(session && session.options && session.options.attachLogs) && isRemoteLogCollectionEnabled(); + } + + // A screen recording is video and can't be masked afterwards: while it runs, the live page blurs + // what screenshots mask. + applyVeil(session) { + this.liftVeil(session); + try { + session.veil = applyPrivacyVeil({ privacyOptions: this.privacyOptions(), maskSelectors: this.maskSelectors() }); + } catch (exp) { + session.veil = null; + } + } + + // veil: a particular one (default: the session's). + liftVeil(session, veil) { + const target = veil || session.veil; + if (!target) { + return; + } + if (session.veil === target) { + session.veil = null; + } + try { + target.remove(); + } catch (exp) {} + } + + // ----- Messenger bridge ------------------------------------------------------------------------- + + /** + * capture-start, capture-cancel, capture-editor and capture-done from the Messenger. + */ + handleMessengerMessage(message) { + try { + const data = message && message.data && typeof message.data === 'object' ? message.data : {}; + switch (message && message.name) { + case 'capture-start': + this.onCaptureStart(data); + break; + case 'capture-cancel': + this.onCaptureCancel(data); + break; + case 'capture-editor': + this.setEditorOpen(!!data.open); + break; + case 'capture-done': + this.onCaptureDone(data); + break; + default: + break; + } + } catch (exp) {} + } + + sendState(requestId, state, extra) { + try { + GleapFrameManager.getInstance().sendMessage({ + name: 'capture-state', + data: Object.assign({ requestId, state }, extra), + }); + } catch (exp) {} + } + + setEditorOpen(open) { + try { + GleapFrameManager.getInstance().setCaptureEditorOpen(open); + } catch (exp) {} + } + + onCaptureStart(data) { + const requestId = typeof data.requestId === 'string' ? data.requestId : null; + const kind = data.kind === 'screenshot' || data.kind === 'recording' ? data.kind : null; + if (!requestId || !kind) { + return; + } + if (!isCaptureSupported()) { + this.sendState(requestId, 'unsupported', { error: 'capture-disabled' }); + reportCaptureEvent(requestId, 'unsupported', 'capture-disabled'); + return; + } + + const current = this.session; + if (current) { + // Nothing interrupts an upload that is already on its way. + if (current.phase === 'uploading') { + return; + } + if (current.requestId !== requestId) { + this.cancelSession(current, 'replaced', true); + } else { + this.endSession(current, { restore: false }); + } + } + + this.setEditorOpen(false); + this.startSession({ + requestId, + kind, + ticketShareToken: typeof data.ticketShareToken === 'string' ? data.ticketShareToken : null, + options: data.options, + labels: data.labels, + resumed: false, + phase: 'bar', + }); + } + + onCaptureCancel(data) { + const session = this.session; + if (session && (!data.requestId || data.requestId === session.requestId)) { + this.endSession(session, { restore: true }); + this.sendState(session.requestId, 'cancelled'); + } + this.setEditorOpen(false); + } + + onCaptureDone(data) { + const session = this.session; + if (session && (!data.requestId || data.requestId === session.requestId)) { + this.endSession(session, { restore: true }); + } + this.setEditorOpen(false); + } + + /** + * The widget iframe went away (Gleap.destroy, a language change): a capture in progress reopens the + * conversation when it is done. + */ + onWidgetDestroyed() { + const session = this.session; + if (!session) { + return; + } + if (session.phase === 'editor') { + this.endSession(session, { restore: false }); + return; + } + session.resumed = true; + } + + destroy() { + if (this.session) { + this.endSession(this.session, { restore: false }); + } + this.setEditorOpen(false); + } + + // ----- Session ---------------------------------------------------------------------------------- + + normalizeOptions(options) { + const source = options && typeof options === 'object' ? options : {}; + return { + annotate: source.annotate !== false, + maxDurationSec: clampDuration(source.maxDurationSec), + audio: source.audio === true, + attachLogs: source.attachLogs !== false, + include: normalizeInclude(source.include), + }; + } + + startSession(params) { + let flowConfig = {}; + try { + flowConfig = GleapConfigManager.getInstance().getFlowConfig() || {}; + } catch (exp) {} + const session = { + key: ++sessionCounter, + requestId: params.requestId, + kind: params.kind, + ticketShareToken: params.ticketShareToken || null, + options: this.normalizeOptions(params.options), + labels: resolveCaptureLabels(params.labels), + rawLabels: params.labels && typeof params.labels === 'object' ? params.labels : null, + resumed: !!params.resumed, + phase: params.phase, + interrupted: params.phase === 'interrupted', + pageRecording: params.pageRecording === true, + micOn: false, + // When the request first showed its bar (kept across page loads; restored bars expire from it). + createdAt: typeof params.createdAt === 'number' ? params.createdAt : Date.now(), + startedAt: Date.now(), + lastTick: -1, + lastProgressSent: 0, + }; + this.session = session; + + // The bar first: when it can't be shown, the widget stays and the request fails (upload offered). + try { + let rtl = false; + try { + rtl = !!GleapTranslationManager.getInstance().isRTLLayout; + } catch (exp) {} + this.ui = new GleapCaptureUI({ + labels: params.labels, + primaryColor: flowConfig.color, + rtl, + onAction: (action) => this.onUiAction(session, action), + }); + this.ui.autoFocus = !session.resumed; + this.renderBar(session); + } catch (error) { + this.failSession(session, 'failed', errorReason(error)); + return; + } + + if (!session.resumed) { + try { + GleapFrameManager.getInstance().setCaptureHidden(true); + } catch (exp) {} + } + this.hideGleapUi(); + this.startWatchdog(); + this.persist(session); + this.sendState(session.requestId, 'bar'); + } + + renderBar(session) { + if (!this.ui) { + return; + } + if (session.kind === 'screenshot') { + this.ui.showScreenshotBar(false); + } else { + this.ui.showRecordBar({ busy: false, mic: this.micState(session), interrupted: session.interrupted }); + } + } + + micState(session) { + const allowed = + session.options.audio === true && + getCaptureConfig().allowMicrophone === true && + this.usesScreenRecording(session) && + hasUserMedia(); + return allowed ? { available: true, on: session.micOn } : null; + } + + // Screen recording (getDisplayMedia) unless the browser has none or the customer chose a page + // recording after screen sharing didn't start. + usesScreenRecording(session) { + return getRecordingMethod() === 'display' && !session.pageRecording; + } + + /** + * Ends a session: stops recordings and uploads, removes the capture UI and shows Gleap's UI again. + * restore: show the widget again (or, after a page load, open the conversation). + */ + endSession(session, { restore }) { + if (!session || this.session !== session) { + return; + } + this.session = null; + try { + if (session.recording) { + session.recording.cancel(); + } + } catch (exp) {} + this.liftVeil(session); + try { + if (session.timeline) { + session.timeline.stop(); + } + } catch (exp) {} + try { + if (session.upload) { + session.upload.abort(); + } + } catch (exp) {} + session.recording = null; + session.timeline = null; + session.upload = null; + session.result = null; + + this.stopWatchdog(); + if (this.hiddenEscape) { + document.removeEventListener('keydown', this.hiddenEscape, true); + this.hiddenEscape = null; + } + if (this.ui) { + try { + this.ui.destroy(); + } catch (exp) {} + this.ui = null; + } + this.showGleapUi(); + writeStorage(STORAGE_KEY, null); + + if (restore) { + this.restoreWidget(session); + } else if (!session.resumed) { + try { + GleapFrameManager.getInstance().setCaptureHidden(false, false); + } catch (exp) {} + } + } + + restoreWidget(session) { + try { + if (session.resumed) { + this.openConversation(session.ticketShareToken); + } else { + GleapFrameManager.getInstance().setCaptureHidden(false, true); + } + } catch (exp) {} + } + + openConversation(shareToken) { + if (shareToken) { + Gleap.openConversation(shareToken, true); + } else { + Gleap.open(); + } + } + + /** + * The customer cancelled (or the capture can't go on): releases the request. + */ + cancelSession(session, reason, silent) { + // Once the screenshot is in the Messenger's editor, the request is the Messenger's to finish. + const handedOver = session.phase === 'editor'; + this.endSession(session, { restore: !silent }); + if (!handedOver) { + this.sendState(session.requestId, 'cancelled'); + reportCaptureEvent(session.requestId, 'released', reason); + } + } + + /** + * The capture failed ('failed'), was declined ('declined') or can't run here ('unsupported'): the + * Messenger falls back to uploading a file. + */ + failSession(session, state, reason) { + if (!session || this.session !== session) { + return; + } + this.endSession(session, { restore: true }); + this.sendState(session.requestId, state, { error: reason }); + reportCaptureEvent(session.requestId, state === 'declined' || state === 'unsupported' ? state : 'failed', reason); + } + + onUiAction(session, action) { + if (this.session !== session) { + return; + } + try { + switch (action) { + case 'capture': + this.takeScreenshot(session); + break; + case 'start': + this.startRecording(session); + break; + case 'record-page': + session.pageRecording = true; + this.startRecording(session); + break; + case 'stop': + if (session.recording) { + session.recording.stop(); + } + break; + case 'mic': + this.toggleMicrophone(session); + break; + case 'send': + this.sendRecording(session); + break; + case 'retake': + this.retakeRecording(session); + break; + case 'cancel': + if (session.phase === 'fallback') { + // Screen sharing didn't start and the customer doesn't want a page recording either. + this.failSession(session, session.fallbackState, session.fallbackReason); + } else { + this.cancelSession(session, 'cancelled'); + } + break; + default: + break; + } + } catch (error) { + this.failSession(session, 'failed', errorReason(error)); + } + } + + // ----- Screenshot ------------------------------------------------------------------------------- + + takeScreenshot(session) { + if (session.kind !== 'screenshot' || session.phase !== 'bar') { + return; + } + const config = getCaptureConfig(); + // Called inside the Capture click: the tab capture needs that user activation. Not from inside a + // frame: the tab shows the whole page, the masks only know this frame. + const useTab = config.webScreenshotMethod !== 'dom-only' && isDesktopChromium() && hasDisplayMedia() && isTopWindow(); + const streamPromise = useTab ? requestTabStream() : null; + + session.phase = 'capturing'; + this.ui.showScreenshotBar(true); + this.sendState(session.requestId, 'capturing'); + if (useTab) { + // Hidden before the browser's share dialog shows: a tab stream can still hand out frames + // painted before a later hide, so the bar may never be on the page while the tab is shared. + this.setCaptureUiHidden(true); + } + + // The logs as they are at the moment of the capture. + let logsBundle = null; + if (this.attachesLogs(session) && this.logsPostedFor.indexOf(session.requestId) === -1) { + try { + logsBundle = buildLogsBundle({ include: session.options.include, deviceId: this.getDeviceId() }); + } catch (exp) {} + } + + const active = () => this.session === session; + captureScreenshot({ + streamPromise, + privacyOptions: this.privacyOptions(), + maskSelectors: this.maskSelectors(), + isActive: active, + setUiHidden: (hidden) => { + if (active()) { + this.setCaptureUiHidden(hidden); + } + }, + }) + .then((image) => { + if (active()) { + this.onScreenshotReady(session, image, logsBundle); + } + }) + .catch((error) => { + if (active()) { + this.failSession(session, 'failed', errorReason(error)); + } + }); + } + + onScreenshotReady(session, image, logsBundle) { + const frameManager = GleapFrameManager.getInstance(); + const message = { + name: 'capture-image', + data: { + requestId: session.requestId, + dataUrl: image.dataUrl, + width: image.width, + height: image.height, + method: image.method, + platform: CAPTURE_PLATFORM, + sdkType: CAPTURE_SDK_TYPE, + sdkVersion: getSdkVersion(), + }, + }; + + // The Messenger's editor takes it from here (capture-editor, capture-done). + session.phase = 'editor'; + writeStorage(STORAGE_KEY, null); + this.stopWatchdog(); + if (this.ui) { + this.ui.destroy(); + this.ui = null; + } + this.showGleapUi(); + + if (session.resumed) { + // After a page load the Messenger is not open yet: it opens on the conversation and then gets + // the image (queued until it is ready). + this.openConversation(session.ticketShareToken); + frameManager.sendMessage(message, true); + } else { + frameManager.sendMessage(message, true); + frameManager.setCaptureHidden(false, true); + } + + if (logsBundle) { + this.postLogs(session.requestId, logsBundle); + } + } + + postLogs(requestId, bundle) { + // Also checked here: the app may switch log collection off between the capture and Send. + if (!isRemoteLogCollectionEnabled() || this.logsPostedFor.indexOf(requestId) !== -1) { + return; + } + this.logsPostedFor.push(requestId); + if (this.logsPostedFor.length > MAX_HANDLED_LOG_REQUESTS) { + this.logsPostedFor.shift(); + } + postCaptureLogs(requestId, bundle).catch(() => {}); + } + + // Hides the capture bar and Gleap's UI for the moment a tab frame is taken; Escape still cancels. + setCaptureUiHidden(hidden) { + if (this.ui) { + this.ui.setVisible(!hidden); + } + if (this.hiddenEscape) { + document.removeEventListener('keydown', this.hiddenEscape, true); + this.hiddenEscape = null; + } + if (hidden) { + this.hideGleapUi(); + const session = this.session; + this.hiddenEscape = (event) => { + try { + if (event.key === 'Escape' && session && this.session === session) { + this.cancelSession(session, 'cancelled'); + } + } catch (exp) {} + }; + document.addEventListener('keydown', this.hiddenEscape, true); + } + } + + // ----- Recording -------------------------------------------------------------------------------- + + startRecording(session) { + if (session.kind !== 'recording' || ['bar', 'interrupted', 'fallback'].indexOf(session.phase) === -1) { + return; + } + const active = () => this.session === session; + const maxDurationSec = session.options.maxDurationSec; + const onTick = (seconds) => { + if (!active() || seconds === session.lastTick) { + return; + } + session.lastTick = seconds; + if (this.ui) { + this.ui.updateTimer(seconds); + } + this.sendState(session.requestId, 'recording', { elapsedSec: seconds }); + }; + const onStop = (result) => { + if (active()) { + this.onRecordingStopped(session, result); + } + }; + const onError = (error) => { + if (active()) { + this.failSession(session, 'failed', errorReason(error)); + } + }; + + if (this.usesScreenRecording(session)) { + // Called inside the Start click: getDisplayMedia needs that user activation. + const streamPromise = requestDisplayStream(isDesktopChromium()); + // The veil is up while the customer picks what to share, so even the first frame has it. + this.applyVeil(session); + const veil = session.veil; + session.phase = 'starting'; + this.ui.showRecordBar({ busy: true, mic: this.micState(session), interrupted: session.interrupted }); + streamPromise + .then((stream) => (veil ? veil.ready() : Promise.resolve()).then(() => stream)) + .then((stream) => { + if (!active()) { + stopStream(stream); + return null; + } + const recording = new DisplayRecording({ + maxDurationSec, + onTick, + onStop, + onError, + // Nothing more can be recorded (stopped, "Stop sharing", failed or cancelled). + onReleased: () => { + if (veil) { + this.liftVeil(session, veil); + } + }, + }); + session.recording = recording; + return recording.start(stream, !!this.micState(session) && session.micOn).then((started) => { + if (active()) { + this.onRecordingStarted(session, recording.startedAt, started.hasMicrophone); + } else { + recording.cancel(); + } + }); + }) + .catch((error) => { + if (active()) { + this.offerPageRecording(session, error); + } + }); + return; + } + + const recording = new PageRecording({ + maxDurationSec, + privacyOptions: this.privacyOptions(), + maskSelectors: this.maskSelectors(), + onTick, + onStop, + onError, + }); + session.recording = recording; + recording.start(); + this.onRecordingStarted(session, recording.startedAt, false); + } + + /** + * Screen sharing didn't start (declined in the picker, blocked by a policy, unavailable, or the + * recorder couldn't start): the bar stays and offers a page recording instead. Only a Cancel + * there reports the outcome ('declined' when sharing was refused). + */ + offerPageRecording(session, error) { + const name = error && error.name; + // Whatever got started of the screen recording stops here. + try { + if (session.recording) { + session.recording.cancel(); + } + } catch (exp) {} + this.liftVeil(session); + session.phase = 'fallback'; + session.recording = null; + session.fallbackState = name === 'NotAllowedError' || name === 'SecurityError' ? 'declined' : 'failed'; + session.fallbackReason = errorReason(error); + this.ui.showPageRecordingOffer(); + this.persist(session); + this.sendState(session.requestId, 'bar'); + } + + onRecordingStarted(session, startedAt, hasMicrophone) { + session.phase = 'recording'; + session.interrupted = false; + session.hasMicrophone = !!hasMicrophone; + session.lastTick = 0; + session.timeline = new CaptureTimeline({ + privacyOptions: this.privacyOptions(), + maskSelectors: this.maskSelectors(), + isOwnElement: (node) => !!(this.ui && this.ui.isOwnElement(node)), + }); + session.timeline.start(startedAt); + this.ui.showRecordingBar({ + mic: session.hasMicrophone ? { available: true, on: session.micOn } : null, + maxSec: session.options.maxDurationSec, + }); + this.persist(session); + this.sendState(session.requestId, 'recording', { elapsedSec: 0 }); + } + + // Before the start it decides whether the microphone is asked for; while recording it mutes. + toggleMicrophone(session) { + if (session.phase !== 'recording' && session.phase !== 'bar' && session.phase !== 'interrupted') { + return; + } + session.micOn = !session.micOn; + if (session.phase === 'recording' && session.recording && session.recording.setMicrophoneEnabled) { + session.recording.setMicrophoneEnabled(session.micOn); + } + this.ui.setMicOn(session.micOn); + } + + onRecordingStopped(session, result) { + this.liftVeil(session); + session.recording = null; + session.result = result; + session.timelineEntries = session.timeline ? session.timeline.stop(result.endedAt) : []; + const interactions = session.timeline ? session.timeline.interactions : 0; + session.timeline = null; + session.phase = 'preview'; + this.ui.showPreview({ + video: result.type.indexOf('video/') === 0 ? result.blob : null, + durationMs: result.durationMs, + interactions, + }); + this.persist(session); + this.sendState(session.requestId, 'preview'); + } + + retakeRecording(session) { + if (session.phase !== 'preview') { + return; + } + session.result = null; + session.uploadedUrl = null; + session.timelineEntries = null; + session.phase = 'bar'; + session.interrupted = false; + this.renderBar(session); + this.persist(session); + this.sendState(session.requestId, 'bar'); + } + + sendRecording(session) { + const result = session.result; + if (session.phase !== 'preview' || !result) { + return; + } + const active = () => this.session === session; + session.phase = 'uploading'; + this.ui.setUploading(0); + this.sendState(session.requestId, 'uploading', { progress: 0 }); + + // Logs for the recording window go along (in parallel with the upload). + if (this.attachesLogs(session)) { + try { + this.postLogs( + session.requestId, + buildLogsBundle({ + include: session.options.include, + windowStart: result.startedAt, + windowEnd: result.endedAt, + deviceId: this.getDeviceId(), + }) + ); + } catch (exp) {} + } + + const onProgress = (progress) => { + if (!active()) { + return; + } + if (this.ui) { + this.ui.setUploading(progress); + } + const now = Date.now(); + if (now - session.lastProgressSent > 250 || progress >= 1) { + session.lastProgressSent = now; + this.sendState(session.requestId, 'uploading', { progress: Math.round(progress * 100) / 100 }); + } + }; + + let uploaded; + if (session.uploadedUrl) { + uploaded = Promise.resolve(session.uploadedUrl); + } else { + session.upload = uploadCaptureFile(result.blob, result.fileName, onProgress); + uploaded = session.upload.promise; + } + + uploaded + .then((url) => { + if (!active()) { + return null; + } + session.upload = null; + session.uploadedUrl = url; + const file = { + url, + name: result.fileName, + type: result.type, + size: result.blob.size, + durationMs: result.durationMs, + }; + if (result.width > 0 && result.height > 0) { + file.width = result.width; + file.height = result.height; + } + return completeCaptureRequest(session.requestId, { + files: [file], + method: result.method, + platform: CAPTURE_PLATFORM, + sdkType: CAPTURE_SDK_TYPE, + sdkVersion: getSdkVersion(), + deviceId: this.getDeviceId(), + annotations: [], + timeline: session.timelineEntries || [], + recordingStartedAt: new Date(result.startedAt).toISOString(), + recordingEndedAt: new Date(result.endedAt).toISOString(), + }).then((response) => { + if (!active()) { + return; + } + if (isOk(response.status)) { + this.endSession(session, { restore: true }); + this.sendState(session.requestId, 'done'); + } else if (response.status === 409 || response.status === 410) { + // Answered on another device, or withdrawn / expired meanwhile. + this.endSession(session, { restore: true }); + this.sendState(session.requestId, 'failed', { + error: response.status === 409 ? 'already-completed' : 'request-closed', + }); + } else { + this.onUploadFailed(session, 'complete-' + response.status); + } + }); + }) + .catch((error) => { + if (active()) { + this.onUploadFailed(session, errorReason(error)); + } + }); + } + + onUploadFailed(session, reason) { + session.upload = null; + session.phase = 'preview'; + if (this.ui) { + this.ui.setPreviewError(session.labels.failed); + } + this.sendState(session.requestId, 'preview', { error: reason }); + } + + // ----- Gleap UI, watchdog, page loads ----------------------------------------------------------- + + hideGleapUi() { + try { + const elements = document.querySelectorAll(HIDDEN_GLEAP_UI_SELECTOR); + for (let i = 0; i < elements.length; i++) { + const element = elements[i]; + if (this.hiddenElements.indexOf(element) === -1) { + this.hiddenElements.push(element); + } + element.style.setProperty('visibility', 'hidden', 'important'); + } + } catch (exp) {} + } + + showGleapUi() { + this.hiddenElements.forEach((element) => { + try { + element.style.removeProperty('visibility'); + } catch (exp) {} + }); + this.hiddenElements = []; + } + + // While a capture UI is up: keeps it attached (pages that replace the body) and Gleap's UI hidden. + startWatchdog() { + this.stopWatchdog(); + this.watchdog = setInterval(() => { + try { + if (this.ui) { + this.ui.ensureAttached(); + } + this.hideGleapUi(); + } catch (exp) {} + }, WATCHDOG_MS); + } + + stopWatchdog() { + if (this.watchdog) { + clearInterval(this.watchdog); + this.watchdog = null; + } + } + + /** + * Keeps the request across page loads of this tab (multi-page sites): the bar comes back after + * the next load; a recording that was running shows "Recording stopped because the page changed". + */ + persist(session) { + if (this.session !== session) { + return; + } + let phase = null; + if (['bar', 'capturing', 'starting', 'fallback'].indexOf(session.phase) !== -1) { + phase = session.interrupted ? 'recording' : 'bar'; + } else if ( + session.kind === 'recording' && + ['recording', 'preview', 'uploading', 'interrupted'].indexOf(session.phase) !== -1 + ) { + phase = 'recording'; + } + if (!phase) { + writeStorage(STORAGE_KEY, null); + return; + } + let gleapId = null; + let sdkKey = null; + try { + const gleapSession = GleapSession.getInstance(); + gleapId = gleapSession.session ? gleapSession.session.gleapId : null; + sdkKey = gleapSession.sdkKey; + } catch (exp) {} + try { + writeStorage( + STORAGE_KEY, + JSON.stringify({ + v: 1, + requestId: session.requestId, + kind: session.kind, + ticketShareToken: session.ticketShareToken, + options: session.options, + labels: session.rawLabels, + phase, + pageRecording: session.pageRecording === true, + gleapId, + sdkKey, + // The original start: a bar restored on every page load still expires. + at: session.createdAt, + }) + ); + } catch (exp) {} + } + + /** + * After a page load: shows the bar of a capture that was in progress on the previous page. + */ + resumeFromStorage() { + try { + if (this.session) { + return; + } + const raw = readStorage(STORAGE_KEY); + if (!raw) { + return; + } + let stored = null; + try { + stored = JSON.parse(raw); + } catch (exp) {} + const gleapSession = GleapSession.getInstance(); + const valid = + stored && + stored.v === 1 && + typeof stored.requestId === 'string' && + (stored.kind === 'screenshot' || stored.kind === 'recording') && + typeof stored.at === 'number' && + Date.now() - stored.at < RESUME_MAX_AGE_MS && + gleapSession.session && + stored.gleapId === gleapSession.session.gleapId && + stored.sdkKey === gleapSession.sdkKey; + if (!valid || !isCaptureSupported()) { + writeStorage(STORAGE_KEY, null); + return; + } + // Only while the request is still open (answered elsewhere, cancelled or expired meanwhile: + // no bar). Without an answer from the server the bar comes back. + getCaptureRequest(stored.requestId).then((response) => { + try { + const data = response.data; + const gone = + (response.status >= 400 && response.status < 500) || + (data && FINAL_STATUSES.indexOf(data.status) !== -1) || + (data && data.expiresAt && Date.parse(data.expiresAt) < Date.now()); + if (gone) { + if (!this.session) { + writeStorage(STORAGE_KEY, null); + } + return; + } + if (this.session) { + return; + } + this.startSession({ + requestId: stored.requestId, + kind: stored.kind, + ticketShareToken: stored.ticketShareToken, + options: stored.options, + labels: stored.labels, + // The customer already switched to a page recording because screen sharing didn't start. + pageRecording: stored.pageRecording === true, + createdAt: stored.at, + resumed: true, + phase: stored.kind === 'recording' && stored.phase === 'recording' ? 'interrupted' : 'bar', + }); + } catch (exp) {} + }); + } catch (exp) {} + } + + // ----- Background log requests (SDK websocket) -------------------------------------------------- + + /** + * A `capture-request` pushed on the SDK websocket. Only kind 'logs' comes this way; it is answered + * once per request id on this page, whatever the widget shows. + */ + handleServerRequest(data) { + try { + if (!data || data.kind !== 'logs' || typeof data.id !== 'string') { + return; + } + const requestId = data.id; + if (this.handledLogRequests.indexOf(requestId) !== -1) { + return; + } + this.handledLogRequests.push(requestId); + if (this.handledLogRequests.length > MAX_HANDLED_LOG_REQUESTS) { + this.handledLogRequests.shift(); + } + if (data.expiresAt && Date.parse(data.expiresAt) < Date.now()) { + return; + } + const options = data.options && typeof data.options === 'object' ? data.options : {}; + GleapConfigManager.getInstance().onConfigLoaded(() => { + this.collectLogs(requestId, options).catch(() => {}); + }); + } catch (exp) {} + } + + collectLogs(requestId, options) { + if (!isRemoteLogCollectionEnabled()) { + return reportCaptureEvent(requestId, 'unsupported', 'disabled-by-app'); + } + if (getCaptureConfig().backgroundLogs === false) { + return reportCaptureEvent(requestId, 'unsupported', 'disabled-by-project'); + } + + const deviceId = this.getDeviceId(); + return claimCaptureRequest(requestId, { + deviceId, + platform: CAPTURE_PLATFORM, + sdkType: CAPTURE_SDK_TYPE, + sdkVersion: getSdkVersion(), + }).then((claim) => { + // 410: already final. Another tab or device claimed it a moment ago: that one answers. + if (!isOk(claim.status) || (claim.data && claim.data.claimedElsewhere)) { + return null; + } + return Promise.resolve() + .then(() => postCaptureLogs(requestId, buildLogsBundle({ include: options.include, deviceId }))) + .then( + (response) => (isOk(response.status) ? null : reportCaptureEvent(requestId, 'failed', 'logs-' + response.status)), + (error) => reportCaptureEvent(requestId, 'failed', errorReason(error)) + ); + }); + } +} diff --git a/src/GleapCaptureMasking.test.js b/src/GleapCaptureMasking.test.js new file mode 100644 index 0000000..ecde78e --- /dev/null +++ b/src/GleapCaptureMasking.test.js @@ -0,0 +1,326 @@ +/** + * @jest-environment jsdom + */ + +// What capture requests must keep private: the black boxes painted over screenshots, and the page +// recording (rrweb) used where there is no screen capture. Both follow the rules bug reports use +// (GleapInputMasking) plus flowConfig.capture.maskSelectors. + +const mockReplayRecorder = { + customOptions: {}, + pauseForCapture: jest.fn(() => false), + resumeAfterCapture: jest.fn(), +}; + +jest.mock('./Gleap', () => ({ + __esModule: true, + default: {}, + GleapNetworkIntercepter: { getInstance: () => ({ getRequests: () => [], stopped: false, setStopped: () => {} }) }, + GleapConsoleLogManager: { getInstance: () => ({ getLogs: () => [] }) }, + GleapReplayRecorder: { getInstance: () => mockReplayRecorder }, +})); + +import { buildMaskSelector, collectMaskRects } from './GleapCaptureScreenshot'; +import { applyPrivacyVeil, buildVeilRules } from './GleapCaptureVeil'; +import { describeElement, PageRecording } from './GleapCaptureRecorder'; +import { unpack } from '@rrweb/packer'; + +// jsdom has no layout: elements get the rect in their data-rect attribute ("x,y,width,height"). +const fakeRect = function () { + const value = this.getAttribute && this.getAttribute('data-rect'); + const [x, y, width, height] = value ? value.split(',').map(Number) : [0, 0, 0, 0]; + return { left: x, top: y, right: x + width, bottom: y + height, x, y, width, height }; +}; +const originalRect = Element.prototype.getBoundingClientRect; +beforeAll(() => { + Element.prototype.getBoundingClientRect = fakeRect; +}); +afterAll(() => { + Element.prototype.getBoundingClientRect = originalRect; +}); + +const rectsFor = (options) => collectMaskRects(options).map((r) => [r.x, r.y, r.width, r.height].join(',')); + +describe('screenshot black boxes', () => { + test('private fields, masked and blocked elements, maskSelectors and payment frames; nothing else', () => { + document.body.innerHTML = ` + + + + + + + +

IBAN

+
salary
+
blocked
+
tax id
+ + +

Visible text

+
+
+
`; + + expect(rectsFor({ maskSelectors: ['.secret-panel'] }).sort()).toEqual( + [ + '0,20,10,10', + '0,40,10,10', + '0,60,10,10', + '0,80,10,10', + '0,100,10,10', + '0,120,10,10', + '0,140,10,10', + '0,160,10,10', + '0,180,10,10', + '0,200,10,10', + '0,220,10,10', + '0,240,10,10', + ].sort() + ); + }); + + test("the site's replay options: classes (string or RegExp), selectors and maskAllInputs", () => { + document.body.innerHTML = ` +
a
+
b
+
c
+ `; + + expect( + rectsFor({ + privacyOptions: { blockClass: 'private', maskTextClass: /^pii-/, maskTextSelector: '[data-private]' }, + }).sort() + ).toEqual(['0,0,10,10', '0,20,10,10', '0,40,10,10'].sort()); + expect(rectsFor({ privacyOptions: { maskAllInputs: true } })).toEqual(['0,60,10,10']); + }); + + test('fields inside open shadow roots', () => { + document.body.innerHTML = ''; + const root = document.querySelector('fancy-field').attachShadow({ mode: 'open' }); + root.innerHTML = ''; + + expect(rectsFor({})).toEqual(['5,5,10,10']); + }); + + test('an invalid mask selector does not switch off the others', () => { + expect(buildMaskSelector({ blockSelector: '[[broken' }, ['.ok', '::nope(', 42])).toBe( + '.rr-block, .gl-block, .rr-mask, .gl-mask, .ok' + ); + + document.body.innerHTML = + '
'; + expect(rectsFor({ maskSelectors: ['[[broken', '.ok'] }).sort()).toEqual(['0,0,10,10', '0,20,10,10']); + }); + + test('boxes of elements in a same-origin frame are offset by the frame and clipped to it', () => { + document.body.innerHTML = ''; + const frame = document.querySelector('iframe'); + frame.contentWindow.Element.prototype.getBoundingClientRect = fakeRect; + const frameDocument = frame.contentDocument; + frameDocument.body.innerHTML = + ''; + + expect(rectsFor({}).sort()).toEqual(['110,110,20,20', '140,140,10,10']); + }); +}); + +describe('privacy veil over screen recordings', () => { + // jsdom can't evaluate :is() with :not(... *); the plain form of each rule (what browsers without + // :is() get) shows which elements a rule covers. + const veiledIds = (privacyOptions, maskSelectors) => { + const selectors = buildVeilRules(privacyOptions, maskSelectors).map((entry) => + (entry.fallback || entry.rules[0]).replace(/\s*\{[^}]*\}\s*$/, '') + ); + return Array.from(document.querySelectorAll('[id]')) + .filter((element) => selectors.some((selector) => element.matches(selector))) + .map((element) => element.id); + }; + + test('covers what screenshots mask: private fields, masked and blocked elements, maskSelectors, payment frames', () => { + document.body.innerHTML = ` + + + + + + + +
+

IBAN

+
salary
+
blocked
+
tax id
+ + + +

Visible text

+ `; + + expect(veiledIds({}, ['.secret-panel'])).toEqual([ + 'password', + 'otp', + 'card', + 'csc', + 'marked', + 'ignored', + 'ignored-inner', + 'iban', + 'salary', + 'blocked', + 'tax', + 'titled-frame', + 'stripe-frame', + ]); + }); + + test("the site's replay options: classes, selectors and maskAllInputs", () => { + document.body.innerHTML = ` + + + +
a
+
b
+

c

`; + + expect(veiledIds({ maskAllInputs: true, blockClass: 'private', maskTextSelector: '[data-private]' }, [])).toEqual([ + 'name', + 'plan', + 'private-class', + 'private-attr', + ]); + }); + + test('stays on the page until removed or the page is left; RegExp class names found now and later', async () => { + document.body.innerHTML = ` + + 4242 +

Visible

`; + const filterOf = (id) => window.getComputedStyle(document.getElementById(id)).filter || ''; + const ruleText = () => + Array.from(document.querySelectorAll('style')) + .map((style) => Array.from(style.sheet.cssRules, (rule) => rule.cssText).join('\n')) + .join('\n'); + + const veil = applyPrivacyVeil({ privacyOptions: { maskTextClass: /^pii-/ }, maskSelectors: [] }); + expect(filterOf('password')).toBe('blur(12px)'); + expect(filterOf('visible')).not.toContain('blur'); + expect(ruleText()).toContain('.pii-card'); + + const later = document.createElement('div'); + later.className = 'pii-iban'; + document.body.appendChild(later); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(ruleText()).toContain('.pii-iban'); + + veil.remove(); + veil.remove(); + expect(document.querySelectorAll('style').length).toBe(0); + expect(filterOf('password')).not.toContain('blur'); + + applyPrivacyVeil({}); + expect(document.querySelectorAll('style').length).toBe(1); + window.dispatchEvent(new Event('pagehide')); + expect(document.querySelectorAll('style').length).toBe(0); + }); +}); + +describe('page recordings (no screen capture)', () => { + const record = (html, options = {}) => + new Promise((resolve, reject) => { + document.body.innerHTML = html; + const recording = new PageRecording({ + maxDurationSec: 60, + privacyOptions: options.privacyOptions || {}, + maskSelectors: options.maskSelectors || [], + onTick: () => {}, + onError: reject, + onStop: (result) => { + const reader = new FileReader(); + reader.onload = () => resolve({ result, file: JSON.parse(reader.result) }); + reader.onerror = reject; + reader.readAsText(result.blob); + }, + }); + recording.start(); + const field = document.querySelector('#typed'); + if (field) { + field.value = 'typed-while-recording'; + field.dispatchEvent(new Event('input', { bubbles: true })); + } + setTimeout(() => recording.stop(), 20); + }); + + test('every input is masked; masked, blocked and maskSelectors elements stay out; the capture bar stays out', async () => { + const { result, file } = await record( + ` +

IBAN DE89

rr text

+
salary 123
tax id 99
+
Go to where the issue happens
+

Visible text

`, + { maskSelectors: ['.secret-panel'] } + ); + const recorded = JSON.stringify(file.events.map((event) => (file.packed ? unpack(event) : event))); + + [ + 'Jane Doe', + 'typed-while-recording', + 'my note', + 'IBAN DE89', + 'rr text', + 'salary 123', + 'tax id 99', + 'Go to where', + ].forEach((secret) => expect(recorded).not.toContain(secret)); + expect(recorded).toContain('Visible text'); + expect(result.method).toBe('rrweb'); + expect(result.type).toBe('application/json'); + expect(file).toEqual( + expect.objectContaining({ type: 'rrweb', packed: true, baseUrl: window.location.origin, startDate: result.startedAt }) + ); + }); + + test('the standard markers stay private when the site set classes of its own', async () => { + const { file } = await record( + '

standard mask

standard block
' + + '

own mask

own block

Visible text

', + { privacyOptions: { blockClass: 'own-block', maskTextClass: 'own-mask' } } + ); + const recorded = JSON.stringify(file.events.map((event) => (file.packed ? unpack(event) : event))); + + ['standard mask', 'standard block', 'own mask', 'own block'].forEach((secret) => expect(recorded).not.toContain(secret)); + expect(recorded).toContain('Visible text'); + }); + + test('the session replay pauses meanwhile and resumes afterwards', async () => { + mockReplayRecorder.pauseForCapture.mockReturnValueOnce(true); + mockReplayRecorder.resumeAfterCapture.mockClear(); + await record('

page

'); + expect(mockReplayRecorder.resumeAfterCapture).toHaveBeenCalledTimes(1); + }); +}); + +describe('timeline labels', () => { + test('fields are named, never their value; text in masked areas is left out', () => { + document.body.innerHTML = ` + + +
+ `; + + expect(describeElement(document.getElementById('email'), {})).toBe('field "Email"'); + expect(describeElement(document.getElementById('pw'), {})).toBe('field "Password"'); + expect(describeElement(document.getElementById('pay'), {})).toBe('button'); + expect(describeElement(document.getElementById('save'), {})).toBe('button "Save"'); + }); + + test('nothing named inside flowConfig.capture.maskSelectors', () => { + document.body.innerHTML = ` +
+
`; + + expect(describeElement(document.getElementById('secret'), {}, ['.secret-panel'])).toBe('button'); + expect(describeElement(document.getElementById('tax'), {}, ['.secret-panel'])).toBe('field'); + expect(describeElement(document.getElementById('secret'), {}, [])).toBe('button "Refund 99-1234567"'); + }); +}); diff --git a/src/GleapCaptureRecorder.js b/src/GleapCaptureRecorder.js new file mode 100644 index 0000000..11efed5 --- /dev/null +++ b/src/GleapCaptureRecorder.js @@ -0,0 +1,899 @@ +import { record } from '@rrweb/record'; +import { pack } from '@rrweb/packer'; +import { GleapConsoleLogManager, GleapNetworkIntercepter, GleapReplayRecorder } from './Gleap'; +import { isMobile } from './GleapHelper'; +import { isBlockedElement, isMaskMarker } from './GleapInputMasking'; +import { approximateSize } from './GleapReplayRecorder'; +import { fixWebmDuration } from './GleapWebmDuration'; +import { now, toJsonParts, yieldToPage } from './GleapCaptureTasks'; + +// Recordings for capture requests (contract §8): the screen through getDisplayMedia + MediaRecorder +// on desktop browsers, a page recording (rrweb) where there is no getDisplayMedia (phones, tablets). +// A timeline digest of what happened goes along with both. + +const VIDEO_BITS_PER_SECOND = 2500000; +const AUDIO_BITS_PER_SECOND = 64000; +const TIMESLICE_MS = 1000; +// A page recording stops by itself before it gets unreasonably large (approximate JSON size). +const MAX_PAGE_RECORDING_SIZE = 25 * 1024 * 1024; +const MAX_TIMELINE_ENTRIES = 200; + +// Contract order, with H.264 as avc3 ahead of avc1: avc3 carries its parameters in-band, so a +// resolution change while recording (a resized window, the browser's sharing bar appearing) keeps +// the file valid; Chrome reports an error for avc1 then. With a microphone the same containers are +// tried with an audio codec first. +const VIDEO_MIME_TYPES = [ + 'video/mp4;codecs=avc3', + 'video/mp4;codecs=avc1', + 'video/mp4', + 'video/webm;codecs=vp9', + 'video/webm;codecs=vp8', + 'video/webm', +]; +const AUDIO_VIDEO_MIME_TYPES = [ + 'video/mp4;codecs=avc3,mp4a.40.2', + 'video/mp4;codecs=avc1,mp4a.40.2', + 'video/mp4', + 'video/webm;codecs=vp9,opus', + 'video/webm;codecs=vp8,opus', + 'video/webm', +]; + +export const pickRecorderMimeType = (withAudio) => { + const candidates = withAudio ? AUDIO_VIDEO_MIME_TYPES.concat(VIDEO_MIME_TYPES) : VIDEO_MIME_TYPES; + try { + if (typeof MediaRecorder !== 'undefined' && typeof MediaRecorder.isTypeSupported === 'function') { + for (let i = 0; i < candidates.length; i++) { + if (MediaRecorder.isTypeSupported(candidates[i])) { + return candidates[i]; + } + } + } + } catch (exp) {} + return ''; +}; + +const stopTracks = (stream) => { + try { + if (stream) { + stream.getTracks().forEach((track) => { + try { + track.stop(); + } catch (exp) {} + }); + } + } catch (exp) {} +}; + +/** + * Asks for the screen to record. Must run synchronously inside the Start click (user activation). + * Chromium offers the current tab first; Firefox and Safari show their own picker. + */ +export const requestDisplayStream = (chromium) => { + const video = { frameRate: { ideal: 15, max: 30 } }; + const options = { video, audio: false }; + if (chromium) { + video.displaySurface = 'browser'; + options.preferCurrentTab = true; + options.selfBrowserSurface = 'include'; + options.surfaceSwitching = 'include'; + } + let promise; + try { + promise = navigator.mediaDevices.getDisplayMedia(options); + } catch (error) { + promise = Promise.reject(error); + } + promise.catch(() => {}); + return promise; +}; + +/** + * A screen recording from a getDisplayMedia stream. Stops on stop(), at maxDurationSec, and when + * the browser's "Stop sharing" ends the track; every track, timer and listener is released on + * every path. + */ +export class DisplayRecording { + stream = null; + micStream = null; + videoTrack = null; + recorder = null; + chunks = []; + mimeType = ''; + timer = null; + startedAt = 0; + endedAt = 0; + settings = {}; + finished = false; + recorderStopped = false; + stopTimeout = null; + onEndedBound = null; + recorderError = null; + + /** + * @param {{maxDurationSec: number, onTick: function(number), onStop: function(object), onError: function(Error), onReleased?: function()}} options + * onReleased: the screen is no longer captured (on every path; may be called more than once). + */ + constructor(options) { + this.options = options; + } + + /** + * @param {MediaStream} stream from requestDisplayStream + * @param {boolean} withMicrophone + * @returns {Promise<{hasMicrophone: boolean}>} + */ + start(stream, withMicrophone) { + this.stream = stream; + this.videoTrack = stream.getVideoTracks()[0] || null; + if (!this.videoTrack) { + return Promise.reject(new Error('no-video-track')); + } + try { + this.settings = this.videoTrack.getSettings ? this.videoTrack.getSettings() : {}; + } catch (exp) { + this.settings = {}; + } + + const microphone = withMicrophone + ? navigator.mediaDevices + .getUserMedia({ + audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }, + video: false, + }) + // Recording goes on without sound. + .catch(() => null) + : Promise.resolve(null); + + return microphone.then((micStream) => { + this.micStream = micStream; + const micTrack = micStream ? micStream.getAudioTracks()[0] || null : null; + if (this.finished || !this.videoTrack || this.videoTrack.readyState === 'ended') { + this.cleanup(); + throw new Error('stopped-before-start'); + } + + const tracks = [this.videoTrack]; + if (micTrack) { + tracks.push(micTrack); + } + this.mimeType = pickRecorderMimeType(!!micTrack); + const recorderOptions = { videoBitsPerSecond: VIDEO_BITS_PER_SECOND }; + if (this.mimeType) { + recorderOptions.mimeType = this.mimeType; + } + if (micTrack) { + recorderOptions.audioBitsPerSecond = AUDIO_BITS_PER_SECOND; + } + try { + this.recorder = new MediaRecorder(new MediaStream(tracks), recorderOptions); + } catch (error) { + this.cleanup(); + throw error; + } + this.recorder.ondataavailable = (event) => { + try { + if (event.data && event.data.size > 0) { + this.chunks.push(event.data); + } + } catch (exp) {} + }; + this.recorder.onstop = () => { + try { + this.recorderStopped = true; + this.finish(); + } catch (exp) {} + }; + this.recorder.onerror = (event) => { + try { + this.onRecorderError((event && event.error) || new Error('recorder-error')); + } catch (exp) {} + }; + + // The browser's "Stop sharing" ends the track: that is a Stop. + this.onEndedBound = () => { + try { + this.stop(); + } catch (exp) {} + }; + this.videoTrack.addEventListener('ended', this.onEndedBound); + + try { + this.recorder.start(TIMESLICE_MS); + } catch (error) { + this.cleanup(); + throw error; + } + this.startedAt = Date.now(); + const maxMs = this.options.maxDurationSec * 1000; + this.timer = setInterval(() => { + try { + const elapsed = Date.now() - this.startedAt; + try { + this.options.onTick(Math.floor(elapsed / 1000)); + } catch (exp) {} + if (elapsed >= maxMs) { + this.stop(); + } + } catch (exp) {} + }, 250); + return { hasMicrophone: !!micTrack }; + }); + } + + setMicrophoneEnabled(enabled) { + try { + if (this.micStream) { + this.micStream.getAudioTracks().forEach((track) => { + track.enabled = !!enabled; + }); + } + } catch (exp) {} + } + + stop() { + if (this.finished || this.stopTimeout) { + return; + } + this.endedAt = this.endedAt || Date.now(); + this.clearTimer(); + if (!this.recorder || this.recorderStopped) { + this.finish(); + return; + } + try { + if (this.recorder.state !== 'inactive') { + this.recorder.stop(); + } + } catch (exp) {} + // Ends the browser's sharing indicator and the microphone right away. + this.releaseTracks(); + // The last chunk and then 'stop' follow (also when the recorder stopped by itself because the + // shared screen ended); finish then. Just in case 'stop' never comes: + this.stopTimeout = setTimeout(() => { + try { + this.finish(); + } catch (exp) {} + }, 3000); + } + + // The recorder failed midway: what it recorded so far still arrives (dataavailable, then stop) and + // is offered like after a Stop; with nothing recorded the error is reported. + onRecorderError(error) { + if (this.finished) { + return; + } + this.recorderError = error; + this.stop(); + } + + finish() { + if (this.finished) { + return; + } + this.finished = true; + this.endedAt = this.endedAt || Date.now(); + const type = (this.recorder && this.recorder.mimeType) || this.mimeType || 'video/webm'; + const baseType = type.indexOf('mp4') !== -1 ? 'video/mp4' : 'video/webm'; + const blob = new Blob(this.chunks, { type: baseType }); + this.chunks = []; + this.cleanup(); + const result = { + blob, + type: baseType, + fileName: 'screen-recording.' + (baseType === 'video/mp4' ? 'mp4' : 'webm'), + startedAt: this.startedAt, + endedAt: this.endedAt, + durationMs: Math.max(0, this.endedAt - this.startedAt), + width: this.settings.width, + height: this.settings.height, + // 'tab' only for a browser tab; windows and screens (and browsers that don't say) are 'display'. + method: this.settings.displaySurface === 'browser' ? 'tab' : 'display', + }; + if (blob.size === 0) { + this.options.onError(this.recorderError || new Error('empty-recording')); + return; + } + const deliver = (finalBlob) => { + result.blob = finalBlob; + try { + this.options.onStop(result); + } catch (error) { + try { + this.options.onError(error); + } catch (exp) {} + } + }; + if (baseType !== 'video/webm') { + deliver(blob); + return; + } + // WebM from MediaRecorder has no duration, so players couldn't seek it (MP4 has one). The patch + // falls back to the file as recorded on any problem. + fixWebmDuration(blob, result.durationMs).then(deliver); + } + + fail(error) { + if (this.finished) { + return; + } + this.finished = true; + this.chunks = []; + this.cleanup(); + try { + this.options.onError(error); + } catch (exp) {} + } + + /** + * Discards the recording. + */ + cancel() { + this.finished = true; + this.chunks = []; + try { + if (this.recorder && this.recorder.state !== 'inactive') { + this.recorder.stop(); + } + } catch (exp) {} + this.cleanup(); + } + + clearTimer() { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + if (this.stopTimeout) { + clearTimeout(this.stopTimeout); + this.stopTimeout = null; + } + } + + releaseTracks() { + if (this.videoTrack && this.onEndedBound) { + try { + this.videoTrack.removeEventListener('ended', this.onEndedBound); + } catch (exp) {} + } + this.onEndedBound = null; + stopTracks(this.stream); + stopTracks(this.micStream); + // Nothing more can be recorded (the recorder stopped first): the privacy veil may go. + if (this.options.onReleased) { + try { + this.options.onReleased(); + } catch (exp) {} + } + } + + cleanup() { + this.clearTimer(); + this.releaseTracks(); + if (this.recorder) { + this.recorder.ondataavailable = null; + this.recorder.onstop = null; + this.recorder.onerror = null; + } + this.recorder = null; + this.stream = null; + this.micStream = null; + this.videoTrack = null; + } +} + +const combineSelectors = (selectors) => + selectors + .filter((selector) => { + if (typeof selector !== 'string' || !selector.trim()) { + return false; + } + try { + document.createDocumentFragment().querySelector(selector); + return true; + } catch (exp) { + return false; + } + }) + .join(', ') || null; + +/** + * A page recording (rrweb) with every input masked and without canvas. The file has exactly the + * shape of a bug report's webReplay, so the dashboard's replay player shows it. + */ +// @rrweb/packer's format: latin1 text of zlib(JSON of the event plus v: MARK). +const PACKER_MARK = 'v1'; +// Events this large (a full snapshot of a big page) are compressed by the browser +// (CompressionStream, off the main thread) instead of fflate's synchronous zlib. +const LARGE_EVENT_CHARS = 200 * 1024; + +const toLatin1 = (bytes) => { + let text = ''; + for (let i = 0; i < bytes.length; i += 0x8000) { + text += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000)); + } + return text; +}; + +// A packed event (string), or a promise of one for a large event. +const packEvent = (event) => { + if (typeof CompressionStream === 'function' && typeof Response === 'function') { + const json = JSON.stringify(Object.assign({}, event, { v: PACKER_MARK })); + if (json.length > LARGE_EVENT_CHARS) { + return new Response(new Blob([json]).stream().pipeThrough(new CompressionStream('deflate'))) + .arrayBuffer() + .then((buffer) => toLatin1(new Uint8Array(buffer))); + } + } + return pack(event); +}; + +/** + * The events packed (@rrweb/packer format) in slices, so the page never waits long; null when + * packing failed (the raw events go then). Rejects when cancelled. + */ +export const packEvents = (events, isCancelled) => + new Promise((resolve, reject) => { + const packed = new Array(events.length); + let index = 0; + const step = () => { + try { + if (isCancelled && isCancelled()) { + reject(new Error('cancelled')); + return; + } + const start = now(); + while (index < events.length) { + const result = packEvent(events[index]); + const at = index; + index += 1; + if (typeof result !== 'string') { + result.then( + (value) => { + packed[at] = value; + step(); + }, + () => resolve(null) + ); + return; + } + packed[at] = result; + if (now() - start > 25 && index < events.length) { + yieldToPage().then(step); + return; + } + } + resolve(packed); + } catch (exp) { + resolve(null); + } + }; + step(); + }); + +export class PageRecording { + events = []; + cancelled = false; + size = 0; + stopFunction = null; + timer = null; + startedAt = 0; + endedAt = 0; + finished = false; + resumeReplay = false; + width = 0; + height = 0; + + /** + * @param {{maxDurationSec: number, privacyOptions: object, maskSelectors: string[], onTick: function(number), onStop: function(object), onError: function(Error)}} options + */ + constructor(options) { + this.options = options; + } + + start() { + const privacyOptions = this.options.privacyOptions || {}; + // rrweb records one session per page: the session replay pauses meanwhile, keeping its buffer. + this.resumeReplay = GleapReplayRecorder.getInstance().pauseForCapture(); + this.startedAt = Date.now(); + this.width = window.innerWidth; + this.height = window.innerHeight; + + try { + this.stopFunction = record({ + emit: (event) => { + try { + if (this.finished) { + return; + } + this.events.push(event); + this.size += approximateSize(event); + if (this.size > MAX_PAGE_RECORDING_SIZE) { + setTimeout(() => { + try { + this.stop(); + } catch (exp) {} + }, 0); + } + } catch (exp) {} + }, + maskAllInputs: true, + blockClass: privacyOptions.blockClass || 'rr-block', + // The standard markers count also when the site set classes of its own. + blockSelector: combineSelectors( + ['.rr-block', '.gl-block', privacyOptions.blockSelector].concat(this.options.maskSelectors || []) + ), + maskTextClass: privacyOptions.maskTextClass || 'rr-mask', + maskTextSelector: combineSelectors(['.rr-mask', '.gl-mask', privacyOptions.maskTextSelector]), + inlineStylesheet: true, + recordCanvas: false, + collectFonts: false, + recordCrossOriginIframes: false, + slimDOMOptions: { + script: true, + comment: true, + headFavicon: true, + headWhitespace: true, + headMetaDescKeywords: true, + headMetaSocial: true, + headMetaRobots: true, + headMetaHttpEquiv: true, + headMetaVerification: true, + }, + dataURLOptions: { quality: 0.7 }, + sampling: { + scroll: 150, + input: 'last', + mouseInteraction: { + MouseUp: false, + MouseDown: false, + Click: true, + ContextMenu: true, + DblClick: true, + Focus: true, + Blur: true, + TouchStart: true, + TouchEnd: false, + }, + }, + }); + } catch (error) { + this.cleanup(); + throw error; + } + if (!this.stopFunction) { + this.cleanup(); + throw new Error('page-recording-unavailable'); + } + + const maxMs = this.options.maxDurationSec * 1000; + this.timer = setInterval(() => { + try { + const elapsed = Date.now() - this.startedAt; + try { + this.options.onTick(Math.floor(elapsed / 1000)); + } catch (exp) {} + if (elapsed >= maxMs) { + this.stop(); + } + } catch (exp) {} + }, 250); + } + + // Packs and serializes the events in slices (up to ~25 MB), then hands over the file. + stop() { + if (this.finished) { + return; + } + this.finished = true; + this.endedAt = Date.now(); + this.cleanup(); + + const raw = this.events; + this.events = []; + const cancelled = () => this.cancelled; + packEvents(raw, cancelled) + .then((packedEvents) => + toJsonParts( + { + startDate: this.startedAt, + events: packedEvents || raw, + packed: !!packedEvents, + baseUrl: window.location.origin, + width: this.width, + height: this.height, + isMobile: isMobile(), + type: 'rrweb', + }, + cancelled + ) + ) + .then((parts) => { + if (this.cancelled) { + return; + } + this.options.onStop({ + blob: new Blob(parts, { type: 'application/json' }), + type: 'application/json', + fileName: 'page-recording.json', + startedAt: this.startedAt, + endedAt: this.endedAt, + durationMs: Math.max(0, this.endedAt - this.startedAt), + width: this.width, + height: this.height, + method: 'rrweb', + }); + }) + .catch((error) => { + if (!this.cancelled) { + try { + this.options.onError(error); + } catch (exp) {} + } + }); + } + + cancel() { + this.finished = true; + this.cancelled = true; + this.events = []; + this.cleanup(); + } + + cleanup() { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + if (this.stopFunction) { + try { + this.stopFunction(); + } catch (exp) {} + this.stopFunction = null; + } + if (this.resumeReplay) { + this.resumeReplay = false; + try { + GleapReplayRecorder.getInstance().resumeAfterCapture(); + } catch (exp) {} + } + } +} + +const ACTIONABLE_SELECTOR = + 'a, button, input, select, textarea, label, summary, [role="button"], [role="link"], [role="menuitem"], [role="tab"], [role="checkbox"], [role="option"], [role="switch"]'; + +const collapse = (text, max) => { + const value = String(text || '') + .replace(/\s+/g, ' ') + .trim(); + return value.length > max ? value.slice(0, max - 1) + '…' : value; +}; + +// Inside a blocked or masked element, or one of flowConfig.capture.maskSelectors (through shadow roots). +const isPrivate = (element, privacyOptions, maskSelector) => { + let current = element; + while (current) { + if (current.nodeType === 1) { + try { + if ( + isBlockedElement(current, privacyOptions) || + isMaskMarker(current, privacyOptions) || + (!!maskSelector && current.matches(maskSelector)) + ) { + return true; + } + } catch (exp) {} + } + current = current.parentElement || (current.parentNode && current.parentNode.host) || null; + } + return false; +}; + +const fieldName = (field) => + collapse( + field.getAttribute('aria-label') || + (field.labels && field.labels[0] && field.labels[0].textContent) || + field.getAttribute('placeholder') || + field.getAttribute('name') || + field.id || + field.type || + field.tagName.toLowerCase(), + 60 + ); + +/** + * How an element is named in the timeline: its role plus a short accessible name. Never a field + * value, and no text inside masked or blocked elements (markers, replay options, maskSelectors). + * @param {string[]} [maskSelectors] flowConfig.capture.maskSelectors + */ +export const describeElement = (element, privacyOptions, maskSelectors) => { + if (!element || element.nodeType !== 1) { + return 'element'; + } + const tag = element.tagName.toLowerCase(); + const role = element.getAttribute('role') || (tag === 'a' ? 'link' : tag); + const hidden = isPrivate(element, privacyOptions, combineSelectors(Array.isArray(maskSelectors) ? maskSelectors : [])); + if (tag === 'input' || tag === 'select' || tag === 'textarea') { + const kind = element.type === 'checkbox' || element.type === 'radio' ? element.type : 'field'; + return hidden ? kind : kind + ' "' + fieldName(element) + '"'; + } + if (hidden) { + return role; + } + const name = collapse( + element.getAttribute('aria-label') || + element.getAttribute('title') || + element.getAttribute('alt') || + element.textContent, + 60 + ); + return name ? role + ' "' + name + '"' : role; +}; + +const pagePath = (href) => { + try { + const url = new URL(href); + return collapse(url.pathname + url.search + url.hash, 150); + } catch (exp) { + return collapse(href, 150); + } +}; + +/** + * The timeline digest of a recording: clicks, inputs (without values), navigations, console errors + * and failed requests, with t in ms since the recording started. + */ +export class CaptureTimeline { + entries = []; + startedAt = 0; + listeners = []; + urlTimer = null; + lastUrl = ''; + lastInputField = null; + lastInputAt = 0; + + constructor({ privacyOptions, maskSelectors, isOwnElement }) { + this.privacyOptions = privacyOptions || {}; + this.maskSelectors = Array.isArray(maskSelectors) ? maskSelectors : []; + this.isOwnElement = isOwnElement || (() => false); + } + + add(type, label, at) { + if (this.entries.length >= MAX_TIMELINE_ENTRIES) { + return; + } + this.entries.push({ t: Math.max(0, (at || Date.now()) - this.startedAt), type, label: collapse(label, 200) }); + } + + // The control the event belongs to (through shadow roots), or null. + target(event) { + try { + const path = event.composedPath ? event.composedPath() : [event.target]; + for (let i = 0; i < path.length; i++) { + const node = path[i]; + if (node && node.nodeType === 1 && node.matches && node.matches(ACTIONABLE_SELECTOR)) { + return node; + } + } + } catch (exp) {} + return null; + } + + start(startedAt) { + this.startedAt = startedAt || Date.now(); + this.lastUrl = window.location.href; + const listen = (target, type, handler) => { + target.addEventListener(type, handler, { capture: true, passive: true }); + this.listeners.push(() => target.removeEventListener(type, handler, { capture: true, passive: true })); + }; + listen(document, 'click', (event) => { + try { + if (this.isOwnElement(event.target)) { + return; + } + const control = this.target(event); + // Only controls are named; other elements go by their tag, without their text. + const label = control + ? describeElement(control, this.privacyOptions, this.maskSelectors) + : event.target && event.target.tagName + ? event.target.tagName.toLowerCase() + : 'page'; + this.add('click', 'Clicked ' + label); + } catch (exp) {} + }); + const onInput = (event) => { + try { + const field = this.target(event); + if (!field || this.isOwnElement(event.target)) { + return; + } + // One entry per field while the customer keeps typing in it. + const now = Date.now(); + if (field === this.lastInputField && now - this.lastInputAt < 5000) { + this.lastInputAt = now; + return; + } + this.lastInputField = field; + this.lastInputAt = now; + this.add( + 'input', + (event.type === 'change' ? 'Changed ' : 'Typed in ') + + describeElement(field, this.privacyOptions, this.maskSelectors) + ); + } catch (exp) {} + }; + listen(document, 'input', onInput); + listen(document, 'change', onInput); + const checkUrl = () => { + try { + const href = window.location.href; + if (href !== this.lastUrl) { + this.lastUrl = href; + this.add('navigation', 'Navigated to ' + pagePath(href)); + } + } catch (exp) {} + }; + listen(window, 'popstate', checkUrl); + listen(window, 'hashchange', checkUrl); + this.urlTimer = setInterval(checkUrl, 500); + } + + /** + * Stops listening and adds the console errors and failed requests of the recording window. + * @returns {Array<{t: number, type: string, label: string}>} + */ + stop(endedAt) { + const end = endedAt || Date.now(); + this.listeners.forEach((remove) => { + try { + remove(); + } catch (exp) {} + }); + this.listeners = []; + if (this.urlTimer) { + clearInterval(this.urlTimer); + this.urlTimer = null; + } + + const inWindow = (date) => { + const time = new Date(date).getTime(); + return time >= this.startedAt && time <= end ? time : null; + }; + try { + GleapConsoleLogManager.getInstance() + .getLogs() + .forEach((entry) => { + const time = entry && entry.priority === 'ERROR' ? inWindow(entry.date) : null; + if (time !== null) { + this.add('error', String(entry.log || '').split('\n')[0], time); + } + }); + } catch (exp) {} + try { + GleapNetworkIntercepter.getInstance() + .getRequests() + .forEach((request) => { + if (!request || request.type === 'RESOURCE') { + return; + } + const status = request.response && request.response.status; + const failed = request.success === false || (typeof status === 'number' && status >= 400); + const time = failed ? inWindow(request.date) : null; + if (time !== null) { + this.add( + 'network-error', + (request.type || 'GET') + ' ' + pagePath(request.url) + ' → ' + (status || 'failed'), + time + ); + } + }); + } catch (exp) {} + + return this.entries.sort((a, b) => a.t - b.t); + } + + get interactions() { + return this.entries.filter((entry) => entry.type === 'click' || entry.type === 'input').length; + } +} diff --git a/src/GleapCaptureScreenshot.js b/src/GleapCaptureScreenshot.js new file mode 100644 index 0000000..26d9aab --- /dev/null +++ b/src/GleapCaptureScreenshot.js @@ -0,0 +1,1559 @@ +import { createContext, destroyContext, domToCanvas } from 'modern-screenshot'; +import { GleapNetworkIntercepter } from './Gleap'; +import { getFieldValueMask, isBlockedElement, isMaskMarker } from './GleapInputMasking'; +import { now, yieldToPage } from './GleapCaptureTasks'; + +// Screenshots for capture requests (contract §8). Desktop Chromium grabs one frame of the current +// tab (getDisplayMedia, one click in the browser's share dialog); everything else, and a declined or +// failed tab capture, renders the DOM in the browser (modern-screenshot). Both paint black boxes over +// everything masked: blocked and masked elements (rr-/gl- classes and the replay options), form +// fields whose value is private (GleapInputMasking), payment frames and flowConfig.capture.maskSelectors. + +export const MAX_CAPTURE_EDGE = 2560; +const JPEG_QUALITY = 0.85; +const TRANSPARENT_GIF = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; + +// Gleap's own UI never appears in a capture: the widget, launcher, notifications, banners, modals, +// tooltips, tours, the admin helper and the capture UI itself. +export const GLEAP_UI_SELECTOR = [ + '.gleap-frame-container', + '.bb-feedback-button', + '.gleap-notification-container', + '.gleap-chatbar', + '.gleap-capture-root', + '.gleap-image-view', + '.bb-capture-editor', + '.gleap-b', + '.gleap-modal-wrapper', + '.gleap-tooltip', + '.gleap-tour-popover', + '.gleap-tour-overlay', + '#copilot-pointer-container', + '#copilot-joined-container', + '.gleap-audio-unmute-modal-overlay', + '.gleap-admin-frame-container', + '.gleap-admin-collapse-ui', + '.click-wave', +].join(', '); + +// Gleap UI that can sit in the page's flow (checklists placed in the page, tooltip hotspots inside +// the page's elements): left blank in a capture, keeping its space. +export const GLEAP_INFLOW_UI_SELECTOR = 'gleap-checklist, .gleap-tooltip-anchor'; + +// Frames that take card details. Their content is not the page's, so no class or field rule can +// reach inside; a tab capture would show what the customer typed. +const PAYMENT_FRAME_HOST_NAMES = [ + 'stripe.com', + 'stripe.network', + 'braintreegateway.com', + 'braintree-api.com', + 'paypal.com', + 'adyen.com', + 'adyenpayments.com', + 'checkout.com', + 'squareup.com', + 'squareupsandbox.com', + 'recurly.com', + 'chargebee.com', + 'paddle.com', + 'mollie.com', + 'klarna.com', + 'worldpay.com', + 'authorize.net', + 'razorpay.com', + '2checkout.com', + 'cybersource.com', + 'spreedly.com', + 'gocardless.com', + 'payu.com', + 'mercadopago.com', + 'paystack.co', + 'flutterwave.com', +]; +const PAYMENT_FRAME_HOSTS = new RegExp( + '(^|\\.)(' + PAYMENT_FRAME_HOST_NAMES.map((host) => host.replace(/\./g, '\\.')).join('|') + ')$', + 'i' +); +const PAYMENT_FRAME_LABEL_WORDS = ['card', 'payment', 'cvc', 'cvv', 'security code', 'expir', 'iban']; +const PAYMENT_FRAME_LABEL = new RegExp(PAYMENT_FRAME_LABEL_WORDS.join('|'), 'i'); + +/** + * isPaymentFrame as one CSS selector, for masking the live page (the privacy veil over screen + * recordings). A host in the src matches a bit more loosely than isPaymentFrame's host test. + */ +export const paymentFrameSelector = () => { + const parts = ['iframe[allow*="payment" i]']; + PAYMENT_FRAME_LABEL_WORDS.forEach((word) => { + parts.push('iframe[title*="' + word + '" i]', 'iframe[name*="' + word + '" i]'); + }); + PAYMENT_FRAME_HOST_NAMES.forEach((host) => { + parts.push('iframe[src*="//' + host + '" i]', 'iframe[src*=".' + host + '" i]'); + }); + return parts.join(', '); +}; + +const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +// Two animation frames (so a style change is painted), or a short timeout in a background tab. +const nextPaint = () => + new Promise((resolve) => { + let done = false; + const finish = () => { + if (!done) { + done = true; + resolve(); + } + }; + setTimeout(finish, 120); + try { + requestAnimationFrame(() => requestAnimationFrame(finish)); + } catch (exp) { + finish(); + } + }); + +const withTimeout = (promise, ms, label) => + new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(label || 'timeout')), ms); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error) => { + clearTimeout(timer); + reject(error); + } + ); + }); + +export const cssEscape = (value) => { + try { + if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') { + return CSS.escape(value); + } + } catch (exp) {} + return String(value).replace(/[^a-zA-Z0-9_-]/g, (char) => '\\' + char); +}; + +const validSelector = (selector) => { + if (typeof selector !== 'string' || selector.trim().length === 0) { + return null; + } + try { + document.createDocumentFragment().querySelector(selector); + return selector.trim(); + } catch (exp) { + return null; + } +}; + +/** + * One selector for everything masked as a whole (block and mask markers, the site's replay options + * and flowConfig.capture.maskSelectors). Invalid parts are left out. + */ +export const buildMaskSelector = (privacyOptions, maskSelectors) => + maskSelectorParts(privacyOptions, maskSelectors).join(', '); + +/** + * The parts of buildMaskSelector: the block and mask markers first (one selector list), then each + * valid selector and class name of the site's replay options and flowConfig.capture.maskSelectors. + */ +export const maskSelectorParts = (privacyOptions, maskSelectors) => { + const options = privacyOptions || {}; + const parts = ['.rr-block, .gl-block, .rr-mask, .gl-mask']; + [options.blockSelector, options.maskTextSelector] + .concat(Array.isArray(maskSelectors) ? maskSelectors : []) + .forEach((selector) => { + const valid = validSelector(selector); + if (valid) { + parts.push(valid); + } + }); + [options.blockClass, options.maskTextClass].forEach((className) => { + if (typeof className === 'string' && className.length > 0) { + const valid = validSelector('.' + cssEscape(className)); + if (valid) { + parts.push(valid); + } + } + }); + return parts; +}; + +// Class names given as a RegExp (blockClass, maskTextClass): no selector can express them. +export const classPatterns = (privacyOptions) => { + const options = privacyOptions || {}; + return [options.blockClass, options.maskTextClass].filter((value) => value && typeof value.test === 'function'); +}; + +const matchesClassPattern = (element, patterns) => { + if (!patterns.length || !element.classList) { + return false; + } + for (let i = 0; i < element.classList.length; i++) { + for (let j = 0; j < patterns.length; j++) { + if (patterns[j].test(element.classList[i])) { + return true; + } + } + } + return false; +}; + +// Whether the element itself is one of Gleap's UI roots (its ancestors are not tested). +const isGleapUiRoot = (element) => { + try { + return !!(element && element.matches && element.matches(GLEAP_UI_SELECTOR)); + } catch (exp) { + return false; + } +}; + +export const isGleapUiElement = (element) => { + try { + return !!(element && element.closest && element.closest(GLEAP_UI_SELECTOR)); + } catch (exp) { + return false; + } +}; + +export const isPaymentFrame = (frame) => { + try { + const allow = (frame.getAttribute('allow') || '').toLowerCase(); + if (allow.indexOf('payment') !== -1) { + return true; + } + const label = (frame.getAttribute('title') || '') + ' ' + (frame.getAttribute('name') || ''); + if (PAYMENT_FRAME_LABEL.test(label)) { + return true; + } + const src = frame.getAttribute('src'); + if (src) { + return PAYMENT_FRAME_HOSTS.test(new URL(src, window.location.href).hostname); + } + } catch (exp) {} + return false; +}; + +const isMaskedField = (field, privacyOptions) => { + try { + return !!getFieldValueMask(field, privacyOptions); + } catch (exp) { + return true; + } +}; + +const unionRect = (a, b) => { + if (!a || a.width <= 0 || a.height <= 0) { + return b; + } + if (!b || b.width <= 0 || b.height <= 0) { + return a; + } + const left = Math.min(a.left, b.left); + const top = Math.min(a.top, b.top); + const right = Math.max(a.left + a.width, b.left + b.width); + const bottom = Math.max(a.top + a.height, b.top + b.height); + return { left, top, width: right - left, height: bottom - top }; +}; + +// The element's box together with its content (text or children that overflow it, and elements +// with display: contents that have no box of their own). +const contentRect = (element) => { + let rect = null; + try { + rect = element.getBoundingClientRect(); + } catch (exp) {} + try { + const range = (element.ownerDocument || document).createRange(); + range.selectNodeContents(element); + rect = unionRect(rect, range.getBoundingClientRect()); + } catch (exp) {} + return rect; +}; + +/** + * Where to paint black boxes, in CSS pixels of the top-level viewport: the rects of everything + * masked, through open shadow roots and same-origin frames. Gleap's own UI is skipped. + * @param {{privacyOptions?: object, maskSelectors?: string[]}} options + * @returns {Array<{x: number, y: number, width: number, height: number}>} + */ +export const collectMaskRects = (options = {}) => { + const rects = []; + const seen = {}; + const privacyOptions = options.privacyOptions || {}; + const selector = buildMaskSelector(privacyOptions, options.maskSelectors); + const patterns = classPatterns(privacyOptions); + + const add = (rect, frame) => { + if (!rect || !(rect.width > 0) || !(rect.height > 0)) { + return; + } + let left = rect.left + frame.x; + let top = rect.top + frame.y; + let right = left + rect.width; + let bottom = top + rect.height; + if (frame.clip) { + left = Math.max(left, frame.clip.left); + top = Math.max(top, frame.clip.top); + right = Math.min(right, frame.clip.right); + bottom = Math.min(bottom, frame.clip.bottom); + } + const key = [left, top, right, bottom].join(','); + if (right > left && bottom > top && !seen[key]) { + seen[key] = true; + rects.push({ x: left, y: top, width: right - left, height: bottom - top }); + } + }; + + const visit = (root, frame, depth) => { + if (!root || depth > 8) { + return; + } + const each = (query, callback) => { + let nodes = []; + try { + nodes = root.querySelectorAll(query); + } catch (exp) {} + for (let i = 0; i < nodes.length; i++) { + if (!isGleapUiElement(nodes[i])) { + callback(nodes[i]); + } + } + }; + + each(selector, (element) => add(contentRect(element), frame)); + each('input, textarea, select', (field) => { + if (isMaskedField(field, privacyOptions)) { + add(field.getBoundingClientRect(), frame); + } + }); + each('iframe, frame', (child) => { + const rect = child.getBoundingClientRect(); + if (isPaymentFrame(child)) { + add(rect, frame); + return; + } + let childDocument = null; + try { + childDocument = child.contentDocument; + } catch (exp) {} + if (childDocument && childDocument.documentElement && rect.width > 0 && rect.height > 0) { + const x = frame.x + rect.left + (child.clientLeft || 0); + const y = frame.y + rect.top + (child.clientTop || 0); + let clip = { + left: frame.x + rect.left, + top: frame.y + rect.top, + right: frame.x + rect.right, + bottom: frame.y + rect.bottom, + }; + if (frame.clip) { + clip = { + left: Math.max(clip.left, frame.clip.left), + top: Math.max(clip.top, frame.clip.top), + right: Math.min(clip.right, frame.clip.right), + bottom: Math.min(clip.bottom, frame.clip.bottom), + }; + } + visit(childDocument, { x, y, clip }, depth + 1); + } + }); + + // Open shadow roots, and class names given as RegExp in the replay options. Gleap's UI is skipped + // as a whole. + try { + const ownerDocument = root.ownerDocument || root; + const walker = ownerDocument.createTreeWalker(root, 1, { + acceptNode: (node) => (isGleapUiRoot(node) ? 2 : 1), + }); + let node = walker.nextNode(); + while (node) { + if (node.shadowRoot) { + visit(node.shadowRoot, frame, depth + 1); + } + if (patterns.length && matchesClassPattern(node, patterns)) { + add(contentRect(node), frame); + } + node = walker.nextNode(); + } + } catch (exp) {} + }; + + visit(document, { x: 0, y: 0, clip: null }, 0); + return rects; +}; + +const paintMaskRects = (context, rects, scaleX, scaleY) => { + context.save(); + context.fillStyle = '#000000'; + for (let i = 0; i < rects.length; i++) { + const rect = rects[i]; + const x = Math.floor(rect.x * scaleX) - 1; + const y = Math.floor(rect.y * scaleY) - 1; + const width = Math.ceil(rect.width * scaleX) + 2; + const height = Math.ceil(rect.height * scaleY) + 2; + context.fillRect(x, y, width, height); + } + context.restore(); +}; + +const createCanvas = (width, height) => { + const canvas = document.createElement('canvas'); + canvas.width = Math.max(1, Math.round(width)); + canvas.height = Math.max(1, Math.round(height)); + return canvas; +}; + +const releaseCanvas = (canvas) => { + try { + if (canvas) { + canvas.width = 0; + canvas.height = 0; + } + } catch (exp) {} +}; + +// Samples the canvas at 48x48: 'transparent' when nothing was drawn, 'black' when all is black. +const sampleCanvas = (canvas) => { + try { + const size = 48; + const sample = createCanvas(size, size); + const context = sample.getContext('2d'); + context.drawImage(canvas, 0, 0, size, size); + const data = context.getImageData(0, 0, size, size).data; + releaseCanvas(sample); + let transparent = true; + let black = true; + for (let i = 0; i < data.length; i += 4) { + if (data[i + 3] > 0) { + transparent = false; + if (data[i] > 12 || data[i + 1] > 12 || data[i + 2] > 12) { + black = false; + } + } + } + return transparent ? 'transparent' : black ? 'black' : 'content'; + } catch (exp) { + return 'content'; + } +}; + +// The color the page's canvas shows where nothing else is painted. +const pageBackgroundColor = () => { + const visible = (color) => color && color !== 'transparent' && !/rgba\(\s*0,\s*0,\s*0,\s*0\s*\)/.test(color); + try { + const htmlColor = window.getComputedStyle(document.documentElement).backgroundColor; + if (visible(htmlColor)) { + return htmlColor; + } + const bodyColor = document.body ? window.getComputedStyle(document.body).backgroundColor : null; + if (visible(bodyColor)) { + return bodyColor; + } + } catch (exp) {} + return '#ffffff'; +}; + +/** + * The final image: page background, the capture, black boxes, scaled down to MAX_CAPTURE_EDGE on + * the long edge, JPEG. + */ +const encodeCapture = ({ canvas, scaleX, scaleY, rects, background }) => { + const longEdge = Math.max(canvas.width, canvas.height); + const factor = longEdge > MAX_CAPTURE_EDGE ? MAX_CAPTURE_EDGE / longEdge : 1; + const output = createCanvas(canvas.width * factor, canvas.height * factor); + const context = output.getContext('2d'); + context.fillStyle = background || '#ffffff'; + context.fillRect(0, 0, output.width, output.height); + context.imageSmoothingEnabled = true; + try { + context.imageSmoothingQuality = 'high'; + } catch (exp) {} + context.drawImage(canvas, 0, 0, output.width, output.height); + paintMaskRects(context, rects, scaleX * factor, scaleY * factor); + const dataUrl = output.toDataURL('image/jpeg', JPEG_QUALITY); + const result = { dataUrl, width: output.width, height: output.height }; + releaseCanvas(output); + return result; +}; + +export const stopStream = (stream) => { + try { + if (stream) { + stream.getTracks().forEach((track) => { + try { + track.stop(); + } catch (exp) {} + }); + } + } catch (exp) {} +}; + +/** + * Asks for the current tab (desktop Chromium). Must run synchronously inside the Capture click: + * that click is the user activation the browser requires. + * @returns {Promise} + */ +export const requestTabStream = () => { + let promise; + try { + promise = navigator.mediaDevices.getDisplayMedia({ + video: { displaySurface: 'browser' }, + audio: false, + preferCurrentTab: true, + selfBrowserSurface: 'include', + surfaceSwitching: 'exclude', + monitorTypeSurfaces: 'exclude', + }); + } catch (error) { + promise = Promise.reject(error); + } + // Handled by the caller; this keeps an unused rejection out of the page's console. + promise.catch(() => {}); + return promise; +}; + +/** + * One frame of a video track: ImageCapture.grabFrame, then MediaStreamTrackProcessor, then a + *