From 8842247cd06d93a1059ae701b8dc01457d74aeec Mon Sep 17 00:00:00 2001 From: tophf Date: Sun, 9 Feb 2020 18:20:34 +0300 Subject: [PATCH] use wrappedJSObject to create style elements in page context --- content/apply.js | 270 +++++++++----------------------------- content/style-injector.js | 171 ++++++++++++++++-------- js/msg.js | 7 +- js/polyfill.js | 4 +- js/prefs.js | 3 +- js/promisify.js | 6 +- 6 files changed, 186 insertions(+), 275 deletions(-) diff --git a/content/apply.js b/content/apply.js index 7b35b1fc..fcb5b918 100644 --- a/content/apply.js +++ b/content/apply.js @@ -1,18 +1,18 @@ -/* eslint no-var: 0 */ /* global msg API prefs createStyleInjector */ -/* exported APPLY */ 'use strict'; -// some weird bug in new Chrome: the content script gets injected multiple times -// define a constant so it throws when redefined -const APPLY = (() => { - const CHROME = chrome.app ? parseInt(navigator.userAgent.match(/Chrom\w+\/(?:\d+\.){2}(\d+)|$/)[1]) : NaN; +// Chrome reruns content script when documentElement is replaced. +// Note, we're checking against a literal `1`, not just `if (truthy)`, +// because is exposed per HTML spec as a global variable and `window.INJECTED`. + +// eslint-disable-next-line no-unused-expressions +self.INJECTED !== 1 && (() => { + self.INJECTED = 1; + const STYLE_VIA_API = !chrome.app && document instanceof XMLDocument; - const IS_OWN_PAGE = location.protocol.endsWith('-extension:'); - const setStyleContent = createSetStyleContent(); + const IS_OWN_PAGE = Boolean(chrome.tabs); const styleInjector = createStyleInjector({ compare: (a, b) => a.id - b.id, - setStyleContent, onUpdate: onInjectorUpdate }); const docRootObserver = createDocRootObserver({ @@ -29,14 +29,17 @@ const APPLY = (() => { } }); const initializing = init(); + // save it now because chrome.runtime will be unavailable in the orphaned script + const orphanEventId = chrome.runtime.id; + let isOrphaned; + // firefox doesn't orphanize content scripts so the old elements stay + if (!chrome.app) styleInjector.clearOrphans(); msg.onTab(applyOnMessage); if (!IS_OWN_PAGE) { - window.dispatchEvent(new CustomEvent(chrome.runtime.id, { - detail: pageObject({method: 'orphan'}) - })); - window.addEventListener(chrome.runtime.id, orphanCheck, true); + window.dispatchEvent(new CustomEvent(orphanEventId)); + window.addEventListener(orphanEventId, orphanCheck, true); } let parentDomain; @@ -54,141 +57,19 @@ const APPLY = (() => { docRewriteObserver.stop(); docRootObserver.stop(); } + if (isOrphaned) return; updateCount(); updateExposeIframes(); } function init() { - if (STYLE_VIA_API) { - return API.styleViaAPI({method: 'styleApply'}); - } - return API.getSectionsByUrl(getMatchUrl()) - .then(result => - applyStyles(result) - .then(() => { - // CSS transition bug workaround: since we insert styles asynchronously, - // the browsers, especially Firefox, may apply all transitions on page load - if (styleInjector.list.some(s => s.code.includes('transition'))) { - applyTransitionPatch(); - } - }) - ); - } - - function pageObject(target) { - // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Sharing_objects_with_page_scripts - const obj = new window.Object(); - Object.assign(obj, target); - return obj; - } - - function createSetStyleContent() { - // FF59+ bug workaround - // See https://github.com/openstyles/stylus/issues/461 - // Since it's easy to spoof the browser version in pre-Quantum FF we're checking - // for getPreventDefault which got removed in FF59 https://bugzil.la/691151 - const EVENT_NAME = chrome.runtime.id; - let ready; - return (el, content, disabled) => - checkPageScript().then(ok => { - if (!ok) { - el.textContent = content; - // https://github.com/openstyles/stylus/issues/693 - el.disabled = disabled; - } else { - const detail = pageObject({ - method: 'setStyleContent', - id: el.id, - content, - disabled - }); - window.dispatchEvent(new CustomEvent(EVENT_NAME, {detail})); - } - }); - - function checkPageScript() { - if (!ready) { - ready = CHROME || IS_OWN_PAGE || Event.prototype.getPreventDefault ? - Promise.resolve(false) : injectPageScript(); - } - return ready; - } - - function injectPageScript() { - const scriptContent = EVENT_NAME => { - document.currentScript.remove(); - const available = checkStyleApplied(); - if (available) { - window.addEventListener(EVENT_NAME, function handler(e) { - const {method, id, content, disabled} = e.detail; - if (method === 'setStyleContent') { - const el = document.getElementById(id); - if (!el) { - return; - } - el.textContent = content; - el.disabled = disabled; - } else if (method === 'orphan') { - window.removeEventListener(EVENT_NAME, handler); - } - }, true); - } - window.dispatchEvent(new CustomEvent(EVENT_NAME, {detail: { - method: 'init', - available - }})); - - function checkStyleApplied() { - const style = document.createElement('style'); - document.documentElement.appendChild(style); - const applied = Boolean(style.sheet); - style.remove(); - return applied; - } - }; - const code = `(${scriptContent})(${JSON.stringify(EVENT_NAME)})`; - // make sure it works in XML - const script = document.createElementNS('http://www.w3.org/1999/xhtml', 'script'); - const {resolve, promise} = deferred(); - // use inline script because using src is too slow - // https://github.com/openstyles/stylus/pull/766 - script.text = code; - script.onerror = resolveFalse; - window.addEventListener('error', resolveFalse); - window.addEventListener(EVENT_NAME, handleInit); - (document.head || document.documentElement).appendChild(script); - // injection failed if handleInit is not called. - resolveFalse(); - return promise.then(result => { - script.remove(); - window.removeEventListener(EVENT_NAME, handleInit); - window.removeEventListener('error', resolveFalse); - return result; - }); - - function resolveFalse() { - resolve(false); - } - - function handleInit(e) { - if (e.detail.method === 'init') { - resolve(e.detail.available); - } - } - } - } - - function deferred() { - const o = {}; - o.promise = new Promise((resolve, reject) => { - o.resolve = resolve; - o.reject = reject; - }); - return o; + return STYLE_VIA_API ? + API.styleViaAPI({method: 'styleApply'}) : + API.getSectionsByUrl(getMatchUrl()).then(applyStyles); } function getMatchUrl() { - var matchUrl = location.href; + let 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 @@ -316,78 +197,40 @@ const APPLY = (() => { }).catch(console.error); } - function rootReady() { - if (document.documentElement) { - return Promise.resolve(); - } - return new Promise(resolve => { - new MutationObserver((mutations, observer) => { - if (document.documentElement) { - observer.disconnect(); - resolve(); - } - }).observe(document, {childList: true}); - }); - } - function applyStyles(sections) { - const styles = Object.values(sections); - if (!styles.length) { - return Promise.resolve(); - } - return rootReady().then(() => - docRootObserver.evade(() => - styleInjector.addMany( - styles.map(s => ({id: s.id, code: s.code.join('')})) - ) - ) - ); + return new Promise(resolve => { + const styles = styleMapToArray(sections); + if (styles.length) { + docRootObserver.evade(() => { + styleInjector.addMany(styles); + resolve(); + }); + } else { + resolve(); + } + }); } function replaceAll(newStyles) { - styleInjector.replaceAll( - Object.values(newStyles) - .map(s => ({id: s.id, code: s.code.join('')})) - ); + styleInjector.replaceAll(styleMapToArray(newStyles)); } - function applyTransitionPatch() { - // CSS transition bug workaround: since we insert styles asynchronously, - // the browsers, especially Firefox, may apply all transitions on page load - const el = styleInjector.createStyle('transition-patch'); - // FIXME: this will trigger docRootObserver and cause a resort. We should - // move this function into style-injector. - document.documentElement.appendChild(el); - setStyleContent(el, ` - :root:not(#\\0):not(#\\0) * { - transition: none !important; - } - `) - .then(afterPaint) - .then(() => { - el.remove(); - }); + function styleMapToArray(styleMap) { + return Object.values(styleMap).map(s => ({ + id: s.id, + code: s.code.join(''), + })); } - function afterPaint() { - return new Promise(resolve => { - requestAnimationFrame(() => { - setTimeout(resolve); - }); - }); - } - - function orphanCheck(e) { - if (e && e.detail.method !== 'orphan') { - return; - } - if (chrome.i18n && chrome.i18n.getUILanguage()) { - return true; - } + function orphanCheck() { + try { + if (chrome.i18n.getUILanguage()) return; + } catch (e) {} // In Chrome content script is orphaned on an extension update/reload // so we need to detach event listeners + window.removeEventListener(orphanEventId, orphanCheck, true); + isOrphaned = true; styleInjector.clear(); - window.removeEventListener(chrome.runtime.id, orphanCheck, true); try { msg.off(applyOnMessage); } catch (e) {} @@ -459,13 +302,26 @@ const APPLY = (() => { } function evade(fn) { - if (!observing) { - return fn(); + if (observing) { + stop(); + _run(fn); + start(); + } else { + _run(fn); + } + } + + function _run(fn) { + if (document.documentElement) { + fn(); + } else { + new MutationObserver((mutations, observer) => { + if (document.documentElement) { + observer.disconnect(); + fn(); + } + }).observe(document, {childList: true}); } - stop(); - const r = fn(); - start(); - return r; } } })(); diff --git a/content/style-injector.js b/content/style-injector.js index 5024d944..dabb709d 100644 --- a/content/style-injector.js +++ b/content/style-injector.js @@ -1,21 +1,27 @@ -/* exported createStyleInjector */ 'use strict'; -function createStyleInjector({compare, setStyleContent, onUpdate}) { - const CHROME = chrome.app ? parseInt(navigator.userAgent.match(/Chrom\w+\/(?:\d+\.){2}(\d+)|$/)[1]) : NaN; +self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({ + compare, + onUpdate = () => {}, +}) => { const PREFIX = 'stylus-'; + const PATCH_ID = 'transition-patch'; // styles are out of order if any of these elements is injected between them const ORDERED_TAGS = new Set(['head', 'body', 'frameset', 'style', 'link']); + // detect Chrome 65 via a feature it added since browser version can be spoofed + const isChromePre65 = chrome.app && typeof Worklet !== 'function'; const list = []; const table = new Map(); - let enabled = true; + let isEnabled = true; + let isTransitionPatched; + // will store the original method refs because the page can override them + let creationDoc, createElement, createElementNS; return { // manipulation - add, addMany, remove, - update, clear, + clearOrphans, replaceAll, // method @@ -30,12 +36,32 @@ function createStyleInjector({compare, setStyleContent, onUpdate}) { createStyle }; + /* + FF59+ workaround: allow the page to read our sheets, https://github.com/openstyles/stylus/issues/461 + First we're trying the page context document where inline styles may be forbidden by CSP + https://bugzilla.mozilla.org/show_bug.cgi?id=1579345#c3 + and since userAgent.navigator can be spoofed via about:config or devtools, + we're checking for getPreventDefault that was removed in FF59 + */ + function _initCreationDoc() { + creationDoc = !Event.prototype.getPreventDefault && document.wrappedJSObject; + if (creationDoc) { + ({createElement, createElementNS} = creationDoc); + const el = document.documentElement.appendChild(createStyle()); + const isApplied = el.sheet; + el.remove(); + if (isApplied) return; + } + creationDoc = document; + ({createElement, createElementNS} = document); + } + function outOfOrder() { if (!list.length) { return false; } let el = list[0].el; - if (el.parentNode !== document.documentElement) { + if (el.parentNode !== creationDoc.documentElement) { return true; } let i = 0; @@ -45,45 +71,59 @@ function createStyleInjector({compare, setStyleContent, onUpdate}) { } else if (ORDERED_TAGS.has(el.localName)) { return true; } - el = el.nextSibling; + el = el.nextElementSibling; } // some styles are not injected to the document return i < list.length; } function addMany(styles) { - const pending = Promise.all(styles.map(_add)); - emitUpdate(); - return pending; - } - - function add(style) { - const pending = _add(style); - emitUpdate(); - return pending; + if (!isTransitionPatched) _applyTransitionPatch(styles); + const els = styles.map(_add); + onUpdate(); + return els; } function _add(style) { if (table.has(style.id)) { - return update(style); + return _update(style); } - style.el = createStyle(style.id); - const pending = setStyleContent(style.el, style.code, !enabled); + const el = style.el = createStyle(style.id, style.code); table.set(style.id, style); const nextIndex = list.findIndex(i => compare(i, style) > 0); if (nextIndex < 0) { - document.documentElement.appendChild(style.el); + document.documentElement.appendChild(el); list.push(style); } else { - document.documentElement.insertBefore(style.el, list[nextIndex].el); + document.documentElement.insertBefore(el, list[nextIndex].el); list.splice(nextIndex, 0, style); } - return pending; + // moving an element resets its 'disabled' state + el.disabled = !isEnabled; + return el; + } + + // CSS transition bug workaround: since we insert styles asynchronously, + // the browsers, especially Firefox, may apply all transitions on page load + function _applyTransitionPatch(styles) { + isTransitionPatched = document.readyState === 'complete'; + if (isTransitionPatched || !styles.some(s => s.code.includes('transition'))) { + return; + } + const el = createStyle(PATCH_ID, ` + :root:not(#\\0):not(#\\0) * { + transition: none !important; + } + `); + document.documentElement.appendChild(el); + // wait for the next paint to complete + // note: requestAnimationFrame won't fire in inactive tabs + requestAnimationFrame(() => setTimeout(() => el.remove())); } function remove(id) { _remove(id); - emitUpdate(); + onUpdate(); } function _remove(id) { @@ -94,40 +134,57 @@ function createStyleInjector({compare, setStyleContent, onUpdate}) { style.el.remove(); } - function update({id, code}) { + function _update({id, code}) { const style = table.get(id); if (style.code === code) return; style.code = code; // workaround for Chrome devtools bug fixed in v65 - // https://github.com/openstyles/stylus/commit/0fa391732ba8e35fa68f326a560fc04c04b8608b - let oldEl; - if (CHROME < 3321) { - oldEl = style.el; - oldEl.id = ''; - style.el = createStyle(id); + if (isChromePre65) { + const oldEl = style.el; + style.el = createStyle(id, code); oldEl.parentNode.insertBefore(style.el, oldEl.nextSibling); - style.el.disabled = !enabled; + oldEl.remove(); + } else { + style.el.textContent = code; } - return setStyleContent(style.el, code, !enabled) - .then(() => oldEl && oldEl.remove()); + // https://github.com/openstyles/stylus/issues/693 + style.el.disabled = !isEnabled; } - function createStyle(id) { + function _supersede(domId) { + const el = document.getElementById(domId); + if (el) { + // remove if it looks like our style that wasn't cleaned up in orphanCheck + // (note, Firefox doesn't orphanize content scripts at all so orphanCheck will never run) + if (el.localName === 'style' && el.classList.contains('stylus')) { + el.remove(); + } else { + el.id += ' superseded by Stylus'; + } + } + } + + function createStyle(id, code = '') { + if (!creationDoc) _initCreationDoc(); let el; if (document.documentElement instanceof SVGSVGElement) { // SVG document style - el = document.createElementNS('http://www.w3.org/2000/svg', 'style'); + el = createElementNS.call(creationDoc, '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'); + el = createElementNS.call(creationDoc, 'http://www.w3.org/1999/xhtml', 'style'); } else { // HTML document style; also works on HTML-embedded SVG - el = document.createElement('style'); + el = createElement.call(creationDoc, 'style'); + } + if (id) { + el.id = `${PREFIX}${id}`; + _supersede(el.id); } - el.id = `${PREFIX}${id}`; el.type = 'text/css'; // SVG className is not a string, but an instance of SVGAnimatedString el.classList.add('stylus'); + el.textContent = code; return el; } @@ -137,14 +194,23 @@ function createStyleInjector({compare, setStyleContent, onUpdate}) { } list.length = 0; table.clear(); - emitUpdate(); + onUpdate(); + } + + function clearOrphans() { + for (const el of document.querySelectorAll(`style[id^="${PREFIX}-"].stylus`)) { + const id = el.id.slice(PREFIX.length + 1); + if (/^\d+$/.test(id) || id === PATCH_ID) { + el.remove(); + } + } } function toggle(_enabled) { - if (enabled === _enabled) return; - enabled = _enabled; + if (isEnabled === _enabled) return; + isEnabled = _enabled; for (const style of list) { - style.el.disabled = !enabled; + style.el.disabled = !isEnabled; } } @@ -156,13 +222,7 @@ function createStyleInjector({compare, setStyleContent, onUpdate}) { // el.textContent += ' '; // invalidate CSSOM cache document.documentElement.appendChild(style.el); // moving an element resets its 'disabled' state - style.el.disabled = !enabled; - } - } - - function emitUpdate() { - if (onUpdate) { - onUpdate(); + style.el.disabled = !isEnabled; } } @@ -174,11 +234,8 @@ function createStyleInjector({compare, setStyleContent, onUpdate}) { removed.push(style.id); } } - // FIXME: is it possible that `docRootObserver` breaks the process? - return Promise.all(styles.map(_add)) - .then(() => { - removed.forEach(_remove); - emitUpdate(); - }); + styles.forEach(_add); + removed.forEach(_remove); + onUpdate(); } -} +}; diff --git a/js/msg.js b/js/msg.js index 5bbef7a4..42dc44dd 100644 --- a/js/msg.js +++ b/js/msg.js @@ -1,9 +1,8 @@ /* global promisify deepCopy */ -/* exported msg API */ // deepCopy is only used if the script is executed in extension pages. 'use strict'; -const msg = (() => { +self.msg = self.INJECTED === 1 ? self.msg : (() => { const runtimeSend = promisify(chrome.runtime.sendMessage.bind(chrome.runtime)); const tabSend = chrome.tabs && promisify(chrome.tabs.sendMessage.bind(chrome.tabs)); const tabQuery = chrome.tabs && promisify(chrome.tabs.query.bind(chrome.tabs)); @@ -239,9 +238,9 @@ const msg = (() => { } })(); -const API = new Proxy({}, { +self.API = self.INJECTED === 1 ? self.API : new Proxy({}, { get: (target, name) => - (...args) => Promise.resolve(msg.sendBg({ + (...args) => Promise.resolve(self.msg.sendBg({ method: 'invokeAPI', name, args diff --git a/js/polyfill.js b/js/polyfill.js index 78665c85..d79b14af 100644 --- a/js/polyfill.js +++ b/js/polyfill.js @@ -1,6 +1,8 @@ 'use strict'; -(() => { +// eslint-disable-next-line no-unused-expressions +self.INJECTED !== 1 && (() => { + if (!Object.entries) { Object.entries = obj => Object.keys(obj).map(k => [k, obj[k]]); } diff --git a/js/prefs.js b/js/prefs.js index 4ac7cf3d..b227d2c6 100644 --- a/js/prefs.js +++ b/js/prefs.js @@ -1,8 +1,7 @@ /* global promisify */ -/* exported prefs */ 'use strict'; -const prefs = (() => { +self.prefs = self.INJECTED === 1 ? self.prefs : (() => { const defaults = { 'openEditInWindow': false, // new editor opens in a own browser window 'windowPosition': {}, // detached window position diff --git a/js/promisify.js b/js/promisify.js index 8362b00d..89605a37 100644 --- a/js/promisify.js +++ b/js/promisify.js @@ -1,4 +1,3 @@ -/* exported promisify */ 'use strict'; /* Convert chrome APIs into promises. Example: @@ -7,8 +6,8 @@ Convert chrome APIs into promises. Example: storageSyncGet(['key']).then(result => {...}); */ -function promisify(fn) { - return (...args) => +self.promisify = self.INJECTED === 1 ? self.promisify : fn => + (...args) => new Promise((resolve, reject) => { fn(...args, (...result) => { if (chrome.runtime.lastError) { @@ -21,4 +20,3 @@ function promisify(fn) { ); }); }); -}