feat(assets): bundle runtime assets and vendor dependencies

This commit is contained in:
2026-02-22 21:43:43 -08:00
parent d3fd47f0ec
commit ae95601698
429 changed files with 165389 additions and 0 deletions

2982
vendor/yomitan/js/background/backend.js vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,32 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2020-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {log} from '../core/log.js';
import {WebExtension} from '../extension/web-extension.js';
import {Backend} from './backend.js';
/** Entry point. */
async function main() {
const webExtension = new WebExtension();
log.configure(webExtension.extensionName);
const backend = new Backend(webExtension);
await backend.prepare();
}
void main();

View File

@@ -0,0 +1,27 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2020-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Offscreen} from './offscreen.js';
/** Entry point. */
function main() {
const offscreen = new Offscreen();
offscreen.prepare();
}
main();

View File

@@ -0,0 +1,318 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2016-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {ExtensionError} from '../core/extension-error.js';
import {isObjectNotArray} from '../core/object-utilities.js';
import {base64ToArrayBuffer} from '../data/array-buffer-util.js';
/**
* This class is responsible for creating and communicating with an offscreen document.
* This offscreen document is used to solve three issues:
*
* - Provide clipboard access for the `ClipboardReader` class in the context of a MV3 extension.
* The background service workers doesn't have access a webpage to read the clipboard from,
* so it must be done in the offscreen page.
*
* - Create a worker for image rendering, which both selects the images from the database,
* decodes/rasterizes them, and then sends (= postMessage transfers) them back to a worker
* in the popup to be rendered onto OffscreenCanvas.
*
* - Provide a longer lifetime for the dictionary database. The background service worker can be
* terminated by the web browser, which means that when it restarts, it has to go through its
* initialization process again. This initialization process can take a non-trivial amount of
* time, which is primarily caused by the startup of the IndexedDB database, especially when a
* large amount of dictionary data is installed.
*
* The offscreen document stays alive longer, potentially forever, which may be an artifact of
* the clipboard access it requests in the `reasons` parameter. Therefore, this initialization
* process should only take place once, or at the very least, less frequently than the service
* worker.
*
* The long lifetime of the offscreen document is not guaranteed by the spec, which could
* result in this code functioning poorly in the future if a web browser vendor changes the
* APIs or the implementation substantially, and this is even referenced on the Chrome
* developer website.
* @see https://developer.chrome.com/blog/Offscreen-Documents-in-Manifest-v3
* @see https://developer.chrome.com/docs/extensions/reference/api/offscreen
*/
export class OffscreenProxy {
/**
* @param {import('../extension/web-extension.js').WebExtension} webExtension
*/
constructor(webExtension) {
/** @type {import('../extension/web-extension.js').WebExtension} */
this._webExtension = webExtension;
/** @type {?Promise<void>} */
this._creatingOffscreen = null;
/** @type {?MessagePort} */
this._currentOffscreenPort = null;
}
/**
* @see https://developer.chrome.com/docs/extensions/reference/offscreen/
*/
async prepare() {
if (await this._hasOffscreenDocument()) {
void this.sendMessagePromise({action: 'createAndRegisterPortOffscreen'});
return;
}
if (this._creatingOffscreen) {
await this._creatingOffscreen;
return;
}
this._creatingOffscreen = chrome.offscreen.createDocument({
url: 'offscreen.html',
reasons: [
/** @type {chrome.offscreen.Reason} */ ('CLIPBOARD'),
],
justification: 'Access to the clipboard',
});
await this._creatingOffscreen;
this._creatingOffscreen = null;
}
/**
* @returns {Promise<boolean>}
*/
async _hasOffscreenDocument() {
const offscreenUrl = chrome.runtime.getURL('offscreen.html');
if (!chrome.runtime.getContexts) { // Chrome version below 116
// Clients: https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/clients
// @ts-expect-error - Types not set up for service workers yet
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const matchedClients = await clients.matchAll();
// @ts-expect-error - Types not set up for service workers yet
return await matchedClients.some((client) => client.url === offscreenUrl);
}
const contexts = await chrome.runtime.getContexts({
contextTypes: [
/** @type {chrome.runtime.ContextType} */ ('OFFSCREEN_DOCUMENT'),
],
documentUrls: [offscreenUrl],
});
return contexts.length > 0;
}
/**
* @template {import('offscreen').ApiNames} TMessageType
* @param {import('offscreen').ApiMessage<TMessageType>} message
* @returns {Promise<import('offscreen').ApiReturn<TMessageType>>}
*/
async sendMessagePromise(message) {
const response = await this._webExtension.sendMessagePromise(message);
return this._getMessageResponseResult(/** @type {import('core').Response<import('offscreen').ApiReturn<TMessageType>>} */ (response));
}
/**
* @template [TReturn=unknown]
* @param {import('core').Response<TReturn>} response
* @returns {TReturn}
* @throws {Error}
*/
_getMessageResponseResult(response) {
const runtimeError = chrome.runtime.lastError;
if (typeof runtimeError !== 'undefined') {
throw new Error(runtimeError.message);
}
if (!isObjectNotArray(response)) {
throw new Error('Offscreen document did not respond');
}
const responseError = response.error;
if (responseError) {
throw ExtensionError.deserialize(responseError);
}
return response.result;
}
/**
* @param {MessagePort} port
*/
async registerOffscreenPort(port) {
if (this._currentOffscreenPort) {
this._currentOffscreenPort.close();
}
this._currentOffscreenPort = port;
}
/**
* When you need to transfer Transferable objects, you can use this method which uses postMessage over the MessageChannel port established with the offscreen document.
* @template {import('offscreen').McApiNames} TMessageType
* @param {import('offscreen').McApiMessage<TMessageType>} message
* @param {Transferable[]} transfers
*/
sendMessageViaPort(message, transfers) {
if (this._currentOffscreenPort !== null) {
this._currentOffscreenPort.postMessage(message, transfers);
} else {
void this.sendMessagePromise({action: 'createAndRegisterPortOffscreen'});
}
}
}
export class DictionaryDatabaseProxy {
/**
* @param {OffscreenProxy} offscreen
*/
constructor(offscreen) {
/** @type {OffscreenProxy} */
this._offscreen = offscreen;
}
/**
* @returns {Promise<void>}
*/
async prepare() {
await this._offscreen.sendMessagePromise({action: 'databasePrepareOffscreen'});
}
/**
* @returns {Promise<import('dictionary-importer').Summary[]>}
*/
async getDictionaryInfo() {
return this._offscreen.sendMessagePromise({action: 'getDictionaryInfoOffscreen'});
}
/**
* @returns {Promise<boolean>}
*/
async purge() {
return await this._offscreen.sendMessagePromise({action: 'databasePurgeOffscreen'});
}
/**
* @param {import('dictionary-database').MediaRequest[]} targets
* @returns {Promise<import('dictionary-database').Media[]>}
*/
async getMedia(targets) {
const serializedMedia = /** @type {import('dictionary-database').Media<string>[]} */ (await this._offscreen.sendMessagePromise({action: 'databaseGetMediaOffscreen', params: {targets}}));
return serializedMedia.map((m) => ({...m, content: base64ToArrayBuffer(m.content)}));
}
/**
* @param {MessagePort} port
* @returns {Promise<void>}
*/
async connectToDatabaseWorker(port) {
this._offscreen.sendMessageViaPort({action: 'connectToDatabaseWorker'}, [port]);
}
}
export class TranslatorProxy {
/**
* @param {OffscreenProxy} offscreen
*/
constructor(offscreen) {
/** @type {OffscreenProxy} */
this._offscreen = offscreen;
}
/** */
async prepare() {
await this._offscreen.sendMessagePromise({action: 'translatorPrepareOffscreen'});
}
/**
* @param {string} text
* @param {import('translation').FindKanjiOptions} options
* @returns {Promise<import('dictionary').KanjiDictionaryEntry[]>}
*/
async findKanji(text, options) {
const enabledDictionaryMapList = [...options.enabledDictionaryMap];
/** @type {import('offscreen').FindKanjiOptionsOffscreen} */
const modifiedOptions = {
...options,
enabledDictionaryMap: enabledDictionaryMapList,
};
return this._offscreen.sendMessagePromise({action: 'findKanjiOffscreen', params: {text, options: modifiedOptions}});
}
/**
* @param {import('translator').FindTermsMode} mode
* @param {string} text
* @param {import('translation').FindTermsOptions} options
* @returns {Promise<import('translator').FindTermsResult>}
*/
async findTerms(mode, text, options) {
const {enabledDictionaryMap, excludeDictionaryDefinitions, textReplacements} = options;
const enabledDictionaryMapList = [...enabledDictionaryMap];
const excludeDictionaryDefinitionsList = excludeDictionaryDefinitions ? [...excludeDictionaryDefinitions] : null;
const textReplacementsSerialized = textReplacements.map((group) => {
return group !== null ? group.map((opt) => ({...opt, pattern: opt.pattern.toString()})) : null;
});
/** @type {import('offscreen').FindTermsOptionsOffscreen} */
const modifiedOptions = {
...options,
enabledDictionaryMap: enabledDictionaryMapList,
excludeDictionaryDefinitions: excludeDictionaryDefinitionsList,
textReplacements: textReplacementsSerialized,
};
return this._offscreen.sendMessagePromise({action: 'findTermsOffscreen', params: {mode, text, options: modifiedOptions}});
}
/**
* @param {import('translator').TermReadingList} termReadingList
* @param {string[]} dictionaries
* @returns {Promise<import('translator').TermFrequencySimple[]>}
*/
async getTermFrequencies(termReadingList, dictionaries) {
return this._offscreen.sendMessagePromise({action: 'getTermFrequenciesOffscreen', params: {termReadingList, dictionaries}});
}
/** */
async clearDatabaseCaches() {
await this._offscreen.sendMessagePromise({action: 'clearDatabaseCachesOffscreen'});
}
}
export class ClipboardReaderProxy {
/**
* @param {OffscreenProxy} offscreen
*/
constructor(offscreen) {
/** @type {?import('environment').Browser} */
this._browser = null;
/** @type {OffscreenProxy} */
this._offscreen = offscreen;
}
/** @type {?import('environment').Browser} */
get browser() { return this._browser; }
set browser(value) {
if (this._browser === value) { return; }
this._browser = value;
void this._offscreen.sendMessagePromise({action: 'clipboardSetBrowserOffscreen', params: {value}});
}
/**
* @param {boolean} useRichText
* @returns {Promise<string>}
*/
async getText(useRichText) {
return await this._offscreen.sendMessagePromise({action: 'clipboardGetTextOffscreen', params: {useRichText}});
}
/**
* @returns {Promise<?string>}
*/
async getImage() {
return await this._offscreen.sendMessagePromise({action: 'clipboardGetImageOffscreen'});
}
}

