db.js 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763
  1. import {
  2. compareVersion, dataUri2text, i18n, getScriptHome, makeDataUri,
  3. getFullUrl, getScriptName, getScriptUpdateUrl, isRemote, sendCmd, trueJoin,
  4. } from '@/common';
  5. import { INJECT_PAGE, INJECT_AUTO, TIMEOUT_WEEK } from '@/common/consts';
  6. import { forEachEntry, forEachKey, forEachValue } from '@/common/object';
  7. import storage from '@/common/storage';
  8. import pluginEvents from '../plugin/events';
  9. import { getNameURI, parseMeta, newScript, getDefaultCustom } from './script';
  10. import { testScript, testBlacklist } from './tester';
  11. import { preInitialize } from './init';
  12. import { commands } from './message';
  13. import patchDB from './patch-db';
  14. import { setOption } from './options';
  15. export const store = {
  16. /** @type VMScript[] */
  17. scripts: [],
  18. /** @type Object<string,VMScript[]> */
  19. scriptMap: {},
  20. storeInfo: {
  21. id: 0,
  22. position: 0,
  23. },
  24. };
  25. Object.assign(commands, {
  26. CheckPosition: sortScripts,
  27. CheckRemove: checkRemove,
  28. /** @return {VMScript} */
  29. GetScript: getScript,
  30. /** @return {Promise<{ items: VMScript[], values? }>} */
  31. async ExportZip({ values }) {
  32. const scripts = getScripts();
  33. const ids = scripts.map(getPropsId);
  34. const codeMap = await storage.code.getMulti(ids);
  35. return {
  36. items: scripts.map(script => ({ script, code: codeMap[script.props.id] })),
  37. values: values ? await storage.value.getMulti(ids) : undefined,
  38. };
  39. },
  40. /** @return {Promise<string>} */
  41. GetScriptCode(id) {
  42. return storage.code.getOne(id);
  43. },
  44. GetScriptVer(opts) {
  45. const script = getScript(opts);
  46. return script && !script.config.removed
  47. ? script.meta.version
  48. : null;
  49. },
  50. /** @return {Promise<void>} */
  51. MarkRemoved({ id, removed }) {
  52. return updateScriptInfo(id, {
  53. config: { removed: removed ? 1 : 0 },
  54. props: { lastModified: Date.now() },
  55. });
  56. },
  57. /** @return {Promise<number>} */
  58. Move({ id, offset }) {
  59. const script = getScriptById(id);
  60. const index = store.scripts.indexOf(script);
  61. store.scripts.splice(index, 1);
  62. store.scripts.splice(index + offset, 0, script);
  63. return normalizePosition();
  64. },
  65. /** @return {Promise<void>} */
  66. async RemoveScript(id) {
  67. const i = store.scripts.indexOf(getScriptById(id));
  68. if (i >= 0) {
  69. store.scripts.splice(i, 1);
  70. await storage.base.remove([
  71. storage.script.toKey(id),
  72. storage.code.toKey(id),
  73. storage.value.toKey(id),
  74. ]);
  75. }
  76. return sendCmd('RemoveScript', id);
  77. },
  78. ParseMeta: parseMeta,
  79. ParseScript: parseScript,
  80. /** @return {Promise<void>} */
  81. UpdateScriptInfo({ id, config, custom }) {
  82. return updateScriptInfo(id, {
  83. config,
  84. custom,
  85. props: { lastModified: Date.now() },
  86. });
  87. },
  88. /** @return {Promise<number>} */
  89. Vacuum: vacuum,
  90. });
  91. preInitialize.push(async () => {
  92. const lastVersion = await storage.base.getOne('version');
  93. const version = process.env.VM_VER;
  94. if (!lastVersion) await patchDB();
  95. if (version !== lastVersion) storage.base.set({ version });
  96. const data = await storage.base.getMulti();
  97. const { scripts, storeInfo, scriptMap } = store;
  98. const uriMap = {};
  99. const mods = [];
  100. const resUrls = new Set();
  101. /** @this VMScriptCustom.pathMap */
  102. const rememberUrl = function _(url) { resUrls.add(this[url] || url); };
  103. data::forEachEntry(([key, script]) => {
  104. let id = +storage.script.toId(key);
  105. if (id) {
  106. if (scriptMap[id] && scriptMap[id] !== script) {
  107. // ID conflicts!
  108. // Should not happen, discard duplicates.
  109. return;
  110. }
  111. const uri = getNameURI(script);
  112. if (uriMap[uri]) {
  113. // Namespace conflicts!
  114. // Should not happen, discard duplicates.
  115. return;
  116. }
  117. uriMap[uri] = script;
  118. script.props = {
  119. ...script.props,
  120. id,
  121. uri,
  122. };
  123. script.custom = {
  124. ...getDefaultCustom(),
  125. ...script.custom,
  126. };
  127. storeInfo.id = Math.max(storeInfo.id, id);
  128. storeInfo.position = Math.max(storeInfo.position, getInt(script.props.position));
  129. scripts.push(script);
  130. // listing all known resource urls in order to remove unused mod keys
  131. const {
  132. custom: { pathMap = {} } = {},
  133. meta = script.meta = {},
  134. } = script;
  135. const {
  136. require = meta.require = [],
  137. resources = meta.resources = {},
  138. } = meta;
  139. meta.grant = [...new Set(meta.grant || [])]; // deduplicate
  140. require.forEach(rememberUrl, pathMap);
  141. resources::forEachValue(rememberUrl, pathMap);
  142. pathMap::rememberUrl(meta.icon);
  143. getScriptUpdateUrl(script, true)?.forEach(rememberUrl, pathMap);
  144. } else if ((id = storage.mod.toId(key))) {
  145. mods.push(id);
  146. }
  147. });
  148. storage.mod.remove(mods.filter(url => !resUrls.has(url)));
  149. // Switch defaultInjectInto from `page` to `auto` when upgrading VM2.12.7 or older
  150. if (version !== lastVersion
  151. && IS_FIREFOX
  152. && data.options?.defaultInjectInto === INJECT_PAGE
  153. && compareVersion(lastVersion, '2.12.7') <= 0) {
  154. setOption('defaultInjectInto', INJECT_AUTO);
  155. }
  156. if (process.env.DEBUG) {
  157. console.log('store:', store); // eslint-disable-line no-console
  158. }
  159. sortScripts();
  160. vacuum(data);
  161. });
  162. /** @return {number} */
  163. function getInt(val) {
  164. return +val || 0;
  165. }
  166. /** @return {?number} */
  167. function getPropsId(script) {
  168. return script?.props.id;
  169. }
  170. /** @return {void} */
  171. function updateLastModified() {
  172. setOption('lastModified', Date.now());
  173. }
  174. /** @return {Promise<boolean>} */
  175. export async function normalizePosition() {
  176. const updates = store.scripts.reduce((res, script, index) => {
  177. const { props } = script;
  178. const position = index + 1;
  179. if (props.position !== position) {
  180. props.position = position;
  181. (res || (res = {}))[props.id] = script;
  182. }
  183. return res;
  184. }, null);
  185. store.storeInfo.position = store.scripts.length;
  186. if (updates) {
  187. await storage.script.set(updates);
  188. updateLastModified();
  189. }
  190. return !!updates;
  191. }
  192. /** @return {Promise<number>} */
  193. export async function sortScripts() {
  194. store.scripts.sort((a, b) => getInt(a.props.position) - getInt(b.props.position));
  195. const changed = await normalizePosition();
  196. sendCmd('ScriptsUpdated', null);
  197. return changed;
  198. }
  199. /** @return {?VMScript} */
  200. export function getScriptById(id) {
  201. return store.scriptMap[id];
  202. }
  203. /** @return {?VMScript} */
  204. export function getScript({ id, uri, meta }) {
  205. let script;
  206. if (id) {
  207. script = getScriptById(id);
  208. } else {
  209. if (!uri) uri = getNameURI({ meta, id: '@@should-have-name' });
  210. script = store.scripts.find(({ props }) => uri === props.uri);
  211. }
  212. return script;
  213. }
  214. /** @return {VMScript[]} */
  215. export function getScripts() {
  216. return store.scripts.filter(script => !script.config.removed);
  217. }
  218. export const ENV_CACHE_KEYS = 'cacheKeys';
  219. export const ENV_REQ_KEYS = 'reqKeys';
  220. export const ENV_SCRIPTS = 'scripts';
  221. export const ENV_VALUE_IDS = 'valueIds';
  222. const GMVALUES_RE = /^GM[_.](listValues|([gs]et|delete)Value)$/;
  223. const RUN_AT_RE = /^document-(start|body|end|idle)$/;
  224. /**
  225. * @desc Get scripts to be injected to page with specific URL.
  226. */
  227. export function getScriptsByURL(url, isTop) {
  228. const allScripts = testBlacklist(url)
  229. ? []
  230. : store.scripts.filter(script => (
  231. !script.config.removed
  232. && (isTop || !(script.custom.noframes ?? script.meta.noframes))
  233. && testScript(url, script)
  234. ));
  235. return getScriptEnv(allScripts);
  236. }
  237. /**
  238. * @param {VMScript[]} scripts
  239. * @param {boolean} [sizing]
  240. * @return {VMScriptByUrlData}
  241. */
  242. function getScriptEnv(scripts, sizing) {
  243. const disabledIds = [];
  244. /** @namespace VMScriptByUrlData */
  245. const [envStart, envDelayed] = [0, 1].map(() => ({
  246. ids: [],
  247. depsMap: {},
  248. sizing,
  249. [ENV_CACHE_KEYS]: [],
  250. [ENV_REQ_KEYS]: [],
  251. [ENV_SCRIPTS]: [],
  252. [ENV_VALUE_IDS]: [],
  253. }));
  254. scripts.forEach((script) => {
  255. const { id } = script.props;
  256. if (!sizing && !script.config.enabled) {
  257. disabledIds.push(id);
  258. return;
  259. }
  260. const { meta, custom } = script;
  261. const { pathMap = buildPathMap(script) } = custom;
  262. const runAt = `${custom.runAt || meta.runAt || ''}`.match(RUN_AT_RE)?.[1] || 'end';
  263. const env = sizing || runAt === 'start' || runAt === 'body' ? envStart : envDelayed;
  264. const { depsMap } = env;
  265. env.ids.push(id);
  266. if (meta.grant.some(GMVALUES_RE.test, GMVALUES_RE)) {
  267. env[ENV_VALUE_IDS].push(id);
  268. }
  269. for (const [list, name] of [
  270. [meta.require, ENV_REQ_KEYS],
  271. [Object.values(meta.resources), ENV_CACHE_KEYS],
  272. ]) {
  273. list.forEach(key => {
  274. key = pathMap[key] || key;
  275. if (key && !(name === ENV_CACHE_KEYS && envStart[name].includes(key))) {
  276. env[name].push(key);
  277. (depsMap[key] || (depsMap[key] = [])).push(id);
  278. }
  279. });
  280. }
  281. /** @namespace VMInjectedScript */
  282. env[ENV_SCRIPTS].push(sizing ? script : { ...script, runAt });
  283. });
  284. envStart.promise = readEnvironmentData(envStart);
  285. if (envDelayed.ids.length) {
  286. envDelayed.promise = readEnvironmentData(envDelayed);
  287. }
  288. return Object.assign(envStart, { disabledIds, envDelayed });
  289. }
  290. /**
  291. * Object keys == areas in `storage` module.
  292. * @namespace VMScriptByUrlData
  293. */
  294. const STORAGE_ROUTES = Object.entries({
  295. cache: ENV_CACHE_KEYS,
  296. code: 'ids',
  297. require: ENV_REQ_KEYS,
  298. value: ENV_VALUE_IDS,
  299. });
  300. const retriedStorageKeys = {};
  301. async function readEnvironmentData(env, isRetry) {
  302. const keys = [];
  303. STORAGE_ROUTES.forEach(([area, srcIds]) => {
  304. env[srcIds].forEach(id => {
  305. if (!/^data:/.test(id)) {
  306. keys.push(storage[area].toKey(id));
  307. }
  308. });
  309. });
  310. const data = await storage.base.getMulti(keys);
  311. const badScripts = new Set();
  312. for (const [area, srcIds] of STORAGE_ROUTES) {
  313. env[area] = {};
  314. for (const id of env[srcIds]) {
  315. const val = /^data:/.test(id)
  316. ? area !== 'require' && id || dataUri2text(id)
  317. : data[storage[area].toKey(id)];
  318. env[area][id] = val;
  319. if (val == null && area !== 'value' && !env.sizing && retriedStorageKeys[area + id] !== 2) {
  320. retriedStorageKeys[area + id] = isRetry ? 2 : 1;
  321. if (!isRetry) {
  322. console.warn(`The "${area}" storage is missing "${id}"! Vacuuming...`);
  323. if ((await vacuum()).fixes) {
  324. return readEnvironmentData(env, true);
  325. }
  326. }
  327. if (area === 'code') {
  328. badScripts.add(id);
  329. } else {
  330. env.depsMap[id]?.forEach(scriptId => badScripts.add(scriptId));
  331. }
  332. }
  333. }
  334. }
  335. if (badScripts.size) {
  336. const title = i18n('msgMissingResources');
  337. const text = i18n('msgReinstallScripts')
  338. + [...badScripts].map(id => `\n#${id}: ${getScriptName(getScriptById(id))}`).join('');
  339. console.error(`${title} ${text}`);
  340. await commands.Notification({ title, text }, undefined, {
  341. onClick() {
  342. badScripts.forEach(id => commands.OpenEditor(id));
  343. },
  344. });
  345. }
  346. return env;
  347. }
  348. /**
  349. * @desc Get data for dashboard.
  350. * @return {Promise<{ scripts: VMScript[], cache: Object }>}
  351. */
  352. export async function getData(ids) {
  353. const scripts = ids ? ids.map(getScriptById) : store.scripts;
  354. return {
  355. scripts,
  356. cache: await getIconCache(scripts),
  357. };
  358. }
  359. /**
  360. * @param {VMScript[]} scripts
  361. * @return {Promise<{}>}
  362. */
  363. function getIconCache(scripts) {
  364. return storage.cache.getMulti(
  365. scripts.reduce((res, { custom, meta: { icon } }) => {
  366. if (isRemote(icon)) res.push(custom.pathMap?.[icon] || icon);
  367. return res;
  368. }, []),
  369. makeDataUri,
  370. );
  371. }
  372. export async function getSizes(ids) {
  373. const scripts = ids ? ids.map(getScriptById) : store.scripts;
  374. const { cache, code, value, require } = await getScriptEnv(scripts, true).promise;
  375. return scripts.map(({
  376. meta,
  377. custom: { pathMap = {} },
  378. props: { id },
  379. }, index) => /** @namespace VMScriptSizeInfo */ ({
  380. c: code[id]?.length,
  381. i: JSON.stringify(scripts[index]).length - 2,
  382. v: JSON.stringify(value[id] || {}).length - 2,
  383. '@require': meta.require.reduce((len, v) => len + (require[pathMap[v] || v]?.length || 0), 0),
  384. '@resource': Object.values(meta.resources)
  385. .reduce((len, v) => len + (cache[pathMap[v] || v]?.length || 0), 0),
  386. }));
  387. }
  388. /** @return {?Promise<void>} only if something was removed, otherwise undefined */
  389. export function checkRemove({ force } = {}) {
  390. const now = Date.now();
  391. const toKeep = [];
  392. const toRemove = [];
  393. store.scripts.forEach(script => {
  394. const { id, lastModified } = script.props;
  395. if (script.config.removed && (force || now - getInt(lastModified) > TIMEOUT_WEEK)) {
  396. toRemove.push(storage.code.toKey(id),
  397. storage.script.toKey(id),
  398. storage.value.toKey(id));
  399. } else {
  400. toKeep.push(script);
  401. }
  402. });
  403. if (toRemove.length) {
  404. store.scripts = toKeep;
  405. return storage.base.remove(toRemove);
  406. }
  407. }
  408. /** @return {string} */
  409. function getUUID() {
  410. const rnd = new Uint16Array(8);
  411. window.crypto.getRandomValues(rnd);
  412. // xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx
  413. // We're using UUIDv4 variant 1 so N=4 and M=8
  414. // See format_uuid_v3or5 in https://tools.ietf.org/rfc/rfc4122.txt
  415. rnd[3] = rnd[3] & 0x0FFF | 0x4000; // eslint-disable-line no-bitwise
  416. rnd[4] = rnd[4] & 0x3FFF | 0x8000; // eslint-disable-line no-bitwise
  417. return '01-2-3-4-567'.replace(/\d/g, i => (rnd[i] + 0x1_0000).toString(16).slice(-4));
  418. }
  419. /**
  420. * @param {VMScript} script
  421. * @param {string} code
  422. * @return {Promise<VMScript[]>}
  423. */
  424. async function saveScript(script, code) {
  425. const config = script.config || {};
  426. config.enabled = getInt(config.enabled);
  427. config.shouldUpdate = getInt(config.shouldUpdate);
  428. const props = script.props || {};
  429. let oldScript;
  430. if (!props.id) {
  431. store.storeInfo.id += 1;
  432. props.id = store.storeInfo.id;
  433. } else {
  434. oldScript = store.scriptMap[props.id];
  435. }
  436. props.uri = getNameURI(script);
  437. props.uuid = props.uuid || crypto.randomUUID?.() || getUUID();
  438. // Do not allow script with same name and namespace
  439. if (store.scripts.some(({ props: { id, uri } = {} }) => props.id !== id && props.uri === uri)) {
  440. throw i18n('msgNamespaceConflict');
  441. }
  442. if (oldScript) {
  443. script.config = { ...oldScript.config, ...config };
  444. script.props = { ...oldScript.props, ...props };
  445. const index = store.scripts.indexOf(oldScript);
  446. store.scripts[index] = script;
  447. } else {
  448. if (!props.position) {
  449. store.storeInfo.position += 1;
  450. props.position = store.storeInfo.position;
  451. } else if (store.storeInfo.position < props.position) {
  452. store.storeInfo.position = props.position;
  453. }
  454. script.config = config;
  455. script.props = props;
  456. store.scripts.push(script);
  457. }
  458. return storage.base.set({
  459. [storage.script.toKey(props.id)]: script,
  460. [storage.code.toKey(props.id)]: code,
  461. });
  462. }
  463. /** @return {Promise<void>} */
  464. export async function updateScriptInfo(id, data) {
  465. const script = store.scriptMap[id];
  466. if (!script) throw null;
  467. script.props = { ...script.props, ...data.props };
  468. script.config = { ...script.config, ...data.config };
  469. script.custom = { ...script.custom, ...data.custom };
  470. await storage.script.setOne(id, script);
  471. return sendCmd('UpdateScript', { where: { id }, update: script });
  472. }
  473. /** @return {Promise<{ isNew?, update, where }>} */
  474. export async function parseScript(src) {
  475. const meta = parseMeta(src.code);
  476. if (!meta.name) throw `${i18n('msgInvalidScript')}\n${i18n('labelNoName')}`;
  477. const result = {
  478. update: {
  479. message: src.message == null ? i18n('msgUpdated') : src.message || '',
  480. },
  481. };
  482. let script;
  483. const oldScript = await getScript({ id: src.id, meta });
  484. if (oldScript) {
  485. if (src.isNew) throw i18n('msgNamespaceConflict');
  486. script = { ...oldScript };
  487. } else {
  488. ({ script } = newScript());
  489. result.isNew = true;
  490. result.update.message = i18n('msgInstalled');
  491. }
  492. script.config = {
  493. ...script.config,
  494. ...src.config,
  495. removed: 0, // force reset `removed` since this is an installation
  496. };
  497. script.custom = {
  498. ...script.custom,
  499. ...src.custom,
  500. };
  501. script.props = {
  502. ...script.props,
  503. lastModified: Date.now(),
  504. lastUpdated: Date.now(),
  505. ...src.props,
  506. };
  507. script.meta = meta;
  508. if (!getScriptHome(script) && isRemote(src.from)) {
  509. script.custom.homepageURL = src.from;
  510. }
  511. if (isRemote(src.url)) script.custom.lastInstallURL = src.url;
  512. if (src.position) script.props.position = +src.position;
  513. buildPathMap(script, src.url);
  514. await saveScript(script, src.code);
  515. fetchResources(script, src);
  516. Object.assign(result.update, script, src.update);
  517. result.where = { id: script.props.id };
  518. sendCmd('UpdateScript', result);
  519. pluginEvents.emit('scriptChanged', result);
  520. return result;
  521. }
  522. /** @return {Object} */
  523. function buildPathMap(script, base) {
  524. const { meta } = script;
  525. const baseUrl = base || script.custom.lastInstallURL;
  526. const pathMap = baseUrl ? [
  527. ...meta.require,
  528. ...Object.values(meta.resources),
  529. meta.icon,
  530. ].reduce((map, key) => {
  531. if (key) {
  532. const fullUrl = getFullUrl(key, baseUrl);
  533. if (fullUrl !== key) map[key] = fullUrl;
  534. }
  535. return map;
  536. }, {}) : {};
  537. script.custom.pathMap = pathMap;
  538. return pathMap;
  539. }
  540. /** @return {Promise<?string>} resolves to error text if `resourceCache` is absent */
  541. export async function fetchResources(script, resourceCache, reqOptions) {
  542. const { custom: { pathMap }, meta } = script;
  543. const snatch = (url, type, validator) => {
  544. url = pathMap[url] || url;
  545. const contents = resourceCache?.[type]?.[url];
  546. return contents != null && !validator
  547. ? storage[type].setOne(url, contents) && null
  548. : storage[type].fetch(url, reqOptions, validator).catch(err => err);
  549. };
  550. const errors = await Promise.all([
  551. ...meta.require.map(url => url && snatch(url, 'require')),
  552. ...Object.values(meta.resources).map(url => url && snatch(url, 'cache')),
  553. isRemote(meta.icon) && snatch(meta.icon, 'cache', validateImage),
  554. ]);
  555. if (!resourceCache?.ignoreDepsErrors) {
  556. const error = errors.map(formatHttpError)::trueJoin('\n');
  557. if (error) {
  558. const message = i18n('msgErrorFetchingResource');
  559. sendCmd('UpdateScript', {
  560. update: { error, message },
  561. where: { id: script.props.id },
  562. });
  563. return `${message}\n${error}`;
  564. }
  565. }
  566. }
  567. /** @return {Promise<void>} resolves on success, rejects on error */
  568. function validateImage(url, buf, type) {
  569. return new Promise((resolve, reject) => {
  570. const blobUrl = URL.createObjectURL(new Blob([buf], { type }));
  571. const onDone = (e) => {
  572. URL.revokeObjectURL(blobUrl);
  573. if (e.type === 'load') resolve();
  574. else reject(`IMAGE_ERROR: ${url}`);
  575. };
  576. const image = new Image();
  577. image.onload = onDone;
  578. image.onerror = onDone;
  579. image.src = blobUrl;
  580. });
  581. }
  582. function formatHttpError(e) {
  583. return e && [e.status && `HTTP${e.status}`, e.url]::trueJoin(' ') || e;
  584. }
  585. let _vacuuming;
  586. /**
  587. * @param {Object} [data]
  588. * @return {Promise<{errors:string[], fixes:number}>}
  589. */
  590. export async function vacuum(data) {
  591. if (_vacuuming) return _vacuuming;
  592. let numFixes = 0;
  593. let resolveSelf;
  594. _vacuuming = new Promise(r => { resolveSelf = r; });
  595. const result = {};
  596. const toFetch = [];
  597. const keysToRemove = [
  598. 'editorThemeNames', // TODO: remove in 2022
  599. ];
  600. const valueKeys = {};
  601. const cacheKeys = {};
  602. const requireKeys = {};
  603. const codeKeys = {};
  604. const mappings = [
  605. [storage.value, valueKeys],
  606. [storage.cache, cacheKeys],
  607. [storage.require, requireKeys],
  608. [storage.code, codeKeys],
  609. ];
  610. if (!data) data = await storage.base.getMulti();
  611. data::forEachKey((key) => {
  612. mappings.some(([substore, map]) => {
  613. const id = substore.toId(key);
  614. // -1 for untouched, 1 for touched, 2 for missing
  615. if (id) map[id] = -1;
  616. return id;
  617. });
  618. });
  619. const touch = (obj, key, scriptId) => {
  620. if (obj[key] < 0) {
  621. obj[key] = 1;
  622. } else if (!obj[key]) {
  623. obj[key] = 2 + scriptId;
  624. }
  625. };
  626. store.scripts.forEach((script) => {
  627. const { id } = script.props;
  628. touch(codeKeys, id, id);
  629. touch(valueKeys, id, id);
  630. if (!script.custom.pathMap) buildPathMap(script);
  631. const { pathMap } = script.custom;
  632. script.meta.require.forEach((url) => {
  633. if (url) touch(requireKeys, pathMap[url] || url, id);
  634. });
  635. script.meta.resources::forEachValue((url) => {
  636. if (url) touch(cacheKeys, pathMap[url] || url, id);
  637. });
  638. const { icon } = script.meta;
  639. if (isRemote(icon)) {
  640. const fullUrl = pathMap[icon] || icon;
  641. touch(cacheKeys, fullUrl, id);
  642. }
  643. });
  644. mappings.forEach(([substore, map]) => {
  645. map::forEachEntry(([key, value]) => {
  646. if (value < 0) {
  647. // redundant value
  648. keysToRemove.push(substore.toKey(key));
  649. numFixes += 1;
  650. } else if (value >= 2 && substore.fetch) {
  651. // missing resource
  652. keysToRemove.push(storage.mod.toKey(key));
  653. toFetch.push(substore.fetch(key).catch(err => `${
  654. getScriptName(getScriptById(value - 2))
  655. }: ${
  656. formatHttpError(err)
  657. }`));
  658. numFixes += 1;
  659. }
  660. });
  661. });
  662. if (numFixes) {
  663. await storage.base.remove(keysToRemove); // Removing `mod` before fetching
  664. result.errors = (await Promise.all(toFetch)).filter(Boolean);
  665. }
  666. _vacuuming = null;
  667. result.fixes = numFixes;
  668. resolveSelf(result);
  669. return result;
  670. }
  671. /** @typedef VMScript
  672. * @property {VMScriptConfig} config
  673. * @property {VMScriptCustom} custom
  674. * @property {VMScriptMeta} meta
  675. * @property {VMScriptProps} props
  676. */
  677. /** @typedef VMScriptConfig *
  678. * @property {Boolean} enabled - stored as 0 or 1
  679. * @property {Boolean} removed - stored as 0 or 1
  680. * @property {Boolean} shouldUpdate - stored as 0 or 1
  681. * @property {Boolean | null} notifyUpdates - stored as 0 or 1 or null (default) which means "use global setting"
  682. */
  683. /** @typedef VMScriptCustom *
  684. * @property {string} name
  685. * @property {string} downloadURL
  686. * @property {string} homepageURL
  687. * @property {string} lastInstallURL
  688. * @property {string} updateURL
  689. * @property {'auto' | 'page' | 'content'} injectInto
  690. * @property {null | 1 | 0} noframes - null or absence == default (script's value)
  691. * @property {string[]} exclude
  692. * @property {string[]} excludeMatch
  693. * @property {string[]} include
  694. * @property {string[]} match
  695. * @property {boolean} origExclude
  696. * @property {boolean} origExcludeMatch
  697. * @property {boolean} origInclude
  698. * @property {boolean} origMatch
  699. * @property {Object} pathMap
  700. * @property {VMScriptRunAt} runAt
  701. */
  702. /** @typedef VMScriptMeta *
  703. * @property {string} description
  704. * @property {string} downloadURL
  705. * @property {string[]} exclude
  706. * @property {string[]} excludeMatch
  707. * @property {string[]} grant
  708. * @property {string} homepageURL
  709. * @property {string} icon
  710. * @property {string[]} include
  711. * @property {'auto' | 'page' | 'content'} injectInto
  712. * @property {string[]} match
  713. * @property {string} namespace
  714. * @property {string} name
  715. * @property {boolean} noframes
  716. * @property {string[]} require
  717. * @property {Object} resources
  718. * @property {VMScriptRunAt} runAt
  719. * @property {string} supportURL
  720. * @property {string} version
  721. */
  722. /** @typedef VMScriptProps *
  723. * @property {number} id
  724. * @property {number} lastModified
  725. * @property {number} lastUpdated
  726. * @property {number} position
  727. * @property {string} uri
  728. * @property {string} uuid
  729. */
  730. /**
  731. * @typedef {
  732. 'document-start' | 'document-body' | 'document-end' | 'document-idle'
  733. } VMScriptRunAt
  734. */