db.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581
  1. import {
  2. i18n, getFullUrl, isRemote, getRnd4,
  3. } from '#/common';
  4. import { objectGet, objectSet } from '#/common/object';
  5. import { CMD_SCRIPT_ADD, CMD_SCRIPT_UPDATE } from '#/common/consts';
  6. import storage from '#/common/storage';
  7. import pluginEvents from '../plugin/events';
  8. import {
  9. getNameURI, parseMeta, newScript, getDefaultCustom,
  10. } from './script';
  11. import { testScript, testBlacklist } from './tester';
  12. import { register } from './init';
  13. import patchDB from './patch-db';
  14. import { setOption } from './options';
  15. import { sendMessageOrIgnore } from './message';
  16. const store = {};
  17. storage.script.onDump = (item) => {
  18. store.scriptMap[item.props.id] = item;
  19. };
  20. register(initialize());
  21. function initialize() {
  22. return browser.storage.local.get('version')
  23. .then(({ version: lastVersion }) => {
  24. const { version } = browser.runtime.getManifest();
  25. return (lastVersion ? Promise.resolve() : patchDB())
  26. .then(() => {
  27. if (version !== lastVersion) return browser.storage.local.set({ version });
  28. });
  29. })
  30. .then(() => browser.storage.local.get())
  31. .then((data) => {
  32. const scripts = [];
  33. const storeInfo = {
  34. id: 0,
  35. position: 0,
  36. };
  37. const idMap = {};
  38. const uriMap = {};
  39. Object.keys(data).forEach((key) => {
  40. const script = data[key];
  41. if (key.startsWith('scr:')) {
  42. // {
  43. // meta,
  44. // custom,
  45. // props: { id, position, uri },
  46. // config: { enabled, shouldUpdate },
  47. // }
  48. const id = getInt(key.slice(4));
  49. if (!id || idMap[id]) {
  50. // ID conflicts!
  51. // Should not happen, discard duplicates.
  52. return;
  53. }
  54. idMap[id] = script;
  55. const uri = getNameURI(script);
  56. if (uriMap[uri]) {
  57. // Namespace conflicts!
  58. // Should not happen, discard duplicates.
  59. return;
  60. }
  61. uriMap[uri] = script;
  62. script.props = {
  63. ...script.props,
  64. id,
  65. uri,
  66. };
  67. script.custom = {
  68. ...getDefaultCustom(),
  69. ...script.custom,
  70. };
  71. storeInfo.id = Math.max(storeInfo.id, id);
  72. storeInfo.position = Math.max(storeInfo.position, getInt(objectGet(script, 'props.position')));
  73. scripts.push(script);
  74. }
  75. });
  76. Object.assign(store, {
  77. scripts,
  78. storeInfo,
  79. scriptMap: scripts.reduce((map, item) => {
  80. map[item.props.id] = item;
  81. return map;
  82. }, {}),
  83. });
  84. if (process.env.DEBUG) {
  85. console.log('store:', store); // eslint-disable-line no-console
  86. }
  87. return sortScripts();
  88. });
  89. }
  90. function getInt(val) {
  91. return +val || 0;
  92. }
  93. function updateLastModified() {
  94. setOption('lastModified', Date.now());
  95. }
  96. export function normalizePosition() {
  97. const updates = [];
  98. const positionKey = 'props.position';
  99. store.scripts.forEach((item, index) => {
  100. const position = index + 1;
  101. if (objectGet(item, positionKey) !== position) {
  102. objectSet(item, positionKey, position);
  103. updates.push(item);
  104. }
  105. });
  106. store.storeInfo.position = store.scripts.length;
  107. const { length } = updates;
  108. if (!length) return Promise.resolve();
  109. return storage.script.dump(updates)
  110. .then(() => {
  111. updateLastModified();
  112. return length;
  113. });
  114. }
  115. export function sortScripts() {
  116. store.scripts.sort((a, b) => {
  117. const [pos1, pos2] = [a, b].map(item => getInt(objectGet(item, 'props.position')));
  118. return pos1 - pos2;
  119. });
  120. return normalizePosition()
  121. .then((changed) => {
  122. sendMessageOrIgnore({ cmd: 'ScriptsUpdated' });
  123. return changed;
  124. });
  125. }
  126. // TODO: depromisify getScript and all dependent code
  127. export function getScriptByIdSync(id) {
  128. return store.scriptMap[id];
  129. }
  130. export function getScript(where) {
  131. let script;
  132. if (where.id) {
  133. script = store.scriptMap[where.id];
  134. } else {
  135. const uri = where.uri || getNameURI({ meta: where.meta, id: '@@should-have-name' });
  136. const predicate = item => uri === objectGet(item, 'props.uri');
  137. script = store.scripts.find(predicate);
  138. }
  139. return Promise.resolve(script);
  140. }
  141. export function getScripts() {
  142. return Promise.resolve(store.scripts)
  143. .then(scripts => scripts.filter(script => !script.config.removed));
  144. }
  145. export function getScriptByIds(ids) {
  146. return Promise.all(ids.map(id => getScript({ id })))
  147. .then(scripts => scripts.filter(Boolean));
  148. }
  149. export function getScriptCode(id) {
  150. return storage.code.getOne(id);
  151. }
  152. /**
  153. * @desc Load values for batch updates.
  154. * @param {Array} ids
  155. */
  156. export function getValueStoresByIds(ids) {
  157. return storage.value.getMulti(ids);
  158. }
  159. /**
  160. * @desc Dump values for batch updates.
  161. * @param {Object} valueDict { id1: value1, id2: value2, ... }
  162. */
  163. export function dumpValueStores(valueDict) {
  164. if (process.env.DEBUG) {
  165. console.info('Update value stores', valueDict);
  166. }
  167. return storage.value.dump(valueDict).then(() => valueDict);
  168. }
  169. export function dumpValueStore(where, valueStore) {
  170. return (where.id
  171. ? Promise.resolve(where.id)
  172. : getScript(where).then(script => objectGet(script, 'props.id')))
  173. .then((id) => {
  174. if (id) return dumpValueStores({ [id]: valueStore });
  175. });
  176. }
  177. const gmValues = [
  178. 'GM_getValue', 'GM.getValue',
  179. 'GM_setValue', 'GM.setValue',
  180. 'GM_listValues', 'GM.listValues',
  181. 'GM_deleteValue', 'GM.deleteValue',
  182. ];
  183. /**
  184. * @desc Get scripts to be injected to page with specific URL.
  185. */
  186. export function getScriptsByURL(url) {
  187. const scripts = testBlacklist(url)
  188. ? []
  189. : store.scripts.filter(script => !script.config.removed && testScript(url, script));
  190. const reqKeys = {};
  191. const cacheKeys = {};
  192. scripts.forEach((script) => {
  193. if (script.config.enabled) {
  194. if (!script.custom.pathMap) buildPathMap(script);
  195. const { pathMap } = script.custom;
  196. script.meta.require.forEach((key) => {
  197. reqKeys[pathMap[key] || key] = 1;
  198. });
  199. Object.values(script.meta.resources).forEach((key) => {
  200. cacheKeys[pathMap[key] || key] = 1;
  201. });
  202. }
  203. });
  204. const enabledScripts = scripts
  205. .filter(script => script.config.enabled);
  206. const scriptsWithValue = enabledScripts
  207. .filter(script => script.meta.grant?.some(gm => gmValues.includes(gm)));
  208. return Promise.all([
  209. storage.require.getMulti(Object.keys(reqKeys)),
  210. storage.cache.getMulti(Object.keys(cacheKeys)),
  211. storage.value.getMulti(scriptsWithValue.map(script => script.props.id), {}),
  212. storage.code.getMulti(enabledScripts.map(script => script.props.id)),
  213. ])
  214. .then(([require, cache, values, code]) => ({
  215. scripts,
  216. require,
  217. cache,
  218. values,
  219. code,
  220. }));
  221. }
  222. /**
  223. * @desc Get data for dashboard.
  224. */
  225. export function getData() {
  226. const cacheKeys = {};
  227. const { scripts } = store;
  228. scripts.forEach((script) => {
  229. const icon = objectGet(script, 'meta.icon');
  230. if (isRemote(icon)) {
  231. const pathMap = objectGet(script, 'custom.pathMap') || {};
  232. const fullUrl = pathMap[icon] || icon;
  233. cacheKeys[fullUrl] = 1;
  234. }
  235. });
  236. return storage.cache.getMulti(Object.keys(cacheKeys))
  237. .then(cache => ({ scripts, cache }));
  238. }
  239. export function checkRemove({ force } = {}) {
  240. const now = Date.now();
  241. const toRemove = store.scripts.filter((script) => {
  242. if (!script.config.removed) return false;
  243. const lastModified = +script.props.lastModified || 0;
  244. return force || now - lastModified > 7 * 24 * 60 * 60 * 1000;
  245. });
  246. if (toRemove.length) {
  247. store.scripts = store.scripts.filter(script => !script.config.removed);
  248. const ids = toRemove.map(script => script.props.id);
  249. storage.script.removeMulti(ids);
  250. storage.code.removeMulti(ids);
  251. storage.value.removeMulti(ids);
  252. }
  253. return Promise.resolve(toRemove.length);
  254. }
  255. export function removeScript(id) {
  256. const i = store.scripts.findIndex(item => id === objectGet(item, 'props.id'));
  257. if (i >= 0) {
  258. store.scripts.splice(i, 1);
  259. storage.script.remove(id);
  260. storage.code.remove(id);
  261. storage.value.remove(id);
  262. }
  263. sendMessageOrIgnore({
  264. cmd: 'RemoveScript',
  265. data: id,
  266. });
  267. return Promise.resolve();
  268. }
  269. export function markRemoved(id, removed) {
  270. return updateScriptInfo(id, {
  271. config: {
  272. removed: removed ? 1 : 0,
  273. },
  274. props: {
  275. lastModified: Date.now(),
  276. },
  277. });
  278. }
  279. export function moveScript(id, offset) {
  280. const index = store.scripts.findIndex(item => id === objectGet(item, 'props.id'));
  281. const step = offset > 0 ? 1 : -1;
  282. const indexStart = index;
  283. const indexEnd = index + offset;
  284. const offsetI = Math.min(indexStart, indexEnd);
  285. const offsetJ = Math.max(indexStart, indexEnd);
  286. const updated = store.scripts.slice(offsetI, offsetJ + 1);
  287. if (step > 0) {
  288. updated.push(updated.shift());
  289. } else {
  290. updated.unshift(updated.pop());
  291. }
  292. store.scripts = [
  293. ...store.scripts.slice(0, offsetI),
  294. ...updated,
  295. ...store.scripts.slice(offsetJ + 1),
  296. ];
  297. return normalizePosition();
  298. }
  299. function getUUID(id) {
  300. const idSec = (id + 0x10bde6a2).toString(16).slice(-8);
  301. return `${idSec}-${getRnd4()}-${getRnd4()}-${getRnd4()}-${getRnd4()}${getRnd4()}${getRnd4()}`;
  302. }
  303. function saveScript(script, code) {
  304. const config = script.config || {};
  305. config.enabled = getInt(config.enabled);
  306. config.shouldUpdate = getInt(config.shouldUpdate);
  307. const props = script.props || {};
  308. let oldScript;
  309. if (!props.id) {
  310. store.storeInfo.id += 1;
  311. props.id = store.storeInfo.id;
  312. } else {
  313. oldScript = store.scriptMap[props.id];
  314. }
  315. props.uri = getNameURI(script);
  316. props.uuid = props.uuid || getUUID(props.id);
  317. // Do not allow script with same name and namespace
  318. if (store.scripts.some((item) => {
  319. const itemProps = item.props || {};
  320. return props.id !== itemProps.id && props.uri === itemProps.uri;
  321. })) {
  322. throw i18n('msgNamespaceConflict');
  323. }
  324. if (oldScript) {
  325. script.config = Object.assign({}, oldScript.config, config);
  326. script.props = Object.assign({}, oldScript.props, props);
  327. const index = store.scripts.indexOf(oldScript);
  328. store.scripts[index] = script;
  329. } else {
  330. if (!props.position) {
  331. store.storeInfo.position += 1;
  332. props.position = store.storeInfo.position;
  333. } else if (store.storeInfo.position < props.position) {
  334. store.storeInfo.position = props.position;
  335. }
  336. script.config = config;
  337. script.props = props;
  338. store.scripts.push(script);
  339. }
  340. return Promise.all([
  341. storage.script.dump(script),
  342. storage.code.set(props.id, code),
  343. ]);
  344. }
  345. export function updateScriptInfo(id, data) {
  346. const script = store.scriptMap[id];
  347. if (!script) return Promise.reject();
  348. script.props = Object.assign({}, script.props, data.props);
  349. script.config = Object.assign({}, script.config, data.config);
  350. // script.custom = Object.assign({}, script.custom, data.custom);
  351. return storage.script.dump(script)
  352. .then(() => sendMessageOrIgnore({
  353. cmd: CMD_SCRIPT_UPDATE,
  354. data: {
  355. where: { id },
  356. update: script,
  357. },
  358. }));
  359. }
  360. export function getExportData(withValues) {
  361. return getScripts()
  362. .then((scripts) => {
  363. const ids = scripts.map(({ props: { id } }) => id);
  364. return storage.code.getMulti(ids)
  365. .then((codeMap) => {
  366. const data = {};
  367. data.items = scripts.map(script => ({ script, code: codeMap[script.props.id] }));
  368. if (withValues) {
  369. return storage.value.getMulti(ids)
  370. .then((values) => {
  371. data.values = values;
  372. return data;
  373. });
  374. }
  375. return data;
  376. });
  377. });
  378. }
  379. export function parseScript(data) {
  380. const {
  381. id, code, message, isNew, config, custom, props, update,
  382. } = data;
  383. const meta = parseMeta(code);
  384. if (!meta.name) return Promise.reject(i18n('msgInvalidScript'));
  385. const result = {
  386. cmd: CMD_SCRIPT_UPDATE,
  387. data: {
  388. update: {
  389. message: message == null ? i18n('msgUpdated') : message || '',
  390. },
  391. },
  392. };
  393. return getScript({ id, meta })
  394. .then((oldScript) => {
  395. let script;
  396. if (oldScript) {
  397. if (isNew) throw i18n('msgNamespaceConflict');
  398. script = Object.assign({}, oldScript);
  399. } else {
  400. ({ script } = newScript());
  401. result.cmd = CMD_SCRIPT_ADD;
  402. result.data.isNew = true;
  403. result.data.update.message = i18n('msgInstalled');
  404. }
  405. script.config = Object.assign({}, script.config, config, {
  406. removed: 0, // force reset `removed` since this is an installation
  407. });
  408. script.custom = Object.assign({}, script.custom, custom);
  409. script.props = Object.assign({}, script.props, {
  410. lastModified: Date.now(),
  411. lastUpdated: Date.now(),
  412. }, props);
  413. script.meta = meta;
  414. if (!meta.homepageURL && !script.custom.homepageURL && isRemote(data.from)) {
  415. script.custom.homepageURL = data.from;
  416. }
  417. if (isRemote(data.url)) script.custom.lastInstallURL = data.url;
  418. const position = +data.position;
  419. if (position) objectSet(script, 'props.position', position);
  420. buildPathMap(script, data.url);
  421. return saveScript(script, code).then(() => script);
  422. })
  423. .then((script) => {
  424. fetchScriptResources(script, data);
  425. Object.assign(result.data.update, script, update);
  426. result.data.where = { id: script.props.id };
  427. sendMessageOrIgnore(result);
  428. pluginEvents.emit('scriptChanged', result.data);
  429. return result;
  430. });
  431. }
  432. function buildPathMap(script, base) {
  433. const { meta } = script;
  434. const baseUrl = base || script.custom.lastInstallURL;
  435. const pathMap = baseUrl ? [
  436. ...meta.require,
  437. ...Object.values(meta.resources),
  438. meta.icon,
  439. ].reduce((map, key) => {
  440. if (key) {
  441. const fullUrl = getFullUrl(key, baseUrl);
  442. if (fullUrl !== key) map[key] = fullUrl;
  443. }
  444. return map;
  445. }, {}) : {};
  446. script.custom.pathMap = pathMap;
  447. return pathMap;
  448. }
  449. function fetchScriptResources(script, cache) {
  450. const { meta, custom: { pathMap } } = script;
  451. // @require
  452. meta.require.forEach((key) => {
  453. const fullUrl = pathMap[key] || key;
  454. const cached = objectGet(cache, ['require', fullUrl]);
  455. if (cached) {
  456. storage.require.set(fullUrl, cached);
  457. } else {
  458. storage.require.fetch(fullUrl);
  459. }
  460. });
  461. // @resource
  462. Object.values(meta.resources).forEach((url) => {
  463. const fullUrl = pathMap[url] || url;
  464. const cached = objectGet(cache, ['resources', fullUrl]);
  465. if (cached) {
  466. storage.cache.set(fullUrl, cached);
  467. } else {
  468. storage.cache.fetch(fullUrl);
  469. }
  470. });
  471. // @icon
  472. if (isRemote(meta.icon)) {
  473. const fullUrl = pathMap[meta.icon] || meta.icon;
  474. storage.cache.fetch(fullUrl, ({ blob: getBlob }) => new Promise((resolve, reject) => {
  475. const blob = getBlob();
  476. const url = URL.createObjectURL(blob);
  477. const image = new Image();
  478. const free = () => URL.revokeObjectURL(url);
  479. image.onload = () => {
  480. free();
  481. resolve();
  482. };
  483. image.onerror = () => {
  484. free();
  485. reject({ type: 'IMAGE_ERROR', url });
  486. };
  487. image.src = url;
  488. }));
  489. }
  490. }
  491. export function vacuum() {
  492. const valueKeys = {};
  493. const cacheKeys = {};
  494. const requireKeys = {};
  495. const codeKeys = {};
  496. const mappings = [
  497. [storage.value, valueKeys],
  498. [storage.cache, cacheKeys],
  499. [storage.require, requireKeys],
  500. [storage.code, codeKeys],
  501. ];
  502. return browser.storage.local.get()
  503. .then((data) => {
  504. Object.keys(data).forEach((key) => {
  505. mappings.some(([substore, map]) => {
  506. const { prefix } = substore;
  507. if (key.startsWith(prefix)) {
  508. // -1 for untouched, 1 for touched, 2 for missing
  509. map[key.slice(prefix.length)] = -1;
  510. return true;
  511. }
  512. return false;
  513. });
  514. });
  515. const touch = (obj, key) => {
  516. if (obj[key] < 0) obj[key] = 1;
  517. else if (!obj[key]) obj[key] = 2;
  518. };
  519. store.scripts.forEach((script) => {
  520. const { id } = script.props;
  521. touch(codeKeys, id);
  522. touch(valueKeys, id);
  523. if (!script.custom.pathMap) buildPathMap(script);
  524. const { pathMap } = script.custom;
  525. script.meta.require.forEach((url) => {
  526. touch(requireKeys, pathMap[url] || url);
  527. });
  528. Object.values(script.meta.resources).forEach((url) => {
  529. touch(cacheKeys, pathMap[url] || url);
  530. });
  531. const { icon } = script.meta;
  532. if (isRemote(icon)) {
  533. const fullUrl = pathMap[icon] || icon;
  534. touch(cacheKeys, fullUrl);
  535. }
  536. });
  537. mappings.forEach(([substore, map]) => {
  538. Object.keys(map).forEach((key) => {
  539. const value = map[key];
  540. if (value < 0) {
  541. // redundant value
  542. substore.remove(key);
  543. } else if (value === 2 && substore.fetch) {
  544. // missing resource
  545. substore.fetch(key);
  546. }
  547. });
  548. });
  549. });
  550. }