View File

@@ -0,0 +1,224 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2016-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {API} from '../comm/api.js';
import {ClipboardReader} from '../comm/clipboard-reader.js';
import {createApiMap, invokeApiMapHandler} from '../core/api-map.js';
import {ExtensionError} from '../core/extension-error.js';
import {log} from '../core/log.js';
import {sanitizeCSS} from '../core/utilities.js';
import {arrayBufferToBase64} from '../data/array-buffer-util.js';
import {DictionaryDatabase} from '../dictionary/dictionary-database.js';
import {WebExtension} from '../extension/web-extension.js';
import {Translator} from '../language/translator.js';
/**
* This class controls the core logic of the extension, including API calls
* and various forms of communication between browser tabs and external applications.
*/
export class Offscreen {
/**
* Creates a new instance.
*/
constructor() {
/** @type {DictionaryDatabase} */
this._dictionaryDatabase = new DictionaryDatabase();
/** @type {Translator} */
this._translator = new Translator(this._dictionaryDatabase);
/** @type {ClipboardReader} */
this._clipboardReader = new ClipboardReader(
(typeof document === 'object' && document !== null ? document : null),
'#clipboard-paste-target',
'#clipboard-rich-content-paste-target',
);
/* eslint-disable @stylistic/no-multi-spaces */
/** @type {import('offscreen').ApiMap} */
this._apiMap = createApiMap([
['clipboardGetTextOffscreen', this._getTextHandler.bind(this)],
['clipboardGetImageOffscreen', this._getImageHandler.bind(this)],
['clipboardSetBrowserOffscreen', this._setClipboardBrowser.bind(this)],
['databasePrepareOffscreen', this._prepareDatabaseHandler.bind(this)],
['getDictionaryInfoOffscreen', this._getDictionaryInfoHandler.bind(this)],
['databasePurgeOffscreen', this._purgeDatabaseHandler.bind(this)],
['databaseGetMediaOffscreen', this._getMediaHandler.bind(this)],
['translatorPrepareOffscreen', this._prepareTranslatorHandler.bind(this)],
['findKanjiOffscreen', this._findKanjiHandler.bind(this)],
['findTermsOffscreen', this._findTermsHandler.bind(this)],
['getTermFrequenciesOffscreen', this._getTermFrequenciesHandler.bind(this)],
['clearDatabaseCachesOffscreen', this._clearDatabaseCachesHandler.bind(this)],
['createAndRegisterPortOffscreen', this._createAndRegisterPort.bind(this)],
['sanitizeCSSOffscreen', this._sanitizeCSSOffscreen.bind(this)],
]);
/* eslint-enable @stylistic/no-multi-spaces */
/** @type {import('offscreen').McApiMap} */
this._mcApiMap = createApiMap([
['connectToDatabaseWorker', this._connectToDatabaseWorkerHandler.bind(this)],
]);
/** @type {?Promise<void>} */
this._prepareDatabasePromise = null;
/**
* @type {API}
*/
this._api = new API(new WebExtension());
}
/** */
prepare() {
chrome.runtime.onMessage.addListener(this._onMessage.bind(this));
navigator.serviceWorker.addEventListener('controllerchange', this._createAndRegisterPort.bind(this));
this._createAndRegisterPort();
}
/** @type {import('offscreen').ApiHandler<'clipboardGetTextOffscreen'>} */
async _getTextHandler({useRichText}) {
return await this._clipboardReader.getText(useRichText);
}
/** @type {import('offscreen').ApiHandler<'clipboardGetImageOffscreen'>} */
async _getImageHandler() {
return await this._clipboardReader.getImage();
}
/** @type {import('offscreen').ApiHandler<'clipboardSetBrowserOffscreen'>} */
_setClipboardBrowser({value}) {
this._clipboardReader.browser = value;
}
/** @type {import('offscreen').ApiHandler<'databasePrepareOffscreen'>} */
_prepareDatabaseHandler() {
if (this._prepareDatabasePromise !== null) {
return this._prepareDatabasePromise;
}
this._prepareDatabasePromise = this._dictionaryDatabase.prepare();
return this._prepareDatabasePromise;
}
/** @type {import('offscreen').ApiHandler<'getDictionaryInfoOffscreen'>} */
async _getDictionaryInfoHandler() {
return await this._dictionaryDatabase.getDictionaryInfo();
}
/** @type {import('offscreen').ApiHandler<'databasePurgeOffscreen'>} */
async _purgeDatabaseHandler() {
return await this._dictionaryDatabase.purge();
}
/** @type {import('offscreen').ApiHandler<'databaseGetMediaOffscreen'>} */
async _getMediaHandler({targets}) {
const media = await this._dictionaryDatabase.getMedia(targets);
return media.map((m) => ({...m, content: arrayBufferToBase64(m.content)}));
}
/** @type {import('offscreen').ApiHandler<'translatorPrepareOffscreen'>} */
_prepareTranslatorHandler() {
this._translator.prepare();
}
/** @type {import('offscreen').ApiHandler<'findKanjiOffscreen'>} */
async _findKanjiHandler({text, options}) {
/** @type {import('translation').FindKanjiOptions} */
const modifiedOptions = {
...options,
enabledDictionaryMap: new Map(options.enabledDictionaryMap),
};
return await this._translator.findKanji(text, modifiedOptions);
}
/** @type {import('offscreen').ApiHandler<'findTermsOffscreen'>} */
async _findTermsHandler({mode, text, options}) {
const enabledDictionaryMap = new Map(options.enabledDictionaryMap);
const excludeDictionaryDefinitions = (
options.excludeDictionaryDefinitions !== null ?
new Set(options.excludeDictionaryDefinitions) :
null
);
const textReplacements = options.textReplacements.map((group) => {
if (group === null) { return null; }
return group.map((opt) => {
// https://stackoverflow.com/a/33642463
const match = opt.pattern.match(/\/(.*?)\/([a-z]*)?$/i);
const [, pattern, flags] = match !== null ? match : ['', '', ''];
return {...opt, pattern: new RegExp(pattern, flags ?? '')};
});
});
/** @type {import('translation').FindTermsOptions} */
const modifiedOptions = {
...options,
enabledDictionaryMap,
excludeDictionaryDefinitions,
textReplacements,
};
return this._translator.findTerms(mode, text, modifiedOptions);
}
/** @type {import('offscreen').ApiHandler<'getTermFrequenciesOffscreen'>} */
_getTermFrequenciesHandler({termReadingList, dictionaries}) {
return this._translator.getTermFrequencies(termReadingList, dictionaries);
}
/** @type {import('offscreen').ApiHandler<'clearDatabaseCachesOffscreen'>} */
_clearDatabaseCachesHandler() {
this._translator.clearDatabaseCaches();
}
/** @type {import('extension').ChromeRuntimeOnMessageCallback<import('offscreen').ApiMessageAny>} */
_onMessage({action, params}, _sender, callback) {
return invokeApiMapHandler(this._apiMap, action, params, [], callback);
}
/**
*
*/
_createAndRegisterPort() {
const mc = new MessageChannel();
mc.port1.onmessage = this._onMcMessage.bind(this);
mc.port1.onmessageerror = this._onMcMessageError.bind(this);
this._api.registerOffscreenPort([mc.port2]);
}
/** @type {import('offscreen').McApiHandler<'connectToDatabaseWorker'>} */
async _connectToDatabaseWorkerHandler(_params, ports) {
await this._dictionaryDatabase.connectToDatabaseWorker(ports[0]);
}
/** @type {import('offscreen').ApiHandler<'sanitizeCSSOffscreen'>} */
_sanitizeCSSOffscreen(params) {
return sanitizeCSS(params.css);
}
/**
* @param {MessageEvent<import('offscreen').McApiMessageAny>} event
*/
_onMcMessage(event) {
const {action, params} = event.data;
invokeApiMapHandler(this._mcApiMap, action, params, [event.ports], () => {});
}
/**
* @param {MessageEvent<import('offscreen').McApiMessageAny>} event
*/
_onMcMessageError(event) {
const error = new ExtensionError('Offscreen: Error receiving message via postMessage');
error.data = event;
log.error(error);
}
}

