Detect and don't update locally edited styles

This commit is contained in:
tophf 2017-04-23 15:19:18 +03:00
parent 36667dece1
commit 32ae088c03
5 changed files with 131 additions and 29 deletions

View File

@ -583,6 +583,18 @@
"message": "Update failed - server unreachable.", "message": "Update failed - server unreachable.",
"description": "Text that displays when an update check failed because the update server is 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": { "updateCheckSucceededNoUpdate": {
"message": "Style is up to date.", "message": "Style is up to date.",
"description": "Text that displays when an update check completed and no update is available" "description": "Text that displays when an update check completed and no update is available"

View File

@ -104,7 +104,10 @@ function saveStyleCode(message, name, addProps) {
} }
getResource(getMeta('stylish-code-chrome')).then(code => { getResource(getMeta('stylish-code-chrome')).then(code => {
chrome.runtime.sendMessage( chrome.runtime.sendMessage(
Object.assign(JSON.parse(code), addProps, {method: 'saveStyle'}), Object.assign(JSON.parse(code), addProps, {
method: 'saveStyle',
reason: 'update',
}),
() => sendEvent('styleInstalledChrome') () => sendEvent('styleInstalledChrome')
); );
resolve(); resolve();

View File

@ -573,6 +573,10 @@ function reportUpdateState(state, style, details) {
details = t('updateCheckFailServerUnreachable'); details = t('updateCheckFailServerUnreachable');
} else if (typeof details == 'number') { } else if (typeof details == 'number') {
details = t('updateCheckFailBadResponseCode', [details]); 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 = const same =
details == BG.updater.SKIPPED_SAME_MD5 || details == BG.updater.SKIPPED_SAME_MD5 ||

View File

@ -5,6 +5,7 @@ const RX_NAMESPACE = new RegExp([/[\s\r\n]*/,
/[\s\r\n]*/].map(rx => rx.source).join(''), 'g'); /[\s\r\n]*/].map(rx => rx.source).join(''), 'g');
const RX_CSS_COMMENTS = /\/\*[\s\S]*?\*\//g; const RX_CSS_COMMENTS = /\/\*[\s\S]*?\*\//g;
const SLOPPY_REGEXP_PREFIX = '\0'; const SLOPPY_REGEXP_PREFIX = '\0';
const DIGEST_KEY_PREFIX = 'originalDigest';
// Note, only 'var'-declared variables are visible from another extension page // Note, only 'var'-declared variables are visible from another extension page
// eslint-disable-next-line no-var // 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) { function getDatabase(ready, error) {
const dbOpenRequest = window.indexedDB.open('stylish', 2); const dbOpenRequest = window.indexedDB.open('stylish', 2);
@ -214,7 +235,7 @@ function saveStyle(style) {
const existed = Boolean(eventGet.target.result); const existed = Boolean(eventGet.target.result);
const oldStyle = Object.assign({}, eventGet.target.result); const oldStyle = Object.assign({}, eventGet.target.result);
const codeIsUpdated = 'sections' in style && !styleSectionsEqual(style, oldStyle); const codeIsUpdated = 'sections' in style && !styleSectionsEqual(style, oldStyle);
write(Object.assign(oldStyle, style), {existed, codeIsUpdated}); write(Object.assign(oldStyle, style), {reason, existed, codeIsUpdated});
}; };
} else { } else {
// Create // Create
@ -226,10 +247,10 @@ function saveStyle(style) {
md5Url: null, md5Url: null,
url: null, url: null,
originalMd5: null, originalMd5: null,
}, style)); }, style), {reason});
} }
function write(style, {existed, codeIsUpdated} = {}) { function write(style, {reason, existed, codeIsUpdated} = {}) {
style.sections = (style.sections || []).map(section => style.sections = (style.sections || []).map(section =>
Object.assign({ Object.assign({
urls: [], urls: [],
@ -248,6 +269,9 @@ function saveStyle(style) {
style, codeIsUpdated, reason, style, codeIsUpdated, reason,
}); });
} }
if (reason == 'update') {
updateStyleDigest(style);
}
resolve(style); resolve(style);
}; };
} }
@ -257,6 +281,7 @@ function saveStyle(style) {
function deleteStyle({id, notify = true}) { function deleteStyle({id, notify = true}) {
chrome.storage.local.remove(DIGEST_KEY_PREFIX + id, ignoreChromeError);
return new Promise(resolve => return new Promise(resolve =>
getDatabase(db => { getDatabase(db => {
const tx = db.transaction(['styles'], 'readwrite'); const tx = db.transaction(['styles'], 'readwrite');
@ -507,3 +532,32 @@ function getDomains(url) {
} }
return domains; 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('');
}
}

View File

@ -1,4 +1,4 @@
/* globals getStyles, saveStyle, styleSectionsEqual */ /* globals getStyles, saveStyle, styleSectionsEqual, getStyleDigests, updateStyleDigest */
'use strict'; 'use strict';
// eslint-disable-next-line no-var // eslint-disable-next-line no-var
@ -7,6 +7,8 @@ var updater = {
COUNT: 'count', COUNT: 'count',
UPDATED: 'updated', UPDATED: 'updated',
SKIPPED: 'skipped', SKIPPED: 'skipped',
SKIPPED_EDITED: 'locally edited',
SKIPPED_MAYBE_EDITED: 'maybe locally edited',
SKIPPED_SAME_MD5: 'up-to-date: MD5 is unchanged', SKIPPED_SAME_MD5: 'up-to-date: MD5 is unchanged',
SKIPPED_SAME_CODE: 'up-to-date: code sections are unchanged', SKIPPED_SAME_CODE: 'up-to-date: code sections are unchanged',
SKIPPED_ERROR_MD5: 'error: MD5 is invalid', SKIPPED_ERROR_MD5: 'error: MD5 is invalid',
@ -32,33 +34,60 @@ var updater = {
}, },
checkStyle(style, observe = () => {}, {save = true} = {}) { checkStyle(style, observe = () => {}, {save = true} = {}) {
return download(style.md5Url) let hasDigest;
.then(md5 => return getStyleDigests(style)
!md5 || md5.length != 32 ? Promise.reject(updater.SKIPPED_ERROR_MD5) : .then(fetchMd5IfNotEdited)
md5 == style.originalMd5 ? Promise.reject(updater.SKIPPED_SAME_MD5) : .then(fetchCodeIfMd5Changed)
style.updateUrl) .then(saveIfUpdated)
.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)
.then(saved => observe(updater.UPDATED, saved)) .then(saved => observe(updater.UPDATED, saved))
.catch(err => observe(updater.SKIPPED, style, err)); .catch(err => observe(updater.SKIPPED, style, err));
},
styleJSONseemsValid(json) { 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 return json
&& json.sections && json.sections
&& json.sections.length && json.sections.length
&& typeof json.sections.every == 'function' && typeof json.sections.every == 'function'
&& typeof json.sections[0].code == 'string'; && typeof json.sections[0].code == 'string';
}
}, },
schedule() { schedule() {