chore: 整理 third-party Dark Reader 目录并移除本地说明文件跟踪

This commit is contained in:
2026-08-08 17:38:08 +08:00
parent 202ab537d1
commit 0bb552b026
1004 changed files with 115066 additions and 20081 deletions
+161
View File
@@ -0,0 +1,161 @@
// @ts-check
import process from 'node:process';
import bundleAPI from './bundle-api.js';
import bundleCSS from './bundle-css.js';
import bundleHTML from './bundle-html.js';
import bundleJS from './bundle-js.js';
import bundleLocales from './bundle-locales.js';
import bundleManifest from './bundle-manifest.js';
import bundleSignature from './bundle-signature.js';
import clean from './clean.js';
import codeStyle from './code-style.js';
import copy from './copy.js';
import saveLog from './log.js';
import {PLATFORM} from './platform.js';
import * as reload from './reload.js';
import {runTasks} from './task.js';
import {log, pathExistsSync} from './utils.js';
import zip from './zip.js';
const standardTask = [
clean,
bundleHTML,
bundleJS,
bundleCSS,
bundleLocales,
bundleManifest,
copy,
saveLog,
];
const buildTask = [
...standardTask,
codeStyle,
zip,
];
const signedBuildTask = [
...standardTask,
codeStyle,
bundleSignature,
zip,
];
async function build({platforms, debug, watch, log: logging, test, version}) {
log.ok('BUILD');
platforms = {
...platforms,
[PLATFORM.API]: false,
};
try {
await runTasks(debug ? standardTask : (version ? signedBuildTask : buildTask), {platforms, debug, watch, log: logging, test, version});
if (watch) {
standardTask.forEach((task) => task.watch(platforms));
reload.reload({type: reload.FULL});
log.ok('Watching...');
} else {
log.ok('MISSION PASSED! RESPECT +');
}
} catch (err) {
console.log(err);
log.error(`MISSION FAILED!`);
process.exit(13);
}
}
async function api(debug, watch) {
log.ok('API');
try {
const tasks = [bundleAPI];
if (!debug) {
tasks.push(codeStyle);
}
await runTasks(tasks, {platforms: {[PLATFORM.API]: true}, debug, watch, version: false, log: false, test: false});
if (watch) {
bundleAPI.watch();
log.ok('Watching...');
}
log.ok('MISSION PASSED! RESPECT +');
} catch (err) {
console.log(err);
log.error(`MISSION FAILED!`);
process.exit(13);
}
}
async function run({release, debug, platforms, watch, log, test, version}) {
const regular = Object.keys(platforms).some((platform) => platform !== PLATFORM.API && platforms[platform]);
if (release && regular) {
await build({platforms, version, debug: false, watch: false, log: null, test: false});
}
if (debug && regular) {
await build({platforms, version, debug, watch, log, test});
}
if (platforms[PLATFORM.API]) {
await api(debug, watch);
}
}
function getParams(args) {
const argMap = {
'--api': PLATFORM.API,
'--chrome': PLATFORM.CHROMIUM_MV2,
'--chrome-mv2': PLATFORM.CHROMIUM_MV2,
'--chrome-mv3': PLATFORM.CHROMIUM_MV3,
'--chrome-plus': PLATFORM.CHROMIUM_MV2_PLUS,
'--firefox': PLATFORM.FIREFOX_MV2,
'--firefox-mv2': PLATFORM.FIREFOX_MV2,
'--firefox-mv3': PLATFORM.FIREFOX_MV3,
'--thunderbird': PLATFORM.THUNDERBIRD,
};
const platforms = {
[PLATFORM.CHROMIUM_MV2]: false,
[PLATFORM.CHROMIUM_MV2_PLUS]: false,
[PLATFORM.CHROMIUM_MV3]: false,
[PLATFORM.FIREFOX_MV2]: false,
[PLATFORM.THUNDERBIRD]: false,
};
let allPlatforms = true;
for (const arg of args) {
if (argMap[arg]) {
platforms[argMap[arg]] = true;
allPlatforms = false;
}
}
if ((args.includes('--chrome') || args.includes('--chrome-mv2')) && args.includes('--plus')) {
platforms[PLATFORM.CHROMIUM_MV2] = false;
platforms[PLATFORM.CHROMIUM_MV2_PLUS] = true;
}
if (allPlatforms) {
Object.keys(platforms).forEach((platform) => platforms[platform] = true);
}
// TODO(Anton): remove me
if (platforms[PLATFORM.FIREFOX_MV3]) {
platforms[PLATFORM.FIREFOX_MV3] = false;
console.log('Firefox MV3 build is not supported yet');
}
if (!pathExistsSync('./src/plus/')) {
platforms[PLATFORM.CHROMIUM_MV2_PLUS] = false;
}
const versionArg = args.find((a) => a.startsWith('--version='));
const version = versionArg ? versionArg.substring('--version='.length) : null;
const release = args.includes('--release');
const debug = args.includes('--debug');
const watch = args.includes('--watch');
const logInfo = watch && args.includes('--log-info');
const logWarn = watch && args.includes('--log-warn');
const logAssert = watch && args.includes('--log-assert');
const log = logWarn ? 'warn' : (logInfo ? 'info' : (logAssert ? 'assert' : null));
const test = args.includes('--test');
return {release, debug, platforms, watch, log, test, version};
}
const args = process.argv.slice(2);
const params = getParams(args);
run(params);
+100
View File
@@ -0,0 +1,100 @@
// @ts-check
import fs from 'node:fs';
import os from 'node:os';
import rollupPluginReplace from '@rollup/plugin-replace';
import rollupPluginTypescript from '@rollup/plugin-typescript';
import * as rollup from 'rollup';
/** @type {any} */
/** @type {any} */
import typescript from 'typescript';
import {absolutePath} from './paths.js';
import {createTask} from './task.js';
async function getVersion() {
const file = await fs.promises.readFile(new URL('../package.json', import.meta.url), 'utf8');
const p = JSON.parse(file);
return p.version;
}
/** @type {string[]} */
let watchFiles = [];
async function bundleAPIModule({debug, watch}, moduleType, dest) {
const src = absolutePath('src/api/index.ts');
const bundle = await rollup.rollup({
input: src,
onwarn: (error) => {
throw error;
},
plugins: [
// @ts-expect-error This expression is not callable
rollupPluginTypescript({
rootDir: absolutePath('.'),
typescript,
tsconfig: absolutePath('src/api/tsconfig.json'),
noImplicitAny: debug ? false : true,
noUnusedLocals: debug ? false : true,
strictNullChecks: debug ? false : true,
strictPropertyInitialization: false,
removeComments: debug ? false : true,
sourceMap: debug ? true : false,
inlineSources: debug ? true : false,
noEmitOnError: watch ? false : true,
outDir: absolutePath('.'),
cacheDir: debug ? `${fs.realpathSync(os.tmpdir())}/darkreader_api_typescript_cache` : undefined,
}),
// @ts-expect-error This expression is not callable
rollupPluginReplace({
preventAssignment: true,
__DEBUG__: false,
__CHROMIUM_MV2__: false,
__CHROMIUM_MV3__: false,
__FIREFOX_MV2__: false,
__THUNDERBIRD__: false,
__TEST__: false,
__PLUS__: false,
}),
].filter(Boolean),
});
watchFiles = bundle.watchFiles;
await bundle.write({
banner: `/**\n * Dark Reader v${await getVersion()}\n * https://darkreader.org/\n */\n`,
// TODO: Consider removing next line
esModule: true,
file: dest,
strict: true,
format: moduleType,
name: 'DarkReader',
sourcemap: debug ? 'inline' : false,
});
}
async function bundleAPI({debug, watch}) {
await bundleAPIModule({debug, watch}, 'umd', 'darkreader.js');
await bundleAPIModule({debug, watch}, 'esm', 'darkreader.mjs');
}
const bundleAPITask = createTask(
'bundle-api',
bundleAPI,
).addWatcher(
() => {
return watchFiles;
},
async (changedFiles, watcher) => {
const oldWatchFiles = watchFiles;
await bundleAPI({debug: true, watch: true});
watcher.unwatch(
oldWatchFiles.filter((oldFile) => !watchFiles.includes(oldFile))
);
watcher.add(
watchFiles.filter((newFile) => oldWatchFiles.includes(newFile))
);
},
);
export default bundleAPITask;
+140
View File
@@ -0,0 +1,140 @@
// @ts-check
import path from 'node:path';
import less from 'less';
import {getDestDir, absolutePath} from './paths.js';
import {PLATFORM} from './platform.js';
import * as reload from './reload.js';
import {createTask} from './task.js';
import {readFile, writeFile} from './utils.js';
/** @typedef {import('chokidar').FSWatcher} FSWatcher */
/** @typedef {import('./types.d.ts').CSSEntry} CSSEntry */
/** @type {CSSEntry[]} */
const cssEntries = [
{
src: 'src/ui/devtools/style.less',
dest: 'ui/devtools/style.css',
},
{
src: 'src/ui/options/style.less',
dest: 'ui/options/style.css',
},
{
src: 'src/ui/popup/style.less',
dest: 'ui/popup/style.css',
},
{
src: 'src/ui/stylesheet-editor/style.less',
dest: 'ui/stylesheet-editor/style.css',
},
];
async function bundleCSSEntry(entry, plus) {
const src = absolutePath(entry.src);
const srcDir = path.dirname(src);
let input = await readFile(src);
if (!plus) {
const startToken = '/* @plus-start */';
const endToken = '/* @plus-end */';
const startIndex = input.indexOf(startToken);
const endIndex = input.indexOf(endToken, startIndex);
if (startIndex >= 0 && endIndex >= 0) {
input = input.substring(0, startIndex) + input.substring(endIndex + endToken.length);
}
}
const output = await less.render(input, {paths: [srcDir], math: 'always'});
entry.watchFiles = output.imports;
return output.css;
}
async function writeFiles(dest, platforms, debug, css) {
const enabledPlatforms = Object.values(PLATFORM).filter((platform) => platform !== PLATFORM.API && platforms[platform]);
for (const platform of enabledPlatforms) {
const dir = getDestDir({debug, platform});
await writeFile(`${dir}/${dest}`, css);
}
}
/**
* @param {CSSEntry} entry
* @returns {string}
*/
function getEntryFile(entry) {
return absolutePath(entry.src);
}
/**
* @param {CSSEntry[]} cssEntries
* @returns {ReturnType<typeof createTask>}
*/
export function createBundleCSSTask(cssEntries) {
/** @type {string[]} */
let currentWatchFiles;
const getWatchFiles = () => {
const watchFiles = new Set();
cssEntries.forEach((entry) => {
entry.watchFiles?.forEach((file) => watchFiles.add(file));
const entryFile = getEntryFile(entry);
if (!watchFiles.has(entryFile)) {
watchFiles.add(entryFile);
}
});
currentWatchFiles = Array.from(watchFiles);
return currentWatchFiles;
};
const bundleCSS = async ({platforms, debug}) => {
for (const entry of cssEntries) {
for (const platform in platforms) {
if (!platforms[platform]) {
continue;
}
const css = await bundleCSSEntry(entry, platform === PLATFORM.CHROMIUM_MV2_PLUS);
await writeFiles(entry.dest, {[platform]: true}, debug, css);
}
}
};
/** @type {(changedFiles: string[], watcher: FSWatcher, platforms: any) => Promise<void>} */
const onChange = async (changedFiles, watcher, platforms) => {
const entries = cssEntries.filter((entry) => {
const entryFile = getEntryFile(entry);
return changedFiles.some((changed) => {
return entry.watchFiles?.includes(changed) || changed === entryFile;
});
});
for (const entry of entries) {
const css = await bundleCSSEntry(entry, true);
await writeFiles(entry.dest, platforms, true, css);
}
const newWatchFiles = getWatchFiles();
watcher.unwatch(
currentWatchFiles.filter((oldFile) => !newWatchFiles.includes(oldFile))
);
watcher.add(
newWatchFiles.filter((newFile) => currentWatchFiles.includes(newFile))
);
reload.reload({type: reload.CSS});
};
return createTask(
'bundle-css',
bundleCSS,
).addWatcher(
() => {
currentWatchFiles = getWatchFiles();
return currentWatchFiles;
},
onChange,
);
}
export default createBundleCSSTask(cssEntries);
+122
View File
@@ -0,0 +1,122 @@
// @ts-check
import {getDestDir} from './paths.js';
import {PLATFORM} from './platform.js';
import * as reload from './reload.js';
import {createTask} from './task.js';
import {writeFile} from './utils.js';
/** @typedef {import('./types.d.ts').HTMLEntry} HTMLEntry */
function html(platform, title, hasLoader, hasStyleSheet, compatibility) {
return [
'<!DOCTYPE html>',
'<html>',
' <head>',
' <meta charset="utf-8" />',
` <title>${title}</title>`,
hasStyleSheet ? [
' <meta name="theme-color" content="#0B2228" />',
' <meta name="viewport" content="width=device-width, initial-scale=1" />',
' <link rel="stylesheet" type="text/css" href="style.css" />',
' <link',
' rel="shortcut icon"',
' href="../assets/images/darkreader-icon-256x256.png"',
' />',
] : null,
' <script src="index.js" defer></script>',
(compatibility && platform === PLATFORM.CHROMIUM_MV2) ? ' <script src="compatibility.js" defer></script>' : null,
' </head>',
'',
hasLoader ? [
' <body>',
' <div class="loader">',
' <label class="loader__message">Loading, please wait</label>',
' </div>',
' </body>',
] : [
' <body></body>',
],
'</html>',
'',
].filter((s) => s !== null).flat().join('\r\n');
}
/** @type {HTMLEntry[]} */
const htmlEntries = [
{
title: 'Dark Reader background',
path: 'background/index.html',
hasLoader: false,
hasStyleSheet: false,
hasCompatibilityCheck: false,
reloadType: reload.FULL,
platforms: [PLATFORM.CHROMIUM_MV2, PLATFORM.CHROMIUM_MV2_PLUS, PLATFORM.FIREFOX_MV2, PLATFORM.THUNDERBIRD],
},
{
title: 'Dark Reader settings',
path: 'ui/popup/index.html',
hasLoader: true,
hasStyleSheet: true,
hasCompatibilityCheck: true,
reloadType: reload.UI,
},
{
title: 'Dark Reader settings',
path: 'ui/options/index.html',
hasLoader: false,
hasStyleSheet: true,
hasCompatibilityCheck: false,
reloadType: reload.UI,
},
{
title: 'Dark Reader developer tools',
path: 'ui/devtools/index.html',
hasLoader: false,
hasStyleSheet: true,
hasCompatibilityCheck: false,
reloadType: reload.UI,
},
{
title: 'Dark Reader CSS editor',
path: 'ui/stylesheet-editor/index.html',
hasLoader: false,
hasStyleSheet: true,
hasCompatibilityCheck: false,
reloadType: reload.UI,
},
];
async function writeEntry({path, title, hasLoader, hasStyleSheet, hasCompatibilityCheck}, {debug, platform}) {
const destDir = getDestDir({debug, platform});
const d = `${destDir}/${path}`;
await writeFile(d, html(platform, title, hasLoader, hasStyleSheet, hasCompatibilityCheck));
}
/**
* @param {HTMLEntry[]} htmlEntries
* @returns {ReturnType<typeof createTask>}
*/
export function createBundleHTMLTask(htmlEntries) {
const bundleHTML = async ({platforms, debug}) => {
const promises = [];
const enabledPlatforms = Object.values(PLATFORM).filter((platform) => platform !== PLATFORM.API && platforms[platform]);
for (const entry of htmlEntries) {
if (entry.platforms && !entry.platforms.some((platform) => platforms[platform])) {
continue;
}
for (const platform of enabledPlatforms) {
if (entry.platforms === undefined || entry.platforms.includes(platform)) {
promises.push(writeEntry(entry, {debug, platform}));
}
}
}
await Promise.all(promises);
};
return createTask(
'bundle-html',
bundleHTML,
);
}
export default createBundleHTMLTask(htmlEntries);
+260
View File
@@ -0,0 +1,260 @@
// @ts-check
// This plugin resolves location of malevic module
import rollupPluginNodeResolve from '@rollup/plugin-node-resolve';
/** @type {any} */
import rollupPluginReplace from '@rollup/plugin-replace';
/** @type {any} */
import rollupPluginTypescript from '@rollup/plugin-typescript';
import * as rollup from 'rollup';
import typescript from 'typescript';
import {getDestDir, absolutePath} from './paths.js';
import {PLATFORM} from './platform.js';
import * as reload from './reload.js';
const {PORT} = reload;
import {createTask} from './task.js';
/** @typedef {import('chokidar').FSWatcher} FSWatcher */
/** @typedef {import('./types.d.ts').JSEntry} JSEntry */
/** @typedef {import('./types.d.ts').TaskOptions} TaskOptions */
/** @type {JSEntry[]} */
const jsEntries = [
{
src: 'src/background/index.ts',
dest: 'background/index.js',
reloadType: reload.FULL,
},
{
src: 'src/inject/index.ts',
dest: 'inject/index.js',
reloadType: reload.FULL,
},
{
src: 'src/inject/dynamic-theme/mv3-proxy.ts',
dest: 'inject/proxy.js',
reloadType: reload.FULL,
platform: PLATFORM.CHROMIUM_MV3,
},
{
src: 'src/inject/fallback.ts',
dest: 'inject/fallback.js',
reloadType: reload.FULL,
},
{
src: 'src/inject/color-scheme-watcher.ts',
dest: 'inject/color-scheme-watcher.js',
reloadType: reload.FULL,
platform: PLATFORM.CHROMIUM_MV3,
},
{
src: 'src/ui/devtools/index.tsx',
dest: 'ui/devtools/index.js',
reloadType: reload.UI,
},
{
src: 'src/ui/options/index.tsx',
dest: 'ui/options/index.js',
reloadType: reload.UI,
},
{
src: 'src/ui/popup/index.tsx',
dest: 'ui/popup/index.js',
reloadType: reload.UI,
},
{
src: 'src/ui/stylesheet-editor/index.tsx',
dest: 'ui/stylesheet-editor/index.js',
reloadType: reload.UI,
},
];
/** @type {Record<string, any>} */
const rollupCache = {};
async function bundleJS(/** @type {JSEntry} */entry, platform, debug, watch, log, test) {
const {src, dest} = entry;
let replace = {};
switch (platform) {
case PLATFORM.FIREFOX_MV2:
case PLATFORM.THUNDERBIRD:
if (entry.src === 'src/ui/popup/index.tsx') {
break;
}
replace = {
'chrome.fontSettings.getFontList': `chrome['font' + 'Settings']['get' + 'Font' + 'List']`,
'chrome.fontSettings': `chrome['font' + 'Settings']`,
};
break;
case PLATFORM.CHROMIUM_MV3:
replace = {
'chrome.browserAction.setIcon': 'chrome.action.setIcon',
'chrome.browserAction.setBadgeBackgroundColor': 'chrome.action.setBadgeBackgroundColor',
'chrome.browserAction.setBadgeText': 'chrome.action.setBadgeText',
};
break;
}
// See comment below
// TODO(anton): remove this once Firefox supports tab.eval() via WebDriver BiDi
const mustRemoveEval = !test && (platform === PLATFORM.FIREFOX_MV2) && (entry.src === 'src/inject/index.ts');
const cacheId = `${entry.src}-${platform}-${debug}-${watch}-${log}-${test}`;
const outDir = getDestDir({debug, platform});
const bundle = await rollup.rollup({
input: absolutePath(src),
preserveSymlinks: true,
onwarn: (error) => {
// TODO(anton): remove this once Firefox supports tab.eval() via WebDriver BiDi
if (error.code === 'EVAL' && !mustRemoveEval) {
return;
}
throw error;
},
plugins: [
// Firefox WebDriver implementation does not currently support tab.eval() functions fully,
// so we have to manually polyfill it via regular eval().
// This plugin is necessary to avoid (benign) warnings in the console during builds, it just replaces
// literally one occurrence of eval() in our code even before TypeScript even encounters it.
// With this plugin, warning appears only on Firefox test builds.
// TODO(anton): remove this once Firefox supports tab.eval() via WebDriver BiDi
// @ts-expect-error This expression is not callable
rollupPluginReplace({
preventAssignment: true,
'eval(': 'void(',
}),
// @ts-expect-error This expression is not callable
rollupPluginNodeResolve(),
// @ts-expect-error This expression is not callable
rollupPluginTypescript({
rootDir: absolutePath('.'),
typescript,
tsconfig: absolutePath('src/tsconfig.json'),
compilerOptions: platform === PLATFORM.CHROMIUM_MV3 ? {
target: 'ES2022',
} : undefined,
noImplicitAny: debug ? false : true,
noUnusedLocals: debug ? false : true,
strictNullChecks: debug ? false : true,
strictPropertyInitialization: false,
removeComments: debug ? false : true,
sourceMap: debug ? true : false,
inlineSources: debug ? true : false,
noEmitOnError: watch ? false : true,
outDir,
paths: platform === PLATFORM.CHROMIUM_MV2_PLUS ? {
'@plus/*': ['./plus/*'],
} : {
'@plus/*': ['./stubs/*'],
},
}),
// @ts-expect-error This expression is not callable
rollupPluginReplace({
preventAssignment: true,
...replace,
__DEBUG__: debug,
__CHROMIUM_MV2__: platform === PLATFORM.CHROMIUM_MV2 || platform === PLATFORM.CHROMIUM_MV2_PLUS,
__CHROMIUM_MV3__: platform === PLATFORM.CHROMIUM_MV3,
__FIREFOX_MV2__: platform === PLATFORM.FIREFOX_MV2,
__THUNDERBIRD__: platform === PLATFORM.THUNDERBIRD,
__PLUS__: platform === PLATFORM.CHROMIUM_MV2_PLUS,
__PORT__: watch ? String(PORT) : '-1',
__TEST__: test,
__WATCH__: watch,
__LOG__: log ? `"${log}"` : false,
}),
].filter(Boolean),
cache: rollupCache[cacheId],
});
rollupCache[cacheId] = bundle.cache;
entry.watchFiles = bundle.watchFiles;
await bundle.write({
file: `${outDir}/${dest}`,
strict: true,
format: 'iife',
sourcemap: debug ? 'inline' : false,
});
}
/**
* @param {JSEntry[]} jsEntries
* @returns {ReturnType<typeof createTask>}
*/
export function createBundleJSTask(jsEntries) {
/** @type {string[]} */
let currentWatchFiles;
const getRelevantWatchFiles = () => {
const watchFiles = new Set();
jsEntries.forEach((entry) => {
entry.watchFiles?.forEach((file) => watchFiles.add(file));
});
return Array.from(watchFiles);
};
/** @type {(options: Partial<TaskOptions> & {platforms: TaskOptions['platforms']}, entries?: JSEntry[]) => Promise<void>} */
const bundleEachPlatform = async ({platforms, debug, watch, log, test}, entries) => {
const allPlatforms = Object.values(PLATFORM).filter((platform) => platform !== PLATFORM.API);
for (const entry of (entries || jsEntries)) {
const possiblePlatforms = entry.platform ? [entry.platform] : allPlatforms;
const targetPlatforms = possiblePlatforms.filter((platform) => platforms[platform]);
for (const platform of targetPlatforms) {
await bundleJS(entry, platform, debug, watch, log, test);
}
}
};
/** @type {(changedFiles: string[], watcher: FSWatcher, platforms: any) => Promise<void>} */
const onChange = async (changedFiles, watcher, initialPlatforms) => {
/** @type {any} */
let platforms = {};
const connectedBrowsers = reload.getConnectedBrowsers();
if (connectedBrowsers.includes('chrome')) {
platforms.chrome = initialPlatforms.chrome;
platforms['chrome-mv3'] = initialPlatforms['chrome-mv3'];
platforms['chrome-plus'] = initialPlatforms['chrome-plus'];
}
if (connectedBrowsers.includes('firefox')) {
platforms.firefox = true;
}
if (connectedBrowsers.length === 0) {
platforms = initialPlatforms;
}
const entries = jsEntries.filter((entry) => {
return changedFiles.some((changed) => {
return entry.watchFiles?.includes(changed);
});
});
await bundleEachPlatform({platforms, debug: true, watch: true}, entries);
const newWatchFiles = getRelevantWatchFiles();
watcher.unwatch(
currentWatchFiles.filter((oldFile) => !newWatchFiles.includes(oldFile))
);
watcher.add(
newWatchFiles.filter((newFile) => currentWatchFiles.includes(newFile))
);
const isUIOnly = entries.every((entry) => entry.reloadType === reload.UI);
reload.reload({
type: isUIOnly ? reload.UI : reload.FULL,
});
};
return createTask(
'bundle-js',
bundleEachPlatform,
).addWatcher(
() => {
currentWatchFiles = getRelevantWatchFiles();
return currentWatchFiles;
},
onChange,
);
}
export default createBundleJSTask(jsEntries);
+122
View File
@@ -0,0 +1,122 @@
// @ts-check
import fs from 'node:fs/promises';
import path from 'node:path';
import {getDestDir, absolutePath} from './paths.js';
import {PLATFORM} from './platform.js';
import * as reload from './reload.js';
import {createTask} from './task.js';
import {readFile, writeFile} from './utils.js';
const srcLocalesDir = 'src/_locales';
/** @typedef {Record<string, {message: string}>} LocaleMessages */
/** @type {(filePath: string) => Promise<LocaleMessages>} */
async function localeFileToJson(filePath) {
let file = await readFile(filePath);
file = file.replace(/^#.*?$/gm, '');
/** @type {LocaleMessages} */
const messages = {};
const regex = /@([a-z0-9_]+)/ig;
let match;
while ((match = regex.exec(file))) {
const messageName = match[1];
const messageStart = match.index + match[0].length;
let messageEnd = file.indexOf('@', messageStart);
if (messageEnd < 0) {
messageEnd = file.length;
}
messages[messageName] = {
message: file.substring(messageStart, messageEnd).trim(),
};
}
return messages;
}
/** @type {(localesDir: string, code: string) => Promise<string>} */
async function mergeLocale(localesDir, code) {
/** @type {LocaleMessages} */
let result = {};
/** @type {(dir: string) => Promise<void>} */
const walk = async (dir) => {
const dirFiles = [];
const dirDirs = [];
const paths = await fs.readdir(dir);
for (const path of paths) {
const stat = await fs.stat(`${dir}/${path}`);
if (stat.isDirectory()) {
dirDirs.push(path);
} else {
dirFiles.push(path);
}
}
const localeFiles = dirFiles.filter((f) => f.split('.').at(-2) === code);
for (const localeFile of localeFiles) {
const messages = await localeFileToJson(`${dir}/${localeFile}`);
result = {...result, ...messages};
}
for (const folder of dirDirs) {
await walk(`${dir}/${folder}`);
}
};
await walk(localesDir);
return JSON.stringify(result, null, 4);
}
async function bundleLocales(srcLocalesDir, {platforms, debug}) {
const absoluteSrcLocalesDir = absolutePath(srcLocalesDir);
const list = await fs.readdir(absoluteSrcLocalesDir);
for (const name of list) {
if (!name.endsWith('.config')) {
continue;
}
const code = /** @type {string} */(name.split('.').at(-2));
const locale = await mergeLocale(absoluteSrcLocalesDir, code);
const fileName = name.substring(name.lastIndexOf('/') + 1);
await writeFiles(locale, fileName, {platforms, debug});
}
}
async function writeFiles(data, fileName, {platforms, debug}){
const locale = fileName.substring(0, fileName.lastIndexOf('.')).replace('-', '_');
const getOutputPath = (dir) => `${dir}/_locales/${locale}/messages.json`;
const enabledPlatforms = Object.values(PLATFORM).filter((platform) => platform !== PLATFORM.API && platforms[platform]);
for (const platform of enabledPlatforms) {
const dir = getDestDir({debug, platform});
await writeFile(getOutputPath(dir), data);
}
}
/**
* @param {string} srcLocalesDir
* @returns {ReturnType<typeof createTask>}
*/
export function createBundleLocalesTask(srcLocalesDir) {
/** @type {(changedFiles: string[], watcher: any, platforms: any) => Promise<void>} */
const onChange = async (changedFiles, _, platforms) => {
const localesSrcDir = absolutePath(srcLocalesDir);
for (const file of changedFiles) {
const fileName = file.substring(file.lastIndexOf(path.sep) + 1);
const code = /** @type {string} */(fileName.split('.').at(-2));
const locale = await mergeLocale(localesSrcDir, code);
await writeFiles(locale, fileName, {platforms, debug: true});
}
reload.reload({type: reload.FULL});
};
return createTask(
'bundle-locales',
(options) => bundleLocales(srcLocalesDir, options),
).addWatcher(
[`${srcLocalesDir}/**/*.config`],
onChange,
);
}
export default createBundleLocalesTask(srcLocalesDir);
+65
View File
@@ -0,0 +1,65 @@
// @ts-check
import {getDestDir, absolutePath} from './paths.js';
import {PLATFORM} from './platform.js';
import * as reload from './reload.js';
import {createTask} from './task.js';
import {readJSON, writeJSON} from './utils.js';
async function patchManifest(platform, debug, watch, test) {
const manifest = await readJSON(absolutePath('src/manifest.json'));
const manifestPatch = platform === PLATFORM.CHROMIUM_MV2 || platform === PLATFORM.CHROMIUM_MV2_PLUS ? {} : await readJSON(absolutePath(`src/manifest-${platform.replace('-plus', '')}.json`));
const manifestExtras = platform === PLATFORM.CHROMIUM_MV2_PLUS ? await readJSON(absolutePath(`src/plus/manifest.json`)) : {};
const patched = {...manifest, ...manifestPatch, ...manifestExtras};
if (debug && platform === PLATFORM.CHROMIUM_MV3) {
patched.name = 'Dark Reader MV3';
}
if (platform === PLATFORM.CHROMIUM_MV3) {
patched.browser_action = undefined;
}
if (debug) {
patched.version = '1';
patched.description = `Debug build, platform: ${platform}, watch: ${watch ? 'yes' : 'no'}.`;
}
if (debug && !test && platform === PLATFORM.CHROMIUM_MV3) {
patched.permissions.push('tabs');
}
if (debug && (platform === PLATFORM.CHROMIUM_MV2 || platform === PLATFORM.CHROMIUM_MV3)) {
patched.version_name = 'Debug';
}
if (debug && platform === PLATFORM.CHROMIUM_MV2_PLUS) {
patched.version_name = 'Debug Plus';
}
// Needed to test settings export and CSS theme export via a download
if (test || debug) {
patched.permissions.push('downloads');
}
return patched;
}
async function manifests({platforms, debug, watch, test}) {
const enabledPlatforms = Object.values(PLATFORM).filter((platform) => platform !== PLATFORM.API && platforms[platform]);
for (const platform of enabledPlatforms) {
const manifest = await patchManifest(platform, debug, watch, test);
const destDir = getDestDir({debug, platform});
await writeJSON(`${destDir}/manifest.json`, manifest);
}
}
const bundleManifestTask = createTask(
'bundle-manifest',
manifests,
).addWatcher(
['src/manifest*.json'],
async (changedFiles, _, buildPlatforms) => {
const chrome = changedFiles.some((file) => file.endsWith('manifest.json'));
const platforms = {};
for (const platform of Object.values(PLATFORM)) {
const changed = chrome || changedFiles.some((file) => file.endsWith(`manifest-${platform.replace('-plus', '')}.json`));
platforms[platform] = changed && buildPlatforms[platform];
}
await manifests({platforms, debug: true, watch: true, test: false});
reload.reload({type: reload.FULL});
},
);
export default bundleManifestTask;
+205
View File
@@ -0,0 +1,205 @@
// @ts-check
import {createHash} from 'node:crypto';
import {readFile} from 'node:fs/promises';
import {getDestDir} from './paths.js';
import {PLATFORM} from './platform.js';
import {createTask} from './task.js';
import {copyFile, getPaths, readJSON, writeFile, fileExists} from './utils.js';
function serializeHashManifest(entries) {
const lines = [];
lines.push('Manifest-Version: 1.0');
for (const {archivePath, integrity} of entries) {
lines.push('');
lines.push(`Name: ${archivePath}`);
lines.push(`Digest-Algorithms:${integrity.md5 ? ' MD5' : ''}${integrity.sha1 ? ' SHA1' : ''}${integrity.sha256 ? ' SHA256' : ''}`);
if (integrity.md5) {
lines.push(`MD5-Digest: ${integrity.md5}`);
}
if (integrity.sha1) {
lines.push(`SHA1-Digest: ${integrity.sha1}`);
}
if (integrity.sha256) {
lines.push(`SHA256-Digest: ${integrity.sha256}`);
}
}
lines.push('');
lines.push('');
return lines.join('\n');
}
async function enumerateStandardPaths(dir, order) {
const path = `./${dir}`;
let realPaths = await getPaths(path);
realPaths = realPaths.sort();
let completeRealPaths = realPaths.map((realPath) => ({
realPath,
archivePath: realPath.substring(dir.length + 1),
}));
completeRealPaths = completeRealPaths.filter(({archivePath}) => archivePath !== 'manifest.json' && !archivePath.startsWith('META-INF/'));
// Re-order paths if needed
if (order) {
const correctPaths = [];
for (let i = 0; i < order.length; i++) {
correctPaths[i] = completeRealPaths[order[i]];
}
completeRealPaths = correctPaths;
}
// manifest.json always comes first
return completeRealPaths;
}
function calculateHashForData(hashes, data) {
const digests = {};
for (const hash of hashes) {
const h = createHash(hash);
h.update(data);
const digest = h.digest('base64');
h.destroy();
digests[hash] = digest;
}
return digests;
}
async function calculateHashesForFile(hashes, realPath, isOptional = false) {
try {
const data = await readFile(realPath, null);
return calculateHashForData(hashes, data);
} catch (e) {
if (!isOptional) {
throw e;
}
}
}
function hashTypes(signatureVersion) {
if (signatureVersion === 0) {
return ['md5', 'sha1'];
} else if (signatureVersion === 1) {
return ['md5', 'sha1', 'sha256'];
} else if (signatureVersion === 2) {
return ['sha1', 'sha256'];
}
}
async function calculateHashes(types, paths) {
for (let i = 0; i < paths.length; i++) {
const digests = await calculateHashesForFile(types, paths[i].realPath, paths[i].isOptional);
paths[i].integrity = digests;
}
}
function serializeSfManifest(types, manifestMf) {
const hashes = calculateHashForData(types, manifestMf);
const lines = [];
lines.push('Signature-Version: 1.0');
if (hashes.md5) {
lines.push(`MD5-Digest-Manifest: ${hashes.md5}`);
}
if (hashes.sha1) {
lines.push(`SHA1-Digest-Manifest: ${hashes.sha1}`);
}
if (hashes.sha256) {
lines.push(`SHA256-Digest-Manifest: ${hashes.sha256}`);
}
lines.push('');
lines.push('');
return lines.join('\n');
}
async function fixManifest(indent, settings) {
const destDir = getDestDir({debug: false, platform: 'firefox'});
const realPath = `${destDir}/manifest.json`;
const manifest = await readJSON(realPath);
let string = JSON.stringify(manifest, null, indent);
if (settings === 1) {
string = string.replace('applications', 'browser_specific_settings');
}
await writeFile(realPath, string);
return {
realPath,
archivePath: 'manifest.json',
};
}
async function createHashes(signatureVersion, version, order, manifest) {
const types = hashTypes(signatureVersion);
const destDir = getDestDir({debug: false, platform: 'firefox'});
/** @type {Array<{archivePath: string; realPath?: string; isOptional?: boolean; integrity?: any}>} */
const regular = [
await fixManifest(manifest?.indent || 2, manifest?.settings),
...(await enumerateStandardPaths(destDir, order)),
];
regular.push({
realPath: `./integrity/firefox/${version}/mozilla-recommendation.json`,
archivePath: 'mozilla-recommendation.json',
isOptional: true,
});
await calculateHashes(types, regular);
const coseManifest = serializeHashManifest(regular);
if (await fileExists(`./integrity/firefox/${version}/cose.sig`)) {
await writeFile(`${destDir}/META-INF/cose.manifest`, coseManifest);
regular.push({
archivePath: 'META-INF/cose.manifest',
integrity: calculateHashForData(types, coseManifest),
});
regular.push({
archivePath: 'META-INF/cose.sig',
integrity: await calculateHashesForFile(types, `./integrity/firefox/${version}/cose.sig`),
});
}
const manifestMf = serializeHashManifest(regular);
await writeFile(`${destDir}/META-INF/manifest.mf`, manifestMf);
const mozillaSf = serializeSfManifest(types, manifestMf);
await writeFile(`${destDir}/META-INF/mozilla.sf`, mozillaSf);
}
/**
* This utility function is written with readability in mind
* It is a naiive implementation which does not take advantage of data streaming
* and trivial parallelism of the task.
*/
async function signature({platforms, debug, version}) {
if (!platforms[PLATFORM.FIREFOX_MV2] || debug) {
throw new Error('Only Firefox builds support signed packages for now.');
}
const infoPath = `./integrity/firefox/${version}/info.json`;
const {type, order, manifest} = await readJSON(infoPath);
await createHashes(type, version, order, manifest);
const destDir = getDestDir({debug, platform: 'firefox'});
const rsa = `./integrity/firefox/${version}/mozilla.rsa`;
const rsaDest = `${destDir}/META-INF/mozilla.rsa`;
const sig = `./integrity/firefox/${version}/cose.sig`;
const sigDest = `${destDir}/META-INF/cose.sig`;
const recommendation = `./integrity/firefox/${version}/mozilla-recommendation.json`;
const recommendationDest = `${destDir}/mozilla-recommendation.json`;
await copyFile(rsa, rsaDest);
try {
await copyFile(sig, sigDest);
} catch (e) {
// Do nothing
}
try {
await copyFile(recommendation, recommendationDest);
} catch (e) {
// Do nothing
}
}
const signatureTask = createTask(
'signature',
signature,
);
export default signatureTask;
+12
View File
@@ -0,0 +1,12 @@
// @ts-check
import {existsSync} from 'node:fs';
import {fileURLToPath} from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const lastArg = process.argv.pop();
if (lastArg === __filename) {
throw new Error('Error: File or directory expected as a single argument');
}
process.exit(existsSync(lastArg || '') ? 0 : 1);
+19
View File
@@ -0,0 +1,19 @@
// @ts-check
import {getDestDir} from './paths.js';
import {PLATFORM} from './platform.js';
import {createTask} from './task.js';
import {removeFolder} from './utils.js';
async function clean({platforms, debug}) {
const enabledPlatforms = Object.values(PLATFORM).filter((platform) => platform !== PLATFORM.API && platforms[platform]);
for (const platform of enabledPlatforms) {
await removeFolder(getDestDir({debug, platform}));
}
}
const cleanTask = createTask(
'clean',
clean,
);
export default cleanTask;
+208
View File
@@ -0,0 +1,208 @@
/**
* This file executes build.js in a child process, this is needed for two things:
* 1. Enable interrupts like Ctrl+C for regular builds
* 2. Support building older versions of Dark Reader and then inserting signatures into archives
*/
// @ts-check
import assert from 'node:assert/strict';
import {fork} from 'node:child_process';
import {rm, stat} from 'node:fs/promises';
import {join} from 'node:path';
import process from 'node:process';
import {fileURLToPath} from 'node:url';
import signature from './bundle-signature.js';
import {PLATFORM} from './platform.js';
import {runTasks} from './task.js';
import {execute, log} from './utils.js';
import zip from './zip.js';
const __filename = join(fileURLToPath(import.meta.url), '../build.js');
function getSignatureDir(version) {
return join(fileURLToPath(import.meta.url), `../../integrity/firefox/`, version);
}
async function executeChildProcess(args) {
const child = fork(__filename, args);
// Send SIGINTs as SIGKILLs, which are not ignored
process.on('SIGINT', () => {
child.kill('SIGKILL');
process.exit(130);
});
return new Promise((resolve, reject) => child.on('error', reject).on('close', resolve));
}
function printHelp() {
console.log([
'Dark Reader build utility',
'',
'Usage: build [build parameters]',
'',
'To narrow down the list of build targets (for efficiency):',
' --api Library build (published to NPM)',
' --chrome MV2 for Chromium-based browsers (published to Chrome Web Store)',
' --chrome-mv3 MV3 for Chromium-based browsers (will replace MV2 version eventually)',
' --firefox MV2 for Firefox (published to Mozilla Add-on store)',
' --thunderbird Thunderbird',
'',
'To specify type of build:',
' --release Release bundle for signing prior to publication',
' --version=* Released bundle complete with digital signature (Firefox only)',
' --debug Build for development',
' --watch Incremental build for development',
'',
'To log errors to disk (for debugging and bug reports):',
' --log-info Log lots of data',
' --log-warn Log only warnings',
'',
'Build for testing (not to be used by humans):',
' --test',
].join('\n'));
}
function getVersion(args) {
const prefix = '--version=';
const arg = args.find((arg) => arg.startsWith(prefix));
if (!arg) {
return null;
}
const version = arg.substring(prefix.length);
if (/^\d+(.\d+){0,3}$/.test(version)) {
return version;
}
throw new Error(`Invalid version argument ${version}`);
}
async function ensureGitClean() {
const diff = await execute('git diff');
if (diff) {
throw new Error('git source tree is not clean. Pease commit your work and try again');
}
}
/**
* Checks out a particular revision of source code and dependencies,
* audits dependencies and applies fixes to vulnerabilities.
* Fixes for vulnerabilities should not affect build output since most
* vulnerabilities reside in code which never gets reached during build.
* However, fixing the vulnerabilities and obtaining a build with all "clean"
* dependencies which is identical to already published version serves as a proof
* that the published version was always free of (now known) vulnerabilities.
*
* @param {string} version The desired git version, e.g., 'v4.9.63' or 'v4.9.37.1'
* @param {boolean} fixVulnerabilities Whether of not to attempt to fix known vulnerabilities
*/
async function checkoutVersion(version, fixVulnerabilities) {
log.ok(`Checking out version ${version}`);
// Use -- to disambiguate the tag (release version) and file paths
await rm('src', {force: true, recursive: true});
await execute(`git restore --source v${version} -- package.json package-lock.json src/ tasks/`);
log.ok(`Installing dependencies`);
await execute('npm install --ignore-scripts');
if (!fixVulnerabilities) {
log.ok(`Skipping dependency audit`);
return;
}
log.ok(`Dependency audit`);
const deps = JSON.parse(await execute('npm audit fix --force --ignore-scripts --json'));
if (deps.audit.auditReportVersion !== 2) {
throw new Error('Could not audit dependencies');
}
if (deps.audit.metadata.vulnerabilities.total !== 0) {
throw new Error('Dependency vulnerability without a fix found, please audit manually');
}
}
async function checkoutHead() {
// Restore current files
await execute('git restore --source HEAD -- package.json package-lock.json src/ tasks/');
// Clean up files which existed earlier but were deleted
await execute('git clean -f -- package.json package-lock.json src/ tasks/');
await execute('npm install --ignore-scripts');
}
function validateArguments(args) {
const validationErrors = [];
const validFlags = ['--api', '--chrome', '--chrome-mv2', '--chrome-mv3', '--firefox', '--firefox-mv2', '--thunderbird', '--release', '--debug', '--watch', '--plus', '--log-info', '--log-warn', '--test'];
const invalidFlags = args.filter((flag) => !validFlags.includes(flag) && !flag.startsWith('--version='));
invalidFlags.forEach((flag) => validationErrors.push(`Invalid flag ${flag}`));
if (args.some((arg) => arg.startsWith('--version='))) {
if (!args.includes('--firefox') || !args.includes('--release') || args.length !== 3) {
validationErrors.push('Only Firefox build currently supports signed builds');
}
}
return validationErrors;
}
function parseArguments(args) {
return args.filter((arg) => !arg.startsWith('--version='));
}
async function run() {
const args = process.argv.slice(3);
const shouldPrintHelp = args.length === 0 || process.argv[2] !== 'build' || args.includes('-h') || args.includes('--help');
if (shouldPrintHelp) {
printHelp();
process.exit(0);
}
const validationErrors = validateArguments(args);
if (validationErrors.length > 0) {
validationErrors.forEach(log.error);
printHelp();
process.exit(130);
}
const version = getVersion(args);
// If building signed build, check that required signature files exist
if (version) {
try {
const signatureDir = getSignatureDir(version);
const stats = await stat(signatureDir);
assert(stats.isDirectory());
} catch (e) {
console.log(`Could not find signature files for version ${version}`);
return;
}
}
// We need to install new deps prior to forking for them to be loaded properly
if (version) {
try {
await ensureGitClean();
await checkoutVersion(version, args.includes('--fix-deps'));
} catch (e) {
log.error(`Could not check out tag ${version}. ${e}`);
return;
}
}
const childArgs = parseArguments(args);
await executeChildProcess(childArgs);
if (version) {
log.ok('PACKING SIGNATURES');
await checkoutHead();
await runTasks([signature, zip], {
version,
platforms: {
[PLATFORM.FIREFOX_MV2]: true,
},
debug: false,
watch: false,
log: false,
test: false,
});
}
}
run();
+73
View File
@@ -0,0 +1,73 @@
// @ts-check
import {format} from 'prettier';
import {getDestDir} from './paths.js';
import {PLATFORM} from './platform.js';
import {createTask} from './task.js';
import {readFile, writeFile, getPaths} from './utils.js';
/** @type {import('prettier').Options} */
const options = {
arrowParens: 'always',
bracketSpacing: false,
endOfLine: 'crlf',
printWidth: 80,
quoteProps: 'consistent',
singleQuote: false,
tabWidth: 4,
trailingComma: 'none',
};
const extensions = ['html', 'css', 'js'];
async function processAPIBuildModule(filepath) {
const code = await readFile(filepath);
const formatted = await format(code, {
...options,
filepath,
});
if (code !== formatted) {
await writeFile(filepath, formatted);
}
}
async function processAPIBuild() {
await processAPIBuildModule('darkreader.js');
await processAPIBuildModule('darkreader.mjs');
}
async function processExtensionPlatform(platform) {
const dir = getDestDir({debug: false, platform});
const files = await getPaths(extensions.map((ext) => `${dir}/**/*.${ext}`));
for (const file of files) {
const code = await readFile(file);
const formatted = await format(code, {
...options,
filepath: file,
});
if (code !== formatted) {
await writeFile(file, formatted);
}
}
}
async function codeStyle({platforms, debug}) {
if (debug) {
throw new Error('code-style task does not support debug builds');
}
const promisses = [];
if (platforms[PLATFORM.API]) {
promisses.push(processAPIBuild());
}
Object.values(PLATFORM)
.filter((platform) => platform !== PLATFORM.API && platforms[platform])
.forEach((platform) => promisses.push(processExtensionPlatform(platform)));
await Promise.all(promisses);
}
const codeStyleTask = createTask(
'code-style',
codeStyle,
);
export default codeStyleTask;
+99
View File
@@ -0,0 +1,99 @@
// @ts-check
import {getDestDir} from './paths.js';
import {PLATFORM} from './platform.js';
import * as reload from './reload.js';
import {createTask} from './task.js';
import {pathExists, copyFile, getPaths} from './utils.js';
/** @typedef {import('chokidar').FSWatcher} FSWatcher */
/** @typedef {import('./types').CopyEntry} CopyEntry */
/** @type {CopyEntry[]} */
const copyEntries = [
{
path: 'config',
reloadType: reload.FULL,
},
{
path: 'icons',
reloadType: reload.FULL,
},
{
path: 'ui/assets',
reloadType: reload.UI,
},
{
path: 'ui/popup/compatibility.js',
reloadType: reload.UI,
platforms: [PLATFORM.CHROMIUM_MV2],
},
{
path: 'plus/assets',
reloadType: reload.UI,
platforms: [PLATFORM.CHROMIUM_MV2_PLUS],
},
];
/**
* @param {string} srcDir
* @param {CopyEntry[]} copyEntries
* @returns {ReturnType<typeof createTask>}
*/
export function createCopyTask(srcDir, copyEntries) {
const paths = copyEntries.map((entry) => entry.path).map((path) => `${srcDir}/${path}`);
/** @type {(path: string) => string} */
const getCwdPath = (srcPath) => {
return srcPath.substring(srcDir.length + 1);
};
/** @type {(path: string, options: {debug: boolean; platform: any}) => Promise<void>} */
const copyEntry = async (path, {debug, platform}) => {
const cwdPath = getCwdPath(path);
const destDir = getDestDir({debug, platform});
const src = `${srcDir}/${cwdPath}`;
const dest = `${destDir}/${cwdPath}`;
await copyFile(src, dest);
};
const copyAll = async ({platforms, debug}) => {
const promises = [];
const enabledPlatforms = Object.values(PLATFORM).filter((platform) => platform !== PLATFORM.API && platforms[platform]);
for (const entry of copyEntries) {
if (entry.platforms && !entry.platforms.some((platform) => platforms[platform])) {
continue;
}
const files = await getPaths(`${srcDir}/${entry.path}`);
for (const file of files) {
for (const platform of enabledPlatforms) {
if (entry.platforms === undefined || entry.platforms.includes(platform)) {
promises.push(copyEntry(file, {debug, platform}));
}
}
}
}
await Promise.all(promises);
};
/** @type {(changedFiles: string[], watcher: FSWatcher, platforms: any) => Promise<void>} */
const onChange = async (changedFiles, _, platforms) => {
for (const file of changedFiles) {
if (await pathExists(file)) {
for (const platform of Object.values(PLATFORM).filter((platform) => platforms[platform])) {
await copyEntry(file, {debug: true, platform});
}
}
}
reload.reload({type: reload.FULL});
};
return createTask(
'copy',
copyAll,
).addWatcher(
paths,
onChange,
);
}
export default createCopyTask('src', copyEntries);
+48
View File
@@ -0,0 +1,48 @@
import {resolve} from 'node:path';
import {readJSON, writeJSON} from './utils.js';
function resolvePath(path) {
return resolve(import.meta.url.replace('file:/', ''), '../../', path);
}
function createImports(dependencies) {
const imports = {};
for (const name in dependencies) {
imports[name] = `npm:${name}@${dependencies[name]}`;
}
return imports;
}
function createTasks(scripts) {
const tasks = {};
for (const name in scripts) {
const command = scripts[name];
tasks[name] = command
.replace('--max-old-space-size=3072', '')
.replace('node ', 'deno run -A ')
.replaceAll('npm run ', 'deno task ');
}
return tasks;
}
async function writeDenoJSON() {
const packageJSON = resolvePath('package.json');
const denoJSON = await resolvePath('deno.json');
const pkg = await readJSON(packageJSON);
if (pkg.dependencies) {
console.error('TODO: support dependencies key in createImports()');
}
const imports = createImports(pkg.devDependencies);
const tasks = createTasks(pkg.scripts);
writeJSON(denoJSON, {imports, tasks});
}
async function main() {
await writeDenoJSON();
}
main();
+103
View File
@@ -0,0 +1,103 @@
// @ts-check
import {exec} from 'node:child_process';
import {readFile, writeFile} from 'node:fs/promises';
import {fileURLToPath} from 'node:url';
import {log} from './utils.js';
const cwd = fileURLToPath(new URL('../', import.meta.url));
const packagePath = `${cwd}/package.json`;
async function getOutdated() {
return /** @type {Promise<object | null>} */(new Promise((resolve, reject) => {
exec('npm outdated --json', {cwd}, (error, stdout) => {
const packages = JSON.parse(stdout.toString());
if (typeof packages !== 'object') {
log.error('Failed to check for dependencies');
reject();
return;
}
if (Object.keys(packages).length === 0) {
log.error('All dependencies are already up to date');
reject();
return;
}
resolve(packages);
});
}));
}
/**
*
* @param {string} script
* @returns {Promise<void>}
*/
async function command(script) {
return (new Promise((resolve, reject) => {
exec(script, {cwd}, (error) => {
if (error) {
reject(error);
return;
}
resolve();
});
}));
}
async function buildAll() {
await command('npm run build -- --release --api --chrome-mv2 --chrome-mv3 --firefox-mv2 --thunderbird');
}
async function patchPackage(outdated) {
const original = JSON.parse((await readFile(packagePath)).toString());
for (const pkg in outdated) {
original.devDependencies[pkg] = outdated[pkg].latest;
}
return original;
}
async function main() {
const outdated = await getOutdated();
if (!outdated){
return;
}
log.ok('Building with old dependencies');
await buildAll();
log.ok('Built with old dependencies');
await command('mv build build-old');
await command('mv darkreader.js darkreader-old.js');
await command('mv darkreader.mjs darkreader-old.mjs');
log.ok('Moved built output');
const patched = await patchPackage(outdated);
log.ok('Upgrading own dependencies');
await writeFile(packagePath, `${JSON.stringify(patched, null, 2)}\n`);
await command('npm i');
await command('git add package.json package-lock.json');
await command('git commit -m "Bump own dependencies"');
log.ok('Upgrading transitive dependencies');
await command('npm upgrade');
await command('git add package.json package-lock.json');
await command('git commit -m "Bump transitive dependencies"');
log.ok('Installed new dependencies');
await buildAll();
log.ok('Built with new dependencies');
await command('diff -r build-old/release/chrome build/release/chrome');
await command('diff -r build-old/release/chrome-mv3 build/release/chrome-mv3');
await command('diff -r build-old/release/firefox build/release/firefox');
await command('diff -r build-old/release/thunderbird build/release/thunderbird');
await command('diff darkreader-old.js darkreader.js');
await command('diff darkreader-old.mjs darkreader.mjs');
log.ok('Dependency upgrade does not result in change to built output');
// TODO: when moving this to CI, provide branch name in CI config, along with
// a token
await command('git push origin HEAD:bump-dependencies');
log.ok('Pushed to GitHub');
}
main().catch(() => log.error('Could not automatically upgrade dependencies'));
+231
View File
@@ -0,0 +1,231 @@
import {mkdir, rm, copyFile, writeFile, readFile, stat} from 'node:fs/promises';
import {tmpdir} from 'node:os';
import unzipper from 'adm-zip';
import {log} from './utils.js';
const tmpDirParent = `${tmpdir()}/darkreader-integrity`;
function assert(claim) {
if (!claim) {
throw new Error('Assertion failed');
}
}
async function firefoxFetchAllReleases() {
try {
const file = await readFile(`${tmpDirParent}/firefox-index.json`);
log.ok('Found previously stored index');
return JSON.parse(file);
} catch {
log.ok('Fetching release URLs from Mozilla');
}
const versions = [];
let dataUrl = 'https://addons.mozilla.org/api/v5/addons/addon/darkreader/versions/';
while (dataUrl) {
const {next, results} = await (await fetch(dataUrl)).json();
dataUrl = next;
for (const {file, version} of results) {
const {hash, url, size} = file;
versions.push({version, hash, url, size});
}
}
return versions;
}
function toBuffer(arrayBuffer) {
const buffer = Buffer.alloc(arrayBuffer.byteLength);
const view = new Uint8Array(arrayBuffer);
for (let i = 0; i < buffer.length; ++i) {
buffer[i] = view[i];
}
return buffer;
}
function firefoxExtractHashMetaInfOrder(manifest) {
function getDigestAlgos(lines) {
const digestHeader = lines[3];
if (digestHeader === 'Digest-Algorithms: MD5 SHA1') {
return {
type: 0,
lineCount: 5,
digestFormat: {
digestHeader,
digestLines: [
'MD5-Digest: ',
'SHA1-Digest: ',
],
digestLinesLengths: [36, 41],
},
};
} else if (digestHeader === 'Digest-Algorithms: MD5 SHA1 SHA256') {
return {
type: 1,
lineCount: 6,
digestFormat: {
digestHeader,
digestLines: [
'MD5-Digest: ',
'SHA1-Digest: ',
'SHA256-Digest: ',
],
digestLinesLengths: [36, 41, 59],
},
};
} else if (digestHeader === 'Digest-Algorithms: SHA1 SHA256') {
return {
type: 2,
lineCount: 5,
digestFormat: {
digestHeader,
digestLines: [
'SHA1-Digest: ',
'SHA256-Digest: ',
],
digestLinesLengths: [41, 59],
},
};
}
throw new Error('Unknown combination of digest algorithms');
}
function getFileName(lines, fileIndex, lineCount, digestFormat) {
const lineIndex = 2 + fileIndex * lineCount;
assert(lines[lineIndex - 1] === '');
assert(lines[lineIndex].startsWith('Name: '));
assert(lines[lineIndex + 1] === digestFormat.digestHeader);
for (let i = 0; i < digestFormat.digestLines.length; i++) {
const line = lines[lineIndex + i + 2];
assert(line.startsWith(digestFormat.digestLines[i]));
assert(line.length === digestFormat.digestLinesLengths[i]);
}
const fileName = lines[lineIndex].substring('Name: '.length);
return fileName;
}
function getFileCount(lines, lineCount) {
const count = (lines.length - 3) / lineCount;
assert(Number.isInteger(count));
return count;
}
function isSorted(arr) {
return arr.every((v, i, a) => !i || a[i - 1] <= v);
}
const lines = manifest.split('\n');
assert(lines[0] === 'Manifest-Version: 1.0');
const {type, lineCount, digestFormat} = getDigestAlgos(lines);
const fileCount = getFileCount(lines, lineCount);
const realOrder = [];
for (let i = 0; i < fileCount; i++) {
const fileName = getFileName(lines, i, lineCount, digestFormat);
if (fileName !== 'manifest.json' && fileName !== 'mozilla-recommendation.json' && !fileName.startsWith('META-INF/')) {
realOrder.push(fileName);
}
}
if (isSorted(realOrder)) {
return {type};
}
const sortedOrder = [...realOrder].sort();
const order = [];
for (let i = 0; i < realOrder.length; i++) {
order.push(sortedOrder.indexOf(realOrder[i]));
}
return {type, order};
}
function getIndent(fileJSON) {
return fileJSON.indexOf('"') - fileJSON.indexOf('\n') - 1;
}
function getManifestJSONData(fileJSON) {
const indent = getIndent(fileJSON);
const settings = JSON.parse(fileJSON).browser_specific_settings ? 1 : undefined;
if (indent !== 2 || settings !== undefined) {
return {
settings,
indent: indent !== 2 ? indent : undefined,
};
}
}
async function firefoxFetchAllMetadata(noCache = false) {
if (noCache) {
await rm(tmpDirParent, {force: true, recursive: true});
}
try {
await mkdir(tmpDirParent, {recursive: true});
} catch (e) {
// No need to create already existing directory
}
const versions = await firefoxFetchAllReleases();
log.ok(`Fetched release URLs (${versions.length})`);
await writeFile(`${tmpDirParent}/firefox-index.json`, JSON.stringify(versions, null, 2));
for (const {version, url, size} of versions) {
const fileName = `${tmpDirParent}/firefox-${version}.xpi`;
const dest = `./integrity/firefox/${version}`;
const tempDest = `${tmpDirParent}/firefox-${version}`;
try {
const st = await stat(fileName);
// Fast-fail path, it is actually never taken in practice
if (st.size !== size) {
throw new Error('Stored file had changed');
}
log.ok(`Found release file (Firefox, ${version})`);
} catch {
log.ok(`Fetching release file (${version})`);
const file = await fetch(url);
await writeFile(fileName, toBuffer(await file.arrayBuffer()));
log.ok(`Wrote release file (${version})`);
}
await rm(tempDest, {force: true, recursive: true});
await mkdir(tempDest, {recursive: true});
await rm(dest, {force: true, recursive: true});
await mkdir(dest, {recursive: true});
const zip = new unzipper(fileName);
zip.extractAllTo(tempDest);
const manifestMf = await readFile(`${tempDest}/META-INF/manifest.mf`, {encoding: 'utf-8'});
const {type, order} = firefoxExtractHashMetaInfOrder(manifestMf);
const manifestJSON = await readFile(`${tempDest}/manifest.json`, {encoding: 'utf-8'});
const manifest = getManifestJSONData(manifestJSON);
const info = {
type,
manifest,
order,
};
await writeFile(`${dest}/info.json`, `${JSON.stringify(info)}\n`);
await copyFile(`${tempDest}/META-INF/mozilla.rsa`, `${dest}/mozilla.rsa`);
try {
await copyFile(`${tempDest}/META-INF/cose.sig`, `${dest}/cose.sig`);
} catch (e) {
// Nothing
}
try {
await copyFile(`${tempDest}/mozilla-recommendation.json`, `${dest}/mozilla-recommendation.json`);
} catch (e) {
// Nothing
}
}
}
async function main(noCache = false) {
await firefoxFetchAllMetadata(noCache);
}
main();
+96
View File
@@ -0,0 +1,96 @@
import {createWriteStream} from 'node:fs';
import process from 'node:process';
import {WebSocketServer} from 'ws';
import {createTask} from './task.js';
import {log} from './utils.js';
export const PORT = 9000;
const WAIT_FOR_CONNECTION = 2000;
/** @type {import('ws').Server} */
let server = null;
/** @type {Set<WebSocket>} */
const sockets = new Set();
const times = new WeakMap();
/**
* @param {string} logLevel
* @returns {Promise<import('ws').Server>}
*/
function createServer(logLevel) {
return new Promise((resolve) => {
const server = new WebSocketServer({port: PORT});
const stream = createWriteStream(`${Date.now()}-${logLevel}.txt`, {flags:'a'});
server.on('listening', () => {
log.ok('Loggings started');
resolve(server);
});
server.on('connection', async (ws) => {
sockets.add(ws);
times.set(ws, Date.now());
ws.on('message', async (data) => {
const message = data.toString();
stream.write(`${message}\n`);
});
ws.on('close', () => sockets.delete(ws));
if (connectionAwaiter !== null) {
connectionAwaiter();
}
});
});
}
function closeServer() {
server && server.close(() => log.ok('Logging exit'));
sockets.forEach((ws) => ws.close());
sockets.clear();
server = null;
}
process.on('exit', closeServer);
process.on('SIGINT', closeServer);
/** @type {() => void} */
let connectionAwaiter = null;
function waitForConnection() {
return new Promise((resolve) => {
connectionAwaiter = () => {
connectionAwaiter = null;
clearTimeout(timeoutId);
setTimeout(resolve, WAIT_FOR_CONNECTION);
};
const timeoutId = setTimeout(() => {
log.warn('Auto-reloader did not connect');
connectionAwaiter = null;
resolve();
}, WAIT_FOR_CONNECTION);
});
}
/**
* @param {Object} options
* @param {string} options.log
*/
export async function logging({log}) {
if (!log) {
return;
}
if (!server) {
server = await createServer(log);
}
if (sockets.size === 0) {
await waitForConnection();
}
}
const loggingTask = createTask(
'logging',
logging,
);
export default loggingTask;
+3
View File
@@ -0,0 +1,3 @@
{
"type": "module"
}
+31
View File
@@ -0,0 +1,31 @@
import {createRequire} from 'node:module';
import {dirname, join} from 'node:path';
let rootDir = dirname(createRequire(import.meta.url).resolve('../package.json'));
/**
* @param {string} path
* @returns {string}
*/
export const absolutePath = (path) => {
return join(rootDir, path);
};
/**
* @param {string} dir
*/
export function setRootDir(dir) {
rootDir = dir;
}
export function getDestDir({debug, platform}) {
const buildTypeDir = `build/${debug ? 'debug' : 'release'}`;
return `${buildTypeDir}/${platform}`;
}
export default {
getDestDir,
rootDir,
absolutePath,
setRootDir,
};
+311
View File
@@ -0,0 +1,311 @@
// @ts-check
import {exec} from 'child_process';
import dns from 'dns/promises';
import fs from 'fs/promises';
import {launch} from 'puppeteer-core';
import {log} from './utils.js';
const DNS_LOOKUP = true;
const HTTPS_GET = true;
const PUPPETEER = false;
const DARK_SITES_FILE = './src/config/dark-sites.config';
const DETECTOR_HINTS_FILE = './src/config/detector-hints.config';
const DYNAMIC_THEME_FIXES_FILE = './src/config/dynamic-theme-fixes.config';
const INVERSION_FIXES_FILE = './src/config/inversion-fixes.config';
const EXCEPTIONS = [
'aliexpress.*',
'alza.*',
'canvas.*',
'gitlab.*',
'googleusercontent.com',
'imap:',
'jenkins.*',
'jira.*',
'lightning.force.com',
'mailbox:',
'polarion.*',
'pop3:',
'realtek.com',
'usos.*',
'usosweb.*',
'westlaw.com',
];
async function lookup(url) {
try {
const lu = await dns.lookup(url);
return lu != null;
} catch (err) {
return false;
}
}
async function ping(url) {
try {
const response = await fetch(url, {
method: 'GET',
redirect: 'follow',
signal: AbortSignal.timeout(5000),
headers: {
'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
'accept-encoding': 'gzip, deflate, br, zstd',
'accept-language': 'en-US,en;q=0.9',
'priority': 'u=0, i',
'sec-ch-ua': '"Not(A:Brand";v="8", "Chromium";v="144", "Google Chrome";v="144"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': 'macOS',
'sec-fetch-dest': 'document',
'sec-fetch-mode': 'navigate',
'sec-fetch-site': 'none',
'sec-fetch-user': '?1',
'upgrade-insecure-requests': '1',
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36',
},
});
if (response.redirected) {
const u = new URL(url);
const r = new URL(response.url);
if (!((u.hostname === r.hostname && r.pathname.startsWith(u.pathname)) || (r.hostname === `www.${u.hostname}`))) {
return `REDIRECT ${response.url}`;
}
return response.status;
}
return response.status;
} catch (err) {
if (err.name === 'AbortError') {
return 'TIMEOUT';
}
return err.cause ?? 'ERR';
}
}
/**
* @param {import('puppeteer-core').Page} page
* @param {string} url
* @returns {Promise<any>}
*/
async function visit(page, url) {
try {
const response = await page.goto(url, {
waitUntil: 'domcontentloaded',
timeout: 5000,
});
return response?.status() ?? 'UNKNOWN';
} catch (err) {
if (err.name === 'TimeoutError') {
return 'TIMEOUT';
}
return 'ERR';
}
}
async function getChromePath() {
if (process.platform === 'darwin') {
return '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
}
if (process.platform === 'win32') {
return `${process.env.PROGRAMFILES}\\Google\\Chrome\\Application\\chrome.exe`;
}
return await new Promise((resolve, reject) => {
exec('which google-chrome', (err, result) => {
if (err) {
reject(err);
} else {
resolve(result.trim());
}
});
});
}
// async function timeout(delay) {
// await new Promise((resolve) => {
// setTimeout(resolve, delay);
// });
// }
async function pingSites(title, patterns) {
const failures = [];
log(title);
/** @type {import('puppeteer-core').Browser | null} */
let browser = null;
/** @type {import('puppeteer-core').Page | null} */
let page = null;
if (PUPPETEER) {
const executablePath = await getChromePath();
browser = await launch({executablePath, headless: false});
page = await browser.newPage();
}
let canClearPrevLine = false;
const clearLineIfNeeded = () => {
if (canClearPrevLine) {
log.clearLine();
}
};
for (const pattern of patterns) {
if (pattern === '*' || EXCEPTIONS.some((ex) => pattern.includes(ex))) {
continue;
}
const url = patternToURL(pattern);
const host = (new URL(url)).hostname;
if (DNS_LOOKUP) {
log(`... ${host}`);
const exists = await lookup(host);
log.clearLine();
if (exists) {
clearLineIfNeeded();
log.ok(`OK ${host}`);
canClearPrevLine = true;
} else {
clearLineIfNeeded();
log.error(`DNS ${host}`);
canClearPrevLine = false;
failures.push(pattern);
continue;
}
}
if (HTTPS_GET) {
log(`... ${url}`);
const status = await ping(url);
log.clearLine();
if (status === 200) {
clearLineIfNeeded();
log.ok(`${status} ${url}`);
canClearPrevLine = true;
} else if ((status > 200 && status <= 299) || status !== 404) {
clearLineIfNeeded();
log.warn(`${status} ${url}`);
canClearPrevLine = false;
} else if (PUPPETEER && page) {
const ps = await visit(page, url);
if (ps === 200) {
clearLineIfNeeded();
log.ok(`${ps} ${url}`);
canClearPrevLine = true;
} else {
clearLineIfNeeded();
log.error(`${ps} ${url}`);
canClearPrevLine = false;
failures.push(pattern);
}
} else {
clearLineIfNeeded();
log.error(`${status} ${url}`);
canClearPrevLine = false;
failures.push(pattern);
}
}
}
if (PUPPETEER && browser) {
await browser.close();
}
clearLineIfNeeded();
log('Done');
return failures;
}
function patternToURL(pattern) {
let host = '';
let path = '';
const slashIndex = pattern.indexOf('/');
if (slashIndex < 0) {
host = pattern;
} else {
host = pattern.slice(0, slashIndex);
path = pattern.slice(slashIndex + 1);
}
if (host.startsWith('^')) {
host = host.slice(1);
}
if (host.startsWith('*.')) {
// host = `www.${host.slice(2)}`;
host = host.slice(2);
}
if (host.endsWith('.*.*')) {
host = `${host.slice(0, -4)}.co.uk`;
}
if (host.endsWith('.*')) {
host = `${host.slice(0, -2)}.com`;
}
if (path.endsWith('/*')) {
path = path.slice(0, -1);
}
if (path.endsWith('/$')) {
path = path.slice(0, -1);
}
if (path === '*' || path === '$') {
path = '';
}
return `https://${host}/${path}`;
}
async function cleanDarkSites() {
const content = await fs.readFile(DARK_SITES_FILE, 'utf8');
const patterns = content.split('\n').filter(Boolean);
const missing = await pingSites('DARK SITES', patterns);
const filtered = patterns.filter((s) => !missing.includes(s));
await fs.writeFile(DARK_SITES_FILE, `${filtered.join('\n')}\n`);
}
async function cleanConfig(title, filePath) {
const content = await fs.readFile(filePath, 'utf8');
const blocks = content.split('================================');
const patterns = [];
const urlsPerBlock = blocks.map((b) => {
const urlStart = 0;
const urlEnd = b.indexOf('\n\n', urlStart + 1);
const urls = b.slice(urlStart, urlEnd).trim().split('\n');
patterns.push(...urls);
return new Set(urls);
});
const missing = new Set(await pingSites(title, patterns));
for (let i = urlsPerBlock.length - 1; i >= 0; i--) {
const urls = urlsPerBlock[i];
urls.forEach((u) => {
if (missing.has(u)) {
urls.delete(u);
const blockLines = blocks[i].split('\n');
const lineIndex = blockLines.indexOf(u);
if (lineIndex < 0) {
throw new Error(`Cannot find line ${u}`);
}
blocks[i] = blockLines.join('\n');
}
});
if (urls.size === 0) {
blocks.splice(i, 1);
}
}
await fs.writeFile(filePath, blocks.join('================================'));
}
async function run() {
const args = process.argv.slice(2);
if (args.includes('dark-sites')) {
await cleanDarkSites();
}
if (args.includes('detector-hints')) {
await cleanConfig('DETECTOR HINTS', DETECTOR_HINTS_FILE);
}
if (args.includes('dynamic-theme-fixes')) {
await cleanConfig('DYNAMIC THEME FIXES', DYNAMIC_THEME_FIXES_FILE);
}
if (args.includes('inversion-fixes')) {
await cleanConfig('INVERSION FIXES', INVERSION_FIXES_FILE);
}
}
run();
+11
View File
@@ -0,0 +1,11 @@
// @ts-check
export const PLATFORM = {
API: /** @type {const} */('api'),
CHROMIUM_MV2: /** @type {const} */('chrome'),
CHROMIUM_MV2_PLUS: /** @type {const} */('chrome-plus'),
CHROMIUM_MV3: /** @type {const} */('chrome-mv3'),
FIREFOX_MV2: /** @type {const} */('firefox'),
FIREFOX_MV3: /** @type {const} */('firefox-mv3'),
THUNDERBIRD: /** @type {const} */('thunderbird'),
};
+130
View File
@@ -0,0 +1,130 @@
// @ts-check
import process from 'node:process';
import {WebSocketServer} from 'ws';
import {log} from './utils.js';
export const PORT = 8890;
const WAIT_FOR_CONNECTION = 2000;
/** @type {import('ws').WebSocketServer | null} */
let server = null;
/** @type {Set<import('ws').WebSocket>} */
const sockets = new Set();
/** @type {WeakMap<import('ws').WebSocket, number>} */
const times = new WeakMap();
/** @type {WeakMap<import('ws').WebSocket, string>} */
const userAgents = new WeakMap();
/**
* @returns {Promise<import('ws').WebSocketServer>}
*/
function createServer() {
return new Promise((resolve) => {
const server = new WebSocketServer({port: PORT});
server.on('listening', () => {
log.ok('Auto-reloader started');
resolve(server);
});
server.on('connection', (ws, request) => {
const userAgent = request.headers['user-agent'];
log.ok(`Extension connected: ${userAgent}`);
sockets.add(ws);
times.set(ws, Date.now());
userAgent && userAgents.set(ws, userAgent);
ws.on('message', (data) => {
const message = JSON.parse(data.toString());
if (message.type === 'reloading') {
log.ok('Extension reloading...');
}
});
ws.on('close', () => {
const userAgent = userAgents.get(ws);
log.warn(`Extension disconnected: ${userAgent}`);
sockets.delete(ws);
});
if (connectionAwaiter !== null) {
connectionAwaiter();
}
});
});
}
function closeServer() {
server && server.close(() => log.ok('Auto-reloader exit'));
sockets.forEach((ws) => ws.close());
sockets.clear();
server = null;
}
process.on('exit', closeServer);
process.on('SIGINT', closeServer);
/** @type {(() => void) | null} */
let connectionAwaiter = null;
function waitForConnection() {
return new Promise((resolve) => {
connectionAwaiter = () => {
connectionAwaiter = null;
clearTimeout(timeoutId);
setTimeout(resolve, WAIT_FOR_CONNECTION);
};
const timeoutId = setTimeout(() => {
log.warn('Auto-reloader did not connect');
connectionAwaiter = null;
resolve(true);
}, WAIT_FOR_CONNECTION);
});
}
/**
* @param {import('ws').WebSocket} ws
* @param {any} message
*/
function send(ws, message) {
ws.send(JSON.stringify(message));
}
/**
* @param {Object} options
* @param {string} options.type
*/
export async function reload({type}) {
if (!server) {
server = await createServer();
}
if (sockets.size === 0) {
await waitForConnection();
}
const now = Date.now();
Array.from(sockets.values())
.filter((ws) => {
const created = times.get(ws);
return created && created < now;
})
.forEach((ws) => send(ws, {type}));
}
export function getConnectedBrowsers() {
/** @type {Set<string>} */
const browsers = new Set();
sockets.forEach((ws) => {
const userAgent = userAgents.get(ws);
if (userAgent?.includes('Chrome') || userAgent?.includes('Chromium')) {
browsers.add('chrome');
}
if (userAgent?.includes('Firefox')) {
browsers.add('firefox');
}
});
return Array.from(browsers);
}
export const CSS = 'reload:css';
export const FULL = 'reload:full';
export const UI = 'reload:ui';
+84
View File
@@ -0,0 +1,84 @@
import {log} from './utils.js';
import watch from './watch.js';
/** @typedef {import('./types').TaskOptions} TaskOptions */
class Task {
/**
* @param {string} name
* @param {(options: TaskOptions) => void | Promise<void>} run
*/
constructor(name, run) {
this.name = name;
this._run = run;
}
/**
* @param {string[] | (() => string[])} files
* @param {(changedFiles: string[], watcher: import('chokidar').FSWatcher, platforms: object) => void | Promise<void>} onChange
*/
addWatcher(files, onChange) {
this._watchFiles = files;
this._onChange = onChange;
return this;
}
/**
* @param {() => void | Promise<void>} fn
*/
async _measureTime(fn) {
const start = Date.now();
await fn();
const end = Date.now();
log(`${this.name} (${(end - start).toFixed(0)}ms)`);
}
/**
* @param {TaskOptions} options
*/
async run(options) {
await this._measureTime(
() => this._run(options)
);
}
watch(platforms) {
if (!this._watchFiles || !this._onChange) {
return;
}
const watcher = watch({
files: typeof this._watchFiles === 'function' ?
this._watchFiles() :
this._watchFiles,
onChange: async (files) => {
await this._measureTime(
() => this._onChange(files, watcher, platforms)
);
},
});
}
}
/**
* @param {string} name
* @param {(options: TaskOptions) => void | Promise<any>} run
*/
export function createTask(name, run) {
return new Task(name, run);
}
/**
* @param {Task[]} tasks
* @param {TaskOptions} options
*/
export async function runTasks(tasks, options) {
for (const task of tasks) {
try {
await task.run(options);
} catch (err) {
log.error(`${task.name} error\n${err.stack || err}`);
throw err;
}
}
}
+228
View File
@@ -0,0 +1,228 @@
// @ts-check
import fs from 'node:fs/promises';
import {readFile, writeFile, fileExists, httpsRequest, timeout, log} from './utils.js';
// To use this tool:
// 1. Edit a line in en.config.
// 2. Run `npm run translate-en-message message_id`.
// 3. The line will be translated and written into other locales.
// TODO: If necessary, new @id and empty lines should be copied as well.
// TODO: Serbian translates into Cyrillic, but it is somehow possible to do Latin.
/** @typedef {{locale: string; file: string; messages: Map<string, string>}} LocaleFile */
const LOCALES_ROOT = 'src/_locales';
/**
* Translates `en` locale message for all locales
* @param {string} messageId Message ID
*/
async function translateEnMessage(messageId) {
log(`Translating message ${messageId}`);
const supportedLocales = await getSupportedLocales();
const enFiles = await getLocaleFiles('en');
let found = false;
for (const enFile of enFiles) {
const enContent = await readFile(enFile);
const enMessages = parseLocale(enContent);
if (enMessages.has(messageId)) {
found = true;
const enMessage = /** @type {string} */(enMessages.get(messageId));
for (const locale of supportedLocales) {
if (locale === 'en') {
continue;
}
await timeout(1000);
const locFile = `${enFile.slice(0, enFile.lastIndexOf('en.config'))}${locale}.config`;
const locContent = await readFile(locFile);
const locMessages = parseLocale(locContent);
const translated = await translate(enMessage, locale);
locMessages.set(messageId, translated);
const output = stringifyLocale(locMessages);
await writeFile(locFile, output);
log(`${locale}: ${translated}`);
}
}
}
if (!found) {
throw new Error(`Could not find message ${messageId}.`);
}
log.ok('Translation done');
}
/**
* Translates new `en` locale lines for all locales
*/
async function translateNewEnMessages() {
log('Translating new lines');
const supportedLocales = await getSupportedLocales();
const enFiles = await getLocaleFiles('en');
for (const enFile of enFiles) {
const enContent = await readFile(enFile);
const enMessages = parseLocale(enContent);
for (const locale of supportedLocales) {
if (locale === 'en') {
continue;
}
/** @type {Map<string, string>} */
let locMessages = new Map();
const locFile = `${enFile.slice(0, enFile.lastIndexOf('en.config'))}${locale}.config`;
if (await fileExists(locFile)) {
const locContent = await readFile(locFile);
locMessages = parseLocale(locContent);
}
for (const messageId of enMessages.keys()) {
const enMessage = /** @type {string} */(enMessages.get(messageId));
const translated = await translate(enMessage, locale);
locMessages.set(messageId, translated);
log(`${locale}: ${translated}`);
}
const output = stringifyLocale(locMessages);
await writeFile(locFile, output);
}
}
log.ok('Translation done');
}
/**
* @param {string} content
* @returns {Map<string, string>}
*/
function parseLocale(content) {
/** @type {Map<string, string>} */
const messages = new Map();
const lines = content.split('\n');
let id = '';
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith('@')) {
id = line.substring(1);
} else if (line.startsWith('#')) {
// Ignore
} else if (messages.has(id)) {
const message = messages.get(id);
messages.set(id, `${message}\n${line}`);
} else {
messages.set(id, line);
}
}
messages.forEach((value, id) => {
messages.set(id, value.trim());
});
return messages;
}
/**
* @param {Map<string, string>} messages
* @returns {string}
*/
function stringifyLocale(messages) {
/** @type {string[]} */
const lines = [];
messages.forEach((message, id) => {
lines.push(`@${id}`);
const hasDoubleNewLines = /\n\n/.test(message);
message.split('\n')
.filter((line) => line.trim())
.forEach((line, index, filtered) => {
lines.push(line);
if (hasDoubleNewLines && index < filtered.length - 1) {
lines.push('');
}
});
lines.push('');
});
return lines.join('\n');
}
/**
* @returns {Promise<string[]>}
*/
async function getSupportedLocales() {
const fileList = await fs.readdir(LOCALES_ROOT);
/** @type {string[]} */
const locales = [];
for (const file of fileList) {
if (file.endsWith('.config')) {
const locale = file.substring(0, file.lastIndexOf('.config'));
locales.push(locale);
}
}
return locales;
}
/**
* @returns {Promise<string[]>}
*/
async function getLocaleFiles(locale) {
/** @type {string[]} */
const results = [];
/** @type {(dir: string) => Promise<void>} */
const walk = async (dir) => {
const entries = await fs.readdir(dir);
const matched = entries.filter((f) => f === `${locale}.config` || f.endsWith(`.${locale}.config`));
results.push(...matched);
for (const e of entries) {
const p = `${dir}/${e}`;
const stat = await fs.stat(p);
if (stat.isDirectory()) {
walk(p);
}
}
};
await walk(LOCALES_ROOT);
return results;
}
/**
* @param {string} text
* @param {string} lang
* @return {Promise<string>}
*/
async function translate(text, lang) {
const url = new URL('https://translate.googleapis.com/translate_a/single');
url.search = (new URLSearchParams({
client: 'gtx',
sl: 'en-US',
tl: lang,
dt: 't',
dj: '1',
q: text,
})).toString();
const response = await httpsRequest(url.toString());
const translation = JSON.parse(response.text());
return translation.sentences.map((s) => s.trans).join('\n').replaceAll(/\n+/g, '\n');
}
const args = process.argv.slice(2);
if (args[0] === '--message') {
const messageId = args[1];
translateEnMessage(messageId);
}
if (args.includes('--new-messages')) {
translateNewEnMessages();
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"module": "node20",
"types": [
"node"
],
"allowJs": true,
"noEmit": true,
"strict": false
}
}
+42
View File
@@ -0,0 +1,42 @@
import type {PLATFORM} from './platform';
type PlatformId = (typeof PLATFORM)[keyof (typeof PLATFORM)];
export interface JSEntry {
src: string;
dest: string;
reloadType: string;
watchFiles?: string[];
platform?: PlatformId;
}
export interface CSSEntry {
src: string;
dest: string;
watchFiles?: string[];
}
export interface HTMLEntry {
title: string;
path: string;
hasLoader: boolean;
hasStyleSheet: boolean;
hasCompatibilityCheck: boolean;
reloadType: string;
platforms?: PlatformId[];
}
export interface CopyEntry {
path: string;
reloadType: string;
platforms?: PlatformId[];
}
export interface TaskOptions {
platforms: Partial<Record<PlatformId, boolean>>;
debug: boolean;
watch: boolean;
test: boolean;
log?: string | false;
version: string | false;
}
+201
View File
@@ -0,0 +1,201 @@
// @ts-check
import {exec} from 'node:child_process';
import {accessSync} from 'node:fs';
import fs from 'node:fs/promises';
import https from 'node:https';
import path from 'node:path';
/** @type {{[color: string]: (text: string) => string}} */
const colors = Object.entries({
gray: '\x1b[90m',
green: '\x1b[32m',
red: '\x1b[31m',
yellow: '\x1b[33m',
}).reduce((map, [key, value]) => Object.assign(map, {[key]: (/** @type {string} */text) => `${value}${text}\x1b[0m`}), {});
/**
* @param {string} command
* @returns {Promise<string>}
*/
export async function execute(command) {
return new Promise((resolve, reject) => exec(command, (error, stdout) => {
if (error) {
reject(`Failed to execute command ${command}`);
} else {
resolve(stdout);
}
}));
}
/**
* @param {string} text
* @returns
*/
export function logWithTime(text) {
const now = new Date();
const hours = now.getHours();
const minutes = now.getMinutes();
const seconds = now.getSeconds();
const leftpad = (/** @type {number} */n) => String(n).padStart(2, '0');
return console.log(`${colors.gray([hours, minutes, seconds].map(leftpad).join(':'))} ${text}`);
}
export const log = Object.assign((/** @type {string} */text) => logWithTime(text), {
ok: (/** @type {string} */text) => logWithTime(colors.green(text)),
warn: (/** @type {string} */text) => logWithTime(colors.yellow(text)),
error: (/** @type {string} */text) => logWithTime(colors.red(text)),
clearLine: () => {
process.stdout.moveCursor(0, -1);
process.stdout.clearLine(1);
},
});
/**
* @param {string} dest
* @returns {Promise<boolean>}
*/
export async function pathExists(dest) {
try {
await fs.access(dest);
return true;
} catch (err) {
return false;
}
}
/**
* @param {string} dest
* @returns {boolean}
*/
export function pathExistsSync(dest) {
try {
accessSync(dest);
return true;
} catch (err) {
return false;
}
}
/**
* @param {string} dir
* @returns {Promise<void>}
*/
export async function removeFolder(dir) {
if (await pathExists(dir)) {
await fs.rm(dir, {recursive: true});
}
}
/**
* @param {string} dest
* @returns {Promise<void>}
*/
export async function mkDirIfMissing(dest) {
const dir = path.dirname(dest);
if (!(await pathExists(dir))) {
await fs.mkdir(dir, {recursive: true});
}
}
/**
* @param {string} src
* @param {string} dest
* @returns {Promise<void>}
*/
export async function copyFile(src, dest) {
await mkDirIfMissing(dest);
await fs.copyFile(src, dest);
}
/**
* @param {string} src
* @param {BufferEncoding} [encoding]
* @returns {Promise<string>}
*/
export async function readFile(src, encoding = 'utf8') {
return await fs.readFile(src, encoding);
}
/**
* @param {string} src
* @returns {Promise<boolean>}
*/
export async function fileExists(src) {
try {
await fs.access(src, fs.constants.R_OK);
return true;
} catch (e) {
return false;
}
}
/**
* @param {string} dest
* @param {string} content
* @param {BufferEncoding | null | undefined} encoding
* @returns {Promise<void>}
*/
export async function writeFile(dest, content, encoding = 'utf8') {
await mkDirIfMissing(dest);
await fs.writeFile(dest, content, encoding);
}
/**
* @param {string} path
* @returns {Promise<Object>}
*/
export async function readJSON(path) {
const file = await readFile(path);
return JSON.parse(file);
}
/**
* @param {string} dest
* @param {string} content
* @param {string | number | undefined} space
* @returns {Promise<void>}
*/
export async function writeJSON(dest, content, space = 4) {
const string = JSON.stringify(content, null, space);
return await writeFile(dest, string);
}
/**
* @param {string | string[]} patterns
* @returns {Promise<string[]>}
*/
export async function getPaths(patterns) {
const {globby} = await import('globby');
return await globby(patterns);
}
/**
* @param {number} delay
* @returns {Promise<void>}
*/
export function timeout(delay) {
return new Promise((resolve) => setTimeout(resolve, delay));
}
/**
* @param {string} url
* @returns {Promise<{buffer(): Buffer; text(encoding?: BufferEncoding): string; type(): string}>}
*/
export function httpsRequest(url) {
return new Promise((resolve) => {
/** @type {Uint8Array[]} */
const data = [];
https.get(url, (response) => {
response
.on('data', (chunk) => data.push(chunk))
.on('end', () => {
const buffer = Buffer.concat(data);
resolve({
buffer: () => buffer,
text: (encoding = 'utf8') => buffer.toString(encoding),
type: () => response.headers['content-type'] || '',
});
});
});
});
}
+51
View File
@@ -0,0 +1,51 @@
import {watch as chokidarWatch} from 'chokidar';
import {log} from './utils.js';
const DEBOUNCE = 200;
/**
* @param {Object} options
* @param {string[]} options.files
* @param {(files: string[]) => void | Promise<void>} options.onChange
*/
function watch(options) {
const queue = new Set();
let timeoutId = null;
function onChange(path) {
queue.add(path);
if (timeoutId !== null) {
return;
}
timeoutId = setTimeout(async () => {
timeoutId = null;
try {
const changedFiles = Array.from(queue).sort();
log.ok(`Files changed:${changedFiles.map((path) => `\n${path}`)}`);
queue.clear();
await options.onChange(changedFiles);
} catch (err) {
log.error(err);
}
}, DEBOUNCE);
}
const watcher = chokidarWatch(options.files, {ignoreInitial: true})
.on('add', onChange)
.on('change', onChange)
.on('unlink', onChange);
function stop() {
watcher.close();
}
process.on('exit', stop);
process.on('SIGINT', stop);
return watcher;
}
export default watch;
+82
View File
@@ -0,0 +1,82 @@
// @ts-check
import {exec} from 'node:child_process';
import fs from 'node:fs';
import yazl from 'yazl';
import {getDestDir} from './paths.js';
import {PLATFORM} from './platform.js';
import {createTask} from './task.js';
import {getPaths} from './utils.js';
/**
* @param {object} details
* @returns {Promise<void>}
*/
function archiveFiles({files, dest, cwd, date, mode}) {
return new Promise((resolve) => {
const archive = new yazl.ZipFile();
// Rproducible builds: sort filenames so files appear in the same order in zip
files.sort();
files.forEach((file) => archive.addFile(
file,
file.startsWith(`${cwd}/`) ? file.substring(cwd.length + 1) : file,
{mtime: date, mode}
));
/** @type {any} */
const writeStream = fs.createWriteStream(dest);
archive.outputStream.pipe(writeStream).on('close', resolve);
archive.end();
});
}
async function archiveDirectory({dir, dest, date, mode}) {
const files = await getPaths(`${dir}/**/*.*`);
await archiveFiles({files, dest, cwd: dir, date, mode});
}
/**
* Reproducible builds: set file timestamp to last commit timestamp
* Returns the date of the last git commit to be used as archive file timestamp
* @returns {Promise<Date>} JavaScript Date object with date adjusted to counterbalance user's time zone
*/
async function getLastCommitTime() {
// We need to offset the user's time zone since yazl can not represent time zone in produced archive
// If called outside of the git tree, make sure we don't pass a negative date.
return new Promise((resolve) =>
exec('git log -1 --format=%ct', (_, stdout) => resolve(new Date(
Math.max(0, Number(stdout) + (new Date()).getTimezoneOffset() * 60) * 1000
))));
}
async function zip({platforms, debug, version}) {
if (debug) {
throw new Error('zip task does not support debug builds');
}
version = version ? `-${version}` : '';
const releaseDir = 'build/release';
const promises = [];
const date = await getLastCommitTime();
/** @type {Array<import('./types.js').PlatformId>} */
const chromePlatforms = [PLATFORM.CHROMIUM_MV2, PLATFORM.CHROMIUM_MV3, PLATFORM.CHROMIUM_MV2_PLUS];
const enabledPlatforms = Object.values(PLATFORM).filter((platform) => platform !== PLATFORM.API && platforms[platform]);
for (const platform of enabledPlatforms) {
const format = chromePlatforms.includes(platform) ? 'zip' : 'xpi';
promises.push(archiveDirectory({
dir: getDestDir({debug, platform}),
dest: `${releaseDir}/darkreader-${platform}${version}.${format}`,
date,
// Reproducible builds: set permission flags on file like chmod 644 or -rw-r--r--
// This is needed because the built file might have different flags on different systems
mode: 0o644,
}));
}
await Promise.all(promises);
}
const zipTask = createTask(
'zip',
zip,
);
export default zipTask;