View File

@@ -0,0 +1,386 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2020-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {JsonSchema} from '../data/json-schema.js';
/** @type {RegExp} */
const splitPattern = /[,;\s]+/;
/** @type {Map<string, {operators: Map<string, import('profile-conditions-util').CreateSchemaFunction>}>} */
const descriptors = new Map([
[
'popupLevel',
{
operators: new Map(/** @type {import('profile-conditions-util').OperatorMapArray} */ ([
['equal', createSchemaPopupLevelEqual.bind(this)],
['notEqual', createSchemaPopupLevelNotEqual.bind(this)],
['lessThan', createSchemaPopupLevelLessThan.bind(this)],
['greaterThan', createSchemaPopupLevelGreaterThan.bind(this)],
['lessThanOrEqual', createSchemaPopupLevelLessThanOrEqual.bind(this)],
['greaterThanOrEqual', createSchemaPopupLevelGreaterThanOrEqual.bind(this)],
])),
},
],
[
'url',
{
operators: new Map(/** @type {import('profile-conditions-util').OperatorMapArray} */ ([
['matchDomain', createSchemaUrlMatchDomain.bind(this)],
['matchRegExp', createSchemaUrlMatchRegExp.bind(this)],
])),
},
],
[
'modifierKeys',
{
operators: new Map(/** @type {import('profile-conditions-util').OperatorMapArray} */ ([
['are', createSchemaModifierKeysAre.bind(this)],
['areNot', createSchemaModifierKeysAreNot.bind(this)],
['include', createSchemaModifierKeysInclude.bind(this)],
['notInclude', createSchemaModifierKeysNotInclude.bind(this)],
])),
},
],
[
'flags',
{
operators: new Map(/** @type {import('profile-conditions-util').OperatorMapArray} */ ([
['are', createSchemaFlagsAre.bind(this)],
['areNot', createSchemaFlagsAreNot.bind(this)],
['include', createSchemaFlagsInclude.bind(this)],
['notInclude', createSchemaFlagsNotInclude.bind(this)],
])),
},
],
]);
/**
* Creates a new JSON schema descriptor for the given set of condition groups.
* @param {import('settings').ProfileConditionGroup[]} conditionGroups An array of condition groups.
* For a profile match, all of the items must return successfully in at least one of the groups.
* @returns {JsonSchema} A new `JsonSchema` object.
*/
export function createSchema(conditionGroups) {
const anyOf = [];
for (const {conditions} of conditionGroups) {
const allOf = [];
for (const {type, operator, value} of conditions) {
const conditionDescriptor = descriptors.get(type);
if (typeof conditionDescriptor === 'undefined') { continue; }
const createSchema2 = conditionDescriptor.operators.get(operator);
if (typeof createSchema2 === 'undefined') { continue; }
const schema = createSchema2(value);
allOf.push(schema);
}
switch (allOf.length) {
case 0: break;
case 1: anyOf.push(allOf[0]); break;
default: anyOf.push({allOf}); break;
}
}
let schema;
switch (anyOf.length) {
case 0: schema = {}; break;
case 1: schema = anyOf[0]; break;
default: schema = {anyOf}; break;
}
return new JsonSchema(schema);
}
/**
* Creates a normalized version of the context object to test,
* assigning dependent fields as needed.
* @param {import('settings').OptionsContext} context A context object which is used during schema validation.
* @returns {import('profile-conditions-util').NormalizedOptionsContext} A normalized context object.
*/
export function normalizeContext(context) {
const normalizedContext = /** @type {import('profile-conditions-util').NormalizedOptionsContext} */ (Object.assign({}, context));
const {url} = normalizedContext;
if (typeof url === 'string') {
try {
normalizedContext.domain = new URL(url).hostname;
} catch (e) {
// NOP
}
}
const {flags} = normalizedContext;
if (!Array.isArray(flags)) {
normalizedContext.flags = [];
}
return normalizedContext;
}
// Private
/**
* @param {string} value
* @returns {string[]}
*/
function split(value) {
return value.split(splitPattern);
}
/**
* @param {string} value
* @returns {number}
*/
function stringToNumber(value) {
const number = Number.parseFloat(value);
return Number.isFinite(number) ? number : 0;
}
// popupLevel schema creation functions
/**
* @param {string} value
* @returns {import('ext/json-schema').Schema}
*/
function createSchemaPopupLevelEqual(value) {
const number = stringToNumber(value);
return {
required: ['depth'],
properties: {
depth: {const: number},
},
};
}
/**
* @param {string} value
* @returns {import('ext/json-schema').Schema}
*/
function createSchemaPopupLevelNotEqual(value) {
return {
not: {
anyOf: [createSchemaPopupLevelEqual(value)],
},
};
}
/**
* @param {string} value
* @returns {import('ext/json-schema').Schema}
*/
function createSchemaPopupLevelLessThan(value) {
const number = stringToNumber(value);
return {
required: ['depth'],
properties: {
depth: {type: 'number', exclusiveMaximum: number},
},
};
}
/**
* @param {string} value
* @returns {import('ext/json-schema').Schema}
*/
function createSchemaPopupLevelGreaterThan(value) {
const number = stringToNumber(value);
return {
required: ['depth'],
properties: {
depth: {type: 'number', exclusiveMinimum: number},
},
};
}
/**
* @param {string} value
* @returns {import('ext/json-schema').Schema}
*/
function createSchemaPopupLevelLessThanOrEqual(value) {
const number = stringToNumber(value);
return {
required: ['depth'],
properties: {
depth: {type: 'number', maximum: number},
},
};
}
/**
* @param {string} value
* @returns {import('ext/json-schema').Schema}
*/
function createSchemaPopupLevelGreaterThanOrEqual(value) {
const number = stringToNumber(value);
return {
required: ['depth'],
properties: {
depth: {type: 'number', minimum: number},
},
};
}
// URL schema creation functions
/**
* @param {string} value
* @returns {import('ext/json-schema').Schema}
*/
function createSchemaUrlMatchDomain(value) {
const oneOf = [];
for (let domain of split(value)) {
if (domain.length === 0) { continue; }
domain = domain.toLowerCase();
oneOf.push({const: domain});
}
return {
required: ['domain'],
properties: {
domain: {oneOf},
},
};
}
/**
* @param {string} value
* @returns {import('ext/json-schema').Schema}
*/
function createSchemaUrlMatchRegExp(value) {
return {
required: ['url'],
properties: {
url: {type: 'string', pattern: value, patternFlags: 'i'},
},
};
}
// modifierKeys schema creation functions
/**
* @param {string} value
* @returns {import('ext/json-schema').Schema}
*/
function createSchemaModifierKeysAre(value) {
return createSchemaArrayCheck('modifierKeys', value, true, false);
}
/**
* @param {string} value
* @returns {import('ext/json-schema').Schema}
*/
function createSchemaModifierKeysAreNot(value) {
return {
not: {
anyOf: [createSchemaArrayCheck('modifierKeys', value, true, false)],
},
};
}
/**
* @param {string} value
* @returns {import('ext/json-schema').Schema}
*/
function createSchemaModifierKeysInclude(value) {
return createSchemaArrayCheck('modifierKeys', value, false, false);
}
/**
* @param {string} value
* @returns {import('ext/json-schema').Schema}
*/
function createSchemaModifierKeysNotInclude(value) {
return createSchemaArrayCheck('modifierKeys', value, false, true);
}
// modifierKeys schema creation functions
/**
* @param {string} value
* @returns {import('ext/json-schema').Schema}
*/
function createSchemaFlagsAre(value) {
return createSchemaArrayCheck('flags', value, true, false);
}
/**
* @param {string} value
* @returns {import('ext/json-schema').Schema}
*/
function createSchemaFlagsAreNot(value) {
return {
not: {
anyOf: [createSchemaArrayCheck('flags', value, true, false)],
},
};
}
/**
* @param {string} value
* @returns {import('ext/json-schema').Schema}
*/
function createSchemaFlagsInclude(value) {
return createSchemaArrayCheck('flags', value, false, false);
}
/**
* @param {string} value
* @returns {import('ext/json-schema').Schema}
*/
function createSchemaFlagsNotInclude(value) {
return createSchemaArrayCheck('flags', value, false, true);
}
// Generic
/**
* @param {string} key
* @param {string} value
* @param {boolean} exact
* @param {boolean} none
* @returns {import('ext/json-schema').Schema}
*/
function createSchemaArrayCheck(key, value, exact, none) {
/** @type {import('ext/json-schema').Schema[]} */
const containsList = [];
for (const item of split(value)) {
if (item.length === 0) { continue; }
containsList.push({
contains: {
const: item,
},
});
}
const containsListCount = containsList.length;
/** @type {import('ext/json-schema').Schema} */
const schema = {
type: 'array',
};
if (exact) {
schema.maxItems = containsListCount;
}
if (none) {
if (containsListCount > 0) {
schema.not = {anyOf: containsList};
}
} else {
schema.minItems = containsListCount;
if (containsListCount > 0) {
schema.allOf = containsList;
}
}
return {
required: [key],
properties: {
[key]: schema,
},
};
}

