From 0f148eac32c759f4bdae3e2f69f38495f1a5a446 Mon Sep 17 00:00:00 2001 From: eight Date: Thu, 4 Oct 2018 03:35:07 +0800 Subject: [PATCH] WIP --- background/db.js | 134 ++++++++++++++++++ background/storage.js | 269 ------------------------------------ background/style-manager.js | 212 ++++++++++++++++++++++++++++ js/cache.js | 48 +++++++ 4 files changed, 394 insertions(+), 269 deletions(-) create mode 100644 background/db.js create mode 100644 background/style-manager.js create mode 100644 js/cache.js diff --git a/background/db.js b/background/db.js new file mode 100644 index 00000000..da088e41 --- /dev/null +++ b/background/db.js @@ -0,0 +1,134 @@ +const db = (() => { + let exec; + const preparing = prepare(); + return { + exec: (...args) => + preparing.then(() => exec(...args)) + }; + + function prepare() { + // we use chrome.storage.local fallback if IndexedDB doesn't save data, + // which, once detected on the first run, is remembered in chrome.storage.local + // for reliablility and in localStorage for fast synchronous access + // (FF may block localStorage depending on its privacy options) + + // test localStorage + const fallbackSet = localStorage.dbInChromeStorage; + if (fallbackSet === 'true' || !tryCatch(() => indexedDB)) { + useChromeStorage(); + return Promise.resolve(); + } + if (fallbackSet === 'false') { + useIndexedDB(); + return Promise.resolve(); + } + // test storage.local + return chromeLocal.get('dbInChromeStorage') + .then(data => + data && data.dbInChromeStorage && Promise.reject()) + .then(() => + tryCatch(dbExecIndexedDB, 'getAllKeys', IDBKeyRange.lowerBound(1), 1) || + Promise.reject()) + .then(({target}) => ( + (target.result || [])[0] ? + Promise.reject('ok') : + dbExecIndexedDB('put', {id: -1}))) + .then(() => + dbExecIndexedDB('get', -1)) + .then(({target}) => ( + (target.result || {}).id === -1 ? + dbExecIndexedDB('delete', -1) : + Promise.reject())) + .then(() => + Promise.reject('ok')) + .catch(result => { + if (result === 'ok') { + useIndexedDB(); + } else { + useChromeStorage(); + } + }); + } + + function useChromeStorage() { + exec = dbExecChromeStorage; + chromeLocal.set({dbInChromeStorage: true}, ignoreChromeError); + localStorage.dbInChromeStorage = 'true'; + } + + function useIndexedDB() { + exec = dbExecIndexedDB; + chromeLocal.set({dbInChromeStorage: false}, ignoreChromeError); + localStorage.dbInChromeStorage = 'false'; + } + + function dbExecIndexedDB(method, ...args) { + return new Promise((resolve, reject) => { + Object.assign(indexedDB.open('stylish', 2), { + onsuccess(event) { + const database = event.target.result; + if (!method) { + resolve(database); + } else { + const transaction = database.transaction(['styles'], 'readwrite'); + const store = transaction.objectStore('styles'); + Object.assign(store[method](...args), { + onsuccess: event => resolve(event, store, transaction, database), + onerror: reject, + }); + } + }, + onerror(event) { + console.warn(event.target.error || event.target.errorCode); + reject(event); + }, + onupgradeneeded(event) { + if (event.oldVersion === 0) { + event.target.result.createObjectStore('styles', { + keyPath: 'id', + autoIncrement: true, + }); + } + }, + }); + }); + } + + function dbExecChromeStorage(method, data) { + const STYLE_KEY_PREFIX = 'style-'; + switch (method) { + case 'get': + return chromeLocal.getValue(STYLE_KEY_PREFIX + data) + .then(result => ({target: {result}})); + + case 'put': + if (!data.id) { + return getStyles().then(() => { + data.id = 1; + for (const style of cachedStyles.list) { + data.id = Math.max(data.id, style.id + 1); + } + return dbExecChromeStorage('put', data); + }); + } + return chromeLocal.setValue(STYLE_KEY_PREFIX + data.id, data) + .then(() => (chrome.runtime.lastError ? Promise.reject() : data.id)); + + case 'delete': + return chromeLocal.remove(STYLE_KEY_PREFIX + data); + + case 'getAll': + return chromeLocal.get(null).then(storage => { + const styles = []; + for (const key in storage) { + if (key.startsWith(STYLE_KEY_PREFIX) && + Number(key.substr(STYLE_KEY_PREFIX.length))) { + styles.push(storage[key]); + } + } + return {target: {result: styles}}; + }); + } + return Promise.reject(); + } +})(); diff --git a/background/storage.js b/background/storage.js index 1dd81751..61ad39f3 100644 --- a/background/storage.js +++ b/background/storage.js @@ -29,139 +29,6 @@ var cachedStyles = { }, }; -// eslint-disable-next-line no-var -var dbExec = dbExecIndexedDB; -dbExec.initialized = false; - -// we use chrome.storage.local fallback if IndexedDB doesn't save data, -// which, once detected on the first run, is remembered in chrome.storage.local -// for reliablility and in localStorage for fast synchronous access -// (FF may block localStorage depending on its privacy options) -do { - const done = () => { - cachedStyles.mutex.inProgress = false; - getStyles().then(() => { - dbExec.initialized = true; - window.dispatchEvent(new Event('storageReady')); - }); - }; - const fallback = () => { - dbExec = dbExecChromeStorage; - chromeLocal.set({dbInChromeStorage: true}); - localStorage.dbInChromeStorage = 'true'; - ignoreChromeError(); - done(); - }; - const fallbackSet = localStorage.dbInChromeStorage; - if (fallbackSet === 'true' || !tryCatch(() => indexedDB)) { - fallback(); - break; - } else if (fallbackSet === 'false') { - done(); - break; - } - chromeLocal.get('dbInChromeStorage') - .then(data => - data && data.dbInChromeStorage && Promise.reject()) - .then(() => - tryCatch(dbExecIndexedDB, 'getAllKeys', IDBKeyRange.lowerBound(1), 1) || - Promise.reject()) - .then(({target}) => ( - (target.result || [])[0] ? - Promise.reject('ok') : - dbExecIndexedDB('put', {id: -1}))) - .then(() => - dbExecIndexedDB('get', -1)) - .then(({target}) => ( - (target.result || {}).id === -1 ? - dbExecIndexedDB('delete', -1) : - Promise.reject())) - .then(() => - Promise.reject('ok')) - .catch(result => { - if (result === 'ok') { - chromeLocal.set({dbInChromeStorage: false}); - localStorage.dbInChromeStorage = 'false'; - done(); - } else { - fallback(); - } - }); -} while (0); - - -function dbExecIndexedDB(method, ...args) { - return new Promise((resolve, reject) => { - Object.assign(indexedDB.open('stylish', 2), { - onsuccess(event) { - const database = event.target.result; - if (!method) { - resolve(database); - } else { - const transaction = database.transaction(['styles'], 'readwrite'); - const store = transaction.objectStore('styles'); - Object.assign(store[method](...args), { - onsuccess: event => resolve(event, store, transaction, database), - onerror: reject, - }); - } - }, - onerror(event) { - console.warn(event.target.error || event.target.errorCode); - reject(event); - }, - onupgradeneeded(event) { - if (event.oldVersion === 0) { - event.target.result.createObjectStore('styles', { - keyPath: 'id', - autoIncrement: true, - }); - } - }, - }); - }); -} - - -function dbExecChromeStorage(method, data) { - const STYLE_KEY_PREFIX = 'style-'; - switch (method) { - case 'get': - return chromeLocal.getValue(STYLE_KEY_PREFIX + data) - .then(result => ({target: {result}})); - - case 'put': - if (!data.id) { - return getStyles().then(() => { - data.id = 1; - for (const style of cachedStyles.list) { - data.id = Math.max(data.id, style.id + 1); - } - return dbExecChromeStorage('put', data); - }); - } - return chromeLocal.setValue(STYLE_KEY_PREFIX + data.id, data) - .then(() => (chrome.runtime.lastError ? Promise.reject() : data.id)); - - case 'delete': - return chromeLocal.remove(STYLE_KEY_PREFIX + data); - - case 'getAll': - return chromeLocal.get(null).then(storage => { - const styles = []; - for (const key in storage) { - if (key.startsWith(STYLE_KEY_PREFIX) && - Number(key.substr(STYLE_KEY_PREFIX.length))) { - styles.push(storage[key]); - } - } - return {target: {result: styles}}; - }); - } - return Promise.reject(); -} - - function getStyles(options) { if (cachedStyles.list) { return Promise.resolve(filterStyles(options)); @@ -172,24 +39,6 @@ function getStyles(options) { }); } cachedStyles.mutex.inProgress = true; - - return dbExec('getAll').then(event => { - cachedStyles.list = event.target.result || []; - cachedStyles.byId.clear(); - for (const style of cachedStyles.list) { - cachedStyles.byId.set(style.id, style); - if (!style.name) { - style.name = 'ID: ' + style.id; - } - } - - cachedStyles.mutex.inProgress = false; - for (const {options, resolve} of cachedStyles.mutex.onDone) { - resolve(filterStyles(options)); - } - cachedStyles.mutex.onDone = []; - return filterStyles(options); - }); } @@ -202,29 +51,10 @@ function filterStyles({ omitCode, strictRegexp = true, // used by the popup to detect bad regexps } = {}) { - if (id) id = Number(id); - if (asHash) enabled = true; - - if ( - enabled === null && - id === null && - matchUrl === null && - md5Url === null && - asHash !== true - ) { - return cachedStyles.list; - } - if (matchUrl && !URLS.supported(matchUrl)) { return asHash ? {length: 0} : []; } - const blankHash = asHash && { - length: 0, - disableAll: prefs.get('disableAll'), - exposeIframes: prefs.get('exposeIframes'), - }; - // make sure to use the same order in updateFiltersCache() const cacheKey = enabled + '\t' + @@ -287,14 +117,6 @@ function filterStylesInternal({ } } - const styles = id === null - ? cachedStyles.list - : [cachedStyles.byId.get(id)]; - if (!styles[0]) { - // may happen when users [accidentally] reopen an old URL - // of edit.html with a non-existent style id parameter - return asHash ? blankHash : []; - } const filtered = asHash ? {length: 0} : []; const needSections = asHash || matchUrl !== null; const matchUrlBase = matchUrl && matchUrl.includes('#') && matchUrl.split('#', 1)[0]; @@ -342,97 +164,6 @@ function filterStylesInternal({ function saveStyle(style) { - const id = Number(style.id) || null; - const reason = style.reason; - const notify = style.notify !== false; - delete style.method; - delete style.reason; - delete style.notify; - if (!style.name) { - delete style.name; - } - let existed; - let codeIsUpdated; - return maybeCalcDigest() - .then(maybeImportFix) - .then(decide); - - function maybeCalcDigest() { - if (['install', 'update', 'update-digest'].includes(reason)) { - return calcStyleDigest(style).then(digest => { - style.originalDigest = digest; - }); - } - return Promise.resolve(); - } - - function maybeImportFix() { - if (reason === 'import') { - style.originalDigest = style.originalDigest || style.styleDigest; // TODO: remove in the future - delete style.styleDigest; // TODO: remove in the future - if (typeof style.originalDigest !== 'string' || style.originalDigest.length !== 40) { - delete style.originalDigest; - } - } - } - - function decide() { - if (id !== null) { - // Update or create - style.id = id; - return dbExec('get', id).then((event, store) => { - const oldStyle = event.target.result; - existed = Boolean(oldStyle); - if (reason === 'update-digest' && oldStyle.originalDigest === style.originalDigest) { - return style; - } - codeIsUpdated = !existed - || 'sections' in style && !styleSectionsEqual(style, oldStyle) - || reason === 'exclusionsUpdated'; - style = Object.assign({installDate: Date.now()}, oldStyle, style); - return write(style, store); - }); - } else { - // Create - delete style.id; - style = Object.assign({ - // Set optional things if they're undefined - enabled: true, - updateUrl: null, - md5Url: null, - url: null, - originalMd5: null, - installDate: Date.now(), - exclusions: {} - }, style); - return write(style); - } - } - - function write(style, store) { - style.sections = normalizeStyleSections(style); - if (store) { - return new Promise(resolve => { - store.put(style).onsuccess = event => resolve(done(event)); - }); - } else { - return dbExec('put', style).then(done); - } - } - - function done(event) { - if (reason === 'update-digest') { - return style; - } - style.id = style.id || event.target.result; - invalidateCache(existed ? {updated: style} : {added: style}); - if (notify) { - const method = reason === 'exclusionsUpdated' ? reason : - existed ? 'styleUpdated' : 'styleAdded'; - notifyAllTabs({method, style, codeIsUpdated, reason}); - } - return style; - } } diff --git a/background/style-manager.js b/background/style-manager.js new file mode 100644 index 00000000..a5b97876 --- /dev/null +++ b/background/style-manager.js @@ -0,0 +1,212 @@ +const styleManager = (() => { + const preparing = prepare(); + const styles = new Map; + const cachedStyleForUrl = createCache(); + const compiledRe = createCache(); + const compiledExclusion = createCache(); + const BAD_MATCHER = {test: () => false}; + + // FIXME: do we have to prepare `styles` map for all methods? + return ensurePrepared({ + getSectionsForURL, + installStyle, + deleteStyle, + setStyleExclusions, + editSave + // TODO: get all styles API? + // TODO: get style by ID? + }); + + function editSave() {} + + function setStyleExclusions() {} + + function ensurePrepared(methods) { + for (const [name, fn] in Object.entries(methods)) { + methods[name] = (...args) => + preparing.then(() => fn(...args)); + } + return methods; + } + + function deleteStyle(id) { + return db.exec('delete', id) + .then(() => { + // FIXME: do we really need to clear the entire cache? + cachedStyleForUrl.clear(); + notifyAllTabs({method: 'styleDeleted', id}); + return id; + }); + } + + function installStyle(style) { + return calcStyleDigest(style) + .then(digest => { + style.originalDigest = digest; + return saveStyle(style); + }) + .then(style => { + // FIXME: do we really need to clear the entire cache? + cachedStyleForUrl.clear(); + // FIXME: invalid signature + notifyAllTabs(); + }); + } + + function importStyle(style) { + // FIXME: move this to importer + // style.originalDigest = style.originalDigest || style.styleDigest; // TODO: remove in the future + // delete style.styleDigest; // TODO: remove in the future + // if (typeof style.originalDigest !== 'string' || style.originalDigest.length !== 40) { + // delete style.originalDigest; + // } + } + + function saveStyle(style) { + return (style.id == null ? getNewStyle() : getOldStyle()) + .then(oldStyle => { + // FIXME: update installDate? + style = Object.assign(oldStyle, style); + style.sections = normalizeStyleSections(style); + return dbExec('put', style); + }) + .then(event => { + if (style.id == null) { + style.id = event.target.result; + } + return style; + }); + + function getOldStyle() { + return db.exec('get', style.id) + .then((event, store) => { + if (!event.target.result) { + throw new Error(`Unknown style id: ${style.id}`); + } + return event.target.result; + }); + } + + // FIXME: don't overwrite style name when the name is empty + + function getNewStyle() { + return Promise.resolve({ + enabled: true, + updateUrl: null, + md5Url: null, + url: null, + originalMd5: null, + installDate: Date.now() + }); + } + } + + function getSectionsForURL(url) { + // if (!URLS.supported(url) || prefs.get('disableAll')) { + // return []; + // } + let result = cachedStyleForUrl.get(url); + if (!result) { + result = []; + for (const style of styles) { + if (!urlMatchStyle(url, style)) { + continue; + } + const item = { + id: style.id, + code: '' + }; + for (const section of style.sections) { + if (urlMatchSection(url, section)) { + item.code += section.code; + } + } + if (item.code) { + result.push(item); + } + } + } + return result; + } + + function prepare() { + return db.exec('getAll').then(event => { + const styleList = event.target.result || []; + for (const style of styleList) { + styles.set(style.id, style); + if (!style.name) { + style.name = 'ID: ' + style.id; + } + } + }); + } + + function urlMatchStyle(url, style) { + if (style.exclusions && style.exclusions.some(e => compileExclusion(e).test(url)) { + return false; + } + return true; + } + + function urlMatchSection(url, section) { + // FIXME: match sub domains? + if (section.domains && section.domains.includes(getDomain(url))) { + return true; + } + if (section.urlPrefixes && section.urlPrefixes.some(p => url.startsWith(p))) { + return true; + } + if (section.urls && section.urls.includes(getUrlNoHash(url))) { + return true; + } + if (section.regexps && section.regexps.some(r => compileRe(r).test(url))) { + return true; + } + return false; + } + + function compileRe(text) { + let re = compiledRe.get(text); + if (!re) { + // FIXME: it should be `$({text})$` but we don't use the standard for compatibility + re = tryRegExp(`^${text}$`); + if (!re) { + re = BAD_MATCHER; + } + compiledRe.set(text, re); + } + return re; + } + + function compileExclusion(text) { + let re = compiledExclusion.get(text); + if (!re) { + re = tryRegExp(buildGlob(text)); + if (!re) { + re = BAD_MATCHER; + } + compiledExclusion.set(text, re); + } + return re; + } + + function buildGlob(text) { + const prefix = text[0] === '^' ? '' : '\\b'; + const suffix = text[text.length - 1] === '$' ? '' : '\\b'; + return `${prefix}${escape(text)}${suffix}`; + + function escape(text) { + // FIXME: using .* everywhere is slow + return text.replace(/[.*]/g, m => m === '.' ? '\\.' : '.*'); + } + } + + function getDomain(url) { + // FIXME: use a naive regexp + return url.match(/\w+:\/\//); + } + + function getUrlNoHash(url) { + return url.split('#')[0]; + } +})(); diff --git a/js/cache.js b/js/cache.js new file mode 100644 index 00000000..10400920 --- /dev/null +++ b/js/cache.js @@ -0,0 +1,48 @@ +function createCache(size = 1000) { + const map = new Map; + const buffer = Array(size); + let index = 0; + let lastIndex = 0; + return { + get, + set, + delete: delete_, + clear, + has: id => map.has(id), + get size: () => map.size + }; + + function get(id) { + const item = map.get(id); + return item && item.data; + } + + function set(id, data) { + if (map.size === size) { + // full + map.delete(buffer[lastIndex].id); + lastIndex = (lastIndex + 1) % size; + } + const item = {id, data, index}; + map.set(id, item); + buffer[index] = item; + index = (index + 1) % size; + } + + function delete_(id) { + const item = map.get(id); + if (!item) { + return; + } + map.delete(item.id); + const lastItem = buffer[lastIndex]; + lastItem.index = item.index; + buffer[item.index] = lastItem; + lastIndex = (lastIndex + 1) % size; + } + + function clear() { + map.clear(); + index = lastIndex = 0; + } +}