Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Changelog

## 19.3.2
Fixed: screenshots showed dialogs and other content inside iframes as an empty box. The capture now includes the page of every same-origin iframe (frames in frames too, with the same masking and blocking rules) and stores it in the frame's `srcdoc`, so the screenshot shows what the user saw instead of loading the frame's URL without their session. Cross-origin iframes, and frames still loading their `src`, render as before. Canvases inside iframes are captured too. Typed values, scroll positions and canvases inside iframes need the matching screenshot renderer update.

## 19.3.1
Security: the default URL handler opens only http(s), mailto, tel and relative links (never `javascript:` or `data:`), new tabs open with `noopener`, banner and modal messages need an exact origin match, and messages to the widget, chatbar and agent conversation frames are pinned to their own origin. A custom `Gleap.setUrlHandler` still receives app deep links, never script URLs.
Fixed: screenshot and recording logs are sent only once the capture is saved (none after a cancel, a retake replaces them), and recording logs leave out paused stretches. With a custom API URL (`Gleap.setApiUrl`) the SDK's own calls stay out of the network logs, and the `Gleap-Hash` session header is always masked. Screenshot downloads stay out of the network logs, images that can't be downloaded are tinted instead of breaking the screenshot, and switching users no longer races.
Expand Down
2 changes: 1 addition & 1 deletion build/browser/index.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/browser/index.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/cjs/index.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/cjs/index.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/esm/index.mjs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/esm/index.mjs.map

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"description": "JavaScript and TypeScript SDK for Gleap, an Intercom alternative with AI customer support, live chat, in-app bug reporting and customer feedback.",
"name": "gleap",
"version": "19.3.1",
"version": "19.3.2",
"main": "build/cjs/index.js",
"module": "build/esm/index.mjs",
"exports": {
Expand Down
2 changes: 2 additions & 0 deletions published/19.3.2/index.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions published/19.3.2/index.js.map

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions published/19.3.2/survey-embed.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion published/latest/index.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion published/latest/index.js.map

Large diffs are not rendered by default.

205 changes: 125 additions & 80 deletions src/ScreenCapture.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ export const startScreenCapture = (isLiveSite, privacyOptions) => {
return prepareScreenshotData(isLiveSite, privacyOptions || {});
};

const documentToHTML = (clone) => {
const documentToHTML = (clone, doc) => {
var html = '';
var node = window.document.doctype;
var node = doc.doctype;
if (node) {
html =
'<!DOCTYPE ' +
Expand Down Expand Up @@ -266,10 +266,10 @@ const getTextContentFromStyleSheet = (styleSheet) => {
return sanitizeCSSBraces(cssTextContent);
};

const downloadAllCSSUrlResources = (clone, remote) => {
const downloadAllCSSUrlResources = (clone, remote, doc) => {
var promises = [];
for (var i = 0; i < document.styleSheets.length; i++) {
const styleSheet = document.styleSheets[i];
for (var i = 0; i < doc.styleSheets.length; i++) {
const styleSheet = doc.styleSheets[i];

// Skip if the stylesheet is meant for print
if (styleSheet.media && styleSheet.media.mediaText === 'print') {
Expand All @@ -280,10 +280,10 @@ const downloadAllCSSUrlResources = (clone, remote) => {
if (styleSheet && styleSheet.ownerNode) {
if (cssTextContent != '') {
// Resolve resources.
const baseTags = document.getElementsByTagName('base');
const baseTags = doc.getElementsByTagName('base');
var basePathURL = baseTags.length
? baseTags[0].href.substr(location.origin.length, 999)
: window.location.href.split(/[?#]/)[0];
? baseTags[0].href.substr(doc.location.origin.length, 999)
: doc.location.href.split(/[?#]/)[0];

if (styleSheet.href) {
basePathURL = styleSheet.href;
Expand Down Expand Up @@ -322,11 +322,11 @@ const downloadAllCSSUrlResources = (clone, remote) => {
});
};

const prepareRemoteData = (clone, remote) => {
const prepareRemoteData = (clone, remote, doc) => {
return new Promise((resolve, reject) => {
if (remote) {
// Always download CSS.
return downloadAllCSSUrlResources(clone, remote)
return downloadAllCSSUrlResources(clone, remote, doc)
.then(() => {
resolve();
})
Expand All @@ -336,7 +336,7 @@ const prepareRemoteData = (clone, remote) => {
} else {
return downloadAllImages(clone)
.then(() => {
return downloadAllCSSUrlResources(clone, remote).then(() => {
return downloadAllCSSUrlResources(clone, remote, doc).then(() => {
resolve();
});
})
Expand Down Expand Up @@ -1074,9 +1074,31 @@ const createBlockedPlaceholder = (node, placeholderDocument, isInnerBox, isInTab
return placeholder;
};

const deepClone = async (host, privacyOptions) => {
// A same-origin frame's page is captured the way the top page is, and goes into the frame's
// srcdoc, which the renderer shows instead of loading src: loaded there, src has none of the
// user's session (a login page, or nothing), and a frame written by script has no src at all.
// A cross-origin page cannot be read, and its frame keeps loading src.
const captureFrame = async (frame, remote, privacyOptions, insideMaskMarker) => {
try {
const frameDocument = frame.contentDocument;
if (!frameDocument || !frameDocument.documentElement) {
return null;
}

// Still on the initial about:blank on its way to src, which may be cross-origin.
if (frameDocument.location.href === 'about:blank' && frame.src && frame.src !== 'about:blank') {
return null;
}

return (await captureDocument(frameDocument, remote, privacyOptions, insideMaskMarker)).html;
} catch (exp) {
return null;
}
};

const deepClone = async (host, remote, privacyOptions, insideHostMaskMarker) => {
let shadowNodeId = 1;
const animationsByTarget = collectAnimationsInto(new Map(), window.document);
const animationsByTarget = collectAnimationsInto(new Map(), host.ownerDocument);
const maskText = getTextMask(privacyOptions);
let placeholderDocument = null;

Expand Down Expand Up @@ -1139,7 +1161,8 @@ const deepClone = async (host, privacyOptions) => {
clone.setAttribute('bb-shadow-child', shadowRoot);
}

if (node instanceof HTMLCanvasElement) {
// By tag name: a frame's canvas is an instance of the frame's HTMLCanvasElement.
if (tagName == 'CANVAS') {
try {
const boundingRect = node.getBoundingClientRect();
const resizedImage = await resizeImage(node.toDataURL(), 1400, 1400);
Expand All @@ -1161,6 +1184,16 @@ const deepClone = async (host, privacyOptions) => {
clone.setAttribute('bb-width', boundingRect.width);
}

if (tagName == 'IFRAME') {
const frameHTML = await captureFrame(node, remote, privacyOptions, masksText);
// Under Trusted Types srcdoc takes no plain string, and the frame keeps loading src.
try {
if (frameHTML !== null) {
clone.setAttribute('srcdoc', frameHTML);
}
} catch (exp) {}
}

if (node.scrollTop > 0 || node.scrollLeft > 0) {
clone.setAttribute('bb-scrollpos', true);
clone.setAttribute('bb-scrolltop', node.scrollTop);
Expand Down Expand Up @@ -1215,14 +1248,14 @@ const deepClone = async (host, privacyOptions) => {
};

const fragment = document.createDocumentFragment();
await cloneNode(host, fragment);
await cloneNode(host, fragment, undefined, insideHostMaskMarker);

// Work on adopted stylesheets.
var clonedHead = fragment.querySelector('head');
if (!clonedHead) {
clonedHead = fragment;
}
handleAdoptedStyleSheets(window.document, clonedHead);
handleAdoptedStyleSheets(host.ownerDocument, clonedHead);

return fragment;
};
Expand Down Expand Up @@ -1267,95 +1300,107 @@ const fixParserUnsafeNesting = (clone) => {
}
};

const prepareScreenshotData = (remote, privacyOptions) => {
return new Promise(async (resolve, reject) => {
try {
const styleTags = window.document.querySelectorAll('style, link');
for (var i = 0; i < styleTags.length; ++i) {
styleTags[i].setAttribute('bb-styleid', i);
}
// Captures one document as HTML: the page, or the page of a same-origin frame in it.
const captureDocument = async (doc, remote, privacyOptions, insideMaskMarker) => {
const styleTags = doc.querySelectorAll('style, link');
for (var i = 0; i < styleTags.length; ++i) {
styleTags[i].setAttribute('bb-styleid', i);
}

const clone = await deepClone(window.document.documentElement, privacyOptions);
const clone = await deepClone(doc.documentElement, remote, privacyOptions, insideMaskMarker);

try {
fixParserUnsafeNesting(clone);
} catch (exp) {}
try {
fixParserUnsafeNesting(clone);
} catch (exp) {}

// Fix for web imports (depracted).
const linkImportElems = clone.querySelectorAll('link[rel=import]');
for (var i = 0; i < linkImportElems.length; ++i) {
const referenceNode = linkImportElems[i];
if (referenceNode && referenceNode.childNodes && referenceNode.childNodes.length > 0) {
const childNodes = referenceNode.childNodes;
while (childNodes.length > 0) {
referenceNode.parentNode.insertBefore(childNodes[0], referenceNode);
}
referenceNode.remove();
// Fix for web imports (depracted).
const linkImportElems = clone.querySelectorAll('link[rel=import]');
for (var i = 0; i < linkImportElems.length; ++i) {
const referenceNode = linkImportElems[i];
if (referenceNode && referenceNode.childNodes && referenceNode.childNodes.length > 0) {
const childNodes = referenceNode.childNodes;
while (childNodes.length > 0) {
referenceNode.parentNode.insertBefore(childNodes[0], referenceNode);
}
referenceNode.remove();
}
}

// Remove all scripts & style
const scriptElems = clone.querySelectorAll('script, noscript');
for (var i = 0; i < scriptElems.length; ++i) {
scriptElems[i].remove();
}
// Remove all scripts & style
const scriptElems = clone.querySelectorAll('script, noscript');
for (var i = 0; i < scriptElems.length; ++i) {
scriptElems[i].remove();
}

// Cleanup base path
var existingBasePath = '';
const baseElems = clone.querySelectorAll('base');
for (var i = 0; i < baseElems.length; ++i) {
if (baseElems[i].href) {
existingBasePath = baseElems[i].href;
}
baseElems[i].remove();
// Cleanup base path
var existingBasePath = '';
const baseElems = clone.querySelectorAll('base');
for (var i = 0; i < baseElems.length; ++i) {
if (baseElems[i].href) {
existingBasePath = baseElems[i].href;
}
baseElems[i].remove();
}

// Adjust the base node
const baseUrl = window.location.href.substring(0, window.location.href.lastIndexOf('/'));
var newBaseUrl = baseUrl + '/';
if (existingBasePath) {
if (existingBasePath.startsWith('http')) {
// Absolute path.
newBaseUrl = existingBasePath;
} else {
// Relative path.
newBaseUrl = baseUrl + existingBasePath;
if (!newBaseUrl.endsWith('/')) {
newBaseUrl += '/';
}
// Adjust the base node
const pageUrl = doc.location.href;
const baseUrl = pageUrl.substring(0, pageUrl.lastIndexOf('/'));
var newBaseUrl = baseUrl + '/';
if (existingBasePath) {
if (existingBasePath.startsWith('http')) {
// Absolute path.
newBaseUrl = existingBasePath;
} else {
// Relative path.
newBaseUrl = baseUrl + existingBasePath;
if (!newBaseUrl.endsWith('/')) {
newBaseUrl += '/';
}
}
}

const baseNode = window.document.createElement('base');
// A frame on about:blank resolves URLs against its parent's base, and so does its srcdoc copy.
if (doc.location.protocol !== 'about:') {
const baseNode = doc.createElement('base');
baseNode.href = newBaseUrl;
const head = clone.querySelector('head');
head.insertBefore(baseNode, head.firstChild);
}

// Do further cleanup.
const dialogElems = clone.querySelectorAll('.bb-feedback-dialog-container, .bb-capture-editor-borderlayer');
for (var i = 0; i < dialogElems.length; ++i) {
dialogElems[i].remove();
}
// Do further cleanup.
const dialogElems = clone.querySelectorAll('.bb-feedback-dialog-container, .bb-capture-editor-borderlayer');
for (var i = 0; i < dialogElems.length; ++i) {
dialogElems[i].remove();
}

// Calculate heights
const bbElems = clone.querySelectorAll('[bb-element=true]');
for (var i = 0; i < bbElems.length; ++i) {
if (bbElems[i]) {
bbElems[i].style.height = bbElems[i].getAttribute('bb-height') + 'px';
}
// Calculate heights
const bbElems = clone.querySelectorAll('[bb-element=true]');
for (var i = 0; i < bbElems.length; ++i) {
if (bbElems[i]) {
bbElems[i].style.height = bbElems[i].getAttribute('bb-height') + 'px';
}
}

prepareRemoteData(clone, remote).then(() => {
const html = documentToHTML(clone);
await prepareRemoteData(clone, remote, doc);

return {
html: documentToHTML(clone, doc),
baseUrl: baseUrl,
};
};

const prepareScreenshotData = (remote, privacyOptions) => {
return new Promise(async (resolve, reject) => {
try {
const capture = await captureDocument(window.document, remote, privacyOptions);

resolve({
html: html,
baseUrl: baseUrl,
html: capture.html,
baseUrl: capture.baseUrl,
width: window.innerWidth,
height: window.innerHeight,
isMobile: isMobile(),
});
});
} catch (exp) {
console.warn('Gleap: Failed to capture screenshot', exp);
resolve(null);
Expand Down
Loading
Loading