View File

@@ -0,0 +1,339 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2020-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* This class is used to generate `fetch()` requests on the background page
* with additional controls over anonymity and error handling.
*/
export class RequestBuilder {
/**
* Creates a new instance.
*/
constructor() {
/** @type {TextEncoder} */
this._textEncoder = new TextEncoder();
/** @type {Set<number>} */
this._ruleIds = new Set();
}
/**
* Initializes the instance.
*/
async prepare() {
try {
await this._clearDynamicRules();
await this._clearSessionRules();
} catch (e) {
// NOP
}
}
/**
* Runs an anonymized fetch request, which strips the `Cookie` header and adjust the `Origin` header.
* @param {string} url The URL to fetch.
* @param {RequestInit} init The initialization parameters passed to the `fetch` function.
* @returns {Promise<Response>} The response of the `fetch` call.
*/
async fetchAnonymous(url, init) {
const id = this._getNewRuleId();
const originUrl = this._getOriginURL(url);
url = encodeURI(decodeURIComponent(url));
this._ruleIds.add(id);
try {
/** @type {chrome.declarativeNetRequest.Rule[]} */
const addRules = [{
id,
priority: 1,
condition: {
urlFilter: `|${this._escapeDnrUrl(url)}|`,
resourceTypes: [
/** @type {chrome.declarativeNetRequest.ResourceType} */ ('xmlhttprequest'),
],
},
action: {
type: /** @type {chrome.declarativeNetRequest.RuleActionType} */ ('modifyHeaders'),
requestHeaders: [
{
operation: /** @type {chrome.declarativeNetRequest.HeaderOperation} */ ('remove'),
header: 'Cookie',
},
{
operation: /** @type {chrome.declarativeNetRequest.HeaderOperation} */ ('set'),
header: 'Origin',
value: originUrl,
},
],
responseHeaders: [
{
operation: /** @type {chrome.declarativeNetRequest.HeaderOperation} */ ('remove'),
header: 'Set-Cookie',
},
],
},
}];
await this._updateSessionRules({addRules});
try {
return await fetch(url, init);
} finally {
await this._tryUpdateSessionRules({removeRuleIds: [id]});
}
} finally {
this._ruleIds.delete(id);
}
}
/**
* Reads the array buffer body of a fetch response, with an optional `onProgress` callback.
* @param {Response} response The response of a `fetch` call.
* @param {?(done: boolean) => void} onProgress The progress callback.
* @returns {Promise<Uint8Array>} The resulting binary data.
*/
static async readFetchResponseArrayBuffer(response, onProgress) {
/** @type {ReadableStreamDefaultReader<Uint8Array>|undefined} */
let reader;
try {
if (onProgress !== null) {
const {body} = response;
if (body !== null) {
reader = body.getReader();
}
}
} catch (e) {
// Not supported
}
if (typeof reader === 'undefined') {
const result = await response.arrayBuffer();
if (onProgress !== null) {
onProgress(true);
}
return new Uint8Array(result);
}
const contentLengthString = response.headers.get('Content-Length');
const contentLength = contentLengthString !== null ? Number.parseInt(contentLengthString, 10) : null;
let target = contentLength !== null && Number.isFinite(contentLength) ? new Uint8Array(contentLength) : null;
let targetPosition = 0;
let totalLength = 0;
const targets = [];
while (true) {
const {done, value} = await reader.read();
if (done) { break; }
if (onProgress !== null) {
onProgress(false);
}
if (target === null) {
targets.push({array: value, length: value.length});
} else if (targetPosition + value.length > target.length) {
targets.push({array: target, length: targetPosition});
target = null;
} else {
target.set(value, targetPosition);
targetPosition += value.length;
}
totalLength += value.length;
}
if (target === null) {
target = this._joinUint8Arrays(targets, totalLength);
} else if (totalLength < target.length) {
target = target.slice(0, totalLength);
}
if (onProgress !== null) {
onProgress(true);
}
return /** @type {Uint8Array} */ (target);
}
// Private
/** */
async _clearSessionRules() {
const rules = await this._getSessionRules();
if (rules.length === 0) { return; }
const removeRuleIds = [];
for (const {id} of rules) {
removeRuleIds.push(id);
}
await this._updateSessionRules({removeRuleIds});
}
/**
* @returns {Promise<chrome.declarativeNetRequest.Rule[]>}
*/
_getSessionRules() {
return new Promise((resolve, reject) => {
chrome.declarativeNetRequest.getSessionRules((result) => {
const e = chrome.runtime.lastError;
if (e) {
reject(new Error(e.message));
} else {
resolve(result);
}
});
});
}
/**
* @param {chrome.declarativeNetRequest.UpdateRuleOptions} options
* @returns {Promise<void>}
*/
_updateSessionRules(options) {
return new Promise((resolve, reject) => {
chrome.declarativeNetRequest.updateSessionRules(options, () => {
const e = chrome.runtime.lastError;
if (e) {
reject(new Error(e.message));
} else {
resolve();
}
});
});
}
/**
* @param {chrome.declarativeNetRequest.UpdateRuleOptions} options
* @returns {Promise<boolean>}
*/
async _tryUpdateSessionRules(options) {
try {
await this._updateSessionRules(options);
return true;
} catch (e) {
return false;
}
}
/** */
async _clearDynamicRules() {
const rules = await this._getDynamicRules();
if (rules.length === 0) { return; }
const removeRuleIds = [];
for (const {id} of rules) {
removeRuleIds.push(id);
}
await this._updateDynamicRules({removeRuleIds});
}
/**
* @returns {Promise<chrome.declarativeNetRequest.Rule[]>}
*/
_getDynamicRules() {
return new Promise((resolve, reject) => {
chrome.declarativeNetRequest.getDynamicRules((result) => {
const e = chrome.runtime.lastError;
if (e) {
reject(new Error(e.message));
} else {
resolve(result);
}
});
});
}
/**
* @param {chrome.declarativeNetRequest.UpdateRuleOptions} options
* @returns {Promise<void>}
*/
_updateDynamicRules(options) {
return new Promise((resolve, reject) => {
chrome.declarativeNetRequest.updateDynamicRules(options, () => {
const e = chrome.runtime.lastError;
if (e) {
reject(new Error(e.message));
} else {
resolve();
}
});
});
}
/**
* @returns {number}
* @throws {Error}
*/
_getNewRuleId() {
let id = 1;
while (this._ruleIds.has(id)) {
const pre = id;
++id;
if (id === pre) { throw new Error('Could not generate an id'); }
}
return id;
}
/**
* @param {string} url
* @returns {string}
*/
_getOriginURL(url) {
const url2 = new URL(url);
return `${url2.protocol}//${url2.host}`;
}
/**
* @param {string} url
* @returns {string}
*/
_escapeDnrUrl(url) {
return url.replace(/[|*^]/g, (char) => this._urlEncodeUtf8(char));
}
/**
* @param {string} text
* @returns {string}
*/
_urlEncodeUtf8(text) {
const array = this._textEncoder.encode(text);
let result = '';
for (const byte of array) {
result += `%${byte.toString(16).toUpperCase().padStart(2, '0')}`;
}
return result;
}
/**
* @param {{array: Uint8Array, length: number}[]} items
* @param {number} totalLength
* @returns {Uint8Array}
*/
static _joinUint8Arrays(items, totalLength) {
if (items.length === 1) {
const {array, length} = items[0];
if (array.length === length) { return array; }
}
const result = new Uint8Array(totalLength);
let position = 0;
for (const {array, length} of items) {
result.set(array, position);
position += length;
}
return result;
}
}

