/* global API msg */// msg.js /* global StyleInjector */ /* global prefs */ 'use strict'; (() => { if (window.INJECTED === 1) return; /** true -> when the page styles are received, * false -> when disableAll mode is on at start, the styles won't be sent * so while disableAll lasts we can ignore messages about style updates because * the tab will explicitly ask for all styles in bulk when disableAll mode ends */ let hasStyles = false; let isDisabled = false; let isTab = !chrome.tabs || location.pathname !== '/popup.html'; const order = {main: [], prio: []}; const calcOrder = ({id}) => (order.prio[id] || 0) * 1e6 || order.main[id] || id + .5e6; // no order = at the end of `main` const isFrame = window !== parent; const isFrameAboutBlank = isFrame && location.href === 'about:blank'; const isUnstylable = !chrome.app && document instanceof XMLDocument; const styleInjector = StyleInjector({ compare: (a, b) => calcOrder(a) - calcOrder(b), onUpdate: onInjectorUpdate, }); // dynamic iframes don't have a URL yet so we'll use their parent's URL (hash isn't inherited) let matchUrl = isFrameAboutBlank && tryCatch(() => parent.location.href.split('#')[0]) || location.href; // 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(); /** @type chrome.runtime.Port */ let port; let lazyBadge = isFrame; let parentDomain; /* about:blank iframes are often used by sites for file upload or background tasks * and they may break if unexpected DOM stuff is present at `load` event * so we'll add the styles only if the iframe becomes visible */ const xoEventId = `${Math.random()}`; /** @type IntersectionObserver */ let xo; window[Symbol.for('xo')] = (el, cb) => { if (!xo) xo = new IntersectionObserver(onIntersect, {rootMargin: '100%'}); el.addEventListener(xoEventId, cb, {once: true}); xo.observe(el); }; // FIXME: move this to background page when following bugs are fixed: // https://bugzil.la/1587723, https://crbug.com/968651 const mqDark = matchMedia('(prefers-color-scheme: dark)'); mqDark.onchange = e => API.colorScheme.updateSystemPreferDark(e.matches); mqDark.onchange(mqDark); // Declare all vars before init() or it'll throw due to "temporal dead zone" of const/let const ready = init(); // the popup needs a check as it's not a tab but can be opened in a tab manually for whatever reason if (!isTab) { chrome.tabs.getCurrent(tab => { isTab = Boolean(tab); if (tab && styleInjector.list.length) updateCount(); }); } msg.onTab(applyOnMessage); window.addEventListener('pageshow', e => { if (e.isTrusted && e.persisted) { // bfcache updateCount(); } }); if (!chrome.tabs) { window.dispatchEvent(new CustomEvent(orphanEventId)); window.addEventListener(orphanEventId, orphanCheck, true); } function onInjectorUpdate() { if (!isOrphaned) { updateCount(); const onOff = prefs[styleInjector.list.length ? 'subscribe' : 'unsubscribe']; onOff('disableAll', updateDisableAll); if (isFrame) { updateExposeIframes(); onOff('exposeIframes', updateExposeIframes); } } } async function init() { if (isUnstylable) { await API.styleViaAPI({method: 'styleApply'}); } else { const SYM_ID = 'styles'; const SYM = Symbol.for(SYM_ID); const parentStyles = isFrameAboutBlank && tryCatch(() => parent[parent.Symbol.for(SYM_ID)]); const styles = window[SYM] || parentStyles && await new Promise(onFrameElementInView) && parentStyles || !isFrameAboutBlank && chrome.app && !chrome.tabs && tryCatch(getStylesViaXhr) || await API.styles.getSectionsByUrl(matchUrl, null, true); if (styles.cfg) { isDisabled = styles.cfg.disableAll; Object.assign(order, styles.cfg.order); } hasStyles = !isDisabled; if (hasStyles) { window[SYM] = styles; await styleInjector.apply(styles); } else { delete window[SYM]; prefs.subscribe('disableAll', updateDisableAll); } styleInjector.toggle(hasStyles); } } /** Must be executed inside try/catch */ function getStylesViaXhr() { const blobId = document.cookie.split(chrome.runtime.id + '=')[1].split(';')[0]; const url = 'blob:' + chrome.runtime.getURL(blobId); document.cookie = `${chrome.runtime.id}=1; max-age=0`; // remove our cookie const xhr = new XMLHttpRequest(); xhr.open('GET', url, false); // synchronous xhr.send(); URL.revokeObjectURL(url); return JSON.parse(xhr.response); } function applyOnMessage(request) { const {method} = request; if (isUnstylable) { if (method === 'urlChanged') { request.method = 'styleReplaceAll'; } if (/^(style|updateCount)/.test(method)) { API.styleViaAPI(request); return; } } const {style} = request; switch (method) { case 'ping': return true; case 'styleDeleted': styleInjector.remove(style.id); break; case 'styleUpdated': if (!hasStyles && isDisabled) break; if (style.enabled) { API.styles.getSectionsByUrl(matchUrl, style.id).then(sections => sections[style.id] ? styleInjector.apply(sections) : styleInjector.remove(style.id)); } else { styleInjector.remove(style.id); } break; case 'styleAdded': if (!hasStyles && isDisabled) break; if (style.enabled) { API.styles.getSectionsByUrl(matchUrl, style.id) .then(styleInjector.apply); } break; case 'styleSort': Object.assign(order, request.order); styleInjector.sort(); break; case 'urlChanged': if (!hasStyles && isDisabled || matchUrl === request.url) break; matchUrl = request.url; API.styles.getSectionsByUrl(matchUrl).then(sections => { hasStyles = true; styleInjector.replace(sections); }); break; case 'backgroundReady': ready.catch(err => msg.isIgnorableError(err) ? init() : console.error(err)); break; case 'updateCount': updateCount(); break; } } function updateDisableAll(key, disableAll) { isDisabled = disableAll; if (isUnstylable) { API.styleViaAPI({method: 'prefChanged', prefs: {disableAll}}); } else if (!hasStyles && !disableAll) { init(); } else { styleInjector.toggle(!disableAll); } } async function updateExposeIframes(key, value = prefs.get('exposeIframes')) { const attr = 'stylus-iframe'; const el = document.documentElement; if (!el) return; // got no styles so styleInjector didn't wait for if (!value || !styleInjector.list.length) { el.removeAttribute(attr); } else { if (!parentDomain) parentDomain = await API.getTabUrlPrefix(); // Check first to avoid triggering DOM mutation if (el.getAttribute(attr) !== parentDomain) { el.setAttribute(attr, parentDomain); } } } function updateCount() { if (!isTab) return; if (isFrame) { if (!port && styleInjector.list.length) { port = chrome.runtime.connect({name: 'iframe'}); } else if (port && !styleInjector.list.length) { port.disconnect(); } if (lazyBadge && performance.now() > 1000) lazyBadge = false; } (isUnstylable ? API.styleViaAPI({method: 'updateCount'}) : API.updateIconBadge(styleInjector.list.map(style => style.id), {lazyBadge}) ).catch(msg.ignoreError); } function onFrameElementInView(cb) { parent[parent.Symbol.for('xo')](frameElement, cb); } /** @param {IntersectionObserverEntry[]} entries */ function onIntersect(entries) { for (const e of entries) { if (e.isIntersecting) { xo.unobserve(e.target); e.target.dispatchEvent(new Event(xoEventId)); } } } function tryCatch(func, ...args) { try { return func(...args); } catch (e) {} } function orphanCheck() { if (tryCatch(() => chrome.i18n.getUILanguage())) return; // In Chrome content script is orphaned on an extension update/reload // so we need to detach event listeners window.removeEventListener(orphanEventId, orphanCheck, true); mqDark.onchange = null; isOrphaned = true; setTimeout(styleInjector.clear, 1000); // avoiding FOUC tryCatch(msg.off, applyOnMessage); } })();