486d4258d3
uBlock-extra rewrites html of some sites known to have bad scripts. Such new documentElement doesn't have our styles so we need to detect this case by observing the parent document node non-recursively, meaning we don't add overhead to the normal browsing experience.
464 lines
13 KiB
JavaScript
464 lines
13 KiB
JavaScript
// Not using some slow features of ES6, see http://kpdecker.github.io/six-speed/
|
|
// like destructring, classes, defaults, spread, calculated key names
|
|
/* eslint no-var: 0 */
|
|
'use strict';
|
|
|
|
var isOwnPage = location.href.startsWith('chrome-extension:');
|
|
var disableAll = false;
|
|
var styleElements = new Map();
|
|
var retiredStyleIds = [];
|
|
var iframeObserver;
|
|
var docRewriteObserver;
|
|
var orphanCheckTimer;
|
|
|
|
initIFrameObserver();
|
|
requestStyles();
|
|
chrome.runtime.onMessage.addListener(applyOnMessage);
|
|
|
|
|
|
function requestStyles(options) {
|
|
// If this is a Stylish page (Edit Style or Manage Styles),
|
|
// we'll request the styles directly to minimize delay and flicker,
|
|
// unless Chrome is still starting up and the background page isn't fully loaded.
|
|
// (Note: in this case the function may be invoked again from applyStyles.)
|
|
const request = Object.assign({
|
|
method: 'getStyles',
|
|
matchUrl: location.href,
|
|
enabled: true,
|
|
asHash: true,
|
|
}, options);
|
|
if (typeof getStylesSafe !== 'undefined') {
|
|
getStylesSafe(request).then(applyStyles);
|
|
} else {
|
|
chrome.runtime.sendMessage(request, applyStyles);
|
|
}
|
|
}
|
|
|
|
|
|
function applyOnMessage(request, sender, sendResponse) {
|
|
// Do-It-Yourself tells our built-in pages to fetch the styles directly
|
|
// which is faster because IPC messaging JSON-ifies everything internally
|
|
if (request.styles == 'DIY') {
|
|
getStylesSafe({
|
|
matchUrl: location.href,
|
|
enabled: true,
|
|
asHash: true,
|
|
}).then(styles =>
|
|
applyOnMessage(Object.assign(request, {styles})));
|
|
return;
|
|
}
|
|
// Also handle special request just for the pop-up
|
|
switch (request.method == 'updatePopup' ? request.reason : request.method) {
|
|
|
|
case 'styleDeleted':
|
|
removeStyle(request.id, document);
|
|
break;
|
|
|
|
case 'styleUpdated':
|
|
if (request.codeIsUpdated === false) {
|
|
applyStyleState(request.style.id, request.style.enabled, document);
|
|
break;
|
|
}
|
|
if (!request.style.enabled) {
|
|
removeStyle(request.style.id, document);
|
|
break;
|
|
}
|
|
retireStyle(request.style.id);
|
|
// fallthrough to 'styleAdded'
|
|
|
|
case 'styleAdded':
|
|
if (request.style.enabled) {
|
|
requestStyles({id: request.style.id}, applyStyles);
|
|
}
|
|
break;
|
|
|
|
case 'styleApply':
|
|
applyStyles(request.styles);
|
|
break;
|
|
|
|
case 'styleReplaceAll':
|
|
replaceAll(request.styles, document);
|
|
break;
|
|
|
|
case 'styleDisableAll':
|
|
doDisableAll(request.disableAll);
|
|
break;
|
|
|
|
case 'ping':
|
|
sendResponse(true);
|
|
break;
|
|
}
|
|
}
|
|
|
|
|
|
function doDisableAll(disable) {
|
|
if (!disable === !disableAll) {
|
|
return;
|
|
}
|
|
disableAll = disable;
|
|
if (disableAll) {
|
|
iframeObserver.disconnect();
|
|
}
|
|
|
|
disableSheets(disableAll, document);
|
|
|
|
if (!disableAll && document.readyState != 'loading') {
|
|
iframeObserver.start();
|
|
}
|
|
|
|
function disableSheets(disable, doc) {
|
|
Array.prototype.forEach.call(doc.styleSheets, stylesheet => {
|
|
if (stylesheet.ownerNode.classList.contains('stylus')
|
|
&& stylesheet.disabled != disable) {
|
|
stylesheet.disabled = disable;
|
|
}
|
|
});
|
|
for (const iframe of getDynamicIFrames(doc)) {
|
|
if (!disable) {
|
|
// update the IFRAME if it was created while the observer was disconnected
|
|
addDocumentStylesToIFrame(iframe);
|
|
}
|
|
disableSheets(disable, iframe.contentDocument);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
function applyStyleState(id, enabled, doc) {
|
|
const el = doc.getElementById('stylus-' + id);
|
|
if (el) {
|
|
el.sheet.disabled = !enabled;
|
|
processDynamicIFrames(doc, applyStyleState, id, enabled);
|
|
} else if (enabled) {
|
|
requestStyles({id});
|
|
}
|
|
}
|
|
|
|
|
|
function removeStyle(id, doc) {
|
|
styleElements.delete('stylus-' + id);
|
|
[doc.getElementById('stylus-' + id)].forEach(e => e && e.remove());
|
|
if (doc == document && !styleElements.size) {
|
|
iframeObserver.disconnect();
|
|
}
|
|
processDynamicIFrames(doc, removeStyle, id);
|
|
}
|
|
|
|
|
|
// to avoid page flicker when the style is updated
|
|
// instead of removing it immediately we rename its ID and queue it
|
|
// to be deleted in applyStyles after a new version is fetched and applied
|
|
function retireStyle(id, doc) {
|
|
const deadID = 'ghost-' + id;
|
|
if (!doc) {
|
|
doc = document;
|
|
retiredStyleIds.push(deadID);
|
|
styleElements.delete('stylus-' + id);
|
|
// in case something went wrong and new style was never applied
|
|
setTimeout(removeStyle, 1000, deadID, doc);
|
|
}
|
|
const el = doc.getElementById('stylus-' + id);
|
|
if (el) {
|
|
el.id = 'stylus-' + deadID;
|
|
}
|
|
processDynamicIFrames(doc, retireStyle, id);
|
|
}
|
|
|
|
|
|
function applyStyles(styleHash) {
|
|
if (!styleHash) { // Chrome is starting up
|
|
requestStyles();
|
|
return;
|
|
}
|
|
if ('disableAll' in styleHash) {
|
|
doDisableAll(styleHash.disableAll);
|
|
delete styleHash.disableAll;
|
|
}
|
|
|
|
for (const styleId in styleHash) {
|
|
applySections(styleId, styleHash[styleId]);
|
|
}
|
|
|
|
if (styleElements.size) {
|
|
// when site response is application/xml Chrome displays our style elements
|
|
// under document.documentElement as plain text so we need to move them into HEAD
|
|
// which is already autogenerated at this moment
|
|
if (document.head && document.head.firstChild && document.head.firstChild.id == 'xml-viewer-style') {
|
|
for (const id of styleElements.keys()) {
|
|
document.head.appendChild(document.getElementById(id));
|
|
}
|
|
}
|
|
if (document.readyState != 'loading') {
|
|
onDOMContentLoaded();
|
|
} else {
|
|
document.addEventListener('DOMContentLoaded', onDOMContentLoaded);
|
|
}
|
|
initDocRewriteObserver();
|
|
}
|
|
|
|
if (retiredStyleIds.length) {
|
|
setTimeout(function() {
|
|
while (retiredStyleIds.length) {
|
|
removeStyle(retiredStyleIds.shift(), document);
|
|
}
|
|
}, 0);
|
|
}
|
|
}
|
|
|
|
|
|
function onDOMContentLoaded() {
|
|
addDocumentStylesToAllIFrames();
|
|
iframeObserver.start();
|
|
}
|
|
|
|
|
|
function applySections(styleId, sections) {
|
|
let el = document.getElementById('stylus-' + styleId);
|
|
// Already there.
|
|
if (el) {
|
|
return;
|
|
}
|
|
if (document.documentElement instanceof SVGSVGElement) {
|
|
// SVG document, make an SVG style element.
|
|
el = document.createElementNS('http://www.w3.org/2000/svg', 'style');
|
|
} else {
|
|
// This will make an HTML style element. If there's SVG embedded in an HTML document, this works on the SVG too.
|
|
el = document.createElement('style');
|
|
}
|
|
el.setAttribute('id', 'stylus-' + styleId);
|
|
el.setAttribute('class', 'stylus');
|
|
el.setAttribute('type', 'text/css');
|
|
el.appendChild(document.createTextNode(sections.map(section => section.code).join('\n')));
|
|
addStyleElement(el, document);
|
|
styleElements.set(el.id, el);
|
|
}
|
|
|
|
|
|
function addStyleElement(el, doc) {
|
|
if (!doc.documentElement || doc.getElementById(el.id)) {
|
|
return;
|
|
}
|
|
doc.documentElement.appendChild(doc.importNode(el, true))
|
|
.disabled = disableAll;
|
|
for (const iframe of getDynamicIFrames(doc)) {
|
|
if (iframeIsLoadingSrcDoc(iframe)) {
|
|
addStyleToIFrameSrcDoc(iframe, el);
|
|
} else {
|
|
addStyleElement(el, iframe.contentDocument);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
function addDocumentStylesToIFrame(iframe) {
|
|
const doc = iframe.contentDocument;
|
|
const srcDocIsLoading = iframeIsLoadingSrcDoc(iframe);
|
|
for (const el of styleElements.values()) {
|
|
if (srcDocIsLoading) {
|
|
addStyleToIFrameSrcDoc(iframe, el);
|
|
} else {
|
|
addStyleElement(el, doc);
|
|
}
|
|
}
|
|
initDocRewriteObserver(doc);
|
|
}
|
|
|
|
|
|
function addDocumentStylesToAllIFrames() {
|
|
getDynamicIFrames(document).forEach(addDocumentStylesToIFrame);
|
|
}
|
|
|
|
|
|
// Only dynamic iframes get the parent document's styles. Other ones should get styles based on their own URLs.
|
|
function getDynamicIFrames(doc) {
|
|
return [...doc.getElementsByTagName('iframe')].filter(iframeIsDynamic);
|
|
}
|
|
|
|
|
|
function iframeIsDynamic(f) {
|
|
let href;
|
|
if (f.src && f.src.startsWith('http') && new URL(f.src).origin != location.origin) {
|
|
return false;
|
|
}
|
|
try {
|
|
href = f.contentDocument.location.href;
|
|
} catch (ex) {
|
|
// Cross-origin, so it's not a dynamic iframe
|
|
return false;
|
|
}
|
|
return href == document.location.href || href.startsWith('about:');
|
|
}
|
|
|
|
|
|
function processDynamicIFrames(doc, fn, ...args) {
|
|
for (const iframe of [...doc.getElementsByTagName('iframe')]) {
|
|
if (iframeIsDynamic(iframe)) {
|
|
fn(...args, iframe.contentDocument);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
function iframeIsLoadingSrcDoc(f) {
|
|
return f.srcdoc && f.contentDocument.all.length <= 3;
|
|
// 3 nodes or less in total (html, head, body) == new empty iframe about to be overwritten by its 'srcdoc'
|
|
}
|
|
|
|
|
|
function addStyleToIFrameSrcDoc(iframe, el) {
|
|
if (disableAll) {
|
|
return;
|
|
}
|
|
iframe.srcdoc += el.outerHTML;
|
|
// make sure the style is added in case srcdoc was malformed
|
|
setTimeout(addStyleElement, 100, el, iframe.contentDocument);
|
|
}
|
|
|
|
|
|
function replaceAll(newStyles, doc) {
|
|
const oldStyles = [...doc.querySelectorAll('STYLE.stylus')];
|
|
oldStyles.forEach(style => (style.id += '-ghost'));
|
|
processDynamicIFrames(doc, replaceAll, newStyles);
|
|
if (doc == document) {
|
|
styleElements.clear();
|
|
applyStyles(newStyles);
|
|
replaceAllpass2(newStyles, doc);
|
|
}
|
|
}
|
|
|
|
|
|
function replaceAllpass2(newStyles, doc) {
|
|
const oldStyles = [...doc.querySelectorAll('STYLE.stylus[id$="-ghost"]')];
|
|
processDynamicIFrames(doc, replaceAllpass2, newStyles);
|
|
oldStyles.forEach(e => e.remove);
|
|
}
|
|
|
|
|
|
function initIFrameObserver() {
|
|
iframeObserver = Object.assign(new MutationObserver(observer), {
|
|
start() {
|
|
this.observe(document, {childList: true, subtree: true});
|
|
}
|
|
});
|
|
const iframesCollection = document.getElementsByTagName('iframe');
|
|
|
|
function observer(mutations) {
|
|
if (!isOwnPage) {
|
|
clearTimeout(orphanCheckTimer);
|
|
orphanCheckTimer = setTimeout(orphanCheck, 1000);
|
|
}
|
|
// autoupdated HTMLCollection is superfast
|
|
if (!iframesCollection[0]) {
|
|
return;
|
|
}
|
|
// use a much faster method for very complex pages with lots of mutations
|
|
// (observer usually receives 1k-10k mutations per call)
|
|
if (mutations.length > 1000) {
|
|
addDocumentStylesToAllIFrames();
|
|
return;
|
|
}
|
|
// move the check out of current execution context
|
|
// because some same-domain (!) iframes fail to load when their 'contentDocument' is accessed (!)
|
|
// namely gmail's old chat iframe talkgadget.google.com
|
|
setTimeout(process, 0, mutations);
|
|
}
|
|
|
|
function process(mutations) {
|
|
for (var m = 0, mutation; (mutation = mutations[m++]);) {
|
|
var added = mutation.addedNodes;
|
|
for (var n = 0, node; (node = added[n++]);) {
|
|
// process only ELEMENT_NODE
|
|
if (node.nodeType != 1) {
|
|
continue;
|
|
}
|
|
var iframes = node.localName === 'iframe' ? [node] :
|
|
node.children.length && node.getElementsByTagName('iframe');
|
|
for (var i = 0, iframe; (iframe = iframes[i++]);) {
|
|
if (iframeIsDynamic(iframe)) {
|
|
addDocumentStylesToIFrame(iframe);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
function initDocRewriteObserver() {
|
|
if (isOwnPage) {
|
|
return;
|
|
}
|
|
// re-add styles if we detect documentElement being recreated
|
|
docRewriteObserver = new MutationObserver(observer);
|
|
docRewriteObserver.observe(document, {childList: true});
|
|
|
|
function observer(mutations) {
|
|
for (const mutation of mutations) {
|
|
for (const node of mutation.addedNodes) {
|
|
if (node.localName != 'html') {
|
|
continue;
|
|
}
|
|
for (const [id, el] of styleElements.entries()) {
|
|
if (!document.getElementById(id)) {
|
|
document.documentElement.appendChild(el);
|
|
}
|
|
}
|
|
document.addEventListener('DOMContentLoaded', onDOMContentLoaded);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
function orphanCheck() {
|
|
orphanCheckTimer = 0;
|
|
const port = chrome.runtime.connect();
|
|
if (port) {
|
|
port.disconnect();
|
|
return;
|
|
}
|
|
|
|
// we're orphaned due to an extension update
|
|
// we can detach the mutation observer
|
|
iframeObserver.takeRecords();
|
|
iframeObserver.disconnect();
|
|
iframeObserver = null;
|
|
if (docRewriteObserver) {
|
|
docRewriteObserver.disconnect();
|
|
docRewriteObserver = null;
|
|
}
|
|
// we can detach event listeners
|
|
document.removeEventListener('DOMContentLoaded', onDOMContentLoaded);
|
|
// we can't detach chrome.runtime.onMessage because it's no longer connected internally
|
|
// we can destroy our globals in this context to free up memory
|
|
[ // functions
|
|
'addDocumentStylesToAllIFrames',
|
|
'addDocumentStylesToIFrame',
|
|
'addStyleElement',
|
|
'addStyleToIFrameSrcDoc',
|
|
'applyOnMessage',
|
|
'applySections',
|
|
'applyStyles',
|
|
'doDisableAll',
|
|
'getDynamicIFrames',
|
|
'iframeIsDynamic',
|
|
'iframeIsLoadingSrcDoc',
|
|
'initDocRewriteObserver',
|
|
'initIFrameObserver',
|
|
'orphanCheck',
|
|
'processDynamicIFrames',
|
|
'removeStyle',
|
|
'replaceAll',
|
|
'replaceAllpass2',
|
|
'requestStyles',
|
|
'retireStyle',
|
|
'styleObserver',
|
|
// variables
|
|
'docRewriteObserver',
|
|
'iframeObserver',
|
|
'retiredStyleIds',
|
|
'styleElements',
|
|
].forEach(fn => (window[fn] = null));
|
|
}
|