db.js 24 KB

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