Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
{
Expand Down
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
65 changes: 36 additions & 29 deletions src/js/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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);
Expand Down Expand Up @@ -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);
}
});
});
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down
38 changes: 22 additions & 16 deletions src/js/ext.js
Original file line number Diff line number Diff line change
@@ -1,34 +1,40 @@
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);
}
});
};
send();
});
}

/******************************************************************************/
/*******************************************************************************/
93 changes: 50 additions & 43 deletions src/js/network.js
Original file line number Diff line number Diff line change
@@ -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 }
*/
Expand All @@ -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));
Expand Down
6 changes: 3 additions & 3 deletions src/js/popup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/******************************************************************************/
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
}
Expand Down
53 changes: 53 additions & 0 deletions src/js/storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<import('../typings/types.d.ts').config>} [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 = {};

Expand Down
Loading