uBlock/platform/mv3/extension/js/scripting/zapper.js
Raymond Hill 6ffffb7b2b
[mv3] Force real origin in postMessage so that use_dynamic_url can be used
Call to postMessage was failing in the zapper when using `use_dynamic_url` in
manifest. Manually overwriting the per-session dynamic hostname with the real
extension id fixes this.
2025-03-31 16:30:17 -04:00

411 lines
13 KiB
JavaScript

/*******************************************************************************
uBlock Origin - a comprehensive, efficient content blocker
Copyright (C) 2025-present Raymond Hill
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see {http://www.gnu.org/licenses/}.
Home: https://github.com/gorhill/uBlock
*/
(async ( ) => {
/******************************************************************************/
const zapper = self.uBOLZapper = self.uBOLZapper || {};
if ( zapper.injected ) { return; }
zapper.injected = true;
/******************************************************************************/
const sendMessage = msg => {
try {
chrome.runtime.sendMessage(msg).catch(( ) => { });
} catch {
}
};
/******************************************************************************/
const zapperSecret = (( ) => {
let secret = String.fromCharCode((Math.random() * 26) + 97);
do {
secret += (Math.floor(Math.random() * 2147483647) + 2147483647)
.toString(36)
.slice(2);
} while ( secret.length < 8 );
return secret;
})();
const zapperCSSStyle = [
'background: transparent',
'border: 0',
'border-radius: 0',
'box-shadow: none',
'color-scheme: light dark',
'display: block',
'filter: none',
'height: 100vh',
' height: 100svh',
'left: 0',
'margin: 0',
'max-height: none',
'max-width: none',
'min-height: unset',
'min-width: unset',
'opacity: 1',
'outline: 0',
'padding: 0',
'pointer-events: auto',
'position: fixed',
'top: 0',
'transform: none',
'visibility: hidden',
'width: 100%',
'z-index: 2147483647',
''
].join(' !important;\n');
const zapperCSS = `
:root > [${zapperSecret}] {
${zapperCSSStyle}
}
:root > [${zapperSecret}-loaded] {
visibility: visible !important;
}
:root [${zapperSecret}-click] {
pointer-events: none !important;
}
`;
sendMessage({ what: 'insertCSS', css: zapperCSS });
/******************************************************************************/
const getElementBoundingClientRect = function(elem) {
let rect = typeof elem.getBoundingClientRect === 'function'
? elem.getBoundingClientRect()
: { height: 0, left: 0, top: 0, width: 0 };
// https://github.com/gorhill/uBlock/issues/1024
// Try not returning an empty bounding rect.
if ( rect.width !== 0 && rect.height !== 0 ) {
return rect;
}
if ( elem.shadowRoot instanceof DocumentFragment ) {
return getElementBoundingClientRect(elem.shadowRoot);
}
let left = rect.left,
right = left + rect.width,
top = rect.top,
bottom = top + rect.height;
for ( const child of elem.children ) {
rect = getElementBoundingClientRect(child);
if ( rect.width === 0 || rect.height === 0 ) { continue; }
if ( rect.left < left ) { left = rect.left; }
if ( rect.right > right ) { right = rect.right; }
if ( rect.top < top ) { top = rect.top; }
if ( rect.bottom > bottom ) { bottom = rect.bottom; }
}
return {
bottom,
height: bottom - top,
left,
right,
top,
width: right - left
};
};
/******************************************************************************/
const highlightElement = function(elem) {
if ( elem !== true ) {
if ( elem !== undefined ) {
if ( elem === highlightElement.current ) { return; }
if ( elem !== zapperFrame ) {
highlightElement.current = elem;
}
}
}
elem = highlightElement.current;
const ow = self.innerWidth;
const oh = self.innerHeight;
const islands = [];
if ( elem !== null ) {
const rect = getElementBoundingClientRect(elem);
// Ignore offscreen areas
if (
rect.left <= ow && rect.top <= oh &&
rect.left + rect.width >= 0 && rect.top + rect.height >= 0
) {
islands.push(
`M${rect.left} ${rect.top}h${rect.width}v${rect.height}h-${rect.width}z`
);
}
}
zapperFramePort.postMessage({
what: 'svgPaths',
ocean: `M0 0h${ow}v${oh}h-${ow}z`,
islands: islands.join(''),
});
};
highlightElement.current = null;
/******************************************************************************/
const elementFromPoint = (( ) => {
let lastX, lastY;
return (x, y) => {
if ( x !== undefined ) {
lastX = x; lastY = y;
} else if ( lastX !== undefined ) {
x = lastX; y = lastY;
} else {
return null;
}
if ( !zapperFrame ) { return null; }
const magicAttr = `${zapperSecret}-click`;
zapperFrame.setAttribute(magicAttr, '');
let elem = document.elementFromPoint(x, y);
if ( elem === document.body || elem === document.documentElement ) {
elem = null;
}
// https://github.com/uBlockOrigin/uBlock-issues/issues/380
zapperFrame.removeAttribute(magicAttr);
return elem;
};
})();
/******************************************************************************/
const highlightElementAtPoint = function(mx, my) {
const elem = elementFromPoint(mx, my);
highlightElement(elem);
};
/******************************************************************************/
// https://www.reddit.com/r/uBlockOrigin/comments/bktxtb/scrolling_doesnt_work/emn901o
// Override 'fixed' position property on body element if present.
// With touch-driven devices, first highlight the element and remove only
// when tapping again the highlighted area.
const zapElementAtPoint = function(mx, my, options) {
if ( options.highlight ) {
const elem = elementFromPoint(mx, my);
if ( elem ) {
highlightElement(elem);
}
return;
}
let elemToRemove = highlightElement.current;
if ( elemToRemove === null && mx !== undefined ) {
elemToRemove = elementFromPoint(mx, my);
}
if ( elemToRemove instanceof Element === false ) { return; }
const getStyleValue = (elem, prop) => {
const style = window.getComputedStyle(elem);
return style ? style[prop] : '';
};
// Heuristic to detect scroll-locking: remove such lock when detected.
let maybeScrollLocked = elemToRemove.shadowRoot instanceof DocumentFragment;
if ( maybeScrollLocked === false ) {
let elem = elemToRemove;
do {
maybeScrollLocked =
parseInt(getStyleValue(elem, 'zIndex'), 10) >= 1000 ||
getStyleValue(elem, 'position') === 'fixed';
elem = elem.parentElement;
} while ( elem !== null && maybeScrollLocked === false );
}
if ( maybeScrollLocked ) {
const doc = document;
if ( getStyleValue(doc.body, 'overflowY') === 'hidden' ) {
doc.body.style.setProperty('overflow', 'auto', 'important');
}
if ( getStyleValue(doc.body, 'position') === 'fixed' ) {
doc.body.style.setProperty('position', 'initial', 'important');
}
if ( getStyleValue(doc.documentElement, 'position') === 'fixed' ) {
doc.documentElement.style.setProperty('position', 'initial', 'important');
}
if ( getStyleValue(doc.documentElement, 'overflowY') === 'hidden' ) {
doc.documentElement.style.setProperty('overflow', 'auto', 'important');
}
}
elemToRemove.remove();
highlightElementAtPoint(mx, my);
};
/******************************************************************************/
const onKeyPressed = function(ev) {
// Delete
if (
(ev.key === 'Delete' || ev.key === 'Backspace') ) {
ev.stopPropagation();
ev.preventDefault();
zapElementAtPoint();
return;
}
// Esc
if ( ev.key === 'Escape' || ev.which === 27 ) {
ev.stopPropagation();
ev.preventDefault();
quitZapper();
return;
}
};
/******************************************************************************/
// https://github.com/chrisaljoudi/uBlock/issues/190
// May need to dynamically adjust the height of the overlay + new position
// of highlighted elements.
const onViewportChanged = function() {
highlightElement(true);
};
/******************************************************************************/
const startZapper = function() {
zapperFrame.focus();
self.addEventListener('scroll', onViewportChanged, { passive: true });
self.addEventListener('resize', onViewportChanged, { passive: true });
self.addEventListener('keydown', onKeyPressed, true);
};
/******************************************************************************/
const quitZapper = function() {
self.removeEventListener('scroll', onViewportChanged, { passive: true });
self.removeEventListener('resize', onViewportChanged, { passive: true });
self.removeEventListener('keydown', onKeyPressed, true);
if ( zapperFramePort ) {
zapperFramePort.close();
zapperFramePort.onmessage = null;
zapperFramePort.onmessageerror = null;
zapperFramePort = null;
}
if ( zapperFrame ) {
zapperFrame.remove();
zapperFrame = null;
}
sendMessage({ what: 'removeCSS', css: zapperCSS });
zapper.injected = false;
};
/******************************************************************************/
const onFrameMessage = function(msg) {
switch ( msg.what ) {
case 'start':
startZapper();
if ( Boolean(zapperFramePort) === false ) { break; }
highlightElement(true);
break;
case 'quitZapper':
quitZapper();
break;
case 'highlightElementAtPoint':
highlightElementAtPoint(msg.mx, msg.my);
break;
case 'unhighlight':
highlightElement(null);
break;
case 'zapElementAtPoint':
zapElementAtPoint(msg.mx, msg.my, msg.options);
if ( msg.options.highlight !== true && msg.options.stay !== true ) {
quitZapper();
}
break;
default:
break;
}
};
/******************************************************************************/
// zapper-ui.html will be injected in the page through an iframe, and
// is a sandboxed so as to prevent the page from interfering with its
// content and behavior.
//
// The purpose of zapper.js is to install the zapper UI, and wait for the
// component to establish a direct communication channel.
//
// When the zapper is installed on a page, the only change the page sees is an
// iframe with a random attribute. The page can't see the content of the
// iframe, and cannot interfere with its style properties. However the page
// can remove the iframe.
const bootstrap = async ( ) => {
const dynamicURL = new URL(chrome.runtime.getURL('/zapper-ui.html'));
return new Promise(resolve => {
const frame = document.createElement('iframe');
frame.setAttribute(zapperSecret, '');
document.documentElement.append(frame);
frame.onload = ( ) => {
frame.onload = null;
frame.setAttribute(`${zapperSecret}-loaded`, '');
const channel = new MessageChannel();
const port = channel.port1;
port.onmessage = ev => {
onFrameMessage(ev.data || {});
};
port.onmessageerror = ( ) => {
quitZapper();
};
const realURL = new URL(dynamicURL);
realURL.hostname = chrome.i18n.getMessage('@@extension_id');
frame.contentWindow.postMessage(
{ what: 'zapperStart' },
realURL.origin,
[ channel.port2 ]
);
frame.contentWindow.focus();
resolve({
zapperFrame: frame,
zapperFramePort: port,
});
};
frame.contentWindow.location = dynamicURL.href;
});
};
let { zapperFrame, zapperFramePort } = await bootstrap();
if ( zapperFrame && zapperFramePort ) { return; }
quitZapper();
/******************************************************************************/
})();
void 0;