6c13db1468
* Add: color-scheme.js * Add: handle color scheme * Add: styleManager.setMeta * Add: make setupLivePrefs work with radio * Change: drop setupRadioButtons * Add: UI for schemeSwitcher * Add: prefer-scheme select in installation page * Fix: add alarm listener * Add: display excluded reason in popup * Fix: rely on data-value-type instead of input name * Fix: oldValue and newValue should have the same type * Change: detect media change in content script * Fix: duplicate capitalize * Fix: minor * Update web-ext * Fix: valueAsNumber doesn't work for all inputs * Fix: disable colorscheme selection after install * Fix: API error
267 lines
8.4 KiB
JavaScript
267 lines
8.4 KiB
JavaScript
/* 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 isFrame = window !== parent;
|
|
const isFrameAboutBlank = isFrame && location.href === 'about:blank';
|
|
const isUnstylable = !chrome.app && document instanceof XMLDocument;
|
|
const styleInjector = StyleInjector({
|
|
compare: (a, b) => a.id - b.id,
|
|
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 {IntersectionObserver} = window;
|
|
const xoEventId = `${Math.random()}`;
|
|
/** @type IntersectionObserver */
|
|
let xo;
|
|
if (IntersectionObserver) {
|
|
window[Symbol.for('xo')] = (el, cb) => {
|
|
if (!xo) xo = new IntersectionObserver(onIntersect, {rootMargin: '100%'});
|
|
el.addEventListener(xoEventId, cb, {once: true});
|
|
xo.observe(el);
|
|
};
|
|
}
|
|
|
|
// 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);
|
|
|
|
if (!chrome.tabs) {
|
|
window.dispatchEvent(new CustomEvent(orphanEventId));
|
|
window.addEventListener(orphanEventId, orphanCheck, true);
|
|
}
|
|
|
|
// detect media change in content script
|
|
// FIXME: move this to background page when following bugs are fixed:
|
|
// https://bugzilla.mozilla.org/show_bug.cgi?id=1561546
|
|
// https://bugs.chromium.org/p/chromium/issues/detail?id=968651
|
|
const media = window.matchMedia('(prefers-color-scheme: dark)');
|
|
media.addListener(() => API.colorScheme.updateSystemPreferDark().catch(console.error));
|
|
|
|
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);
|
|
isDisabled = styles.disableAll;
|
|
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 '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 <html>
|
|
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) {
|
|
if (IntersectionObserver) {
|
|
parent[parent.Symbol.for('xo')](frameElement, cb);
|
|
} else {
|
|
requestAnimationFrame(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);
|
|
isOrphaned = true;
|
|
setTimeout(styleInjector.clear, 1000); // avoiding FOUC
|
|
tryCatch(msg.off, applyOnMessage);
|
|
}
|
|
})();
|