diff --git a/background/background.js b/background/background.js index a8e5dd02..16c60801 100644 --- a/background/background.js +++ b/background/background.js @@ -6,16 +6,9 @@ /* global syncMan */ /* global updateMan */ /* global usercssMan */ +/* global usoApi */ /* global uswApi */ -/* global - FIREFOX - UA - URLS - activateTab - download - findExistingTab - openURL -*/ // toolbox.js +/* global FIREFOX UA activateTab findExistingTab openURL */ // toolbox.js /* global colorScheme */ // color-scheme.js 'use strict'; @@ -42,17 +35,12 @@ addAPI(/** @namespace API */ { sync: syncMan, updater: updateMan, usercss: usercssMan, + uso: usoApi, usw: uswApi, colorScheme, /** @type {BackgroundWorker} */ worker: createWorker({url: '/background/background-worker'}), - download(url, opts) { - return typeof url === 'string' && url.startsWith(URLS.uso) && - this.sender.url.startsWith(URLS.uso) && - download(url, opts || {}); - }, - /** @returns {string} */ getTabUrlPrefix() { return this.sender.tab.url.match(/^([\w-]+:\/+[^/#]+)/)[1]; diff --git a/background/style-manager.js b/background/style-manager.js index 4fc44024..fb58a8f7 100644 --- a/background/style-manager.js +++ b/background/style-manager.js @@ -139,12 +139,14 @@ const styleMan = (() => { }, /** @returns {Promise} */ - async find(filter) { + async find(...filters) { if (ready.then) await ready; - const filterEntries = Object.entries(filter); - for (const {style} of dataMap.values()) { - if (filterEntries.every(([key, val]) => style[key] === val)) { - return style; + 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; diff --git a/background/update-manager.js b/background/update-manager.js index caf8107f..7cd4cc52 100644 --- a/background/update-manager.js +++ b/background/update-manager.js @@ -1,6 +1,6 @@ /* global API */// msg.js /* global RX_META URLS debounce deepMerge download ignoreChromeError */// toolbox.js -/* global calcStyleDigest styleJSONseemsValid styleSectionsEqual */ // sections-util.js +/* global calcStyleDigest styleSectionsEqual */ // sections-util.js /* global chromeLocal */// storage-util.js /* global compareVersion */// cmpver.js /* global db */ @@ -23,6 +23,7 @@ const updateMan = (() => { ERROR_JSON: 'error: JSON is invalid', ERROR_VERSION: 'error: version is older than installed style', }; + const USO_STYLES_API = `${URLS.uso}api/v1/styles/`; const RH_ETAG = {responseHeaders: ['etag']}; // a hashsum of file contents const RX_DATE2VER = new RegExp([ /^(\d{4})/, @@ -37,6 +38,7 @@ const updateMan = (() => { 503, // service unavailable 429, // too many requests ]; + let usoReferers = 0; let lastUpdateTime; let checkingAll = false; let logQueue = []; @@ -113,12 +115,13 @@ const updateMan = (() => { save, } = opts; if (!id) id = style.id; - const ucd = style.usercssData; + const {md5Url} = style; + let {usercssData: ucd, updateUrl} = style; let res, state; try { await checkIfEdited(); res = { - style: await (ucd ? updateUsercss : updateUSO)().then(maybeSave), + style: await (ucd && !md5Url ? updateUsercss : updateUSO)().then(maybeSave), updated: true, }; state = STATES.UPDATED; @@ -142,76 +145,45 @@ const updateMan = (() => { } async function updateUSO() { - const url = URLS.makeUsoArchiveCodeUrl(style.md5Url.match(/\d+/)[0]); - const req = await tryDownload(url, RH_ETAG).catch(() => null); - if (req) { - return updateToUSOArchive(url, req); - } - const md5 = await tryDownload(style.md5Url); + const md5 = await tryDownload(md5Url); if (!md5 || md5.length !== 32) { return Promise.reject(STATES.ERROR_MD5); } if (md5 === style.originalMd5 && style.originalDigest && !ignoreDigest) { return Promise.reject(STATES.SAME_MD5); } - const json = await tryDownload(style.updateUrl, {responseType: 'json'}); - if (!styleJSONseemsValid(json)) { - return Promise.reject(STATES.ERROR_JSON); + let varsUrl = ''; + if (!ucd) { + ucd = {}; + varsUrl = updateUrl; + updateUrl = style.updateUrl = `${USO_STYLES_API}${md5Url.match(/\/(\d+)/)[1]}`; + } + usoSpooferStart(); + let json; + try { + json = await tryDownload(style.updateUrl, {responseType: 'json'}); + json = await updateUsercss(json.css) || + (await API.uso.toUsercss(json)).style; + if (varsUrl) await API.uso.useVarsUrl(json, varsUrl); + } finally { + usoSpooferStop(); } // USO may not provide a correctly updated originalMd5 (#555) json.originalMd5 = md5; return json; } - async function updateToUSOArchive(url, req) { - const m2 = await getUsoEmbeddedMeta(req.response); - if (m2) { - url = m2.updateUrl; - req = await tryDownload(url, RH_ETAG); - } - const json = await API.usercss.buildMeta({ - id, - etag: req.headers.etag, - md5Url: null, - originalMd5: null, - sourceCode: req.response, - updateUrl: url, - url: URLS.extractUsoArchiveInstallUrl(url), - }); - const varUrlValues = style.updateUrl.split('?')[1]; - const varData = json.usercssData.vars; - if (varUrlValues && varData) { - const IK = 'ik-'; - const IK_LEN = IK.length; - for (let [key, val] of new URLSearchParams(varUrlValues)) { - if (!key.startsWith(IK)) continue; - key = key.slice(IK_LEN); - const varDef = varData[key]; - if (!varDef) continue; - if (varDef.options) { - let sel = val.startsWith(IK) && getVarOptByName(varDef, val.slice(IK_LEN)); - if (!sel) { - key += '-custom'; - sel = getVarOptByName(varDef, key + '-dropdown'); - if (sel) varData[key].value = val; - } - if (sel) varDef.value = sel.name; - } else { - varDef.value = val; - } - } - } - return API.usercss.buildCode(json); - } - - async function updateUsercss() { + async function updateUsercss(css) { let oldVer = ucd.version; let {etag: oldEtag, updateUrl} = style; - const m2 = URLS.extractUsoArchiveId(updateUrl) && await getUsoEmbeddedMeta(); + const m2 = (css || URLS.extractUsoArchiveId(updateUrl)) && + await getUsoEmbeddedMeta(css); if (m2 && m2.updateUrl) { updateUrl = m2.updateUrl; oldVer = m2.usercssData.version || '0'; oldEtag = ''; + } else if (css) { + return; } if (oldEtag && oldEtag === await downloadEtag()) { return Promise.reject(STATES.SAME_CODE); @@ -284,8 +256,7 @@ const updateMan = (() => { } function getDateFromVer(style) { - const m = URLS.extractUsoArchiveId(style.updateUrl) && - style.usercssData.version.match(RX_DATE2VER); + const m = RX_DATE2VER.exec((style.usercssData || {}).version); if (m) { m[2]--; // month is 0-based in `Date` constructor return new Date(...m.slice(1)).getTime(); @@ -294,13 +265,10 @@ const updateMan = (() => { /** UserCSS metadata may be embedded in the original USO style so let's use its updateURL */ function getUsoEmbeddedMeta(code = style.sourceCode) { - const m = code.includes('@updateURL') && code.replace(RX_META, '').match(RX_META); + const isRaw = arguments[0]; + const m = code.includes('@updateURL') && (isRaw ? code : code.replace(RX_META, '')).match(RX_META); return m && API.usercss.buildMeta({sourceCode: m[0]}).catch(() => null); } - - function getVarOptByName(varDef, name) { - return varDef.options.find(o => o.name === name); - } } function schedule() { @@ -349,4 +317,32 @@ const updateMan = (() => { logLastWriteTime = Date.now(); logQueue = []; } + + function usoSpooferStart() { + if (++usoReferers === 1) { + chrome.webRequest.onBeforeSendHeaders.addListener( + usoSpoofer, + {types: ['xmlhttprequest'], urls: [USO_STYLES_API + '*']}, + ['blocking', 'requestHeaders', chrome.webRequest.OnBeforeSendHeadersOptions.EXTRA_HEADERS] + .filter(Boolean)); + } + } + + function usoSpooferStop() { + if (--usoReferers <= 0) { + usoReferers = 0; + chrome.webRequest.onBeforeSendHeaders.removeListener(usoSpoofer); + } + } + + /** @param {chrome.webRequest.WebResponseHeadersDetails | browser.webRequest._OnBeforeSendHeadersDetails} info */ + function usoSpoofer(info) { + if (info.tabId < 0 && URLS.ownOrigin.startsWith(info.initiator || info.originUrl || '')) { + const {requestHeaders: hh} = info; + const i = (hh.findIndex(h => /^referer$/i.test(h.name)) + 1 || hh.push({})) - 1; + hh[i].name = 'referer'; + hh[i].value = URLS.uso; + return {requestHeaders: hh}; + } + } })(); diff --git a/background/usercss-manager.js b/background/usercss-manager.js index f54f463c..6ffda091 100644 --- a/background/usercss-manager.js +++ b/background/usercss-manager.js @@ -12,10 +12,12 @@ const usercssMan = { name: null, }), - async assignVars(style, oldStyle) { + /** `src` is a style or vars */ + async assignVars(style, src) { const meta = style.usercssData; - const vars = meta.vars; - const oldVars = (oldStyle.usercssData || {}).vars; + const meta2 = src.usercssData; + const {vars} = meta; + const oldVars = meta2 ? meta2.vars : src; if (vars && oldVars) { // The type of var might be changed during the update. Set value to null if the value is invalid. for (const [key, v] of Object.entries(vars)) { @@ -43,7 +45,7 @@ const usercssMan = { let log; if (!metaOnly) { if (vars || assignVars) { - await usercssMan.assignVars(style, vars ? {usercssData: {vars}} : dup); + await usercssMan.assignVars(style, vars || dup); } await usercssMan.buildCode(style); log = style.log; // extracting the non-enumerable prop, otherwise it won't survive messaging @@ -137,17 +139,18 @@ const usercssMan = { } }, - async install(style) { - return API.styles.install(await usercssMan.parse(style)); + async install(style, opts) { + return API.styles.install(await usercssMan.parse(style, opts)); }, - async parse(style) { + async parse(style, {dup, vars} = {}) { style = await usercssMan.buildMeta(style); // preserve style.vars during update - const dup = await usercssMan.find(style); - if (dup) { + if (dup || (dup = await usercssMan.find(style))) { style.id = dup.id; - await usercssMan.assignVars(style, dup); + } + if (vars || (vars = dup)) { + await usercssMan.assignVars(style, vars); } return usercssMan.buildCode(style); }, diff --git a/background/uso-api.js b/background/uso-api.js new file mode 100644 index 00000000..ad30347c --- /dev/null +++ b/background/uso-api.js @@ -0,0 +1,157 @@ +/* global URLS stringAsRegExp */// toolbox.js +/* global usercssMan */ +'use strict'; + +const usoApi = {}; + +(() => { + const pingers = {}; + + usoApi.pingback = (usoId, delay) => { + clearTimeout(pingers[usoId]); + delete pingers[usoId]; + if (delay > 0) { + return new Promise(resolve => (pingers[usoId] = setTimeout(ping, delay, usoId, resolve))); + } else if (delay !== false) { + return ping(usoId); + } + }; + + /** + * Replicating USO-Archive format + * https://github.com/33kk/uso-archive/blob/flomaster/lib/uso.js + * https://github.com/33kk/uso-archive/blob/flomaster/lib/converters.js + */ + usoApi.toUsercss = async (data, {metaOnly = true, varsUrl} = {}) => { + const badKeys = {}; + const newKeys = []; + const descr = JSON.stringify(data.description.trim()); + const vars = (data.style_settings || []).map(makeVar, {badKeys, newKeys}).join(''); + const sourceCode = `\ +/* ==UserStyle== +@name ${data.name} +@namespace USO Archive +@version ${data.updated.replace(/-/g, '').replace(/[T:]/g, '.').slice(0, 14)} +@description ${/^"['`]|\\/.test(descr) ? descr : descr.slice(1, -1)} +@author ${(data.user || {}).name || '?'} +@license ${makeLicense(data.license)}${vars ? '\n@preprocessor uso' + vars : ''}` + .replace(/\*\//g, '*\\/') + + `==/UserStyle== */\n${newKeys[0] ? useNewKeys(data.css, badKeys) : data.css}`; + const {style} = await usercssMan.build({sourceCode, metaOnly}); + usoApi.useVarsUrl(style, varsUrl); + return {style, badKeys, newKeys}; + }; + + usoApi.useVarsUrl = (style, url) => { + if (!/\?ik-/.test(url)) { + return; + } + const cfg = {badKeys: {}, newKeys: []}; + const {vars} = style.usercssData; + if (!vars) { + return; + } + for (let [key, val] of new URLSearchParams(url.split('?')[1])) { + if (!key.startsWith('ik-')) continue; + key = makeKey(key.slice(3), cfg); + const v = vars[key]; + if (!v) continue; + if (v.options) { + let sel = val.startsWith('ik-') && optByName(v, makeKey(val.slice(3), cfg)); + if (!sel) { + key += '-custom'; + sel = optByName(v, key + '-dropdown'); + if (sel) vars[key].value = val; + } + if (sel) v.value = sel.name; + } else { + v.value = val; + } + } + return true; + }; + + function ping(id, resolve) { + return fetch(`${URLS.uso}styles/install/${id}?source=stylish-ch`) + .then(resolve); + } + + function makeKey(key, {badKeys, newKeys}) { + let res = badKeys[key]; + if (!res) { + res = key.replace(/[^-\w]/g, '-'); + res += newKeys.includes(res) ? '-' : ''; + if (key !== res) { + badKeys[key] = res; + newKeys.push(res); + } + } + return res; + } + + function makeLicense(s) { + return !s ? 'NO-REDISTRIBUTION' : + s === 'publicdomain' ? 'CC0-1.0' : + s.startsWith('ccby') ? `${s.toUpperCase().match(/(..)/g).join('-')}-4.0` : + s; + } + + function makeVar({ + label, + setting_type: type, + install_key: ik, + style_setting_options: opts, + }) { + const cfg = this; + let value, suffix; + ik = makeKey(ik, cfg); + label = JSON.stringify(label); + switch (type) { + + case 'color': + value = opts[0].value; + break; + + case 'text': + value = JSON.stringify(opts[0].value); + break; + + case 'image': { + const ikCust = `${ik}-custom`; + opts.push({ + label: 'Custom', + install_key: `${ikCust}-dropdown`, + value: `/*[[${ikCust}]]*/`, + }); + suffix = `\n@advanced text ${ikCust} ${label.slice(0, -1)} (Custom)" "https://foo.com/123.jpg"`; + type = 'dropdown'; + } // fallthrough + + case 'dropdown': + value = ''; + for (const o of opts) { + const def = o.default ? '*' : ''; + const val = o.value; + const s = ` ${makeKey(o.install_key, cfg)} ${JSON.stringify(o.label + def)} << o.name === name); + } + + function useNewKeys(css, badKeys) { + const rxsKeys = stringAsRegExp(Object.keys(badKeys).join('\n'), '', true).replace(/\n/g, '|'); + const rxUsoVars = new RegExp(`(/\\*\\[\\[)(${rxsKeys})(?=]]\\*/)`, 'g'); + return css.replace(rxUsoVars, (s, a, key) => a + badKeys[key]); + } +})(); diff --git a/content/install-hook-userstyles.js b/content/install-hook-userstyles.js index f41c9b33..786e7ce4 100644 --- a/content/install-hook-userstyles.js +++ b/content/install-hook-userstyles.js @@ -1,378 +1,296 @@ -/* global API msg */// msg.js +/* global API */// msg.js 'use strict'; // eslint-disable-next-line no-unused-expressions -/^\/styles\/(\d+)(\/([^/]*))?([?#].*)?$/.test(location.pathname) && (() => { - const styleId = RegExp.$1; +/^\/styles\/(\d+)(\/([^/]*))?([?#].*)?$/.test(location.pathname) && (async () => { + if (window.INJECTED_USO === 1) return; + window.INJECTED_USO = 1; + + const usoId = RegExp.$1; + const USO = 'https://userstyles.org'; + const apiUrl = `${USO}/api/v1/styles/${usoId}`; + const md5Url = `https://update.userstyles.org/${usoId}.md5`; + const CLICK = { + customize: '.customize_button', + install: '#install_style_button', + uninstall: '#uninstall_style_button', + update: '#update_style_button', + }; + const CLICK_SEL = Object.values(CLICK).join(','); const pageEventId = `${performance.now()}${Math.random()}`; + const contentEventId = pageEventId + ':'; + const orphanEventId = chrome.runtime.id; // id won't be available in the orphaned script + const $ = (sel, base = document) => base.querySelector(sel); + const toggleListener = (isOn, ...args) => (isOn ? addEventListener : removeEventListener)(...args); + const togglePageListener = isOn => toggleListener(isOn, contentEventId, onPageEvent, true); - window.dispatchEvent(new CustomEvent(chrome.runtime.id + '-install')); - window.addEventListener(chrome.runtime.id + '-install', orphanCheck, true); + const mo = new MutationObserver(onMutation); + const observeColors = isOn => + isOn ? mo.observe(document.body, {subtree: true, attributes: true, attributeFilter: ['value']}) + : mo.disconnect(); - document.addEventListener('stylishInstallChrome', onClick); - document.addEventListener('stylishUpdateChrome', onClick); + let style, dup, md5, pageData, badKeys; - msg.on(onMessage); + runInPage(inPageContext, pageEventId, contentEventId, usoId, apiUrl); + addEventListener(orphanEventId, orphanCheck, true); + addEventListener('click', onClick, true); + togglePageListener(true); - let currentMd5; - const md5Url = getMeta('stylish-md5-url') || `https://update.userstyles.org/${styleId}.md5`; - Promise.all([ - API.styles.find({md5Url}), - getResource(md5Url), - onDOMready(), - ]).then(checkUpdatability); + [md5, dup] = await Promise.all([ + fetch(md5Url).then(r => r.text()), + API.styles.find({md5Url}, {installationUrl: `https://uso.kkx.one/style/${usoId}`}) + .then(sendVarsToPage), + document.body || new Promise(resolve => addEventListener('load', resolve, {once: true})), + ]); - document.documentElement.appendChild( - Object.assign(document.createElement('script'), { - textContent: `(${inPageContext})('${pageEventId}')`, - })); - - function onMessage(msg) { - switch (msg.method) { - case 'ping': - // orphaned content script check - return true; - case 'openSettings': - openSettings(); - return true; - } + if (!dup.id) { + sendStylishEvent('styleCanBeInstalledChrome'); + } else if (dup.originalMd5 && dup.originalMd5 !== md5 || !dup.usercssData || !dup.md5Url) { + // allow update if 1) changed, 2) is a classic USO style, 3) is from USO-archive + sendStylishEvent('styleCanBeUpdatedChrome'); + } else { + sendStylishEvent('styleAlreadyInstalledChrome'); } - /* 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, md5]) { - // TODO: remove the following statement when USO is fixed - document.dispatchEvent(new CustomEvent(pageEventId, { - detail: installedStyle && installedStyle.updateUrl, - })); - currentMd5 = md5; - if (!installedStyle) { - sendEvent({type: 'styleCanBeInstalledChrome'}); - return; - } - const isCustomizable = /\?/.test(installedStyle.updateUrl); - const md5Url = getMeta('stylish-md5-url'); - if (md5Url && installedStyle.md5Url && installedStyle.originalMd5) { - reportUpdatable(isCustomizable || md5 !== installedStyle.originalMd5); - } else { - getStyleJson().then(json => { - reportUpdatable( - isCustomizable || - !json || - !styleSectionsEqual(json, installedStyle)); - }); - } - - function prepareInstallButton() { - return new Promise(resolve => { - const observer = new MutationObserver(check); - observer.observe(document.documentElement, { - childList: true, - subtree: true, - }); - check(); - - function check() { - if (document.querySelector('#install_style_button')) { - resolve(); - observer.disconnect(); - } - } - }); - } - - function reportUpdatable(isUpdatable) { - prepareInstallButton().then(() => { - sendEvent({ - type: isUpdatable - ? 'styleCanBeUpdatedChrome' - : 'styleAlreadyInstalledChrome', - detail: { - updateUrl: installedStyle.updateUrl, - }, - }); + async function onClick(e) { + const el = e.target.closest(CLICK_SEL); + if (!el) return; + el.disabled = true; + const {id} = dup; + try { + if (el.matches(CLICK.uninstall)) { + dup = style = false; + removeEventListener('change', onChange); + await API.styles.delete(id); + return; + } + if (el.matches(CLICK.customize)) { + const isOn = dup && !$('#style-settings'); + toggleListener(isOn, 'change', onChange); + observeColors(isOn); + return; + } + e.stopPropagation(); + if (!style) await buildStyle(); + style = dup = await API.usercss.install(style, { + dup: {id}, + vars: getPageVars(), }); + sendStylishEvent('styleInstalledChrome'); + API.uso.pingback(id); + } catch (e) { + alert(chrome.i18n.getMessage('styleInstallFailed', e.message || e)); + } finally { + el.disabled = false; } } - - function sendEvent(event) { - sendEvent.lastEvent = event; - let {type, detail = null} = event; - 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); /* global cloneInto */ - } else { - detail = {detail}; - } - document.dispatchEvent(new CustomEvent(type, detail)); - } - - function onClick(event) { - if (onClick.processing || !orphanCheck()) { - return; - } - onClick.processing = true; - doInstall() - .then(() => { - if (!event.type.includes('Update')) { - // FIXME: sometimes the button is broken i.e. the button sends - // 'install' instead of 'update' event while the style is already - // install. - // This triggers an incorrect install count but we don't really care. - return getResource(getMeta('stylish-install-ping-url-chrome')); - } - }) - .catch(console.error) - .then(done); - function done() { - setTimeout(() => { - onClick.processing = false; - }); + function onChange({target: el}) { + if (dup && el.matches('[name^="ik-"], [type=file]')) { + API.usercss.configVars(dup.id, getPageVars()); } } - function doInstall() { - let oldStyle; - return API.styles.find({ - md5Url: getMeta('stylish-md5-url') || location.href, - }) - .then(_oldStyle => { - oldStyle = _oldStyle; - return oldStyle ? - oldStyle.name : - getResource(getMeta('stylish-description')); - }) - .then(name => { - const props = {}; - if (oldStyle) { - props.id = oldStyle.id; - } - return saveStyleCode(oldStyle ? 'styleUpdate' : 'styleInstall', name, props); - }); - } - - async function saveStyleCode(message, name, addProps = {}) { - const isNew = message === 'styleInstall'; - const needsConfirmation = isNew || !saveStyleCode.confirmed; - if (needsConfirmation && !confirm(chrome.i18n.getMessage(message, [name]))) { - return Promise.reject(); - } - saveStyleCode.confirmed = true; - enableUpdateButton(false); - const json = await getStyleJson(); - if (!json) { - prompt(chrome.i18n.getMessage('styleInstallFailed', ''), - 'https://github.com/openstyles/stylus/issues/195'); - return; - } - // Update originalMd5 since USO changed it (2018-11-11) to NOT match the current md5 - const style = await API.styles.install(Object.assign(json, addProps, {originalMd5: currentMd5})); - if (!isNew && style.updateUrl.includes('?')) { - enableUpdateButton(true); - } else { - sendEvent({type: 'styleInstalledChrome'}); - } - - 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 onMutation(mutations) { + for (const {target: el} of mutations) { + if (el.style.display === 'none' && + /^ik-/.test(el.name) && + /^#[\da-f]{6}$/.test(el.value)) { + onChange({target: el}); } } } - function getMeta(name) { - const e = document.querySelector(`link[rel="${name}"]`); - return e ? e.getAttribute('href') : null; + function onPageEvent(e) { + pageData = e.detail; + togglePageListener(false); } - async function getResource(url, opts) { - try { - return url.startsWith('#') - ? document.getElementById(url.slice(1)).textContent - : await API.download(url, opts); - } catch (error) { - alert('Error\n' + error.message); - return Promise.reject(error); - } + async function buildStyle() { + if (!pageData) pageData = await (await fetch(apiUrl)).json(); + ({style, badKeys} = await API.uso.toUsercss(pageData, {varsUrl: dup.updateUrl})); + Object.assign(style, { + md5Url, + id: dup.id, + originalMd5: md5, + updateUrl: apiUrl, + }); } - // USO providing md5Url as "https://update.update.userstyles.org/#####.md5" - // instead of "https://update.userstyles.org/#####.md5" - async function getStyleJson() { - try { - const style = await getResource(getStyleURL(), {responseType: 'json'}); - const codeElement = document.getElementById('stylish-code'); - if (!style || !Array.isArray(style.sections) || style.sections.length || - codeElement && !codeElement.textContent.trim()) { - return style; - } - const code = await getResource(getMeta('stylish-update-url')); - style.sections = (await API.worker.parseMozFormat({code})).sections; - if (style.md5Url) style.md5Url = style.md5Url.replace('update.update', 'update'); - return style; - } catch (e) {} - } - - /** - * The sections are checked in successive order because it matters when many sections - * match the same URL and they have rules with the same CSS specificity - * @param {Object} a - first style object - * @param {Object} b - second style object - * @returns {?boolean} - */ - function styleSectionsEqual({sections: a}, {sections: b}) { - const targets = ['urls', 'urlPrefixes', 'domains', 'regexps']; - return a && b && a.length === b.length && a.every(sameSection); - function sameSection(secA, i) { - return equalOrEmpty(secA.code, b[i].code, 'string', (a, b) => a === b) && - targets.every(target => equalOrEmpty(secA[target], b[i][target], 'array', arrayMirrors)); - } - function equalOrEmpty(a, b, type, comparator) { - const typeA = type === 'array' ? Array.isArray(a) : typeof a === type; - const typeB = type === 'array' ? Array.isArray(b) : typeof b === type; - return typeA && typeB && comparator(a, b) || - (a == null || typeA && !a.length) && - (b == null || typeB && !b.length); - } - function arrayMirrors(a, b) { - return a.length === b.length && - a.every(el => b.includes(el)) && - b.every(el => a.includes(el)); - } - } - - function onDOMready() { - return document.readyState !== 'loading' - ? Promise.resolve() - : new Promise(resolve => document.addEventListener('DOMContentLoaded', resolve, {once: true})); - } - - function openSettings(countdown = 10e3) { - const button = document.querySelector('.customize_button'); - if (button) { - button.dispatchEvent(new MouseEvent('click', {bubbles: true})); - setTimeout(function pollArea(countdown = 2000) { - const area = document.getElementById('advancedsettings_area'); - if (area || countdown < 0) { - (area || button).scrollIntoView({behavior: 'smooth', block: area ? 'end' : 'center'}); + function getPageVars() { + const {vars} = (style || dup).usercssData; + for (const el of document.querySelectorAll('[name^="ik-"]')) { + const name = el.name.slice(3); // dropping "ik-" + const ik = badKeys[name] || name; + const v = vars[ik] || false; + const isImage = el.type === 'radio'; + if (v && (!isImage || el.checked)) { + const val = el.value; + const isFile = val === 'user-upload'; + if (isImage && (isFile || val === 'user-url')) { + const el2 = $(`[type=${isFile ? 'file' : 'url'}]`, el.parentElement); + const ikCust = `${ik}-custom`; + v.value = `${ikCust}-dropdown`; + vars[ikCust].value = isFile ? getFileUriFromPage(el2) : el2.value; } else { - setTimeout(pollArea, 100, countdown - 100); + v.value = v.type === 'select' ? val.replace(/^ik-/, '') : val; } - }, 500); - } else if (countdown > 0) { - setTimeout(openSettings, 100, countdown - 100); + } } + return vars; + } + + function getFileUriFromPage(el) { + togglePageListener(true); + sendPageEvent(el); + return pageData; + } + + function runInPage(fn, ...args) { + const div = document.createElement('div'); + div.attachShadow({mode: 'closed'}) + .appendChild(document.createElement('script')) + .textContent = `(${fn})(${JSON.stringify(args).slice(1, -1)})`; + document.documentElement.appendChild(div).remove(); + } + + function sendPageEvent(data) { + dispatchEvent(data instanceof Node + ? new MouseEvent(pageEventId, {relatedTarget: data}) + : new CustomEvent(pageEventId, {detail: data})); + //* global cloneInto */// WARNING! Firefox requires cloning of an object `detail` + } + + function sendStylishEvent(type) { + document.dispatchEvent(new Event(type)); + } + + function sendVarsToPage(style) { + if (style) { + const vars = (style.usercssData || {}).vars || `${style.updateUrl}`.split('?')[1]; + if (vars) sendPageEvent('vars:' + JSON.stringify(vars)); + } + return style || false; } function orphanCheck() { - try { - if (chrome.i18n.getUILanguage()) { - return true; - } - } catch (e) {} - // 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); - document.removeEventListener('stylishInstallChrome', onClick); - document.removeEventListener('stylishUpdateChrome', onClick); - try { - msg.off(onMessage); - } catch (e) {} + if (chrome.i18n) return true; + removeEventListener(orphanEventId, orphanCheck, true); + removeEventListener('click', onClick, true); + removeEventListener('change', onChange); + sendPageEvent('quit'); + observeColors(false); + togglePageListener(false); } })(); -function inPageContext(eventId) { - document.currentScript.remove(); +function inPageContext(eventId, eventIdHost, styleId, apiUrl) { window.isInstalled = true; - const origMethods = { - json: Response.prototype.json, - byId: document.getElementById, + const {dispatchEvent, CustomEvent, removeEventListener} = window; + const apply = Map.call.bind(Map.apply); + const CR = chrome.runtime; + const {sendMessage} = CR; + const RP = Response.prototype; + const origJson = RP.json; + let done, vars; + CR.sendMessage = function (id, msg, opts, cb = opts) { + if (id === 'fjnbnpbmkenffdnngjfgmeleoegfcffe' && + msg && msg.type === 'deleteStyle' && + typeof cb === 'function') { + cb(true); + } else { + return sendMessage(...arguments); + } }; - let vars; - // USO bug workaround: prevent errors in console after install and busy cursor - document.getElementById = id => - origMethods.byId.call(document, id) || - (/^(stylish-code|stylish-installed-style-installed-\w+|post-install-ad|style-install-unknown)$/.test(id) - ? Object.assign(document.createElement('p'), {className: 'afterdownload-ad'}) - : null); - // USO bug workaround: use the actual image data in customized settings - document.addEventListener(eventId, ({detail}) => { - vars = /\?/.test(detail) && new URL(detail).searchParams; - if (!vars) Response.prototype.json = origMethods.json; - }, {once: true}); - Response.prototype.json = async function () { - const json = await origMethods.json.apply(this, arguments); - if (vars && json && Array.isArray(json.style_settings)) { - Response.prototype.json = origMethods.json; - const images = new Map(); - for (const ss of json.style_settings) { - let value = vars.get('ik-' + ss.install_key); - if (!value || !(ss.style_setting_options || [])[0]) { - continue; - } - if (value.startsWith('ik-')) { - value = value.replace(/^ik-/, ''); - const def = ss.style_setting_options.find(item => item.default); - if (!def || def.install_key !== value) { - if (def) def.default = false; - for (const item of ss.style_setting_options) { - if (item.install_key === value) { - item.default = true; - break; - } - } - } - } else if (ss.setting_type === 'image') { - let isListed; - for (const opt of ss.style_setting_options) { - isListed |= opt.default = (opt.value === value); - } - images.set(ss.install_key, {url: value, isListed}); - } else { - const item = ss.style_setting_options[0]; - if (item.value !== value && item.install_key === 'placeholder') { - item.value = value; - } + RP.json = async function () { + const res = await apply(origJson, this, arguments); + try { + if (!done && this.url === apiUrl) { + RP.json = origJson; + done = true; // will be used if called by another script that saved our RP.json hook + send(res); + setVars(res); + } + } catch (e) {} + return res; + }; + addEventListener(eventId, onCommand, true); + function onCommand(e) { + if (e.detail === 'quit') { + removeEventListener(eventId, onCommand, true); + CR.sendMessage = sendMessage; + RP.json = origJson; + done = true; + } else if (/^vars:/.test(e.detail)) { + vars = JSON.parse(e.detail.slice(5)); + } else if (e.relatedTarget) { + send(e.relatedTarget.uploadedData); + } + } + function send(data) { + dispatchEvent(new CustomEvent(eventIdHost, {__proto: null, detail: data})); + } + function setVars(json) { + const images = new Map(); + const isNew = typeof vars === 'object'; + const badKeys = {}; + const newKeys = []; + const makeKey = ({install_key: key}) => { + let res = isNew ? badKeys[key] : key; + if (!res) { + res = key.replace(/[^-\w]/g, '-'); + res += newKeys.includes(res) ? '-' : ''; + if (key !== res) { + badKeys[key] = res; + newKeys.push(res); } } - if (images.size) { - new MutationObserver((_, observer) => { - if (document.getElementById('style-settings')) { - observer.disconnect(); - for (const [name, {url, isListed}] of images) { - const elRadio = document.querySelector(`input[name="ik-${name}"][value="user-url"]`); - const elUrl = elRadio && - document.getElementById(elRadio.id.replace('url-choice', 'user-url')); - if (elUrl) { - elRadio.checked = !isListed; - elUrl.value = url; - } + return res; + }; + if (!isNew) vars = new URLSearchParams(vars); + for (const ss of json.style_settings || []) { + const ik = makeKey(ss); + let value = isNew ? (vars[ik] || {}).value : vars.get('ik-' + ik); + if (value == null || !(ss.style_setting_options || [])[0]) { + continue; + } + if (ss.setting_type === 'image') { + let isListed; + for (const opt of ss.style_setting_options) { + isListed |= opt.default = (opt.value === value); + } + images.set(ik, {url: isNew && !isListed ? vars[`${ik}-custom`].value : value, isListed}); + } else if (value.startsWith('ik-') || isNew && vars[ik].type === 'select') { + value = value.replace(/^ik-/, ''); + const def = ss.style_setting_options.find(item => item.default); + if (!def || makeKey(def) !== value) { + if (def) def.default = false; + for (const item of ss.style_setting_options) { + if (makeKey(item) === value) { + item.default = true; + break; } } - }).observe(document, {childList: true, subtree: true}); + } + } else { + const item = ss.style_setting_options[0]; + if (item.value !== value && item.install_key === 'placeholder') { + item.value = value; + } } } - return json; - }; + if (!images.size) return; + new MutationObserver((_, observer) => { + if (!document.getElementById('style-settings')) return; + observer.disconnect(); + for (const [name, {url, isListed}] of images) { + const elRadio = document.querySelector(`input[name="ik-${name}"][value="user-url"]`); + const elUrl = elRadio && document.getElementById(elRadio.id.replace('url-choice', 'user-url')); + if (elUrl) { + elRadio.checked = !isListed; + elUrl.value = url; + } + } + }).observe(document, {childList: true, subtree: true}); + } } diff --git a/manage/render.js b/manage/render.js index 9ec50501..06a118b4 100644 --- a/manage/render.js +++ b/manage/render.js @@ -108,7 +108,8 @@ function createStyleElement({style, name: nameLC}) { parts.homepage.href = parts.homepage.title = style.url || ''; parts.infoVer.textContent = ud ? ud.version : ''; parts.infoVer.dataset.value = ud ? ud.version : ''; - if (URLS.extractUsoArchiveId(style.updateUrl)) { + // USO-raw and USO-archive version is a date for which we show the Age column + if (ud && (style.md5Url || URLS.extractUsoArchiveId(style.updateUrl))) { parts.infoVer.dataset.isDate = ''; } else { delete parts.infoVer.dataset.isDate; diff --git a/manifest.json b/manifest.json index b70aae91..3b21cb00 100644 --- a/manifest.json +++ b/manifest.json @@ -48,6 +48,7 @@ "background/update-manager.js", "background/usercss-install-helper.js", "background/usercss-manager.js", + "background/uso-api.js", "background/usw-api.js", "background/style-manager.js", diff --git a/popup/search.js b/popup/search.js index 48e897c9..4c57e627 100644 --- a/popup/search.js +++ b/popup/search.js @@ -41,7 +41,6 @@ * --------------------- Stylus' internally added extras * @prop {boolean} installed * @prop {number} installedStyleId - * @prop {number} pingbackTimer */ /** @type IndexEntry[] */ let results; @@ -149,7 +148,7 @@ restoreScrollPosition(); const result = results.find(r => r.installedStyleId === id); if (result) { - clearTimeout(result.pingbackTimer); + API.uso.pingback(result.i, false); renderActionButtons(result.i, -1); } }); @@ -437,11 +436,7 @@ installButton.disabled = true; entry.style.setProperty('pointer-events', 'none', 'important'); delete entry.dataset.error; - if (fmt) { - // FIXME: move this to background page and create an API like installUSOStyle - result.pingbackTimer = setTimeout(download, PINGBACK_DELAY, - `${URLS.uso}styles/install/${id}?source=stylish-ch`); - } + if (fmt) API.uso.pingback(id, PINGBACK_DELAY); const updateUrl = fmt ? URLS.makeUsoArchiveCodeUrl(id) : URLS.makeUswCodeUrl(id);