diff --git a/_locales/en/messages.json b/_locales/en/messages.json index b1cd5297..ae1f59bd 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -583,6 +583,18 @@ "message": "Update failed - server unreachable.", "description": "Text that displays when an update check failed because the update server is unreachable" }, + "updateCheckSkippedLocallyEdited": { + "message": "This style was edited locally.", + "description": "Text that displays when an update check skipped updating the style to avoid losing local modifications" + }, + "updateCheckSkippedMaybeLocallyEdited": { + "message": "This style might have been edited locally.", + "description": "Text that displays when an update check skipped updating the style to avoid losing possible local modifications" + }, + "updateCheckManualUpdateHint": { + "message": "Do a one-time manual update on its userstyles.org page (your edits will be lost)", + "description": "Additional text displayed when an update check skipped updating the style to avoid losing local modifications" + }, "updateCheckSucceededNoUpdate": { "message": "Style is up to date.", "description": "Text that displays when an update check completed and no update is available" diff --git a/install.js b/install.js index 770758a4..9923bad3 100644 --- a/install.js +++ b/install.js @@ -104,7 +104,10 @@ function saveStyleCode(message, name, addProps) { } getResource(getMeta('stylish-code-chrome')).then(code => { chrome.runtime.sendMessage( - Object.assign(JSON.parse(code), addProps, {method: 'saveStyle'}), + Object.assign(JSON.parse(code), addProps, { + method: 'saveStyle', + reason: 'update', + }), () => sendEvent('styleInstalledChrome') ); resolve(); diff --git a/manage.js b/manage.js index f1a42779..cfb04e66 100644 --- a/manage.js +++ b/manage.js @@ -573,6 +573,10 @@ function reportUpdateState(state, style, details) { details = t('updateCheckFailServerUnreachable'); } else if (typeof details == 'number') { details = t('updateCheckFailBadResponseCode', [details]); + } else if (details == BG.updater.SKIPPED_EDITED) { + details = t('updateCheckSkippedLocallyEdited') + '\n' + t('updateCheckManualUpdateHint'); + } else if (details == BG.updater.SKIPPED_MAYBE_EDITED) { + details = t('updateCheckSkippedMaybeLocallyEdited') + '\n' + t('updateCheckManualUpdateHint'); } const same = details == BG.updater.SKIPPED_SAME_MD5 || diff --git a/storage.js b/storage.js index 2ea84ecc..a98e95f1 100644 --- a/storage.js +++ b/storage.js @@ -5,6 +5,7 @@ const RX_NAMESPACE = new RegExp([/[\s\r\n]*/, /[\s\r\n]*/].map(rx => rx.source).join(''), 'g'); const RX_CSS_COMMENTS = /\/\*[\s\S]*?\*\//g; const SLOPPY_REGEXP_PREFIX = '\0'; +const DIGEST_KEY_PREFIX = 'originalDigest'; // Note, only 'var'-declared variables are visible from another extension page // eslint-disable-next-line no-var @@ -21,6 +22,26 @@ var cachedStyles = { }, }; +// eslint-disable-next-line no-var +var chromeLocal = { + get(options) { + return new Promise(resolve => { + chrome.storage.local.get(options, data => resolve(data)); + }); + }, + set(data) { + return new Promise(resolve => { + chrome.storage.local.set(data, () => resolve(data)); + }); + }, + getValue(key) { + return chromeLocal.get(key).then(data => data[key]); + }, + setValue(key, value) { + return chromeLocal.set({[key]: value}); + }, +}; + function getDatabase(ready, error) { const dbOpenRequest = window.indexedDB.open('stylish', 2); @@ -214,7 +235,7 @@ function saveStyle(style) { const existed = Boolean(eventGet.target.result); const oldStyle = Object.assign({}, eventGet.target.result); const codeIsUpdated = 'sections' in style && !styleSectionsEqual(style, oldStyle); - write(Object.assign(oldStyle, style), {existed, codeIsUpdated}); + write(Object.assign(oldStyle, style), {reason, existed, codeIsUpdated}); }; } else { // Create @@ -226,10 +247,10 @@ function saveStyle(style) { md5Url: null, url: null, originalMd5: null, - }, style)); + }, style), {reason}); } - function write(style, {existed, codeIsUpdated} = {}) { + function write(style, {reason, existed, codeIsUpdated} = {}) { style.sections = (style.sections || []).map(section => Object.assign({ urls: [], @@ -248,6 +269,9 @@ function saveStyle(style) { style, codeIsUpdated, reason, }); } + if (reason == 'update') { + updateStyleDigest(style); + } resolve(style); }; } @@ -257,6 +281,7 @@ function saveStyle(style) { function deleteStyle({id, notify = true}) { + chrome.storage.local.remove(DIGEST_KEY_PREFIX + id, ignoreChromeError); return new Promise(resolve => getDatabase(db => { const tx = db.transaction(['styles'], 'readwrite'); @@ -507,3 +532,32 @@ function getDomains(url) { } return domains; } + + +function getStyleDigests(style) { + return Promise.all([ + chromeLocal.getValue(DIGEST_KEY_PREFIX + style.id), + calcStyleDigest(style), + ]); +} + + +function updateStyleDigest(style) { + calcStyleDigest(style).then(digest => + chromeLocal.set({[DIGEST_KEY_PREFIX + style.id]: digest})); +} + + +function calcStyleDigest({sections}) { + const text = new TextEncoder('utf-8').encode(JSON.stringify(sections)); + return crypto.subtle.digest('SHA-1', text).then(hex); + function hex(buffer) { + const parts = []; + const PAD8 = '00000000'; + const view = new DataView(buffer); + for (let i = 0; i < view.byteLength; i += 4) { + parts.push((PAD8 + view.getUint32(i).toString(16)).slice(-8)); + } + return parts.join(''); + } +} diff --git a/update.js b/update.js index aeee808b..1055a55b 100644 --- a/update.js +++ b/update.js @@ -1,4 +1,4 @@ -/* globals getStyles, saveStyle, styleSectionsEqual */ +/* globals getStyles, saveStyle, styleSectionsEqual, getStyleDigests, updateStyleDigest */ 'use strict'; // eslint-disable-next-line no-var @@ -7,6 +7,8 @@ var updater = { COUNT: 'count', UPDATED: 'updated', SKIPPED: 'skipped', + SKIPPED_EDITED: 'locally edited', + SKIPPED_MAYBE_EDITED: 'maybe locally edited', SKIPPED_SAME_MD5: 'up-to-date: MD5 is unchanged', SKIPPED_SAME_CODE: 'up-to-date: code sections are unchanged', SKIPPED_ERROR_MD5: 'error: MD5 is invalid', @@ -32,33 +34,60 @@ var updater = { }, checkStyle(style, observe = () => {}, {save = true} = {}) { - return download(style.md5Url) - .then(md5 => - !md5 || md5.length != 32 ? Promise.reject(updater.SKIPPED_ERROR_MD5) : - md5 == style.originalMd5 ? Promise.reject(updater.SKIPPED_SAME_MD5) : - style.updateUrl) - .then(download) - .then(text => tryJSONparse(text)) - .then(json => - !updater.styleJSONseemsValid(json) ? Promise.reject(updater.SKIPPED_ERROR_JSON) : - styleSectionsEqual(json, style) ? Promise.reject(updater.SKIPPED_SAME_CODE) : - // keep the local name as it could've been customized by the user - Object.assign(json, { - id: style.id, - name: null, - reason: 'update', - })) - .then(json => save ? saveStyle(json) : json) + let hasDigest; + return getStyleDigests(style) + .then(fetchMd5IfNotEdited) + .then(fetchCodeIfMd5Changed) + .then(saveIfUpdated) .then(saved => observe(updater.UPDATED, saved)) .catch(err => observe(updater.SKIPPED, style, err)); - }, - styleJSONseemsValid(json) { - return json - && json.sections - && json.sections.length - && typeof json.sections.every == 'function' - && typeof json.sections[0].code == 'string'; + function fetchMd5IfNotEdited([originalDigest, current]) { + hasDigest = Boolean(originalDigest); + if (hasDigest && originalDigest != current) { + return Promise.reject(updater.SKIPPED_EDITED); + } + return download(style.md5Url); + } + + function fetchCodeIfMd5Changed(md5) { + if (!md5 || md5.length != 32) { + return Promise.reject(updater.SKIPPED_ERROR_MD5); + } + if (md5 == style.originalMd5 && hasDigest) { + return Promise.reject(updater.SKIPPED_SAME_MD5); + } + return download(style.updateUrl); + } + + function saveIfUpdated(text) { + const json = tryJSONparse(text); + if (!styleJSONseemsValid(json)) { + return Promise.reject(updater.SKIPPED_ERROR_JSON); + } + json.id = style.id; + if (styleSectionsEqual(json, style)) { + if (!hasDigest) { + updateStyleDigest(json); + } + return Promise.reject(updater.SKIPPED_SAME_CODE); + } else if (!hasDigest) { + return Promise.reject(updater.SKIPPED_MAYBE_EDITED); + } + return !save ? json : + saveStyle(Object.assign(json, { + name: null, // keep local name customizations + reason: 'update', + })); + } + + function styleJSONseemsValid(json) { + return json + && json.sections + && json.sections.length + && typeof json.sections.every == 'function' + && typeof json.sections[0].code == 'string'; + } }, schedule() {