fix USO site installation (#1461)

This commit is contained in:
tophf 2022-08-03 22:37:04 +03:00 committed by GitHub
parent 6995483ec0
commit 685bf1fa3e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 495 additions and 434 deletions

View File

@ -6,16 +6,9 @@
/* global syncMan */ /* global syncMan */
/* global updateMan */ /* global updateMan */
/* global usercssMan */ /* global usercssMan */
/* global usoApi */
/* global uswApi */ /* global uswApi */
/* global /* global FIREFOX UA activateTab findExistingTab openURL */ // toolbox.js
FIREFOX
UA
URLS
activateTab
download
findExistingTab
openURL
*/ // toolbox.js
/* global colorScheme */ // color-scheme.js /* global colorScheme */ // color-scheme.js
'use strict'; 'use strict';
@ -42,17 +35,12 @@ addAPI(/** @namespace API */ {
sync: syncMan, sync: syncMan,
updater: updateMan, updater: updateMan,
usercss: usercssMan, usercss: usercssMan,
uso: usoApi,
usw: uswApi, usw: uswApi,
colorScheme, colorScheme,
/** @type {BackgroundWorker} */ /** @type {BackgroundWorker} */
worker: createWorker({url: '/background/background-worker'}), 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} */ /** @returns {string} */
getTabUrlPrefix() { getTabUrlPrefix() {
return this.sender.tab.url.match(/^([\w-]+:\/+[^/#]+)/)[1]; return this.sender.tab.url.match(/^([\w-]+:\/+[^/#]+)/)[1];

View File

@ -139,12 +139,14 @@ const styleMan = (() => {
}, },
/** @returns {Promise<?StyleObj>} */ /** @returns {Promise<?StyleObj>} */
async find(filter) { async find(...filters) {
if (ready.then) await ready; if (ready.then) await ready;
const filterEntries = Object.entries(filter); for (const filter of filters) {
for (const {style} of dataMap.values()) { const filterEntries = Object.entries(filter);
if (filterEntries.every(([key, val]) => style[key] === val)) { for (const {style} of dataMap.values()) {
return style; if (filterEntries.every(([key, val]) => style[key] === val)) {
return style;
}
} }
} }
return null; return null;

View File

@ -1,6 +1,6 @@
/* global API */// msg.js /* global API */// msg.js
/* global RX_META URLS debounce deepMerge download ignoreChromeError */// toolbox.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 chromeLocal */// storage-util.js
/* global compareVersion */// cmpver.js /* global compareVersion */// cmpver.js
/* global db */ /* global db */
@ -23,6 +23,7 @@ const updateMan = (() => {
ERROR_JSON: 'error: JSON is invalid', ERROR_JSON: 'error: JSON is invalid',
ERROR_VERSION: 'error: version is older than installed style', 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 RH_ETAG = {responseHeaders: ['etag']}; // a hashsum of file contents
const RX_DATE2VER = new RegExp([ const RX_DATE2VER = new RegExp([
/^(\d{4})/, /^(\d{4})/,
@ -37,6 +38,7 @@ const updateMan = (() => {
503, // service unavailable 503, // service unavailable
429, // too many requests 429, // too many requests
]; ];
let usoReferers = 0;
let lastUpdateTime; let lastUpdateTime;
let checkingAll = false; let checkingAll = false;
let logQueue = []; let logQueue = [];
@ -113,12 +115,13 @@ const updateMan = (() => {
save, save,
} = opts; } = opts;
if (!id) id = style.id; if (!id) id = style.id;
const ucd = style.usercssData; const {md5Url} = style;
let {usercssData: ucd, updateUrl} = style;
let res, state; let res, state;
try { try {
await checkIfEdited(); await checkIfEdited();
res = { res = {
style: await (ucd ? updateUsercss : updateUSO)().then(maybeSave), style: await (ucd && !md5Url ? updateUsercss : updateUSO)().then(maybeSave),
updated: true, updated: true,
}; };
state = STATES.UPDATED; state = STATES.UPDATED;
@ -142,76 +145,45 @@ const updateMan = (() => {
} }
async function updateUSO() { async function updateUSO() {
const url = URLS.makeUsoArchiveCodeUrl(style.md5Url.match(/\d+/)[0]); const md5 = await tryDownload(md5Url);
const req = await tryDownload(url, RH_ETAG).catch(() => null);
if (req) {
return updateToUSOArchive(url, req);
}
const md5 = await tryDownload(style.md5Url);
if (!md5 || md5.length !== 32) { if (!md5 || md5.length !== 32) {
return Promise.reject(STATES.ERROR_MD5); return Promise.reject(STATES.ERROR_MD5);
} }
if (md5 === style.originalMd5 && style.originalDigest && !ignoreDigest) { if (md5 === style.originalMd5 && style.originalDigest && !ignoreDigest) {
return Promise.reject(STATES.SAME_MD5); return Promise.reject(STATES.SAME_MD5);
} }
const json = await tryDownload(style.updateUrl, {responseType: 'json'}); let varsUrl = '';
if (!styleJSONseemsValid(json)) { if (!ucd) {
return Promise.reject(STATES.ERROR_JSON); 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) // USO may not provide a correctly updated originalMd5 (#555)
json.originalMd5 = md5; json.originalMd5 = md5;
return json; return json;
} }
async function updateToUSOArchive(url, req) { async function updateUsercss(css) {
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() {
let oldVer = ucd.version; let oldVer = ucd.version;
let {etag: oldEtag, updateUrl} = style; 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) { if (m2 && m2.updateUrl) {
updateUrl = m2.updateUrl; updateUrl = m2.updateUrl;
oldVer = m2.usercssData.version || '0'; oldVer = m2.usercssData.version || '0';
oldEtag = ''; oldEtag = '';
} else if (css) {
return;
} }
if (oldEtag && oldEtag === await downloadEtag()) { if (oldEtag && oldEtag === await downloadEtag()) {
return Promise.reject(STATES.SAME_CODE); return Promise.reject(STATES.SAME_CODE);
@ -284,8 +256,7 @@ const updateMan = (() => {
} }
function getDateFromVer(style) { function getDateFromVer(style) {
const m = URLS.extractUsoArchiveId(style.updateUrl) && const m = RX_DATE2VER.exec((style.usercssData || {}).version);
style.usercssData.version.match(RX_DATE2VER);
if (m) { if (m) {
m[2]--; // month is 0-based in `Date` constructor m[2]--; // month is 0-based in `Date` constructor
return new Date(...m.slice(1)).getTime(); 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 */ /** UserCSS metadata may be embedded in the original USO style so let's use its updateURL */
function getUsoEmbeddedMeta(code = style.sourceCode) { 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); return m && API.usercss.buildMeta({sourceCode: m[0]}).catch(() => null);
} }
function getVarOptByName(varDef, name) {
return varDef.options.find(o => o.name === name);
}
} }
function schedule() { function schedule() {
@ -349,4 +317,32 @@ const updateMan = (() => {
logLastWriteTime = Date.now(); logLastWriteTime = Date.now();
logQueue = []; 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};
}
}
})(); })();

View File

@ -12,10 +12,12 @@ const usercssMan = {
name: null, name: null,
}), }),
async assignVars(style, oldStyle) { /** `src` is a style or vars */
async assignVars(style, src) {
const meta = style.usercssData; const meta = style.usercssData;
const vars = meta.vars; const meta2 = src.usercssData;
const oldVars = (oldStyle.usercssData || {}).vars; const {vars} = meta;
const oldVars = meta2 ? meta2.vars : src;
if (vars && oldVars) { if (vars && oldVars) {
// The type of var might be changed during the update. Set value to null if the value is invalid. // 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)) { for (const [key, v] of Object.entries(vars)) {
@ -43,7 +45,7 @@ const usercssMan = {
let log; let log;
if (!metaOnly) { if (!metaOnly) {
if (vars || assignVars) { if (vars || assignVars) {
await usercssMan.assignVars(style, vars ? {usercssData: {vars}} : dup); await usercssMan.assignVars(style, vars || dup);
} }
await usercssMan.buildCode(style); await usercssMan.buildCode(style);
log = style.log; // extracting the non-enumerable prop, otherwise it won't survive messaging log = style.log; // extracting the non-enumerable prop, otherwise it won't survive messaging
@ -137,17 +139,18 @@ const usercssMan = {
} }
}, },
async install(style) { async install(style, opts) {
return API.styles.install(await usercssMan.parse(style)); return API.styles.install(await usercssMan.parse(style, opts));
}, },
async parse(style) { async parse(style, {dup, vars} = {}) {
style = await usercssMan.buildMeta(style); style = await usercssMan.buildMeta(style);
// preserve style.vars during update // preserve style.vars during update
const dup = await usercssMan.find(style); if (dup || (dup = await usercssMan.find(style))) {
if (dup) {
style.id = dup.id; style.id = dup.id;
await usercssMan.assignVars(style, dup); }
if (vars || (vars = dup)) {
await usercssMan.assignVars(style, vars);
} }
return usercssMan.buildCode(style); return usercssMan.buildCode(style);
}, },

157
background/uso-api.js Normal file
View File

@ -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)} <<<EOT${
val.includes('\n') ? '\n' : ' '}${val} EOT;\n`;
value = def ? s + value : value + s;
}
value = `{\n${value}}`;
break;
default:
value = '"ERROR: unknown type"';
}
return `\n@advanced ${type} ${ik} ${label} ${value}${suffix || ''}`;
}
function optByName(v, name) {
return v.options.find(o => 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]);
}
})();

View File

@ -1,378 +1,296 @@
/* global API msg */// msg.js /* global API */// msg.js
'use strict'; 'use strict';
// eslint-disable-next-line no-unused-expressions // eslint-disable-next-line no-unused-expressions
/^\/styles\/(\d+)(\/([^/]*))?([?#].*)?$/.test(location.pathname) && (() => { /^\/styles\/(\d+)(\/([^/]*))?([?#].*)?$/.test(location.pathname) && (async () => {
const styleId = RegExp.$1; 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 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')); const mo = new MutationObserver(onMutation);
window.addEventListener(chrome.runtime.id + '-install', orphanCheck, true); const observeColors = isOn =>
isOn ? mo.observe(document.body, {subtree: true, attributes: true, attributeFilter: ['value']})
: mo.disconnect();
document.addEventListener('stylishInstallChrome', onClick); let style, dup, md5, pageData, badKeys;
document.addEventListener('stylishUpdateChrome', onClick);
msg.on(onMessage); runInPage(inPageContext, pageEventId, contentEventId, usoId, apiUrl);
addEventListener(orphanEventId, orphanCheck, true);
addEventListener('click', onClick, true);
togglePageListener(true);
let currentMd5; [md5, dup] = await Promise.all([
const md5Url = getMeta('stylish-md5-url') || `https://update.userstyles.org/${styleId}.md5`; fetch(md5Url).then(r => r.text()),
Promise.all([ API.styles.find({md5Url}, {installationUrl: `https://uso.kkx.one/style/${usoId}`})
API.styles.find({md5Url}), .then(sendVarsToPage),
getResource(md5Url), document.body || new Promise(resolve => addEventListener('load', resolve, {once: true})),
onDOMready(), ]);
]).then(checkUpdatability);
document.documentElement.appendChild( if (!dup.id) {
Object.assign(document.createElement('script'), { sendStylishEvent('styleCanBeInstalledChrome');
textContent: `(${inPageContext})('${pageEventId}')`, } 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');
function onMessage(msg) { } else {
switch (msg.method) { sendStylishEvent('styleAlreadyInstalledChrome');
case 'ping':
// orphaned content script check
return true;
case 'openSettings':
openSettings();
return true;
}
} }
/* since we are using "stylish-code-chrome" meta key on all browsers and async function onClick(e) {
US.o does not provide "advanced settings" on this url if browser is not Chrome, const el = e.target.closest(CLICK_SEL);
we need to fix this URL using "stylish-update-url" meta key if (!el) return;
*/ el.disabled = true;
function getStyleURL() { const {id} = dup;
const textUrl = getMeta('stylish-update-url') || ''; try {
const jsonUrl = getMeta('stylish-code-chrome') || if (el.matches(CLICK.uninstall)) {
textUrl.replace(/styles\/(\d+)\/[^?]*/, 'styles/chrome/$1.json'); dup = style = false;
const paramsMissing = !jsonUrl.includes('?') && textUrl.includes('?'); removeEventListener('change', onChange);
return jsonUrl + (paramsMissing ? textUrl.replace(/^[^?]+/, '') : ''); await API.styles.delete(id);
} return;
}
function checkUpdatability([installedStyle, md5]) { if (el.matches(CLICK.customize)) {
// TODO: remove the following statement when USO is fixed const isOn = dup && !$('#style-settings');
document.dispatchEvent(new CustomEvent(pageEventId, { toggleListener(isOn, 'change', onChange);
detail: installedStyle && installedStyle.updateUrl, observeColors(isOn);
})); return;
currentMd5 = md5; }
if (!installedStyle) { e.stopPropagation();
sendEvent({type: 'styleCanBeInstalledChrome'}); if (!style) await buildStyle();
return; style = dup = await API.usercss.install(style, {
} dup: {id},
const isCustomizable = /\?/.test(installedStyle.updateUrl); vars: getPageVars(),
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,
},
});
}); });
sendStylishEvent('styleInstalledChrome');
API.uso.pingback(id);
} catch (e) {
alert(chrome.i18n.getMessage('styleInstallFailed', e.message || e));
} finally {
el.disabled = false;
} }
} }
function onChange({target: el}) {
function sendEvent(event) { if (dup && el.matches('[name^="ik-"], [type=file]')) {
sendEvent.lastEvent = event; API.usercss.configVars(dup.id, getPageVars());
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 doInstall() { function onMutation(mutations) {
let oldStyle; for (const {target: el} of mutations) {
return API.styles.find({ if (el.style.display === 'none' &&
md5Url: getMeta('stylish-md5-url') || location.href, /^ik-/.test(el.name) &&
}) /^#[\da-f]{6}$/.test(el.value)) {
.then(_oldStyle => { onChange({target: el});
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 getMeta(name) { function onPageEvent(e) {
const e = document.querySelector(`link[rel="${name}"]`); pageData = e.detail;
return e ? e.getAttribute('href') : null; togglePageListener(false);
} }
async function getResource(url, opts) { async function buildStyle() {
try { if (!pageData) pageData = await (await fetch(apiUrl)).json();
return url.startsWith('#') ({style, badKeys} = await API.uso.toUsercss(pageData, {varsUrl: dup.updateUrl}));
? document.getElementById(url.slice(1)).textContent Object.assign(style, {
: await API.download(url, opts); md5Url,
} catch (error) { id: dup.id,
alert('Error\n' + error.message); originalMd5: md5,
return Promise.reject(error); updateUrl: apiUrl,
} });
} }
// USO providing md5Url as "https://update.update.userstyles.org/#####.md5" function getPageVars() {
// instead of "https://update.userstyles.org/#####.md5" const {vars} = (style || dup).usercssData;
async function getStyleJson() { for (const el of document.querySelectorAll('[name^="ik-"]')) {
try { const name = el.name.slice(3); // dropping "ik-"
const style = await getResource(getStyleURL(), {responseType: 'json'}); const ik = badKeys[name] || name;
const codeElement = document.getElementById('stylish-code'); const v = vars[ik] || false;
if (!style || !Array.isArray(style.sections) || style.sections.length || const isImage = el.type === 'radio';
codeElement && !codeElement.textContent.trim()) { if (v && (!isImage || el.checked)) {
return style; const val = el.value;
} const isFile = val === 'user-upload';
const code = await getResource(getMeta('stylish-update-url')); if (isImage && (isFile || val === 'user-url')) {
style.sections = (await API.worker.parseMozFormat({code})).sections; const el2 = $(`[type=${isFile ? 'file' : 'url'}]`, el.parentElement);
if (style.md5Url) style.md5Url = style.md5Url.replace('update.update', 'update'); const ikCust = `${ik}-custom`;
return style; v.value = `${ikCust}-dropdown`;
} catch (e) {} vars[ikCust].value = isFile ? getFileUriFromPage(el2) : el2.value;
}
/**
* 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'});
} else { } 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() { function orphanCheck() {
try { if (chrome.i18n) return true;
if (chrome.i18n.getUILanguage()) { removeEventListener(orphanEventId, orphanCheck, true);
return true; removeEventListener('click', onClick, true);
} removeEventListener('change', onChange);
} catch (e) {} sendPageEvent('quit');
// In Chrome content script is orphaned on an extension update/reload observeColors(false);
// so we need to detach event listeners togglePageListener(false);
window.removeEventListener(chrome.runtime.id + '-install', orphanCheck, true);
document.removeEventListener('stylishInstallChrome', onClick);
document.removeEventListener('stylishUpdateChrome', onClick);
try {
msg.off(onMessage);
} catch (e) {}
} }
})(); })();
function inPageContext(eventId) { function inPageContext(eventId, eventIdHost, styleId, apiUrl) {
document.currentScript.remove();
window.isInstalled = true; window.isInstalled = true;
const origMethods = { const {dispatchEvent, CustomEvent, removeEventListener} = window;
json: Response.prototype.json, const apply = Map.call.bind(Map.apply);
byId: document.getElementById, 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; RP.json = async function () {
// USO bug workaround: prevent errors in console after install and busy cursor const res = await apply(origJson, this, arguments);
document.getElementById = id => try {
origMethods.byId.call(document, id) || if (!done && this.url === apiUrl) {
(/^(stylish-code|stylish-installed-style-installed-\w+|post-install-ad|style-install-unknown)$/.test(id) RP.json = origJson;
? Object.assign(document.createElement('p'), {className: 'afterdownload-ad'}) done = true; // will be used if called by another script that saved our RP.json hook
: null); send(res);
// USO bug workaround: use the actual image data in customized settings setVars(res);
document.addEventListener(eventId, ({detail}) => { }
vars = /\?/.test(detail) && new URL(detail).searchParams; } catch (e) {}
if (!vars) Response.prototype.json = origMethods.json; return res;
}, {once: true}); };
Response.prototype.json = async function () { addEventListener(eventId, onCommand, true);
const json = await origMethods.json.apply(this, arguments); function onCommand(e) {
if (vars && json && Array.isArray(json.style_settings)) { if (e.detail === 'quit') {
Response.prototype.json = origMethods.json; removeEventListener(eventId, onCommand, true);
const images = new Map(); CR.sendMessage = sendMessage;
for (const ss of json.style_settings) { RP.json = origJson;
let value = vars.get('ik-' + ss.install_key); done = true;
if (!value || !(ss.style_setting_options || [])[0]) { } else if (/^vars:/.test(e.detail)) {
continue; vars = JSON.parse(e.detail.slice(5));
} } else if (e.relatedTarget) {
if (value.startsWith('ik-')) { send(e.relatedTarget.uploadedData);
value = value.replace(/^ik-/, ''); }
const def = ss.style_setting_options.find(item => item.default); }
if (!def || def.install_key !== value) { function send(data) {
if (def) def.default = false; dispatchEvent(new CustomEvent(eventIdHost, {__proto: null, detail: data}));
for (const item of ss.style_setting_options) { }
if (item.install_key === value) { function setVars(json) {
item.default = true; const images = new Map();
break; const isNew = typeof vars === 'object';
} const badKeys = {};
} const newKeys = [];
} const makeKey = ({install_key: key}) => {
} else if (ss.setting_type === 'image') { let res = isNew ? badKeys[key] : key;
let isListed; if (!res) {
for (const opt of ss.style_setting_options) { res = key.replace(/[^-\w]/g, '-');
isListed |= opt.default = (opt.value === value); res += newKeys.includes(res) ? '-' : '';
} if (key !== res) {
images.set(ss.install_key, {url: value, isListed}); badKeys[key] = res;
} else { newKeys.push(res);
const item = ss.style_setting_options[0];
if (item.value !== value && item.install_key === 'placeholder') {
item.value = value;
}
} }
} }
if (images.size) { return res;
new MutationObserver((_, observer) => { };
if (document.getElementById('style-settings')) { if (!isNew) vars = new URLSearchParams(vars);
observer.disconnect(); for (const ss of json.style_settings || []) {
for (const [name, {url, isListed}] of images) { const ik = makeKey(ss);
const elRadio = document.querySelector(`input[name="ik-${name}"][value="user-url"]`); let value = isNew ? (vars[ik] || {}).value : vars.get('ik-' + ik);
const elUrl = elRadio && if (value == null || !(ss.style_setting_options || [])[0]) {
document.getElementById(elRadio.id.replace('url-choice', 'user-url')); continue;
if (elUrl) { }
elRadio.checked = !isListed; if (ss.setting_type === 'image') {
elUrl.value = url; 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});
}
} }

View File

@ -108,7 +108,8 @@ function createStyleElement({style, name: nameLC}) {
parts.homepage.href = parts.homepage.title = style.url || ''; parts.homepage.href = parts.homepage.title = style.url || '';
parts.infoVer.textContent = ud ? ud.version : ''; parts.infoVer.textContent = ud ? ud.version : '';
parts.infoVer.dataset.value = 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 = ''; parts.infoVer.dataset.isDate = '';
} else { } else {
delete parts.infoVer.dataset.isDate; delete parts.infoVer.dataset.isDate;

View File

@ -48,6 +48,7 @@
"background/update-manager.js", "background/update-manager.js",
"background/usercss-install-helper.js", "background/usercss-install-helper.js",
"background/usercss-manager.js", "background/usercss-manager.js",
"background/uso-api.js",
"background/usw-api.js", "background/usw-api.js",
"background/style-manager.js", "background/style-manager.js",

View File

@ -41,7 +41,6 @@
* --------------------- Stylus' internally added extras * --------------------- Stylus' internally added extras
* @prop {boolean} installed * @prop {boolean} installed
* @prop {number} installedStyleId * @prop {number} installedStyleId
* @prop {number} pingbackTimer
*/ */
/** @type IndexEntry[] */ /** @type IndexEntry[] */
let results; let results;
@ -149,7 +148,7 @@
restoreScrollPosition(); restoreScrollPosition();
const result = results.find(r => r.installedStyleId === id); const result = results.find(r => r.installedStyleId === id);
if (result) { if (result) {
clearTimeout(result.pingbackTimer); API.uso.pingback(result.i, false);
renderActionButtons(result.i, -1); renderActionButtons(result.i, -1);
} }
}); });
@ -437,11 +436,7 @@
installButton.disabled = true; installButton.disabled = true;
entry.style.setProperty('pointer-events', 'none', 'important'); entry.style.setProperty('pointer-events', 'none', 'important');
delete entry.dataset.error; delete entry.dataset.error;
if (fmt) { if (fmt) API.uso.pingback(id, PINGBACK_DELAY);
// 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`);
}
const updateUrl = fmt ? URLS.makeUsoArchiveCodeUrl(id) : URLS.makeUswCodeUrl(id); const updateUrl = fmt ? URLS.makeUsoArchiveCodeUrl(id) : URLS.makeUswCodeUrl(id);