chore: 整理 third-party Dark Reader 目录并移除本地说明文件跟踪
This commit is contained in:
+161
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
Vendored
+208
@@ -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
@@ -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;
|
||||
Vendored
+99
@@ -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);
|
||||
Vendored
+48
@@ -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
@@ -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
@@ -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();
|
||||
Vendored
+96
@@ -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
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
+31
@@ -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,
|
||||
};
|
||||
Vendored
+311
@@ -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
@@ -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
@@ -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';
|
||||
Vendored
+84
@@ -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
@@ -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
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "node20",
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"allowJs": true,
|
||||
"noEmit": true,
|
||||
"strict": false
|
||||
}
|
||||
}
|
||||
+42
@@ -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
@@ -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
@@ -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;
|
||||
Vendored
+82
@@ -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;
|
||||
Reference in New Issue
Block a user