| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351 |
- /* global API */// msg.js
- /* global RX_META URLS debounce download ignoreChromeError */// toolbox.js
- /* global calcStyleDigest styleJSONseemsValid styleSectionsEqual */ // sections-util.js
- /* global chromeLocal */// storage-util.js
- /* global compareVersion */// cmpver.js
- /* global db */
- /* global prefs */
- 'use strict';
- /* exported updateMan */
- const updateMan = (() => {
- const STATES = /** @namespace UpdaterStates */ {
- UPDATED: 'updated',
- SKIPPED: 'skipped',
- UNREACHABLE: 'server unreachable',
- // details for SKIPPED status
- EDITED: 'locally edited',
- MAYBE_EDITED: 'may be locally edited',
- SAME_MD5: 'up-to-date: MD5 is unchanged',
- SAME_CODE: 'up-to-date: code sections are unchanged',
- SAME_VERSION: 'up-to-date: version is unchanged',
- ERROR_MD5: 'error: MD5 is invalid',
- ERROR_JSON: 'error: JSON is invalid',
- ERROR_VERSION: 'error: version is older than installed style',
- };
- const RH_ETAG = {responseHeaders: ['etag']}; // a hashsum of file contents
- const RX_DATE2VER = new RegExp([
- /^(\d{4})/,
- /(0[1-9]|1(?:0|[12](?=\d\d))?|[2-9])/, // in ambiguous cases like yyyy123 the month will be 1
- /(0[1-9]|[1-2][0-9]?|3[0-1]?|[4-9])/,
- /\.([01][0-9]?|2[0-3]?|[3-9])/,
- /\.([0-5][0-9]?|[6-9])$/,
- ].map(rx => rx.source).join(''));
- const ALARM_NAME = 'scheduledUpdate';
- const MIN_INTERVAL_MS = 60e3;
- const RETRY_ERRORS = [
- 503, // service unavailable
- 429, // too many requests
- ];
- let lastUpdateTime;
- let checkingAll = false;
- let logQueue = [];
- let logLastWriteTime = 0;
- chromeLocal.getValue('lastUpdateTime').then(val => {
- lastUpdateTime = val || Date.now();
- prefs.subscribe('updateInterval', schedule, {runNow: true});
- chrome.alarms.onAlarm.addListener(onAlarm);
- });
- return {
- checkAllStyles,
- checkStyle,
- getStates: () => STATES,
- };
- async function checkAllStyles({
- save = true,
- ignoreDigest,
- observe,
- } = {}) {
- resetInterval();
- checkingAll = true;
- const port = observe && chrome.runtime.connect({name: 'updater'});
- const styles = (await API.styles.getAll())
- .filter(style => style.updateUrl);
- if (port) port.postMessage({count: styles.length});
- log('');
- log(`${save ? 'Scheduled' : 'Manual'} update check for ${styles.length} styles`);
- await Promise.all(
- styles.map(style =>
- checkStyle({style, port, save, ignoreDigest})));
- if (port) port.postMessage({done: true});
- if (port) port.disconnect();
- log('');
- checkingAll = false;
- }
- /**
- * @param {{
- id?: number
- style?: StyleObj
- port?: chrome.runtime.Port
- save?: boolean = true
- ignoreDigest?: boolean
- }} opts
- * @returns {{
- style: StyleObj
- updated?: boolean
- error?: any
- STATES: UpdaterStates
- }}
- Original style digests are calculated in these cases:
- * style is installed or updated from server
- * non-usercss style is checked for an update and styleSectionsEqual considers it unchanged
- Update check proceeds in these cases:
- * style has the original digest and it's equal to the current digest
- * [ignoreDigest: true] style doesn't yet have the original digest but we ignore it
- * [ignoreDigest: none/false] style doesn't yet have the original digest
- so we compare the code to the server code and if it's the same we save the digest,
- otherwise we skip the style and report MAYBE_EDITED status
- 'ignoreDigest' option is set on the second manual individual update check on the manage page.
- */
- async function checkStyle(opts) {
- let {id} = opts;
- const {
- style = await API.styles.get(id),
- ignoreDigest,
- port,
- save,
- } = opts;
- if (!id) id = style.id;
- const ucd = style.usercssData;
- let res, state;
- try {
- await checkIfEdited();
- res = {
- style: await (ucd ? updateUsercss : updateUSO)().then(maybeSave),
- updated: true,
- };
- state = STATES.UPDATED;
- } catch (err) {
- const error = err === 0 && STATES.UNREACHABLE ||
- err && err.message ||
- err;
- res = {error, style, STATES};
- state = `${STATES.SKIPPED} (${error})`;
- }
- log(`${state} #${id} ${style.customName || style.name}`);
- if (port) port.postMessage(res);
- return res;
- async function checkIfEdited() {
- if (!ignoreDigest &&
- style.originalDigest &&
- style.originalDigest !== await calcStyleDigest(style)) {
- return Promise.reject(STATES.EDITED);
- }
- }
- 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);
- 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);
- }
- // USO may not provide a correctly updated originalMd5 (#555)
- json.originalMd5 = md5;
- return json;
- }
- async function updateToUSOArchive(url, req) {
- const m2 = getUsoEmbeddedMeta(req.response);
- if (m2) {
- url = (await 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 {etag: oldEtag, updateUrl} = style;
- let m2 = URLS.extractUsoArchiveId(updateUrl) && getUsoEmbeddedMeta();
- if (m2 && (m2 = await m2).updateUrl) {
- updateUrl = m2.updateUrl;
- oldVer = m2.usercssData.version || '0';
- oldEtag = '';
- }
- if (oldEtag && oldEtag === await downloadEtag()) {
- return Promise.reject(STATES.SAME_CODE);
- }
- // TODO: when sourceCode is > 100kB use http range request(s) for version check
- const {headers: {etag}, response} = await tryDownload(updateUrl, RH_ETAG);
- const json = await API.usercss.buildMeta({sourceCode: response, etag, updateUrl});
- const delta = compareVersion(json.usercssData.version, oldVer);
- let err;
- if (!delta && !ignoreDigest) {
- // re-install is invalid in a soft upgrade
- err = response === style.sourceCode
- ? STATES.SAME_CODE
- : !URLS.isLocalhost(updateUrl) && STATES.SAME_VERSION;
- }
- if (delta < 0) {
- // downgrade is always invalid
- err = STATES.ERROR_VERSION;
- }
- if (err && etag && !style.etag) {
- // first check of ETAG, gonna write it directly to DB as it's too trivial to sync or announce
- style.etag = etag;
- await db.exec('put', style);
- }
- return err
- ? Promise.reject(err)
- : API.usercss.buildCode(json);
- }
- async function maybeSave(json) {
- json.id = id;
- // keep current state
- delete json.customName;
- delete json.enabled;
- const newStyle = Object.assign({}, style, json);
- newStyle.updateDate = getDateFromVer(newStyle) || Date.now();
- // update digest even if save === false as there might be just a space added etc.
- if (!ucd && styleSectionsEqual(json, style)) {
- style.originalDigest = (await API.styles.install(newStyle)).originalDigest;
- return Promise.reject(STATES.SAME_CODE);
- }
- if (!style.originalDigest && !ignoreDigest) {
- return Promise.reject(STATES.MAYBE_EDITED);
- }
- return !save ? newStyle :
- (ucd ? API.usercss.install : API.styles.install)(newStyle);
- }
- async function tryDownload(url, params) {
- let {retryDelay = 1000} = opts;
- while (true) {
- try {
- return await download(url, params);
- } catch (code) {
- if (!RETRY_ERRORS.includes(code) ||
- retryDelay > MIN_INTERVAL_MS) {
- return Promise.reject(code);
- }
- }
- retryDelay *= 1.25;
- await new Promise(resolve => setTimeout(resolve, retryDelay));
- }
- }
- async function downloadEtag() {
- const opts = Object.assign({method: 'head'}, RH_ETAG);
- const req = await tryDownload(style.updateUrl, opts);
- return req.headers.etag;
- }
- function getDateFromVer(style) {
- const m = URLS.extractUsoArchiveId(style.updateUrl) &&
- style.usercssData.version.match(RX_DATE2VER);
- if (m) {
- m[2]--; // month is 0-based in `Date` constructor
- return new Date(...m.slice(1)).getTime();
- }
- }
- /** 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);
- return m && API.usercss.buildMeta({sourceCode: m[0]}).catch(() => null);
- }
- function getVarOptByName(varDef, name) {
- return varDef.options.find(o => o.name === name);
- }
- }
- function schedule() {
- const interval = prefs.get('updateInterval') * 60 * 60 * 1000;
- if (interval > 0) {
- const elapsed = Math.max(0, Date.now() - lastUpdateTime);
- chrome.alarms.create(ALARM_NAME, {
- when: Date.now() + Math.max(MIN_INTERVAL_MS, interval - elapsed),
- });
- } else {
- chrome.alarms.clear(ALARM_NAME, ignoreChromeError);
- }
- }
- function onAlarm({name}) {
- if (name === ALARM_NAME) checkAllStyles();
- }
- function resetInterval() {
- chromeLocal.setValue('lastUpdateTime', lastUpdateTime = Date.now());
- schedule();
- }
- function log(text) {
- logQueue.push({text, time: new Date().toLocaleString()});
- debounce(flushQueue, text && checkingAll ? 1000 : 0);
- }
- async function flushQueue(lines) {
- if (!lines) {
- flushQueue(await chromeLocal.getValue('updateLog') || []);
- return;
- }
- const time = Date.now() - logLastWriteTime > 11e3 ?
- logQueue[0].time + ' ' :
- '';
- if (logQueue[0] && !logQueue[0].text) {
- logQueue.shift();
- if (lines[lines.length - 1]) lines.push('');
- }
- lines.splice(0, lines.length - 1000);
- lines.push(time + (logQueue[0] && logQueue[0].text || ''));
- lines.push(...logQueue.slice(1).map(item => item.text));
- chromeLocal.setValue('updateLog', lines);
- logLastWriteTime = Date.now();
- logQueue = [];
- }
- })();
|