View File

@@ -0,0 +1,165 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2021-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Injects a stylesheet into a tab.
* @param {'file'|'code'} type The type of content to inject; either 'file' or 'code'.
* @param {string} content The content to inject.
* - If type is `'file'`, this argument should be a path to a file.
* - If type is `'code'`, this argument should be the CSS content.
* @param {number} tabId The id of the tab to inject into.
* @param {number|undefined} frameId The id of the frame to inject into.
* @param {boolean} allFrames Whether or not the stylesheet should be injected into all frames.
* @returns {Promise<void>}
*/
export function injectStylesheet(type, content, tabId, frameId, allFrames) {
return new Promise((resolve, reject) => {
/** @type {chrome.scripting.InjectionTarget} */
const target = {
tabId,
allFrames,
};
/** @type {chrome.scripting.CSSInjection} */
const details = (
type === 'file' ?
{origin: 'AUTHOR', files: [content], target} :
{origin: 'USER', css: content, target}
);
if (!allFrames && typeof frameId === 'number') {
details.target.frameIds = [frameId];
}
chrome.scripting.insertCSS(details, () => {
const e = chrome.runtime.lastError;
if (e) {
reject(new Error(e.message));
} else {
resolve();
}
});
});
}
/**
* Checks whether or not a content script is registered.
* @param {string} id The identifier used with a call to `registerContentScript`.
* @returns {Promise<boolean>} `true` if a script is registered, `false` otherwise.
*/
export async function isContentScriptRegistered(id) {
const scripts = await getRegisteredContentScripts([id]);
for (const script of scripts) {
if (script.id === id) {
return true;
}
}
return false;
}
/**
* Registers a dynamic content script.
* Note: if the fallback handler is used and the 'webNavigation' permission isn't granted,
* there is a possibility that the script can be injected more than once due to the events used.
* Therefore, a reentrant check may need to be performed by the content script.
* @param {string} id A unique identifier for the registration.
* @param {import('script-manager').RegistrationDetails} details The script registration details.
* @throws An error is thrown if the id is already in use.
*/
export async function registerContentScript(id, details) {
if (await isContentScriptRegistered(id)) {
throw new Error('Registration already exists');
}
const details2 = createContentScriptRegistrationOptions(details, id);
await /** @type {Promise<void>} */ (new Promise((resolve, reject) => {
chrome.scripting.registerContentScripts([details2], () => {
const e = chrome.runtime.lastError;
if (e) {
reject(new Error(e.message));
} else {
resolve();
}
});
}));
}
/**
* Unregisters a previously registered content script.
* @param {string} id The identifier passed to a previous call to `registerContentScript`.
* @returns {Promise<void>}
*/
export async function unregisterContentScript(id) {
return new Promise((resolve, reject) => {
chrome.scripting.unregisterContentScripts({ids: [id]}, () => {
const e = chrome.runtime.lastError;
if (e) {
reject(new Error(e.message));
} else {
resolve();
}
});
});
}
/**
* @param {import('script-manager').RegistrationDetails} details
* @param {string} id
* @returns {chrome.scripting.RegisteredContentScript}
*/
function createContentScriptRegistrationOptions(details, id) {
const {css, js, allFrames, matches, runAt, world} = details;
/** @type {chrome.scripting.RegisteredContentScript} */
const options = {
id: id,
persistAcrossSessions: true,
};
if (Array.isArray(css)) {
options.css = [...css];
}
if (Array.isArray(js)) {
options.js = [...js];
}
if (typeof allFrames !== 'undefined') {
options.allFrames = allFrames;
}
if (Array.isArray(matches)) {
options.matches = [...matches];
}
if (typeof runAt !== 'undefined') {
options.runAt = runAt;
}
if (typeof world !== 'undefined') {
options.world = world;
}
return options;
}
/**
* @param {string[]} ids
* @returns {Promise<chrome.scripting.RegisteredContentScript[]>}
*/
function getRegisteredContentScripts(ids) {
return new Promise((resolve, reject) => {
chrome.scripting.getRegisteredContentScripts({ids}, (result) => {
const e = chrome.runtime.lastError;
if (e) {
reject(new Error(e.message));
} else {
resolve(result);
}
});
});
}