diff --git a/content/apply.js b/content/apply.js index aeb82f88..e2f52124 100644 --- a/content/apply.js +++ b/content/apply.js @@ -1,499 +1,498 @@ /* eslint no-var: 0 */ 'use strict'; -var ID_PREFIX = 'stylus-'; -var ROOT = document.documentElement; -var isOwnPage = location.protocol.endsWith('-extension:'); -var disableAll = false; -var exposeIframes = false; -var styleElements = new Map(); -var disabledElements = new Map(); -var retiredStyleTimers = new Map(); -var docRewriteObserver; -var docRootObserver; +(() => { + var ID_PREFIX = 'stylus-'; + var ROOT = document.documentElement; + var isOwnPage = location.protocol.endsWith('-extension:'); + var disableAll = false; + var exposeIframes = false; + var styleElements = new Map(); + var disabledElements = new Map(); + var retiredStyleTimers = new Map(); + var docRewriteObserver; + var docRootObserver; -requestStyles(); -chrome.runtime.onMessage.addListener(applyOnMessage); + requestStyles(); + chrome.runtime.onMessage.addListener(applyOnMessage); + window.applyOnMessage = applyOnMessage; -if (!isOwnPage) { - window.dispatchEvent(new CustomEvent(chrome.runtime.id)); - window.addEventListener(chrome.runtime.id, orphanCheck, true); -} - -function requestStyles(options, callback = applyStyles) { - if (!chrome.app && document instanceof XMLDocument) { - chrome.runtime.sendMessage({method: 'styleViaAPI', action: 'styleApply'}); - return; - } - var matchUrl = location.href; - if (!matchUrl.match(/^(http|file|chrome|ftp)/)) { - // dynamic about: and javascript: iframes don't have an URL yet - // so we'll try the parent frame which is guaranteed to have a real URL - try { - if (window !== parent) { - matchUrl = parent.location.href; - } - } catch (e) {} - } - const request = Object.assign({ - method: 'getStyles', - matchUrl, - enabled: true, - asHash: true, - }, options); - // On own pages we request the styles directly to minimize delay and flicker - if (typeof getStylesSafe === 'function') { - getStylesSafe(request).then(callback); - } else { - chrome.runtime.sendMessage(request, callback); - } -} - - -function applyOnMessage(request, sender, sendResponse) { - if (request.styles === 'DIY') { - // Do-It-Yourself tells our built-in pages to fetch the styles directly - // which is faster because IPC messaging JSON-ifies everything internally - requestStyles({}, styles => { - request.styles = styles; - applyOnMessage(request); - }); - return; + if (!isOwnPage) { + window.dispatchEvent(new CustomEvent(chrome.runtime.id)); + window.addEventListener(chrome.runtime.id, orphanCheck, true); } - if (!chrome.app && document instanceof XMLDocument && request.method !== 'ping') { - request.action = request.method; - request.method = 'styleViaAPI'; - request.styles = null; - if (request.style) { - request.style.sections = null; + function requestStyles(options, callback = applyStyles) { + if (!chrome.app && document instanceof XMLDocument) { + chrome.runtime.sendMessage({method: 'styleViaAPI', action: 'styleApply'}); + return; + } + var matchUrl = location.href; + if (!matchUrl.match(/^(http|file|chrome|ftp)/)) { + // dynamic about: and javascript: iframes don't have an URL yet + // so we'll try the parent frame which is guaranteed to have a real URL + try { + if (window !== parent) { + matchUrl = parent.location.href; + } + } catch (e) {} + } + const request = Object.assign({ + method: 'getStyles', + matchUrl, + enabled: true, + asHash: true, + }, options); + // On own pages we request the styles directly to minimize delay and flicker + if (typeof getStylesSafe === 'function') { + getStylesSafe(request).then(callback); + } else { + chrome.runtime.sendMessage(request, callback); } - chrome.runtime.sendMessage(request); - return; } - switch (request.method) { - case 'styleDeleted': - removeStyle(request); - break; - case 'styleUpdated': - if (request.codeIsUpdated === false) { - applyStyleState(request.style); + function applyOnMessage(request, sender, sendResponse) { + if (request.styles === 'DIY') { + // Do-It-Yourself tells our built-in pages to fetch the styles directly + // which is faster because IPC messaging JSON-ifies everything internally + requestStyles({}, styles => { + request.styles = styles; + applyOnMessage(request); + }); + return; + } + + if (!chrome.app && document instanceof XMLDocument && request.method !== 'ping') { + request.action = request.method; + request.method = 'styleViaAPI'; + request.styles = null; + if (request.style) { + request.style.sections = null; + } + chrome.runtime.sendMessage(request); + return; + } + + switch (request.method) { + case 'styleDeleted': + removeStyle(request); + break; + + case 'styleUpdated': + if (request.codeIsUpdated === false) { + applyStyleState(request.style); + break; + } + if (request.style.enabled) { + removeStyle({id: request.style.id, retire: true}); + requestStyles({id: request.style.id}); + } else { + removeStyle(request.style); + } + break; + + case 'styleAdded': + if (request.style.enabled) { + requestStyles({id: request.style.id}); + } + break; + + case 'styleApply': + applyStyles(request.styles); + break; + + case 'styleReplaceAll': + replaceAll(request.styles); + break; + + case 'prefChanged': + if ('disableAll' in request.prefs) { + doDisableAll(request.prefs.disableAll); + } + if ('exposeIframes' in request.prefs) { + doExposeIframes(request.prefs.exposeIframes); + } + break; + + case 'ping': + sendResponse(true); + break; + } + } + + + function doDisableAll(disable = disableAll) { + if (!disable === !disableAll) { + return; + } + disableAll = disable; + Array.prototype.forEach.call(document.styleSheets, stylesheet => { + if (stylesheet.ownerNode.matches(`style.stylus[id^="${ID_PREFIX}"]`) + && stylesheet.disabled !== disable) { + stylesheet.disabled = disable; + } + }); + } + + + function doExposeIframes(state = exposeIframes) { + if (state === exposeIframes || window === parent) { + return; + } + exposeIframes = state; + const attr = document.documentElement.getAttribute('stylus-iframe'); + if (state && attr !== '') { + document.documentElement.setAttribute('stylus-iframe', ''); + } else if (!state && attr === '') { + document.documentElement.removeAttribute('stylus-iframe'); + } + } + + + function applyStyleState({id, enabled}) { + const inCache = disabledElements.get(id) || styleElements.get(id); + const inDoc = document.getElementById(ID_PREFIX + id); + if (enabled) { + if (inDoc) { + return; + } else if (inCache) { + addStyleElement(inCache); + disabledElements.delete(id); + } else { + requestStyles({id}); + } + } else { + if (inDoc) { + disabledElements.set(id, inDoc); + docRootObserver.stop(); + inDoc.remove(); + docRootObserver.start(); + } + } + } + + + function removeStyle({id, retire = false}) { + const el = document.getElementById(ID_PREFIX + id); + if (el) { + if (retire) { + // 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 + const deadID = 'ghost-' + id; + el.id = ID_PREFIX + deadID; + // in case something went wrong and new style was never applied + retiredStyleTimers.set(deadID, setTimeout(removeStyle, 1000, {id: deadID})); + } else { + el.remove(); + } + } + styleElements.delete(ID_PREFIX + id); + disabledElements.delete(id); + retiredStyleTimers.delete(id); + } + + + function applyStyles(styles) { + if (!styles) { + // Chrome is starting up + requestStyles(); + return; + } + if ('disableAll' in styles) { + doDisableAll(styles.disableAll); + delete styles.disableAll; + } + if ('exposeIframes' in styles) { + doExposeIframes(styles.exposeIframes); + delete styles.exposeIframes; + } + + const gotNewStyles = Object.keys(styles).length || styles.needTransitionPatch; + if (gotNewStyles) { + if (docRootObserver) { + docRootObserver.stop(); + } else { + initDocRootObserver(); + } + } + + if (styles.needTransitionPatch) { + // CSS transition bug workaround: since we insert styles asynchronously, + // the browsers, especially Firefox, may apply all transitions on page load + delete styles.needTransitionPatch; + const className = chrome.runtime.id + '-transition-bug-fix'; + const docId = document.documentElement.id ? '#' + document.documentElement.id : ''; + document.documentElement.classList.add(className); + applySections(0, ` + ${docId}.${className}:root * { + transition: none !important; + } + `); + setTimeout(() => { + removeStyle({id: 0}); + document.documentElement.classList.remove(className); + }); + } + + if (gotNewStyles) { + for (const id in styles) { + applySections(id, styles[id].map(section => section.code).join('\n')); + } + docRootObserver.start({sort: true}); + } + + if (!isOwnPage && !docRewriteObserver && styleElements.size) { + initDocRewriteObserver(); + } + + if (retiredStyleTimers.size) { + setTimeout(() => { + for (const [id, timer] of retiredStyleTimers.entries()) { + removeStyle({id}); + clearTimeout(timer); + } + }); + } + } + + + function applySections(styleId, code) { + const id = ID_PREFIX + styleId; + let el = styleElements.get(id) || document.getElementById(id); + if (!el) { + if (document.documentElement instanceof SVGSVGElement) { + // SVG document style + el = document.createElementNS('http://www.w3.org/2000/svg', 'style'); + } else if (document instanceof XMLDocument) { + // XML document style + el = document.createElementNS('http://www.w3.org/1999/xhtml', 'style'); + } else { + // HTML document style; also works on HTML-embedded SVG + el = document.createElement('style'); + } + Object.assign(el, { + id, + type: 'text/css', + textContent: code, + }); + // SVG className is not a string, but an instance of SVGAnimatedString + el.classList.add('stylus'); + addStyleElement(el); + } + styleElements.set(id, el); + disabledElements.delete(Number(styleId)); + return el; + } + + + function addStyleElement(newElement) { + if (!ROOT) { + return; + } + let next; + const newStyleId = getStyleId(newElement); + for (const el of styleElements.values()) { + if (el.parentNode && !el.id.endsWith('-ghost') && getStyleId(el) > newStyleId) { + next = el.parentNode === ROOT ? el : null; break; } - if (request.style.enabled) { - removeStyle({id: request.style.id, retire: true}); - requestStyles({id: request.style.id}); - } else { - removeStyle(request.style); - } - break; - - case 'styleAdded': - if (request.style.enabled) { - requestStyles({id: request.style.id}); - } - break; - - case 'styleApply': - applyStyles(request.styles); - break; - - case 'styleReplaceAll': - replaceAll(request.styles); - break; - - case 'prefChanged': - if ('disableAll' in request.prefs) { - doDisableAll(request.prefs.disableAll); - } - if ('exposeIframes' in request.prefs) { - doExposeIframes(request.prefs.exposeIframes); - } - break; - - case 'ping': - sendResponse(true); - break; - } -} - - -function doDisableAll(disable = disableAll) { - if (!disable === !disableAll) { - return; - } - disableAll = disable; - Array.prototype.forEach.call(document.styleSheets, stylesheet => { - if (stylesheet.ownerNode.matches(`style.stylus[id^="${ID_PREFIX}"]`) - && stylesheet.disabled !== disable) { - stylesheet.disabled = disable; } - }); -} - - -function doExposeIframes(state = exposeIframes) { - if (state === exposeIframes || window === parent) { - return; - } - exposeIframes = state; - const attr = document.documentElement.getAttribute('stylus-iframe'); - if (state && attr !== '') { - document.documentElement.setAttribute('stylus-iframe', ''); - } else if (!state && attr === '') { - document.documentElement.removeAttribute('stylus-iframe'); - } -} - - -function applyStyleState({id, enabled}) { - const inCache = disabledElements.get(id) || styleElements.get(id); - const inDoc = document.getElementById(ID_PREFIX + id); - if (enabled) { - if (inDoc) { - return; - } else if (inCache) { - addStyleElement(inCache); - disabledElements.delete(id); - } else { - requestStyles({id}); - } - } else { - if (inDoc) { - disabledElements.set(id, inDoc); - docRootObserver.stop(); - inDoc.remove(); - docRootObserver.start(); - } - } -} - - -function removeStyle({id, retire = false}) { - const el = document.getElementById(ID_PREFIX + id); - if (el) { - if (retire) { - // 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 - const deadID = 'ghost-' + id; - el.id = ID_PREFIX + deadID; - // in case something went wrong and new style was never applied - retiredStyleTimers.set(deadID, setTimeout(removeStyle, 1000, {id: deadID})); - } else { - el.remove(); - } - } - styleElements.delete(ID_PREFIX + id); - disabledElements.delete(id); - retiredStyleTimers.delete(id); -} - - -function applyStyles(styles) { - if (!styles) { - // Chrome is starting up - requestStyles(); - return; - } - if ('disableAll' in styles) { - doDisableAll(styles.disableAll); - delete styles.disableAll; - } - if ('exposeIframes' in styles) { - doExposeIframes(styles.exposeIframes); - delete styles.exposeIframes; - } - - const gotNewStyles = Object.keys(styles).length || styles.needTransitionPatch; - if (gotNewStyles) { - if (docRootObserver) { - docRootObserver.stop(); - } else { - initDocRootObserver(); - } - } - - if (styles.needTransitionPatch) { - // CSS transition bug workaround: since we insert styles asynchronously, - // the browsers, especially Firefox, may apply all transitions on page load - delete styles.needTransitionPatch; - const className = chrome.runtime.id + '-transition-bug-fix'; - const docId = document.documentElement.id ? '#' + document.documentElement.id : ''; - document.documentElement.classList.add(className); - applySections(0, ` - ${docId}.${className}:root * { - transition: none !important; - } - `); - setTimeout(() => { - removeStyle({id: 0}); - document.documentElement.classList.remove(className); - }); - } - - if (gotNewStyles) { - for (const id in styles) { - applySections(id, styles[id].map(section => section.code).join('\n')); - } - docRootObserver.start({sort: true}); - } - - if (!isOwnPage && !docRewriteObserver && styleElements.size) { - initDocRewriteObserver(); - } - - if (retiredStyleTimers.size) { - setTimeout(() => { - for (const [id, timer] of retiredStyleTimers.entries()) { - removeStyle({id}); - clearTimeout(timer); - } - }); - } -} - - -function applySections(styleId, code) { - let el = document.getElementById(ID_PREFIX + styleId); - if (el) { - return; - } - if (document.documentElement instanceof SVGSVGElement) { - // SVG document style - el = document.createElementNS('http://www.w3.org/2000/svg', 'style'); - } else if (document instanceof XMLDocument) { - // XML document style - el = document.createElementNS('http://www.w3.org/1999/xhtml', 'style'); - } else { - // HTML document style; also works on HTML-embedded SVG - el = document.createElement('style'); - } - Object.assign(el, { - styleId, - id: ID_PREFIX + styleId, - type: 'text/css', - textContent: code, - }); - // SVG className is not a string, but an instance of SVGAnimatedString - el.classList.add('stylus'); - addStyleElement(el); - styleElements.set(el.id, el); - disabledElements.delete(Number(styleId)); - return el; -} - - -function addStyleElement(newElement) { - if (!ROOT) { - return; - } - let next; - const newStyleId = getStyleId(newElement); - for (const el of styleElements.values()) { - if (el.parentNode && !el.id.endsWith('-ghost') && getStyleId(el) > newStyleId) { - next = el.parentNode === ROOT ? el : null; - break; - } - } - if (next === newElement.nextElementSibling) { - return; - } - docRootObserver.stop(); - ROOT.insertBefore(newElement, next || null); - if (disableAll) { - newElement.disabled = true; - } - docRootObserver.start(); -} - - -function replaceAll(newStyles) { - const oldStyles = Array.prototype.slice.call( - document.querySelectorAll(`style.stylus[id^="${ID_PREFIX}"]`)); - oldStyles.forEach(el => (el.id += '-ghost')); - styleElements.clear(); - disabledElements.clear(); - [...retiredStyleTimers.values()].forEach(clearTimeout); - retiredStyleTimers.clear(); - applyStyles(newStyles); - oldStyles.forEach(el => el.remove()); -} - - -function initDocRewriteObserver() { - // detect documentElement being rewritten from inside the script - docRewriteObserver = new MutationObserver(mutations => { - for (let m = mutations.length; --m >= 0;) { - const added = mutations[m].addedNodes; - for (let n = added.length; --n >= 0;) { - if (added[n].localName === 'html') { - reinjectStyles(); - return; - } - } - } - }); - docRewriteObserver.observe(document, {childList: true}); - // detect dynamic iframes rewritten after creation by the embedder i.e. externally - setTimeout(() => { - if (document.documentElement !== ROOT) { - reinjectStyles(); - } - }); - // re-add styles if we detect documentElement being recreated - function reinjectStyles() { - if (!styleElements) { - if (orphanCheck) { - orphanCheck(); - } + if (next === newElement.nextElementSibling) { return; } - ROOT = document.documentElement; docRootObserver.stop(); - const imported = []; - for (const [id, el] of styleElements.entries()) { - const copy = document.importNode(el, true); - el.textContent += ' '; // invalidate CSSOM cache - imported.push([id, copy]); - addStyleElement(copy); + ROOT.insertBefore(newElement, next || null); + if (disableAll) { + newElement.disabled = true; } docRootObserver.start(); - styleElements = new Map(imported); } -} -function initDocRootObserver() { - let lastRestorationTime = 0; - let restorationCounter = 0; - let observing = false; + function replaceAll(newStyles) { + const oldStyles = Array.prototype.slice.call( + document.querySelectorAll(`style.stylus[id^="${ID_PREFIX}"]`)); + oldStyles.forEach(el => (el.id += '-ghost')); + styleElements.clear(); + disabledElements.clear(); + [...retiredStyleTimers.values()].forEach(clearTimeout); + retiredStyleTimers.clear(); + applyStyles(newStyles); + oldStyles.forEach(el => el.remove()); + } - docRootObserver = Object.assign(new MutationObserver(sortStyleElements), { - start({sort = false} = {}) { + + function getStyleId(el) { + return parseInt(el.id.substr(ID_PREFIX.length)); + } + + + function orphanCheck() { + if (chrome.i18n && chrome.i18n.getUILanguage()) { + return true; + } + // In Chrome content script is orphaned on an extension update/reload + // so we need to detach event listeners + [docRewriteObserver, docRootObserver].forEach(ob => ob && ob.takeRecords() && ob.disconnect()); + window.removeEventListener(chrome.runtime.id, orphanCheck, true); + } + + + function initDocRewriteObserver() { + // detect documentElement being rewritten from inside the script + docRewriteObserver = new MutationObserver(mutations => { + for (let m = mutations.length; --m >= 0;) { + const added = mutations[m].addedNodes; + for (let n = added.length; --n >= 0;) { + if (added[n].localName === 'html') { + reinjectStyles(); + return; + } + } + } + }); + docRewriteObserver.observe(document, {childList: true}); + // detect dynamic iframes rewritten after creation by the embedder i.e. externally + setTimeout(() => { + if (document.documentElement !== ROOT) { + reinjectStyles(); + } + }); + // re-add styles if we detect documentElement being recreated + function reinjectStyles() { + if (!styleElements) { + orphanCheck(); + return; + } + ROOT = document.documentElement; + docRootObserver.stop(); + const imported = []; + for (const [id, el] of styleElements.entries()) { + const copy = document.importNode(el, true); + el.textContent += ' '; // invalidate CSSOM cache + imported.push([id, copy]); + addStyleElement(copy); + } + docRootObserver.start(); + styleElements = new Map(imported); + } + } + + + function initDocRootObserver() { + let lastRestorationTime = 0; + let restorationCounter = 0; + let observing = false; + let sorting = false; + // allow any types of elements between ours, except for the following: + const ORDERED_TAGS = ['head', 'body', 'style', 'link']; + + init(); + return; + + function init() { + docRootObserver = new MutationObserver(sortStyleElements); + Object.assign(docRootObserver, {start, stop}); + } + function start({sort = false} = {}) { if (sort && sortStyleMap()) { sortStyleElements(); } - if (!observing) { - this.observe(ROOT, {childList: true}); + if (!observing && ROOT) { + docRootObserver.observe(ROOT, {childList: true}); observing = true; } - }, - stop() { + } + function stop() { if (observing) { - this.disconnect(); + docRootObserver.disconnect(); observing = false; } - }, - }); - return; - - function sortStyleMap() { - const list = []; - let prevStyleId = 0; - let needsSorting = false; - for (const entry of styleElements.entries()) { - list.push(entry); - const el = entry[1]; - const styleId = getStyleId(el); - el.styleId = styleId; - needsSorting |= styleId < prevStyleId; - prevStyleId = styleId; } - if (needsSorting) { - styleElements = new Map(list.sort((a, b) => a[1].styleId - b[1].styleId)); - return true; - } - } - - function sortStyleElements() { - let prev = document.body || document.head; - if (!prev) { - return; - } - let appliedChanges = false; - for (const [idStr, el] of styleElements.entries()) { - if (!el.parentNode && disabledElements.has(getStyleId(idStr))) { - continue; + function sortStyleMap() { + const list = []; + let prevStyleId = 0; + let needsSorting = false; + for (const entry of styleElements.entries()) { + list.push(entry); + const el = entry[1]; + const styleId = getStyleId(el); + el.styleId = styleId; + needsSorting |= styleId < prevStyleId; + prevStyleId = styleId; } - if (el.previousElementSibling === prev) { - prev = el; - continue; + if (needsSorting) { + styleElements = new Map(list.sort((a, b) => a[1].styleId - b[1].styleId)); + return true; } - if (!appliedChanges) { - if (restorationLimitExceeded()) { - return; + } + function sortStyleElements() { + let expected = document.body || document.head; + if (!expected || sorting) { + return; + } + for (const el of styleElements.values()) { + if (!isMovable(el)) { + continue; } - appliedChanges = true; + let prev = el.previousElementSibling; + while (prev !== expected) { + if (prev && isSkippable(prev)) { + expected = prev; + prev = prev.nextElementSibling; + } else if (!moveAfter(el, expected)) { + return; + } else { + break; + } + } + expected = el; + } + if (sorting) { + sorting = false; + docRootObserver.takeRecords(); + setTimeout(start); + //docRootObserver.start(); + } + } + function isMovable(el) { + return el.parentNode || !disabledElements.has(getStyleId(el)); + } + function isSkippable(el) { + return !ORDERED_TAGS.includes(el.localName) || + el.id.startsWith(ID_PREFIX) && + el.id.endsWith('-ghost') && + el.localName === 'style' && + el.className === 'stylus'; + } + function moveAfter(el, expected) { + if (!sorting) { + if (restorationLimitExceeded()) { + return false; + } + sorting = true; docRootObserver.stop(); } - prev.insertAdjacentElement('afterend', el); + expected.insertAdjacentElement('afterend', el); if (el.disabled !== disableAll) { // moving an element resets its 'disabled' state el.disabled = disableAll; } - prev = el; - } - if (appliedChanges) { - docRootObserver.start(); - } - } - - function restorationLimitExceeded() { - const t = performance.now(); - if (t - lastRestorationTime > 1000) { - restorationCounter = 0; - } - lastRestorationTime = t; - if (++restorationCounter > 100) { - console.error('Stylus stopped restoring userstyle elements after 100 failed attempts.\n' + - 'Please report on https://github.com/openstyles/stylus/issues'); return true; } + function restorationLimitExceeded() { + const t = performance.now(); + if (t - lastRestorationTime > 1000) { + restorationCounter = 0; + } + lastRestorationTime = t; + if (++restorationCounter > 100) { + console.error('Stylus stopped restoring userstyle elements after 100 failed attempts.\n' + + 'Please report on https://github.com/openstyles/stylus/issues'); + return true; + } + } } -} - - -function getStyleId(el) { - return parseInt((el.id || el).substr(ID_PREFIX.length)); -} - - -function orphanCheck() { - const port = chrome.runtime.connect(); - if (port) { - port.disconnect(); - return; - } - - // we're orphaned due to an extension update - // we can detach the mutation observer - [docRewriteObserver, docRootObserver].forEach(ob => ob && ob.disconnect()); - // we can detach event listeners - window.removeEventListener(chrome.runtime.id, orphanCheck, true); - // 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 - 'addStyleElement', - 'applyOnMessage', - 'applySections', - 'applyStyles', - 'applyStyleState', - 'doDisableAll', - 'initDocRewriteObserver', - 'initDocRootObserver', - 'orphanCheck', - 'removeStyle', - 'replaceAll', - 'requestStyles', - // variables - 'ROOT', - 'disabledElements', - 'retiredStyleTimers', - 'styleElements', - 'docRewriteObserver', - 'docRootObserver', - ].forEach(fn => (window[fn] = null)); -} +})(); diff --git a/content/install-hook-userstyles.js b/content/install-hook-userstyles.js index 0324016e..c9002c23 100644 --- a/content/install-hook-userstyles.js +++ b/content/install-hook-userstyles.js @@ -1,28 +1,283 @@ 'use strict'; -const FIREFOX = !chrome.app; -const VIVALDI = chrome.app && /Vivaldi/.test(navigator.userAgent); -const OPERA = chrome.app && /OPR/.test(navigator.userAgent); +(() => { + const FIREFOX = !chrome.app; + const VIVALDI = chrome.app && /Vivaldi/.test(navigator.userAgent); + const OPERA = chrome.app && /OPR/.test(navigator.userAgent); -window.dispatchEvent(new CustomEvent(chrome.runtime.id + '-install')); -window.addEventListener(chrome.runtime.id + '-install', orphanCheck, true); + window.dispatchEvent(new CustomEvent(chrome.runtime.id + '-install')); + window.addEventListener(chrome.runtime.id + '-install', orphanCheck, true); -['Update', 'Install'].forEach(type => - ['', 'Chrome', 'Opera'].forEach(browser => - document.addEventListener('stylish' + type + browser, onClick))); + ['Update', 'Install'].forEach(type => + ['', 'Chrome', 'Opera'].forEach(browser => + document.addEventListener('stylish' + type + browser, onClick))); -chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { - // orphaned content script check - if (msg.method === 'ping') { - sendResponse(true); + chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { + // orphaned content script check + if (msg.method === 'ping') { + sendResponse(true); + } + }); + + new MutationObserver((mutations, observer) => { + if (document.body) { + observer.disconnect(); + // TODO: remove the following statement when USO pagination title is fixed + document.title = document.title.replace(/^(\d+)&\w+=/, '#$1: '); + chrome.runtime.sendMessage({ + method: 'getStyles', + url: getMeta('stylish-id-url') || location.href + }, checkUpdatability); + } + }).observe(document.documentElement, {childList: true}); + + /* since we are using "stylish-code-chrome" meta key on all browsers and + US.o does not provide "advanced settings" on this url if browser is not Chrome, + we need to fix this URL using "stylish-update-url" meta key + */ + function getStyleURL() { + const textUrl = getMeta('stylish-update-url') || ''; + const jsonUrl = getMeta('stylish-code-chrome') || + textUrl.replace(/styles\/(\d+)\/[^?]*/, 'styles/chrome/$1.json'); + const paramsMissing = !jsonUrl.includes('?') && textUrl.includes('?'); + return jsonUrl + (paramsMissing ? textUrl.replace(/^[^?]+/, '') : ''); } -}); + + function checkUpdatability([installedStyle]) { + // TODO: remove the following statement when USO is fixed + document.dispatchEvent(new CustomEvent('stylusFixBuggyUSOsettings', { + detail: installedStyle && installedStyle.updateUrl, + })); + if (!installedStyle) { + sendEvent('styleCanBeInstalledChrome'); + return; + } + const md5Url = getMeta('stylish-md5-url'); + if (md5Url && installedStyle.md5Url && installedStyle.originalMd5) { + getResource(md5Url).then(md5 => { + reportUpdatable(md5 !== installedStyle.originalMd5); + }); + } else { + getStyleJson().then(json => { + reportUpdatable(!json || + !styleSectionsEqual(json, installedStyle)); + }); + } + + function reportUpdatable(isUpdatable) { + sendEvent( + isUpdatable + ? 'styleCanBeUpdatedChrome' + : 'styleAlreadyInstalledChrome', + { + updateUrl: installedStyle.updateUrl + } + ); + } + } + + + function sendEvent(type, detail = null) { + if (FIREFOX) { + type = type.replace('Chrome', ''); + } else if (OPERA || VIVALDI) { + type = type.replace('Chrome', 'Opera'); + } + detail = {detail}; + if (typeof cloneInto !== 'undefined') { + // Firefox requires explicit cloning, however USO can't process our messages anyway + // because USO tries to use a global "event" variable deprecated in Firefox + detail = cloneInto(detail, document); // eslint-disable-line no-undef + } + onDOMready().then(() => { + document.dispatchEvent(new CustomEvent(type, detail)); + }); + } + + + function onClick(event) { + if (onClick.processing || !orphanCheck()) { + return; + } + onClick.processing = true; + (event.type.includes('Update') ? onUpdate() : onInstall()) + .then(done, done); + function done() { + setTimeout(() => { + onClick.processing = false; + }); + } + } + + + function onInstall() { + return getResource(getMeta('stylish-description')) + .then(name => saveStyleCode('styleInstall', name)) + .then(() => getResource(getMeta('stylish-install-ping-url-chrome'))); + } + + + function onUpdate() { + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage({ + method: 'getStyles', + url: getMeta('stylish-id-url') || location.href, + }, ([style]) => { + saveStyleCode('styleUpdate', style.name, {id: style.id}) + .then(resolve, reject); + }); + }); + } + + + function saveStyleCode(message, name, addProps) { + return new Promise((resolve, reject) => { + const needsConfirmation = message === 'styleInstall' || !saveStyleCode.confirmed; + if (needsConfirmation && !confirm(chrome.i18n.getMessage(message, [name]))) { + reject(); + return; + } + saveStyleCode.confirmed = true; + enableUpdateButton(false); + getStyleJson().then(json => { + if (!json) { + prompt(chrome.i18n.getMessage('styleInstallFailed', ''), + 'https://github.com/openstyles/stylus/issues/195'); + return; + } + chrome.runtime.sendMessage( + Object.assign(json, addProps, { + method: 'saveStyle', + reason: 'update', + }), + style => { + if (message === 'styleUpdate' && style.updateUrl.includes('?')) { + enableUpdateButton(true); + } else { + sendEvent('styleInstalledChrome'); + } + } + ); + resolve(); + }); + }); + + function enableUpdateButton(state) { + const important = s => s.replace(/;/g, '!important;'); + const button = document.getElementById('update_style_button'); + if (button) { + button.style.cssText = state ? '' : important('pointer-events: none; opacity: .35;'); + const icon = button.querySelector('img[src*=".svg"]'); + if (icon) { + icon.style.cssText = state ? '' : important('transition: transform 5s; transform: rotate(0);'); + if (state) { + setTimeout(() => (icon.style.cssText += important('transform: rotate(10turn);'))); + } + } + } + } + } + + + function getMeta(name) { + const e = document.querySelector(`link[rel="${name}"]`); + return e ? e.getAttribute('href') : null; + } + + + function getResource(url) { + return new Promise(resolve => { + if (url.startsWith('#')) { + resolve(document.getElementById(url.slice(1)).textContent); + } else { + chrome.runtime.sendMessage({method: 'download', url}, resolve); + } + }); + } + + + function getStyleJson() { + const url = getStyleURL(); + return getResource(url).then(code => { + try { + return JSON.parse(code); + } catch (e) { + return fetch(url).then(r => r.json()).catch(() => null); + } + }); + } + + + function styleSectionsEqual({sections: a}, {sections: b}) { + if (!a || !b) { + return undefined; + } + if (a.length !== b.length) { + return false; + } + // order of sections should be identical to account for the case of multiple + // sections matching the same URL because the order of rules is part of cascading + return a.every((sectionA, index) => propertiesEqual(sectionA, b[index])); + + function propertiesEqual(secA, secB) { + for (const name of ['urlPrefixes', 'urls', 'domains', 'regexps']) { + if (!equalOrEmpty(secA[name], secB[name], 'every', arrayMirrors)) { + return false; + } + } + return equalOrEmpty(secA.code, secB.code, 'substr', (a, b) => a === b); + } + + function equalOrEmpty(a, b, telltale, comparator) { + const typeA = a && typeof a[telltale] === 'function'; + const typeB = b && typeof b[telltale] === 'function'; + return ( + (a === null || a === undefined || (typeA && !a.length)) && + (b === null || b === undefined || (typeB && !b.length)) + ) || typeA && typeB && a.length === b.length && comparator(a, b); + } + + function arrayMirrors(array1, array2) { + return ( + array1.every(el => array2.includes(el)) && + array2.every(el => array1.includes(el)) + ); + } + } + + + function onDOMready() { + if (document.readyState !== 'loading') { + return Promise.resolve(); + } + return new Promise(resolve => { + document.addEventListener('DOMContentLoaded', function _() { + document.removeEventListener('DOMContentLoaded', _); + resolve(); + }); + }); + } + + + function orphanCheck() { + if (chrome.i18n && chrome.i18n.getUILanguage()) { + return true; + } + // In Chrome content script is orphaned on an extension update/reload + // so we need to detach event listeners + window.removeEventListener(chrome.runtime.id + '-install', orphanCheck, true); + ['Update', 'Install'].forEach(type => + ['', 'Chrome', 'Opera'].forEach(browser => + document.addEventListener('stylish' + type + browser, onClick))); + } +})(); // TODO: remove the following statement when USO is fixed document.documentElement.appendChild(document.createElement('script')).text = '(' + function () { let settings; const originalResponseJson = Response.prototype.json; + document.currentScript.remove(); document.addEventListener('stylusFixBuggyUSOsettings', function _({detail}) { document.removeEventListener('stylusFixBuggyUSOsettings', _); settings = /\?/.test(detail) && new URLSearchParams(new URL(detail).search); @@ -110,257 +365,3 @@ if (location.search.includes('category=')) { }).observe(document, {childList: true, subtree: true}); }); } - -new MutationObserver((mutations, observer) => { - if (document.body) { - observer.disconnect(); - // TODO: remove the following statement when USO pagination title is fixed - document.title = document.title.replace(/^(\d+)&\w+=/, '#$1: '); - chrome.runtime.sendMessage({ - method: 'getStyles', - url: getMeta('stylish-id-url') || location.href - }, checkUpdatability); - } -}).observe(document.documentElement, {childList: true}); - -/* since we are using "stylish-code-chrome" meta key on all browsers and - US.o does not provide "advanced settings" on this url if browser is not Chrome, - we need to fix this URL using "stylish-update-url" meta key -*/ -function getStyleURL() { - const textUrl = getMeta('stylish-update-url') || ''; - const jsonUrl = getMeta('stylish-code-chrome') || - textUrl.replace(/styles\/(\d+)\/[^?]*/, 'styles/chrome/$1.json'); - const paramsMissing = !jsonUrl.includes('?') && textUrl.includes('?'); - return jsonUrl + (paramsMissing ? textUrl.replace(/^[^?]+/, '') : ''); -} - -function checkUpdatability([installedStyle]) { - // TODO: remove the following statement when USO is fixed - document.dispatchEvent(new CustomEvent('stylusFixBuggyUSOsettings', { - detail: installedStyle && installedStyle.updateUrl, - })); - if (!installedStyle) { - sendEvent('styleCanBeInstalledChrome'); - return; - } - const md5Url = getMeta('stylish-md5-url'); - if (md5Url && installedStyle.md5Url && installedStyle.originalMd5) { - getResource(md5Url).then(md5 => { - reportUpdatable(md5 !== installedStyle.originalMd5); - }); - } else { - getStyleJson().then(json => { - reportUpdatable(!json || - !styleSectionsEqual(json, installedStyle)); - }); - } - - function reportUpdatable(isUpdatable) { - sendEvent( - isUpdatable - ? 'styleCanBeUpdatedChrome' - : 'styleAlreadyInstalledChrome', - { - updateUrl: installedStyle.updateUrl - } - ); - } -} - - -function sendEvent(type, detail = null) { - if (FIREFOX) { - type = type.replace('Chrome', ''); - } else if (OPERA || VIVALDI) { - type = type.replace('Chrome', 'Opera'); - } - detail = {detail}; - if (typeof cloneInto !== 'undefined') { - // Firefox requires explicit cloning, however USO can't process our messages anyway - // because USO tries to use a global "event" variable deprecated in Firefox - detail = cloneInto(detail, document); // eslint-disable-line no-undef - } - onDOMready().then(() => { - document.dispatchEvent(new CustomEvent(type, detail)); - }); -} - - -function onClick(event) { - if (onClick.processing) { - return; - } - onClick.processing = true; - (event.type.includes('Update') ? onUpdate() : onInstall()) - .then(done, done); - function done() { - setTimeout(() => { - onClick.processing = false; - }); - } -} - - -function onInstall() { - return getResource(getMeta('stylish-description')) - .then(name => saveStyleCode('styleInstall', name)) - .then(() => getResource(getMeta('stylish-install-ping-url-chrome'))); -} - - -function onUpdate() { - return new Promise((resolve, reject) => { - chrome.runtime.sendMessage({ - method: 'getStyles', - url: getMeta('stylish-id-url') || location.href, - }, ([style]) => { - saveStyleCode('styleUpdate', style.name, {id: style.id}) - .then(resolve, reject); - }); - }); -} - - -function saveStyleCode(message, name, addProps) { - return new Promise((resolve, reject) => { - const needsConfirmation = message === 'styleInstall' || !saveStyleCode.confirmed; - if (needsConfirmation && !confirm(chrome.i18n.getMessage(message, [name]))) { - reject(); - return; - } - saveStyleCode.confirmed = true; - enableUpdateButton(false); - getStyleJson().then(json => { - if (!json) { - prompt(chrome.i18n.getMessage('styleInstallFailed', ''), - 'https://github.com/openstyles/stylus/issues/195'); - return; - } - chrome.runtime.sendMessage( - Object.assign(json, addProps, { - method: 'saveStyle', - reason: 'update', - }), - style => { - if (message === 'styleUpdate' && style.updateUrl.includes('?')) { - enableUpdateButton(true); - } else { - sendEvent('styleInstalledChrome'); - } - } - ); - resolve(); - }); - }); - - function enableUpdateButton(state) { - const important = s => s.replace(/;/g, '!important;'); - const button = document.getElementById('update_style_button'); - if (button) { - button.style.cssText = state ? '' : important('pointer-events: none; opacity: .35;'); - const icon = button.querySelector('img[src*=".svg"]'); - if (icon) { - icon.style.cssText = state ? '' : important('transition: transform 5s; transform: rotate(0);'); - if (state) { - setTimeout(() => (icon.style.cssText += important('transform: rotate(10turn);'))); - } - } - } - } -} - - -function getMeta(name) { - const e = document.querySelector(`link[rel="${name}"]`); - return e ? e.getAttribute('href') : null; -} - - -function getResource(url) { - return new Promise(resolve => { - if (url.startsWith('#')) { - resolve(document.getElementById(url.slice(1)).textContent); - } else { - chrome.runtime.sendMessage({method: 'download', url}, resolve); - } - }); -} - - -function getStyleJson() { - const url = getStyleURL(); - return getResource(url).then(code => { - try { - return JSON.parse(code); - } catch (e) { - return fetch(url).then(r => r.json()).catch(() => null); - } - }); -} - - -function styleSectionsEqual({sections: a}, {sections: b}) { - if (!a || !b) { - return undefined; - } - if (a.length !== b.length) { - return false; - } - // order of sections should be identical to account for the case of multiple - // sections matching the same URL because the order of rules is part of cascading - return a.every((sectionA, index) => propertiesEqual(sectionA, b[index])); - - function propertiesEqual(secA, secB) { - for (const name of ['urlPrefixes', 'urls', 'domains', 'regexps']) { - if (!equalOrEmpty(secA[name], secB[name], 'every', arrayMirrors)) { - return false; - } - } - return equalOrEmpty(secA.code, secB.code, 'substr', (a, b) => a === b); - } - - function equalOrEmpty(a, b, telltale, comparator) { - const typeA = a && typeof a[telltale] === 'function'; - const typeB = b && typeof b[telltale] === 'function'; - return ( - (a === null || a === undefined || (typeA && !a.length)) && - (b === null || b === undefined || (typeB && !b.length)) - ) || typeA && typeB && a.length === b.length && comparator(a, b); - } - - function arrayMirrors(array1, array2) { - return ( - array1.every(el => array2.includes(el)) && - array2.every(el => array1.includes(el)) - ); - } -} - - -function onDOMready() { - if (document.readyState !== 'loading') { - return Promise.resolve(); - } - return new Promise(resolve => { - document.addEventListener('DOMContentLoaded', function _() { - document.removeEventListener('DOMContentLoaded', _); - resolve(); - }); - }); -} - - -function orphanCheck() { - const port = chrome.runtime.connect(); - if (port) { - port.disconnect(); - return true; - } - // we're orphaned due to an extension update - // we can detach event listeners - window.removeEventListener(chrome.runtime.id + '-install', orphanCheck, true); - ['Update', 'Install'].forEach(type => - ['', 'Chrome', 'Opera'].forEach(browser => - document.addEventListener('stylish' + type + browser, onClick))); -}