/* global API msg */// msg.js /* global CHROME URLS deepEqual isEmptyObj mapObj stringAsRegExp tryRegExp tryURL */// toolbox.js /* global bgReady createCache uuidIndex */// common.js /* global calcStyleDigest styleCodeEmpty */// sections-util.js /* global db */ /* global prefs */ /* global tabMan */ /* global usercssMan */ /* global colorScheme */ 'use strict'; /* This style manager is a layer between content script and the DB. When a style is added/updated, it broadcast a message to content script and the content script would try to fetch the new code. The live preview feature relies on `runtime.connect` and `port.onDisconnect` to cleanup the temporary code. See livePreview in /edit. */ const styleUtil = {}; /* exported styleMan */ const styleMan = (() => { Object.assign(styleUtil, { id2style, handleSave, uuid2style, }); //#region Declarations /** @typedef {{ style: StyleObj, preview?: StyleObj, appliesTo: Set, }} StyleMapData */ /** @type {Map} */ const dataMap = new Map(); /** @typedef {Object} StyleSectionsToApply */ /** @type {Map, sections: StyleSectionsToApply}>} */ const cachedStyleForUrl = createCache({ onDeleted(url, cache) { for (const section of Object.values(cache.sections)) { const data = id2data(section.id); if (data) data.appliesTo.delete(url); } }, }); const BAD_MATCHER = {test: () => false}; const compileRe = createCompiler(text => `^(${text})$`); const compileSloppyRe = createCompiler(text => `^${text}$`); const compileExclusion = createCompiler(buildExclusion); const uuidv4 = crypto.randomUUID ? crypto.randomUUID.bind(crypto) : (() => { const seeds = crypto.getRandomValues(new Uint16Array(8)); // 00001111-2222-M333-N444-555566667777 seeds[3] = seeds[3] & 0x0FFF | 0x4000; // UUID version 4, M = 4 seeds[4] = seeds[4] & 0x3FFF | 0x8000; // UUID variant 1, N = 8..0xB return Array.from(seeds, hex4dashed).join(''); }); const MISSING_PROPS = { name: style => `ID: ${style.id}`, _id: () => uuidv4(), _rev: () => Date.now(), }; const DELETE_IF_NULL = ['id', 'customName', 'md5Url', 'originalMd5']; const INJ_ORDER = 'injectionOrder'; const order = {main: {}, prio: {}}; const orderWrap = { id: INJ_ORDER, value: mapObj(order, () => []), _id: `${chrome.runtime.id}-${INJ_ORDER}`, _rev: 0, }; uuidIndex.addCustomId(orderWrap, {set: setOrder}); class MatchQuery { constructor(url) { this.url = url; } get urlWithoutHash() { return this._set('urlWithoutHash', this.url.split('#', 1)[0]); } get urlWithoutParams() { return this._set('urlWithoutParams', this.url.split(/[?#]/, 1)[0]); } get domain() { return this._set('domain', tryURL(this.url).hostname); } get isOwnPage() { return this._set('isOwnPage', this.url.startsWith(URLS.ownOrigin)); } _set(name, value) { Object.defineProperty(this, name, {value}); return value; } } /** @type {Promise|boolean} will be `true` to avoid wasting a microtask tick on each `await` */ let ready = Promise.all([init(), prefs.ready]); chrome.runtime.onConnect.addListener(port => { if (port.name === 'livePreview') { handleLivePreview(port); } else if (port.name.startsWith('draft:')) { handleDraft(port); } }); colorScheme.onChange(value => { msg.broadcastExtension({method: 'colorScheme', value}); for (const {style} of dataMap.values()) { if (colorScheme.SCHEMES.includes(style.preferScheme)) { broadcastStyleUpdated(style, 'colorScheme'); } } }); //#endregion //#region Exports return { /** @returns {Promise} style id */ async delete(id, reason) { if (ready.then) await ready; const {style, appliesTo} = dataMap.get(id); const sync = reason !== 'sync'; const uuid = style._id; db.styles.delete(id); if (sync) API.sync.delete(uuid, Date.now()); for (const url of appliesTo) { const cache = cachedStyleForUrl.get(url); if (cache) delete cache.sections[id]; } dataMap.delete(id); uuidIndex.delete(uuid); mapObj(orderWrap.value, (group, type) => { delete order[type][id]; const i = group.indexOf(uuid); if (i >= 0) group.splice(i, 1); }); setOrder(orderWrap, {calc: false}); if (style._usw && style._usw.token) { // Must be called after the style is deleted from dataMap API.usw.revoke(id); } API.drafts.delete(id); await msg.broadcast({ method: 'styleDeleted', style: {id}, }); return id; }, /** @returns {Promise} */ async editSave(style) { if (ready.then) await ready; style = mergeWithMapped(style); style.updateDate = Date.now(); return saveStyle(style, {reason: 'editSave'}); }, /** @returns {Promise} */ async find(...filters) { if (ready.then) await ready; for (const filter of filters) { const filterEntries = Object.entries(filter); for (const {style} of dataMap.values()) { if (filterEntries.every(([key, val]) => style[key] === val)) { return style; } } } return null; }, /** @returns {Promise} */ async getAll() { if (ready.then) await ready; return getAllAsArray(); }, /** @returns {Promise>}>} */ async getAllOrdered(keys) { if (ready.then) await ready; const res = mapObj(orderWrap.value, group => group.map(uuid2style).filter(Boolean)); if (res.main.length + res.prio.length < dataMap.size) { for (const {style} of dataMap.values()) { if (!(style.id in order.main) && !(style.id in order.prio)) { res.main.push(style); } } } return keys ? mapObj(res, group => group.map(style => mapObj(style, null, keys))) : res; }, getOrder: () => orderWrap.value, /** @returns {Promise}>} */ async getRemoteInfo(id) { if (ready.then) await ready; if (id) return calcRemoteId(id2style(id)); const res = {}; for (const {style} of dataMap.values()) { const [rid, vars] = calcRemoteId(style); if (rid) res[rid] = [style.id, vars]; } return res; }, /** @returns {Promise} */ async getSectionsByUrl(url, id, isInitialApply) { if (ready.then) await ready; if (isInitialApply && prefs.get('disableAll')) { return { cfg: { disableAll: true, }, }; } // TODO: enable in FF when it supports sourceURL comment in style elements (also options.html) const {exposeStyleName} = CHROME && prefs.__values; const sender = CHROME && this && this.sender || {}; if (sender.frameId === 0) { /* Chrome hides text frament from location.href of the page e.g. #:~:text=foo so we'll use the real URL reported by webNavigation API. TODO: if FF will do the same, this won't work as is: FF reports onCommitted too late */ url = tabMan.get(sender.tab.id, 'url', 0) || url; } let cache = cachedStyleForUrl.get(url); if (!cache) { cache = { sections: {}, maybeMatch: new Set(), }; buildCache(cache, url, dataMap.values()); cachedStyleForUrl.set(url, cache); } else if (cache.maybeMatch.size) { buildCache(cache, url, Array.from(cache.maybeMatch, id2data).filter(Boolean)); } return Object.assign({cfg: {exposeStyleName, order}}, id ? mapObj(cache.sections, null, [id]) : cache.sections); }, /** @returns {Promise} */ async get(id) { if (ready.then) await ready; return id2style(id); }, /** @returns {Promise} */ async getByUrl(url, id = null) { if (ready.then) await ready; // FIXME: do we want to cache this? Who would like to open popup rapidly // or search the DB with the same URL? const result = []; const styles = id ? [id2style(id)].filter(Boolean) : getAllAsArray(); const query = new MatchQuery(url); for (const style of styles) { let excluded = false; let excludedScheme = false; let included = false; let sloppy = false; let sectionMatched = false; const match = urlMatchStyle(query, style); // TODO: enable this when the function starts returning false // if (match === false) { // continue; // } if (match === 'included') { included = true; } if (match === 'excluded') { excluded = true; } if (match === 'excludedScheme') { excludedScheme = true; } for (const section of style.sections) { const match = urlMatchSection(query, section, true); if (match) { if (match === 'sloppy') { sloppy = true; } sectionMatched = true; break; } } if (sectionMatched || included) { result.push(/** @namespace StylesByUrlResult */ { style, excluded, sloppy, excludedScheme, sectionMatched, included}); } } return result; }, /** @returns {Promise} */ async importMany(items) { if (ready.then) await ready; for (const style of items) { beforeSave(style); if (style.sourceCode && style.usercssData) { await usercssMan.buildCode(style); } } const events = await db.styles.putMany(items); return Promise.all(items.map((item, i) => handleSave(item, {reason: 'import'}, events[i]) )); }, /** @returns {Promise} */ async install(style, reason = null) { if (ready.then) await ready; reason = reason || dataMap.has(style.id) ? 'update' : 'install'; style = mergeWithMapped(style); style.originalDigest = await calcStyleDigest(style); // FIXME: update updateDate? what about usercss config? return saveStyle(style, {reason}); }, save: saveStyle, async setOrder(value) { if (ready.then) await ready; return setOrder({value}, {broadcast: true, sync: true}); }, /** @returns {Promise} style id */ async toggle(id, enabled) { if (ready.then) await ready; const style = Object.assign({}, id2style(id), {enabled}); await saveStyle(style, {reason: 'toggle'}); return id; }, // using bind() to skip step-into when debugging /** @returns {Promise} */ addExclusion: addIncludeExclude.bind(null, 'exclusions'), /** @returns {Promise} */ addInclusion: addIncludeExclude.bind(null, 'inclusions'), /** @returns {Promise} */ removeExclusion: removeIncludeExclude.bind(null, 'exclusions'), /** @returns {Promise} */ removeInclusion: removeIncludeExclude.bind(null, 'inclusions'), async config(id, prop, value) { if (ready.then) await ready; const style = Object.assign({}, id2style(id)); const {preview = {}} = dataMap.get(id); style[prop] = preview[prop] = value; return saveStyle(style, {reason: 'config'}); }, }; //#endregion //#region Implementation /** @returns {StyleMapData} */ function id2data(id) { return dataMap.get(id); } /** @returns {?StyleObj} */ function id2style(id) { return (dataMap.get(Number(id)) || {}).style; } /** @returns {?StyleObj} */ function uuid2style(uuid) { return id2style(uuidIndex.get(uuid)); } function calcRemoteId({md5Url, updateUrl, usercssData: ucd} = {}) { let id; id = (id = /\d+/.test(md5Url) || URLS.extractUsoArchiveId(updateUrl)) && `uso-${id}` || (id = URLS.extractUSwId(updateUrl)) && `usw-${id}` || ''; return id && [ id, ucd && !isEmptyObj(ucd.vars), ]; } /** @returns {StyleObj} */ function createNewStyle() { return /** @namespace StyleObj */ { enabled: true, updateUrl: null, md5Url: null, url: null, originalMd5: null, installDate: Date.now(), }; } /** @returns {void} */ function storeInMap(style) { dataMap.set(style.id, { style, appliesTo: new Set(), }); uuidIndex.set(style._id, style.id); } /** @returns {StyleObj} */ function mergeWithMapped(style) { return Object.assign({}, id2style(style.id) || createNewStyle(), style); } function handleDraft(port) { const id = port.name.split(':').pop(); port.onDisconnect.addListener(() => API.drafts.delete(Number(id) || id)); } function handleLivePreview(port) { let id; port.onMessage.addListener(style => { if (!id) id = style.id; const data = id2data(id); data.preview = style; broadcastStyleUpdated(style, 'editPreview'); }); port.onDisconnect.addListener(() => { port = null; if (id) { const data = id2data(id); if (data) { data.preview = null; broadcastStyleUpdated(data.style, 'editPreviewEnd'); } } }); } async function addIncludeExclude(type, id, rule) { if (ready.then) await ready; const style = Object.assign({}, id2style(id)); const list = style[type] || (style[type] = []); if (list.includes(rule)) { throw new Error('The rule already exists'); } style[type] = list.concat([rule]); return saveStyle(style, {reason: 'config'}); } async function removeIncludeExclude(type, id, rule) { if (ready.then) await ready; const style = Object.assign({}, id2style(id)); const list = style[type]; if (!list || !list.includes(rule)) { return; } style[type] = list.filter(r => r !== rule); return saveStyle(style, {reason: 'config'}); } function broadcastStyleUpdated(style, reason, method = 'styleUpdated') { const {id} = style; const data = id2data(id); const excluded = new Set(); const updated = new Set(); for (const [url, cache] of cachedStyleForUrl.entries()) { if (!data.appliesTo.has(url)) { cache.maybeMatch.add(id); continue; } const code = getAppliedCode(new MatchQuery(url), style); if (code) { updated.add(url); buildCacheEntry(cache, style, code); } else { excluded.add(url); delete cache.sections[id]; } } data.appliesTo = updated; return msg.broadcast({ method, reason, style: { id, md5Url: style.md5Url, enabled: style.enabled, }, }); } function beforeSave(style) { if (!style.name) { throw new Error('Style name is empty'); } for (const key of DELETE_IF_NULL) { if (style[key] == null) { delete style[key]; } } if (!style._id) { style._id = uuidv4(); } style._rev = Date.now(); fixKnownProblems(style); } async function saveStyle(style, handlingOptions) { beforeSave(style); const newId = await db.styles.put(style); return handleSave(style, handlingOptions, newId); } function handleSave(style, {reason, broadcast = true}, id = style.id) { if (style.id == null) style.id = id; const data = id2data(id); const method = data ? 'styleUpdated' : 'styleAdded'; if (!data) { storeInMap(style); } else { data.style = style; } if (reason !== 'sync') { API.sync.putDoc(style); } if (broadcast) broadcastStyleUpdated(style, reason, method); return style; } // get styles matching a URL, including sloppy regexps and excluded items. function getAppliedCode(query, data) { const result = urlMatchStyle(query, data); if (result === 'included') { // return all sections return data.sections.map(s => s.code); } if (result !== true) { return; } const code = []; for (const section of data.sections) { if (urlMatchSection(query, section) === true && !styleCodeEmpty(section.code)) { code.push(section.code); } } return code.length && code; } async function init() { const orderPromise = API.prefsDb.get(orderWrap.id); const styles = await db.styles.getAll() || []; const updated = await Promise.all(styles.map(fixKnownProblems).filter(Boolean)); if (updated.length) { await db.styles.putMany(updated); } setOrder(await orderPromise, {store: false}); styles.forEach(storeInMap); ready = true; bgReady._resolveStyles(); } function fixKnownProblems(style, initIndex, initArray) { let res = 0; for (const key in MISSING_PROPS) { if (!style[key]) { style[key] = MISSING_PROPS[key](style); res = 1; } } /* Upgrade the old way of customizing local names */ const {originalName} = style; if (originalName) { if (originalName !== style.name) { style.customName = style.name; style.name = originalName; } delete style.originalName; res = 1; } /* wrong homepage url in 1.5.20-1.5.21 due to commit 1e5f118d */ for (const key of ['url', 'installationUrl']) { const url = style[key]; const fixedUrl = url && url.replace(/([^:]\/)\//, '$1'); if (fixedUrl !== url) { res = 1; style[key] = fixedUrl; } } let url; /* USO bug, duplicate "update" subdomain, see #523 */ if ((url = style.md5Url) && url.includes('update.update.userstyles')) { res = style.md5Url = url.replace('update.update.userstyles', 'update.userstyles'); } /* Default homepage URL for external styles installed from a known distro */ if ( (!style.url || !style.installationUrl) && (url = style.updateUrl) && (url = URLS.extractGreasyForkInstallUrl(url) || URLS.extractUsoArchiveInstallUrl(url) || URLS.extractUSwInstallUrl(url) ) ) { if (!style.url) res = style.url = url; if (!style.installationUrl) res = style.installationUrl = url; } /* @import must precede `vars` that we add at beginning */ if ( initArray && !isEmptyObj((style.usercssData || {}).vars) && style.sections.some(({code}) => code.startsWith(':root {\n --') && /@import\s/i.test(code)) ) { return usercssMan.buildCode(style); } return res && style; } function urlMatchStyle(query, style) { if ( style.exclusions && style.exclusions.some(e => compileExclusion(e).test(query.urlWithoutParams)) ) { return 'excluded'; } if (!style.enabled) { return 'disabled'; } if (!colorScheme.shouldIncludeStyle(style)) { return 'excludedScheme'; } if ( style.inclusions && style.inclusions.some(r => compileExclusion(r).test(query.urlWithoutParams)) ) { return 'included'; } return true; } function urlMatchSection(query, section, skipEmptyGlobal) { let dd, ddL, pp, ppL, rr, rrL, uu, uuL; if ( (dd = section.domains) && (ddL = dd.length) && dd.some(urlMatchDomain, query) || (pp = section.urlPrefixes) && (ppL = pp.length) && pp.some(urlMatchPrefix, query) || /* Per the specification the fragment portion is ignored in @-moz-document: https://www.w3.org/TR/2012/WD-css3-conditional-20120911/#url-of-doc but the spec is outdated and doesn't account for SPA sites, so we only respect it for `url()` function */ (uu = section.urls) && (uuL = uu.length) && ( uu.includes(query.url) || uu.includes(query.urlWithoutHash) ) || (rr = section.regexps) && (rrL = rr.length) && rr.some(urlMatchRegexp, query) ) { return true; } /* According to CSS4 @document specification the entire URL must match. Stylish-for-Chrome implemented it incorrectly since the very beginning. We'll detect styles that abuse the bug by finding the sections that would have been applied by Stylish but not by us as we follow the spec. */ if (rrL && rr.some(urlMatchRegexpSloppy, query)) { return 'sloppy'; } // TODO: check for invalid regexps? return !rrL && !ppL && !uuL && !ddL && !query.isOwnPage && // We allow only intentionally targeted sections for own pages (!skipEmptyGlobal || !styleCodeEmpty(section.code)); } /** @this {MatchQuery} */ function urlMatchDomain(d) { const _d = this.domain; return d === _d || _d[_d.length - d.length - 1] === '.' && _d.endsWith(d); } /** @this {MatchQuery} */ function urlMatchPrefix(p) { return p && this.url.startsWith(p); } /** @this {MatchQuery} */ function urlMatchRegexp(r) { return (!this.isOwnPage || /\bextension\b/.test(r)) && compileRe(r).test(this.url); } /** @this {MatchQuery} */ function urlMatchRegexpSloppy(r) { return (!this.isOwnPage || /\bextension\b/.test(r)) && compileSloppyRe(r).test(this.url); } function createCompiler(compile) { // FIXME: FIFO cache doesn't work well here, if we want to match many // regexps more than the cache size, we will never hit the cache because // the first cache is deleted. So we use a simple map but it leaks memory. const cache = new Map(); return text => { let re = cache.get(text); if (!re) { re = tryRegExp(compile(text)); if (!re) { re = BAD_MATCHER; } cache.set(text, re); } return re; }; } function compileGlob(text) { return stringAsRegExp(text, '', true) .replace(/\\\\\\\*|\\\*/g, m => m.length > 2 ? m : '.*'); } function buildExclusion(text) { // match pattern const match = text.match(/^(\*|[\w-]+):\/\/(\*\.)?([\w.]+\/.*)/); if (!match) { return '^' + compileGlob(text) + '$'; } return '^' + (match[1] === '*' ? '[\\w-]+' : match[1]) + '://' + (match[2] ? '(?:[\\w.]+\\.)?' : '') + compileGlob(match[3]) + '$'; } function buildCache(cache, url, styleList) { const query = new MatchQuery(url); for (const {style, appliesTo, preview} of styleList) { const code = getAppliedCode(query, preview || style); if (code) { buildCacheEntry(cache, style, code); appliesTo.add(url); } } } function buildCacheEntry(cache, style, code = style.code) { cache.sections[style.id] = { code, id: style.id, name: style.customName || style.name, }; } /** @returns {StyleObj[]} */ function getAllAsArray() { return Array.from(dataMap.values(), v => v.style); } /** uuidv4 helper: converts to a 4-digit hex string and adds "-" at required positions */ function hex4dashed(num, i) { return (num + 0x10000).toString(16).slice(-4) + (i >= 1 && i <= 4 ? '-' : ''); } async function setOrder(data, {broadcast, calc = true, store = true, sync} = {}) { if (!data || !data.value || deepEqual(data.value, orderWrap.value)) { return; } Object.assign(orderWrap, data, sync && {_rev: Date.now()}); if (calc) { for (const [type, group] of Object.entries(data.value)) { const dst = order[type] = {}; group.forEach((uuid, i) => { const id = uuidIndex.get(uuid); if (id) dst[id] = i; }); } } if (broadcast) { msg.broadcast({method: 'styleSort', order}); } if (store) { await API.prefsDb.put(orderWrap, orderWrap.id); } if (sync) { API.sync.putDoc(orderWrap); } } //#endregion })();