diff --git a/eslint.config.js b/eslint.config.js index 85de384..3523394 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,6 +5,9 @@ import pluginJs from '@eslint/js'; import eslintConfigPrettier from 'eslint-config-prettier/flat'; export default defineConfig([ + { + ignores: ['build/**', 'dist/**', 'node_modules/**', 'web-server/**', 'src/js/punycode.js'] + }, pluginJs.configs.recommended, eslintConfigPrettier, { diff --git a/package.json b/package.json index 11fdcd9..1c01980 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,12 @@ "webpack:Cr": "webpack --progress --mode development --config=tools/webpack.config.js --env brws=chrome", "webpack:FF": "webpack --progress --mode development --config=tools/webpack.config.js --env brws=firefox", "web-ext:run": "web-ext run --config=./tools/web-ext.mjs", - "web-ext:android": "web-ext run --config=./tools/web-ext.mjs -t firefox-android --firefox-apk org.mozilla.firefox" + "web-ext:android": "web-ext run --config=./tools/web-ext.mjs -t firefox-android --firefox-apk org.mozilla.firefox", + "lint": "eslint .", + "typecheck": "tsc --noEmit", + "build:Cr": "webpack --mode production --config=tools/webpack.config.js --env brws=chrome", + "build:FF": "webpack --mode production --config=tools/webpack.config.js --env brws=firefox", + "test": "node --test" }, "devDependencies": { "@dotenvx/dotenvx": "^1.39.0", diff --git a/src/js/background.js b/src/js/background.js index 82394d6..d206a1a 100644 --- a/src/js/background.js +++ b/src/js/background.js @@ -15,7 +15,7 @@ import { decode } from './util.js'; import { BLANK_PAGE, template, builtinList } from './constants.js'; -import storage, { DEFAULT_CONFIG } from './storage.js'; +import storage, { DEFAULT_CONFIG, normalizeConfig } from './storage.js'; import { BaseContainer, BaseList } from './container.js'; import { i18n$ } from './i18n.js'; @@ -25,17 +25,32 @@ import { i18n$ } from './i18n.js'; let cfg = {}; const initCfg = async () => { const c = await storage.config.getMulti(); - if (!c) { - await storage.config.set(DEFAULT_CONFIG); + cfg = normalizeConfig(c); + if (!c || Object.keys(c).length === 0) { + await storage.config.set(cfg); } - cfg = { - ...DEFAULT_CONFIG, - ...c - }; return cfg; }; initCfg(); +const persistConfig = (operation) => operation.then(initCfg).catch(err); +const saveConfig = (nextConfig) => persistConfig(storage.config.set(normalizeConfig(nextConfig))); +const resetConfig = () => saveConfig(DEFAULT_CONFIG); +const saveConfigValue = (prop, value) => persistConfig(storage.config.setOne(prop, value)); +const updateEngineConfig = (engineUpdate) => { + if (!engineUpdate?.name) { + return Promise.resolve(cfg); + } + const nextConfig = normalizeConfig(cfg); + const engine = nextConfig.engines.find((item) => item.name === engineUpdate.name); + if (engine) { + Object.assign(engine, engineUpdate); + } else { + nextConfig.engines.push(engineUpdate); + } + return saveConfig(nextConfig); +}; + class Container extends BaseContainer { constructor(url) { super(url); @@ -309,25 +324,21 @@ runtime.onConnect.addListener((p) => { hermes.send('Config', msgCache); hermes.getPort().onMessage.addListener((root) => { log('Background Script: received message from content script', root); - const r = root.msg; - if (root.channel === 'Save') { + const r = root?.msg ?? {}; + if (root?.channel === 'Save') { if (r.cfg) { - storage.config.set(r.cfg).then(initCfg); + saveConfig(r.cfg); } else { const v = isNull(r.value) ? cfg[r.prop] : r.value; - storage.config.setOne(r.prop, v).then(initCfg); + saveConfigValue(r.prop, v); } - } else if (root.channel === 'Reset') { - storage.config.set(DEFAULT_CONFIG); - } else if (root.channel === 'Clear') { + } else if (root?.channel === 'Reset') { + resetConfig(); + } else if (root?.channel === 'Clear') { const cache = Array.from(container).filter(({ _mujs }) => _mujs.info.host === r.host); for (const ujs of cache) container.userjsCache.delete(ujs.id); - } else if (root.channel === 'Engine' && cfg.engines) { - const engine = cfg.engines.find((engine) => engine.name === r.engine.name); - for (const [k, v] of Object.entries(r.engine)) { - engine[k] = v; - } - storage.config.set(cfg).then(initCfg); + } else if (root?.channel === 'Engine' && cfg.engines) { + updateEngineConfig(r.engine); } }); }); @@ -386,7 +397,7 @@ webext.webRequest.onHeadersReceived.addListener( * @param {(response: any) => void} sendResponse - A function to call, at most once, to send a response to the message. The function takes a single argument, which may be any JSON-ifiable object. This argument is passed back to the message sender. */ function onMessage(message, sender, sendResponse) { - if (sender.url.includes('popup.html')) { + if (sender.url?.includes('popup.html')) { if (message.type === 'getData') { if (MUList.host !== message.hostname) { MUList.host = message.hostname ?? BLANK_PAGE; @@ -401,22 +412,18 @@ function onMessage(message, sender, sendResponse) { }); } else if (message.type === 'save') { if (message.cfg) { - storage.config.set(message.cfg).then(initCfg); + saveConfig(message.cfg); } else { const v = isNull(message.value) ? cfg[message.prop] : message.value; - storage.config.setOne(message.prop, v).then(initCfg); + saveConfigValue(message.prop, v); } } else if (message.type === 'reset') { - storage.config.set(DEFAULT_CONFIG); + resetConfig(); } else if (message.type === 'clear') { const cache = Array.from(container).filter(({ _mujs }) => _mujs.info.host === message.host); for (const ujs of cache) container.userjsCache.delete(ujs.id); } else if (message.type === 'engine') { - const engine = cfg.engines.find((engine) => engine.name === message.engine.name); - for (const [k, v] of Object.entries(message.engine)) { - engine[k] = v; - } - storage.config.set(cfg).then(initCfg); + updateEngineConfig(message.engine); } else if (message.location) { MUList.host = formatURL(message.location); MUList.build().then(sendResponse); diff --git a/src/js/ext.js b/src/js/ext.js index ebd6ec7..f640f1f 100644 --- a/src/js/ext.js +++ b/src/js/ext.js @@ -1,29 +1,35 @@ -export const webext = - self.browser instanceof Object && self.browser instanceof Element === false - ? self.browser - : self.chrome; +'use strict'; + +const globalScope = globalThis; +const browserApi = globalScope.browser; +const chromeApi = globalScope.chrome; +const hasRuntime = (api) => api && typeof api === 'object' && api.runtime; + +export const webext = hasRuntime(browserApi) ? browserApi : chromeApi; + +if (!webext?.runtime) { + throw new Error('WebExtension runtime API is unavailable'); +} + export const runtime = webext.runtime; -/******************************************************************************/ +/*******************************************************************************/ // The extension's service worker can be evicted at any time, so when we // send a message, we try a few more times when the message fails to be sent. -export function sendMessage(msg) { +export function sendMessage(msg, { attempts = 5, delay = 200 } = {}) { return new Promise((resolve, reject) => { - let i = 5; + let remainingAttempts = attempts; const send = () => { - runtime - .sendMessage(msg) - .then((response) => { - resolve(response); - }) + Promise.resolve(runtime.sendMessage(msg)) + .then(resolve) .catch((reason) => { - i -= 1; - if (i <= 0) { + remainingAttempts -= 1; + if (remainingAttempts <= 0) { reject(reason); } else { - setTimeout(send, 200); + setTimeout(send, delay); } }); }; @@ -31,4 +37,4 @@ export function sendMessage(msg) { }); } -/******************************************************************************/ +/*******************************************************************************/ diff --git a/src/js/network.js b/src/js/network.js index 7a8440d..bdd9cb7 100644 --- a/src/js/network.js +++ b/src/js/network.js @@ -1,6 +1,16 @@ 'use strict'; import { isEmpty, isFN } from './util.js'; +export class NetworkError extends Error { + constructor(response, url) { + super(`${response.status} ${response.statusText || 'Request failed'}: ${url}`); + this.name = 'NetworkError'; + this.response = response; + this.status = response.status; + this.url = url; + } +} + /** * @type { import("../typings/UserJS.d.ts").Network } */ @@ -9,53 +19,50 @@ const Network = { if (isEmpty(url)) { throw new Error('"url" parameter is empty'); } - data = Object.assign({}, data); - method = this.bscStr(method, false); - responseType = this.bscStr(responseType); + + const requestUrl = String(url); const params = { - method, - ...data + method: this.bscStr(method, false), + ...Object.assign({}, data) + }; + const normalizedResponseType = this.bscStr(responseType); + const response = await fetch(requestUrl, params); + + if (!response.ok) { + throw new NetworkError(response, requestUrl); + } + + const read = (reader = 'text') => { + return isFN(response[reader]) ? response[reader]() : response; }; - return new Promise((resolve, reject) => { - fetch(url, params) - .then((response_1) => { - if (!response_1.ok) reject(response_1); - const check = (str_2 = 'text') => { - return isFN(response_1[str_2]) ? response_1[str_2]() : response_1; - }; - if (responseType.match(/buffer/)) { - resolve(check('arrayBuffer')); - } else if (responseType.match(/json/)) { - resolve(check('json')); - } else if (responseType.match(/text/)) { - resolve(check('text')); - } else if (responseType.match(/blob/)) { - resolve(check('blob')); - } else if (responseType.match(/formdata/)) { - resolve(check('formData')); - } else if (responseType.match(/clone/)) { - resolve(check('clone')); - } else if (responseType.match(/document/)) { - const respTxt = check('text'); - const domParser = new DOMParser(); - if (respTxt instanceof Promise) { - respTxt.then((txt) => { - const doc = domParser.parseFromString(txt, 'text/html'); - resolve(doc); - }); - } else { - const doc = domParser.parseFromString(respTxt, 'text/html'); - resolve(doc); - } - } else { - resolve(response_1); - } - }) - .catch(reject); - }); + + if (normalizedResponseType.match(/buffer/)) { + return read('arrayBuffer'); + } + if (normalizedResponseType.match(/json/)) { + return read('json'); + } + if (normalizedResponseType.match(/text/)) { + return read('text'); + } + if (normalizedResponseType.match(/blob/)) { + return read('blob'); + } + if (normalizedResponseType.match(/formdata/)) { + return read('formData'); + } + if (normalizedResponseType.match(/clone/)) { + return read('clone'); + } + if (normalizedResponseType.match(/document/)) { + const domParser = new DOMParser(); + return domParser.parseFromString(await read('text'), 'text/html'); + } + + return response; }, format(bytes, decimals = 2) { - if (Number.isNaN(bytes)) return `0 ${this.sizes[0]}`; + if (!Number.isFinite(bytes) || bytes <= 0) return `0 ${this.sizes[0]}`; const k = 1024; const dm = decimals < 0 ? 0 : decimals; const i = Math.floor(Math.log(bytes) / Math.log(k)); diff --git a/src/js/popup.js b/src/js/popup.js index 4f6febe..9b55e03 100644 --- a/src/js/popup.js +++ b/src/js/popup.js @@ -21,7 +21,7 @@ import { normalizedHostname } from './util.js'; import { builtinList, template, BLANK_PAGE, badUserJS, authorID, goodUserJS } from './constants.js'; -import { DEFAULT_CONFIG } from './storage.js'; +import { DEFAULT_CONFIG, normalizeConfig } from './storage.js'; import { BaseContainer, BaseList } from './container.js'; /******************************************************************************/ @@ -1183,7 +1183,7 @@ ael(main, 'click', async (evt) => { } } else if (cmd === 'reset') { sendMessage({ type: 'reset' }); - cfg = DEFAULT_CONFIG; + cfg = normalizeConfig(); container.unsaved = true; container.rebuild = true; rebuildCfg(); @@ -2089,7 +2089,7 @@ async function init() { hostname }); if (response instanceof Object) { - Object.assign(cfg, response.cfg ?? DEFAULT_CONFIG); + Object.assign(cfg, normalizeConfig(response.cfg)); if (Array.isArray(response.data)) { data.push(...response.data); } diff --git a/src/js/storage.js b/src/js/storage.js index 19b5300..34ed90c 100644 --- a/src/js/storage.js +++ b/src/js/storage.js @@ -112,6 +112,59 @@ export const DEFAULT_CONFIG = { }; // #endregion +const clone = (value) => { + if (typeof structuredClone === 'function') { + return structuredClone(value); + } + return JSON.parse(JSON.stringify(value)); +}; + +const mergePlainObject = (fallback, value) => { + if (Array.isArray(fallback)) { + return Array.isArray(value) ? value : clone(fallback); + } + if (!fallback || typeof fallback !== 'object') { + return value ?? fallback; + } + const merged = clone(fallback); + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return merged; + } + for (const [key, val] of Object.entries(value)) { + if (key in fallback) { + merged[key] = mergePlainObject(fallback[key], val); + } else { + merged[key] = val; + } + } + return merged; +}; + +/** + * Returns a complete configuration object without discarding user-defined engine entries. + * Built-in engines are merged with their defaults, and the user's engine order is preserved + * so imports/exports do not unexpectedly reorder custom search providers. + * + * @param {Partial} [config] + * @returns {import('../typings/types.d.ts').config} + */ +export const normalizeConfig = (config = {}) => { + const normalized = mergePlainObject(DEFAULT_CONFIG, config); + const configuredEngines = Array.isArray(config?.engines) ? config.engines : []; + const defaultEngines = new Map(DEFAULT_CONFIG.engines.map((engine) => [engine.name, engine])); + const configuredNames = new Set(configuredEngines.map((engine) => engine?.name).filter(Boolean)); + const mergeEngine = (engine) => { + const fallback = defaultEngines.get(engine.name); + return fallback ? { ...fallback, ...engine } : engine; + }; + + normalized.engines = [ + ...configuredEngines.filter((engine) => engine?.name).map(mergeEngine), + ...DEFAULT_CONFIG.engines.filter((engine) => !configuredNames.has(engine.name)) + ]; + return normalized; +}; + /** @type {{ [prefix: string]: StorageArea }} */ export const storageByPrefix = {}; diff --git a/src/js/util.js b/src/js/util.js index e890117..7e38c64 100644 --- a/src/js/util.js +++ b/src/js/util.js @@ -3,6 +3,19 @@ import { webext } from './ext.js'; import { err } from './logger.js'; +const safeRegExp = (pattern, flags = '') => { + if (typeof pattern !== 'string' || pattern === '') { + return null; + } + try { + return new RegExp(pattern, flags); + } catch (ex) { + ex.cause = `Invalid regular expression: ${pattern}`; + err(ex); + return null; + } +}; + // #region Utilities const userjs = {}; const getUAData = () => { @@ -10,7 +23,7 @@ const getUAData = () => { return userjs.isMobile; } try { - if (navigator) { + if (typeof navigator !== 'undefined') { const { userAgent, userAgentData } = navigator; const { platform, mobile } = userAgentData ? Object(userAgentData) : {}; userjs.isMobile = @@ -48,7 +61,7 @@ const strToURL = (str) => { err(ex); } if (url !== undefined) { - return url + return url; } return str; }; @@ -205,6 +218,24 @@ const union = (...arr) => [...new Set(arr.flat())]; const loadFilters = (cfg) => { /** @type {Map} */ const pool = new Map(); + const addFilter = (key, value) => { + if (pool.has(key) || !value?.regExp || !value?.name) { + return; + } + const normalizedKey = key.trim().toLocaleLowerCase(); + const normalizedName = value.name.trim().toLocaleLowerCase(); + const reg = safeRegExp(value.regExp, value.flag); + const keyReg = safeRegExp(normalizedKey, 'gi'); + const valueReg = safeRegExp(normalizedName, 'gi'); + if (reg && keyReg && valueReg) { + pool.set(key, { + ...value, + reg, + keyReg, + valueReg + }); + } + }; const handles = { pool, enabled() { @@ -212,14 +243,8 @@ const loadFilters = (cfg) => { }, refresh() { if (!Object.is(pool.size, 0)) pool.clear(); - for (const [key, value] of Object.entries(cfg.filters)) { - if (!pool.has(key)) - pool.set(key, { - ...value, - reg: new RegExp(value.regExp, value.flag), - keyReg: new RegExp(key.trim().toLocaleLowerCase(), 'gi'), - valueReg: new RegExp(value.name.trim().toLocaleLowerCase(), 'gi') - }); + for (const [key, value] of Object.entries(cfg.filters ?? {})) { + addFilter(key, value); } return this; }, @@ -233,20 +258,11 @@ const loadFilters = (cfg) => { const p = handles.enabled(); if (Object.is(p.length, 0)) return true; for (const v of p) { - if ([{ name }, ...users].find((o) => o.name.match(v.reg))) return false; + if ([{ name }, ...(users ?? [])].find((o) => o?.name?.match(v.reg))) return false; } return true; } }; - for (const [key, value] of Object.entries(cfg.filters)) { - if (!pool.has(key)) - pool.set(key, { - ...value, - reg: new RegExp(value.regExp, value.flag), - keyReg: new RegExp(key.trim().toLocaleLowerCase(), 'gi'), - valueReg: new RegExp(value.name.trim().toLocaleLowerCase(), 'gi') - }); - } return handles.refresh(); }; /** @@ -331,5 +347,6 @@ export { loadFilters, matchesFromHostnames, hostnamesFromMatches, - normalizedHostname + normalizedHostname, + safeRegExp }; diff --git a/test/ext.test.mjs b/test/ext.test.mjs new file mode 100644 index 0000000..ea4cfb4 --- /dev/null +++ b/test/ext.test.mjs @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +const importExt = async (name) => import(`../src/js/ext.js?${name}-${Date.now()}-${Math.random()}`); + +test('webext falls back to chrome when browser has no runtime', async () => { + const chromeRuntime = { sendMessage: async () => 'ok' }; + globalThis.browser = {}; + globalThis.chrome = { runtime: chromeRuntime }; + + const { runtime, webext } = await importExt('chrome-fallback'); + + assert.equal(webext, globalThis.chrome); + assert.equal(runtime, chromeRuntime); +}); + +test('sendMessage retries transient failures with configurable attempts and delay', async () => { + let attempts = 0; + globalThis.browser = { + runtime: { + sendMessage: async () => { + attempts += 1; + if (attempts < 2) { + throw new Error('service worker unavailable'); + } + return { ok: true }; + } + } + }; + globalThis.chrome = undefined; + + const { sendMessage } = await importExt('retry'); + + assert.deepEqual(await sendMessage({ type: 'ping' }, { attempts: 2, delay: 0 }), { ok: true }); + assert.equal(attempts, 2); +}); diff --git a/test/network.test.mjs b/test/network.test.mjs new file mode 100644 index 0000000..f5891c2 --- /dev/null +++ b/test/network.test.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +globalThis.chrome = { runtime: {}, storage: { local: {} } }; + +const { default: Network, NetworkError } = await import('../src/js/network.js'); + +test('Network.req returns parsed JSON for successful responses', async () => { + globalThis.fetch = async (url, params) => ({ + ok: true, + async json() { + return { url, method: params.method }; + } + }); + + assert.deepEqual(await Network.req('https://example.test/data', 'get'), { + url: 'https://example.test/data', + method: 'GET' + }); +}); + +test('Network.req throws NetworkError without parsing failed responses', async () => { + let parsed = false; + globalThis.fetch = async () => ({ + ok: false, + status: 503, + statusText: 'Service Unavailable', + async json() { + parsed = true; + return {}; + } + }); + + await assert.rejects( + () => Network.req('https://example.test/fail'), + (error) => error instanceof NetworkError && error.status === 503 + ); + assert.equal(parsed, false); +}); diff --git a/test/storage.test.mjs b/test/storage.test.mjs new file mode 100644 index 0000000..3acf248 --- /dev/null +++ b/test/storage.test.mjs @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +globalThis.chrome = { + runtime: {}, + storage: { + local: { + async get() { + return {}; + }, + async set() {}, + async remove() {} + } + } +}; + +const { DEFAULT_CONFIG, normalizeConfig } = await import('../src/js/storage.js'); + +test('normalizeConfig preserves defaults for missing nested config', () => { + const cfg = normalizeConfig({ preview: { code: true } }); + + assert.equal(cfg.preview.code, true); + assert.equal(cfg.preview.metadata, DEFAULT_CONFIG.preview.metadata); + assert.deepEqual(Object.keys(cfg.theme), Object.keys(DEFAULT_CONFIG.theme)); + assert.deepEqual( + cfg.engines.map((engine) => engine.name), + DEFAULT_CONFIG.engines.map((engine) => engine.name) + ); +}); + +test('normalizeConfig carries known engine customizations without dropping defaults', () => { + const cfg = normalizeConfig({ engines: [{ name: 'github', enabled: true, token: 'abc123' }] }); + const github = cfg.engines.find((engine) => engine.name === 'github'); + + assert.equal(github.enabled, true); + assert.equal(github.token, 'abc123'); + assert.equal(typeof github.query, 'string'); +}); + +test('normalizeConfig preserves custom engines while restoring known defaults', () => { + const cfg = normalizeConfig({ + engines: [ + { name: 'custom', enabled: true, query: 'https://example.test/{host}.json' }, + { name: 'greasyfork', enabled: false } + ] + }); + + assert.deepEqual( + cfg.engines.map((engine) => engine.name), + ['custom', 'greasyfork', 'sleazyfork', 'github'] + ); + assert.equal(cfg.engines.find((engine) => engine.name === 'greasyfork').enabled, false); + assert.deepEqual(cfg.engines[0], { + name: 'custom', + enabled: true, + query: 'https://example.test/{host}.json' + }); +}); diff --git a/tsconfig.json b/tsconfig.json index a7e8176..0c27d5a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,10 +6,14 @@ "module": "ESNext", "moduleResolution": "node", "declaration": true, - "allowSyntheticDefaultImports": true + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "types": [] }, "include": [ "src/typings/*.d.ts" ], - "exclude": ["node_modules"] + "exclude": [ + "node_modules" + ] }