voice-over-translation/vite/vite.extension.shared.ts
NullVerdict 8ddf6bbe58
Some checks failed
Build / node (22.x) (push) Has been cancelled
Build / extension (22.x) (push) Has been cancelled
Build / bun (push) Has been cancelled
Release / Build Release (push) Has been cancelled
Release / create_release (push) Has been cancelled
refactor: tooltip & UI behaviors; move Vite configs
Major UI/tooling updates:

- Rewrite Tooltip API and internals: accept opts object, use portal parent, simplify positioning logic, improve observers and event handling (pointer-based), stricter typing, and various lifecycle fixes.
- Update SubtitlesWidget to use PointerEvent, add global pointerdown to dismiss tooltips, fix tooltip mount/update flows and related event handling.
- Style adjustments: .vot-portal display -> contents and simplify overlay pointer-events so interactive controls receive events.
- UI tweaks: stopPropagation on dialog backdrop click, remove mousedown listeners where unnecessary, refine overlay view reparenting and menu listeners.
- OverlayVisibilityController: improved pointer path checks, immediate hide() method, touch-specific behavior and activity handling fixes.
- FullscreenHelper: prefer video rect when determining container size.
- Move Vite config files into vite/ subdirectory and update package.json and imports (paths updated and rootDir adjusted).

These changes consolidate tooltip/overlay behavior around pointer events, tidy DOM mounting, and reorganize build configs.
2026-05-13 14:28:13 +04:00

1243 lines
33 KiB
TypeScript

import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { build as viteBuild } from "vite";
import { COMPRESSION_LEVEL, zip } from "zip-a-folder";
import {
createViteConfig,
defineConstants,
rootDir,
type ViteDefine,
} from "./vite.base.config";
export { rootDir } from "./vite.base.config";
export const srcDir = path.join(rootDir, "src");
export const outBase = path.join(rootDir, "dist-ext");
export const outTmp = path.join(outBase, "_tmp");
const DEFAULT_EXTENSION_NAME = "Voice Over Translation";
const DEFAULT_EXTENSION_DESCRIPTION = "Voice Over Translation";
const DEFAULT_EXTENSION_VERSION = "0.0.0";
const EXTENSION_ICON_SIZES = [16, 32, 48, 64, 96, 128, 256] as const;
const EXTENSION_LOADER_FILES = ["prelude.js", "content.js"] as const;
const EXTENSION_MODULE_FILES = [
"prelude.module.js",
"content.module.js",
] as const;
const EXTENSION_WEB_ACCESSIBLE_JS_GLOBS = ["*.js"] as const;
const GITHUB_DIST_EXT_RAW_BASE =
"https://raw.githubusercontent.com/ilyhalight/voice-over-translation/master/dist-ext";
const CHROME_UPDATES_MANIFEST_FILE = "vot-extension-chrome-updates.xml";
const FIREFOX_UPDATES_MANIFEST_FILE = "vot-extension-firefox-updates.json";
const FIREFOX_UPDATES_MANIFEST_URL = `${GITHUB_DIST_EXT_RAW_BASE}/${FIREFOX_UPDATES_MANIFEST_FILE}`;
export type ExtensionBuildTarget = "chrome" | "firefox" | "all";
export interface ExtensionHeaders {
name?: string;
description?: string;
version?: string;
author?: string;
match?: string[];
exclude?: string[];
connect?: string[];
}
export interface ExtensionBuildContext {
availableLocales: string[];
repoBranch: string;
}
interface ExtensionEntry {
entry: string;
format: "iife" | "es";
fileName: string;
emptyOutDir: boolean;
}
const extensionEntries: ExtensionEntry[] = [
{
entry: "src/index.ts",
format: "es",
fileName: "content.module.js",
emptyOutDir: true,
},
{
entry: "src/extension/prelude.ts",
format: "es",
fileName: "prelude.module.js",
emptyOutDir: false,
},
{
entry: "src/extension/bridge.ts",
format: "iife",
fileName: "bridge.js",
emptyOutDir: false,
},
{
entry: "src/extension/background.ts",
format: "es",
fileName: "background.js",
emptyOutDir: false,
},
{
entry: "src/extension/background.ts",
format: "iife",
fileName: "background-ff.js",
emptyOutDir: false,
},
];
export async function exists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
function parseNumericVersionParts(version: string): number[] | null {
const parts = String(version)
.trim()
.split(".")
.map((part) => part.trim());
if (!parts.length || parts.some((part) => !/^\d+$/.test(part))) {
return null;
}
return parts.map(Number);
}
function compareVersions(left: string, right: string): number {
const leftNumeric = parseNumericVersionParts(left);
const rightNumeric = parseNumericVersionParts(right);
if (leftNumeric && rightNumeric) {
const maxLength = Math.max(leftNumeric.length, rightNumeric.length);
for (let index = 0; index < maxLength; index += 1) {
const leftPart = leftNumeric[index] ?? 0;
const rightPart = rightNumeric[index] ?? 0;
if (leftPart > rightPart) return 1;
if (leftPart < rightPart) return -1;
}
return 0;
}
return left.localeCompare(right, undefined, {
numeric: true,
sensitivity: "base",
});
}
function escapeRegExp(value: string): string {
return value.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
async function cleanupOlderVersionedArtifacts({
artifactPrefix,
fileExtension,
currentVersion,
}: {
artifactPrefix: string;
fileExtension: string;
currentVersion: string;
}): Promise<string[]> {
const removedFiles: string[] = [];
const versionedArtifactPattern = new RegExp(
`^${escapeRegExp(artifactPrefix)}-(.+)${escapeRegExp(fileExtension)}$`,
);
let entries: string[] = [];
try {
entries = await fs.readdir(outBase);
} catch {
return removedFiles;
}
for (const entry of entries) {
const match = versionedArtifactPattern.exec(entry);
if (!match) continue;
const artifactVersion = match[1];
if (compareVersions(artifactVersion, currentVersion) >= 0) continue;
await fs.rm(path.join(outBase, entry), { force: true });
removedFiles.push(entry);
}
return removedFiles;
}
export async function readJson<T>(filePath: string): Promise<T> {
return JSON.parse(await fs.readFile(filePath, "utf8")) as T;
}
export async function ensureCleanDir(dir: string): Promise<void> {
await fs.rm(dir, { recursive: true, force: true });
await fs.mkdir(dir, { recursive: true });
}
export async function getLocaleCodes(): Promise<string[]> {
const localesDir = path.join(srcDir, "localization", "locales");
const entries = await fs.readdir(localesDir, { withFileTypes: true });
const codes = entries
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
.map((entry) => entry.name.replace(/\.json$/i, ""))
.sort((a, b) => a.localeCompare(b));
const priority = ["auto", "en", "ru"];
const normalized = [
...priority.filter((item) => item === "auto" || codes.includes(item)),
...codes.filter((code) => !priority.includes(code)),
];
return [...new Set(normalized)];
}
export function getRepoBranch(): string {
return process.env.GITHUB_REF_NAME || process.env.REPO_BRANCH || "master";
}
export async function createExtensionBuildContext(): Promise<ExtensionBuildContext> {
return {
availableLocales: await getLocaleCodes(),
repoBranch: getRepoBranch(),
};
}
export async function getExtensionHeaders(): Promise<ExtensionHeaders> {
return readJson<ExtensionHeaders>(path.join(srcDir, "headers.json"));
}
async function buildEntry({
entry,
format,
fileName,
emptyOutDir,
define,
}: {
entry: string;
format: "iife" | "es";
fileName: string;
emptyOutDir: boolean;
define: ViteDefine;
}): Promise<void> {
await viteBuild(
createViteConfig({
root: rootDir,
configFile: false,
define,
build: {
outDir: outTmp,
emptyOutDir,
sourcemap: false,
minify: "oxc",
lib: {
entry: path.join(rootDir, entry),
name: "VOT",
formats: [format],
fileName: () => fileName,
},
},
}),
);
}
export async function buildExtensionBundles({
context,
headers,
}: {
context: ExtensionBuildContext;
headers: ExtensionHeaders;
}): Promise<void> {
const defineMeta = defineConstants({
DEBUG_MODE: false,
IS_EXTENSION: true,
AVAILABLE_LOCALES: context.availableLocales,
REPO_BRANCH: context.repoBranch,
VOT_VERSION: String(headers.version || ""),
VOT_AUTHORS: String(headers.author || ""),
});
await ensureCleanDir(outTmp);
for (const entry of extensionEntries) {
await buildEntry({
...entry,
define: defineMeta,
});
}
}
async function copyExtensionFiles(
targetDir: string,
{
backgroundSrc = "background.js",
backgroundDst = "background.js",
}: { backgroundSrc?: string; backgroundDst?: string } = {},
): Promise<void> {
await fs.mkdir(targetDir, { recursive: true });
const bundleFiles = await fs.readdir(outTmp);
const frontEndJsFiles = bundleFiles.filter((fileName) => {
if (!fileName.endsWith(".js")) {
return false;
}
return !["background.js", "background-ff.js"].includes(fileName);
});
for (const fileName of frontEndJsFiles) {
await fs.copyFile(
path.join(outTmp, fileName),
path.join(targetDir, fileName),
);
}
await writeExtensionLoaders(targetDir);
await fs.copyFile(
path.join(outTmp, backgroundSrc),
path.join(targetDir, backgroundDst),
);
const iconsSrcDir = path.join(srcDir, "extension", "icons");
const iconsDstDir = path.join(targetDir, "icons");
await fs.mkdir(iconsDstDir, { recursive: true });
const iconFiles = await fs.readdir(iconsSrcDir);
for (const iconFile of iconFiles) {
await fs.copyFile(
path.join(iconsSrcDir, iconFile),
path.join(iconsDstDir, iconFile),
);
}
}
async function writeExtensionLoaders(targetDir: string): Promise<void> {
const loaders: Record<(typeof EXTENSION_LOADER_FILES)[number], string> = {
"prelude.js": "\n",
"content.js": "\n",
};
await Promise.all(
EXTENSION_LOADER_FILES.map((fileName) =>
fs.writeFile(path.join(targetDir, fileName), loaders[fileName], "utf8"),
),
);
}
function normalizeHostPermission(
entry: string | undefined | null,
): string | null {
if (!entry) return null;
const value = String(entry).trim();
if (!value) return null;
if (value === "<all_urls>") return value;
if (/^[a-z*]+:\/\//i.test(value)) {
if (/^[a-z*]+:\/\/[^/]+\/?$/i.test(value)) {
return `${value.replace(/\/?$/, "")}/*`;
}
return value;
}
return `*://*.${value}/*`;
}
function normalizeHostPermissions(list: string[] = []): string[] {
const normalized = list
.map((item) => normalizeHostPermission(item))
.filter((value): value is string => Boolean(value));
return [...new Set(normalized)];
}
function normalizeWebAccessibleResourceMatch(
pattern: string | undefined | null,
): string | null {
if (!pattern) return null;
const value = String(pattern).trim();
if (!value) return null;
if (value === "<all_urls>") {
return "*://*/*";
}
const match = /^([^:]+:\/\/[^/]+)(\/.*)?$/.exec(value);
if (!match) {
return null;
}
return `${match[1]}/*`;
}
function normalizeWebAccessibleResourceMatches(
matches: string[] = [],
): string[] {
return [
...new Set(
matches
.map((pattern) => normalizeWebAccessibleResourceMatch(pattern))
.filter((pattern): pattern is string => Boolean(pattern)),
),
];
}
function splitMatchesForOriginFallback(matches: string[] = []): {
originFallbackMatches: string[];
directMatches: string[];
} {
const originFallbackMatches: string[] = [];
const directMatches: string[] = [];
const getPath = (pattern: string): string | null => {
if (pattern === "<all_urls>") return "/*";
const result = /^[^:]+:\/\/[^/]+(\/.*)$/.exec(pattern);
return result ? result[1] : null;
};
for (const match of matches) {
const pathPart = getPath(String(match));
if (pathPart === "/*") originFallbackMatches.push(String(match));
else directMatches.push(String(match));
}
return { originFallbackMatches, directMatches };
}
function buildIconsMap(sizes: readonly number[]): Record<number, string> {
return Object.fromEntries(
sizes.map((size) => [size, `icons/icon-${size}.png`]),
) as Record<number, string>;
}
function createContentScriptEntries({
matches,
excludeMatches,
includeWorld,
matchOriginAsFallback = false,
}: {
matches: string[];
excludeMatches: string[];
includeWorld: boolean;
matchOriginAsFallback?: boolean;
}): Record<string, unknown>[] {
if (!matches.length) return [];
const fallbackConfig = matchOriginAsFallback
? { match_origin_as_fallback: true }
: {};
return [
{
matches,
exclude_matches: excludeMatches,
js: ["bridge.js"],
all_frames: true,
match_about_blank: true,
run_at: "document_start",
...fallbackConfig,
},
{
matches,
exclude_matches: excludeMatches,
js: ["prelude.js", "content.js"],
all_frames: true,
match_about_blank: true,
run_at: "document_idle",
...(includeWorld ? { world: "MAIN" } : {}),
...fallbackConfig,
},
];
}
function createWebAccessibleResources(
matches: string[],
): Array<Record<string, unknown>> {
const normalizedMatches = normalizeWebAccessibleResourceMatches(matches);
if (!normalizedMatches.length) {
return [];
}
return [
{
resources: [...EXTENSION_WEB_ACCESSIBLE_JS_GLOBS],
matches: normalizedMatches,
},
];
}
function buildManifestChrome({
headers,
includeWorld,
}: {
headers: ExtensionHeaders;
includeWorld: boolean;
}): Record<string, unknown> {
const name = headers.name || DEFAULT_EXTENSION_NAME;
const description = headers.description || DEFAULT_EXTENSION_DESCRIPTION;
const version = headers.version || DEFAULT_EXTENSION_VERSION;
const matches = headers.match || [];
const excludeMatches = headers.exclude || [];
const { originFallbackMatches, directMatches } =
splitMatchesForOriginFallback(matches);
const hostPermissions = normalizeHostPermissions(headers.connect || []);
const contentScripts = [
...createContentScriptEntries({
matches: originFallbackMatches,
excludeMatches,
includeWorld,
matchOriginAsFallback: true,
}),
...createContentScriptEntries({
matches: directMatches,
excludeMatches,
includeWorld,
}),
];
return {
manifest_version: 3,
name,
description,
version,
action: {
default_title: name,
default_icon: buildIconsMap([16, 32]),
},
permissions: [
"storage",
"notifications",
"tabs",
"declarativeNetRequestWithHostAccess",
],
host_permissions: hostPermissions,
background: {
service_worker: "background.js",
type: "module",
},
icons: buildIconsMap(EXTENSION_ICON_SIZES),
content_scripts: contentScripts,
web_accessible_resources: createWebAccessibleResources(matches),
};
}
function getFirefoxAddonId(): string {
return (
process.env.FIREFOX_ADDON_ID ||
process.env.GECKO_ID ||
"vot-extension@firefox"
);
}
function getFirefoxStrictMinVersion(): string {
return process.env.FIREFOX_STRICT_MIN_VERSION || "140.0";
}
function compareFirefoxVersions(left: string, right: string): number {
const leftParts = left
.split(".")
.map((part) => Number.parseInt(part, 10) || 0);
const rightParts = right
.split(".")
.map((part) => Number.parseInt(part, 10) || 0);
const length = Math.max(leftParts.length, rightParts.length);
for (let index = 0; index < length; index += 1) {
const diff = (leftParts[index] || 0) - (rightParts[index] || 0);
if (diff !== 0) {
return diff;
}
}
return 0;
}
function getFirefoxAndroidSettings(): Record<string, string> {
const strictMinVersion =
process.env.FIREFOX_ANDROID_STRICT_MIN_VERSION?.trim();
const strictMaxVersion =
process.env.FIREFOX_ANDROID_STRICT_MAX_VERSION?.trim();
return {
...(strictMinVersion ? { strict_min_version: strictMinVersion } : {}),
...(strictMaxVersion ? { strict_max_version: strictMaxVersion } : {}),
};
}
function getFirefoxDataCollectionPermissions(): {
required: string[];
optional?: string[];
} {
const requiredRaw = process.env.FIREFOX_DATA_COLLECTION_REQUIRED;
const optionalRaw = process.env.FIREFOX_DATA_COLLECTION_OPTIONAL;
const parseList = (raw: string): string[] =>
String(raw)
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
const required = requiredRaw ? parseList(requiredRaw) : ["none"];
const optional = optionalRaw ? parseList(optionalRaw) : null;
return {
required,
...(optional?.length ? { optional } : {}),
};
}
function getFirefoxAndroidSettingsForDataCollectionPermissions(): Record<
string,
string
> {
const androidSettings = getFirefoxAndroidSettings();
const androidMinVersion = androidSettings.strict_min_version?.trim();
const requiredAndroidMinVersion = "142.0";
if (
!androidMinVersion ||
compareFirefoxVersions(androidMinVersion, requiredAndroidMinVersion) < 0
) {
return {
...androidSettings,
strict_min_version: requiredAndroidMinVersion,
};
}
return androidSettings;
}
function getFirefoxXpiRawUrl(version: string): string {
return `${GITHUB_DIST_EXT_RAW_BASE}/vot-extension-firefox-${version}.xpi`;
}
function buildManifestFirefox({
headers,
includeWorld,
}: {
headers: ExtensionHeaders;
includeWorld: boolean;
}): Record<string, unknown> {
const manifest = buildManifestChrome({ headers, includeWorld });
const dataCollectionPermissions = getFirefoxDataCollectionPermissions();
const action = manifest.action as Record<string, unknown> | undefined;
const defaultIcon = action?.default_icon as
| Record<string, unknown>
| undefined;
if (defaultIcon) {
defaultIcon[64] = "icons/icon-64.png";
}
delete manifest.update_url;
delete manifest.background;
manifest.background = {
scripts: ["background.js"],
};
manifest.browser_specific_settings = {
gecko: {
id: getFirefoxAddonId(),
update_url: FIREFOX_UPDATES_MANIFEST_URL,
strict_min_version: getFirefoxStrictMinVersion(),
data_collection_permissions: dataCollectionPermissions,
},
gecko_android:
dataCollectionPermissions.required.length ||
dataCollectionPermissions.optional?.length
? getFirefoxAndroidSettingsForDataCollectionPermissions()
: getFirefoxAndroidSettings(),
};
return manifest;
}
async function writeManifest(
targetDir: string,
manifest: Record<string, unknown>,
): Promise<void> {
await fs.writeFile(
path.join(targetDir, "manifest.json"),
JSON.stringify(manifest, null, 3),
"utf8",
);
}
async function writeFirefoxUpdatesManifest({
version,
addonId,
}: {
version: string;
addonId: string;
}): Promise<string> {
const updatesManifestPath = path.join(outBase, FIREFOX_UPDATES_MANIFEST_FILE);
const updatesManifest = {
addons: {
[addonId]: {
updates: [
{
version,
update_link: getFirefoxXpiRawUrl(version),
},
],
},
},
};
await fs.writeFile(
updatesManifestPath,
JSON.stringify(updatesManifest, null, 3),
"utf8",
);
return updatesManifestPath;
}
async function zipDir(
sourceDirPath: string,
outZipPath: string,
): Promise<void> {
await fs.rm(outZipPath, { force: true });
await fs.mkdir(path.dirname(outZipPath), { recursive: true });
await zip(sourceDirPath, outZipPath, {
compression: COMPRESSION_LEVEL.high,
zlib: { level: 9 },
});
}
function getCrx3Bin(): string {
return path.join(
rootDir,
"node_modules",
".bin",
process.platform === "win32" ? "crx3.cmd" : "crx3",
);
}
async function runCmd(
cmd: string,
args: string[],
cwd?: string,
): Promise<void> {
await new Promise<void>((resolve, reject) => {
const child = spawn(cmd, args, {
cwd,
stdio: "inherit",
shell: process.platform === "win32",
env: process.env,
});
child.on("error", reject);
child.on("close", (code) => {
if (code === 0) resolve();
else reject(new Error(`${cmd} exited with code ${code}`));
});
});
}
async function _maybeBuildCrx({
sourceDir,
version,
}: {
sourceDir: string;
version: string;
}): Promise<{ crxPath: string }> {
const customKeyPath = process.env.CHROME_CRX_KEY_PATH?.trim() || null;
const useTemporaryKey = !customKeyPath;
const keyPath = path.resolve(
rootDir,
customKeyPath || path.join(outTmp, "vot-extension-chrome.pem"),
);
await fs.mkdir(path.dirname(keyPath), { recursive: true });
const crx3Bin = getCrx3Bin();
if (!(await exists(crx3Bin))) {
throw new Error(
`CRX builder not found: ${crx3Bin}. Install dependencies first.`,
);
}
const outCrx = path.join(outBase, `vot-extension-chrome-${version}.zip`);
try {
await runCmd(
crx3Bin,
["-p", keyPath, "-o", outCrx, "--appVersion", version, sourceDir],
rootDir,
);
} finally {
if (useTemporaryKey) {
await fs.rm(keyPath, { force: true });
await fs.rm(path.join(outBase, "vot-extension-chrome.pem"), {
force: true,
});
}
}
return {
crxPath: outCrx,
};
}
function isValidMatchPattern(pattern: string): boolean {
if (pattern === "<all_urls>") return true;
return /^(\*|http|https|file|ftp|ws|wss):\/\/(\*|\*\.[^/*]+|[^/*]+)\/.*$/.test(
pattern,
);
}
function assertValidPatterns(
label: string,
browserName: string,
patterns: string[] = [],
): void {
for (const pattern of patterns) {
if (!isValidMatchPattern(pattern)) {
throw new Error(
`${browserName}: ${label} contains malformed URL pattern: ${pattern}`,
);
}
}
}
function assertOriginFallbackPathIsWildcard(
browserName: string,
patterns: string[] = [],
): void {
for (const pattern of patterns) {
const match = /^[^:]+:\/\/[^/]+(\/.*)$/.exec(pattern);
const pathPart = match ? match[1] : null;
if (pathPart !== "/*") {
throw new Error(
`${browserName}: match_origin_as_fallback requires path /*. Got: ${pattern}`,
);
}
}
}
async function readVerifiedManifest(
browserName: "chrome" | "firefox",
manifestPath: string,
): Promise<Record<string, any>> {
if (!(await exists(manifestPath))) {
throw new Error(`${browserName}: missing manifest.json at ${manifestPath}`);
}
const manifest = await readJson<Record<string, any>>(manifestPath);
if (manifest.manifest_version !== 3) {
throw new Error(
`${browserName}: expected manifest_version 3, got ${manifest.manifest_version}`,
);
}
return manifest;
}
function verifyManifestPermissions(
browserName: "chrome" | "firefox",
manifest: Record<string, any>,
): void {
const permissions = new Set(manifest.permissions || []);
if (
!permissions.has("declarativeNetRequestWithHostAccess") &&
!permissions.has("declarativeNetRequest")
) {
throw new Error(
`${browserName}: expected declarativeNetRequestWithHostAccess or declarativeNetRequest permission`,
);
}
}
function verifyBrowserSpecificManifestFields(
browserName: "chrome" | "firefox",
manifest: Record<string, any>,
): void {
if (browserName === "chrome") {
if (!manifest.background?.service_worker) {
throw new Error(`${browserName}: expected background.service_worker`);
}
if (manifest.update_url) {
throw new Error(`${browserName}: update_url must not be set`);
}
return;
}
if (
!Array.isArray(manifest.background?.scripts) ||
!manifest.background.scripts.length
) {
throw new Error(`${browserName}: expected background.scripts[]`);
}
if (
manifest.browser_specific_settings?.gecko?.update_url !==
FIREFOX_UPDATES_MANIFEST_URL
) {
throw new Error(
`${browserName}: expected browser_specific_settings.gecko.update_url to be ${FIREFOX_UPDATES_MANIFEST_URL}, got ${manifest.browser_specific_settings?.gecko?.update_url}`,
);
}
if (
!manifest.browser_specific_settings?.gecko_android ||
typeof manifest.browser_specific_settings.gecko_android !== "object" ||
Array.isArray(manifest.browser_specific_settings.gecko_android)
) {
throw new Error(
`${browserName}: expected browser_specific_settings.gecko_android to be an object`,
);
}
}
function verifyContentScripts(
browserName: "chrome" | "firefox",
manifest: Record<string, any>,
): void {
assertValidPatterns(
"host_permissions",
browserName,
manifest.host_permissions,
);
let sawOriginFallback = false;
for (const contentScript of manifest.content_scripts || []) {
assertValidPatterns(
"content_scripts.matches",
browserName,
contentScript.matches,
);
if (contentScript.all_frames !== true) {
throw new Error(
`${browserName}: content_scripts entry must set all_frames: true`,
);
}
if (contentScript.match_about_blank !== true) {
throw new Error(
`${browserName}: content_scripts entry must set match_about_blank: true`,
);
}
if (contentScript.match_origin_as_fallback === true) {
sawOriginFallback = true;
assertOriginFallbackPathIsWildcard(browserName, contentScript.matches);
}
}
if (!sawOriginFallback) {
throw new Error(
`${browserName}: expected at least one content_scripts entry with match_origin_as_fallback: true`,
);
}
}
async function verifyRequiredOutputFiles(
browserName: "chrome" | "firefox",
dir: string,
): Promise<void> {
const requiredFiles = [
"bridge.js",
...EXTENSION_LOADER_FILES,
...EXTENSION_MODULE_FILES,
"background.js",
...EXTENSION_ICON_SIZES.map((size) => `icons/icon-${size}.png`),
];
for (const relPath of requiredFiles) {
const fullPath = path.join(dir, relPath);
if (!(await exists(fullPath))) {
throw new Error(`${browserName}: missing required file: ${relPath}`);
}
}
}
async function verifyBundleSources(
browserName: "chrome" | "firefox",
dir: string,
): Promise<void> {
const bridge = await fs.readFile(path.join(dir, "bridge.js"), "utf8");
const prelude = await fs.readFile(path.join(dir, "prelude.js"), "utf8");
const preludeModule = await fs.readFile(
path.join(dir, "prelude.module.js"),
"utf8",
);
const content = await fs.readFile(path.join(dir, "content.js"), "utf8");
const contentModule = await fs.readFile(
path.join(dir, "content.module.js"),
"utf8",
);
const background = await fs.readFile(path.join(dir, "background.js"), "utf8");
const combined = `${bridge}\n${prelude}\n${preludeModule}\n${content}\n${contentModule}\n${background}`;
const forbiddenSnippets = [
"cdnjs.cloudflare.com/ajax/libs/hls.js",
"@require",
];
for (const snippet of forbiddenSnippets) {
if (combined.includes(snippet)) {
throw new Error(
`${browserName}: bundle contains forbidden snippet (${snippet})`,
);
}
}
if (/import\s*\(/.test(bridge)) {
throw new Error(
`${browserName}: expected bridge.js to avoid dynamic imports`,
);
}
for (const [fileName, source] of [
["prelude.js", prelude],
["content.js", content],
] as const) {
if (/import\s*\(/.test(source)) {
throw new Error(
`${browserName}: expected ${fileName} to stay inert and avoid dynamic imports`,
);
}
}
const moduleImportTargets = Array.from(
new Set(
[preludeModule, contentModule]
.flatMap((source) =>
Array.from(
source.matchAll(/from\s+"(\.\/[^"]+\.js)"/g),
(match) => match[1],
),
)
.map((pathValue) => pathValue.replace(/^\.\//, "")),
),
);
for (const importedFile of moduleImportTargets) {
if (!(await exists(path.join(dir, importedFile)))) {
throw new Error(
`${browserName}: missing module dependency referenced by extension bundle: ${importedFile}`,
);
}
}
const legacyIifePrefixes = [
"var VOT = (function",
"var VOT = (() =>",
"(function () {",
"(() => {",
];
for (const [fileName, source] of [
["prelude.module.js", preludeModule],
["content.module.js", contentModule],
] as const) {
const normalizedSource = source.trimStart();
if (
legacyIifePrefixes.some((prefix) => normalizedSource.startsWith(prefix))
) {
throw new Error(
`${browserName}: expected ${fileName} to avoid legacy IIFE wrapping`,
);
}
}
}
async function verifyBodySerializationGuards(
browserName: "chrome" | "firefox",
): Promise<void> {
const bridgeSrcPath = path.join(rootDir, "src/extension/bridge.ts");
const bridgeXhrSrcPath = path.join(rootDir, "src/extension/bridgeXhr.ts");
const serializationSrcPath = path.join(
rootDir,
"src/extension/bodySerialization.ts",
);
let sourceToCheck: string | null = null;
if (await exists(serializationSrcPath)) {
sourceToCheck = await fs.readFile(serializationSrcPath, "utf8");
} else if (await exists(bridgeSrcPath)) {
sourceToCheck = await fs.readFile(bridgeSrcPath, "utf8");
}
if (sourceToCheck && !/(Blob|FileReader)/.test(sourceToCheck)) {
throw new Error(
`${browserName}: regression guard failed: expected Blob/FileReader handling in body serialization`,
);
}
const bridgeSerializationSourcePaths = [bridgeXhrSrcPath, bridgeSrcPath];
for (const sourcePath of bridgeSerializationSourcePaths) {
if (!(await exists(sourcePath))) {
continue;
}
const source = await fs.readFile(sourcePath, "utf8");
if (/await\s+serializeBodyForPort\(/.test(source)) {
return;
}
}
throw new Error(
`${browserName}: regression guard failed: expected bridge XHR flow to await serializeBodyForPort(...)`,
);
}
async function verifyOne(browserName: "chrome" | "firefox"): Promise<void> {
const dir = path.join(outBase, browserName);
const manifestPath = path.join(dir, "manifest.json");
const manifest = await readVerifiedManifest(browserName, manifestPath);
verifyManifestPermissions(browserName, manifest);
verifyBrowserSpecificManifestFields(browserName, manifest);
verifyContentScripts(browserName, manifest);
await verifyRequiredOutputFiles(browserName, dir);
await verifyBundleSources(browserName, dir);
await verifyBodySerializationGuards(browserName);
console.log(`OK ${browserName}: basic structure checks passed`);
}
export async function verifyExtensionOutputs(
target: ExtensionBuildTarget = "all",
): Promise<void> {
if (target === "all" || target === "chrome") {
await verifyOne("chrome");
}
if (target === "all" || target === "firefox") {
await verifyOne("firefox");
const firefoxUpdatesManifestPath = path.join(
outBase,
FIREFOX_UPDATES_MANIFEST_FILE,
);
if (!(await exists(firefoxUpdatesManifestPath))) {
throw new Error(
`firefox: missing updates manifest at ${firefoxUpdatesManifestPath}`,
);
}
}
console.log("\nExtension verification complete.");
}
interface BrowserBuildResult {
outDir: string;
packagePath: string;
removedPackages: string[];
updatesPath?: string;
}
async function buildBrowserArtifacts({
browserDir,
artifactPrefix,
fileExtension,
backgroundSrc,
headers,
version,
includeWorld,
buildManifest,
afterPackage,
}: {
browserDir: "chrome" | "firefox";
artifactPrefix: string;
fileExtension: ".zip" | ".xpi";
backgroundSrc: string;
headers: ExtensionHeaders;
version: string;
includeWorld: boolean;
buildManifest: (args: {
headers: ExtensionHeaders;
includeWorld: boolean;
}) => Record<string, unknown>;
afterPackage?: () => Promise<string>;
}): Promise<BrowserBuildResult> {
const removedPackages = await cleanupOlderVersionedArtifacts({
artifactPrefix,
fileExtension,
currentVersion: version,
});
const outDir = path.join(outBase, browserDir);
await ensureCleanDir(outDir);
await copyExtensionFiles(outDir, {
backgroundSrc,
backgroundDst: "background.js",
});
await writeManifest(outDir, buildManifest({ headers, includeWorld }));
const packagePath = path.join(
outBase,
`${artifactPrefix}-${version}${fileExtension}`,
);
await zipDir(outDir, packagePath);
const updatesPath = afterPackage ? await afterPackage() : undefined;
return { outDir, packagePath, removedPackages, updatesPath };
}
export async function finalizeExtensionBuildArtifacts(
target: ExtensionBuildTarget = "all",
): Promise<void> {
const headers = await getExtensionHeaders();
const includeWorld = true;
const shouldBuildChrome = target === "all" || target === "chrome";
const shouldBuildFirefox = target === "all" || target === "firefox";
if (shouldBuildChrome) {
await fs.rm(path.join(outBase, CHROME_UPDATES_MANIFEST_FILE), {
force: true,
});
}
const version = headers.version || DEFAULT_EXTENSION_VERSION;
let chromeBuild: BrowserBuildResult | null = null;
let firefoxBuild: BrowserBuildResult | null = null;
if (shouldBuildChrome) {
chromeBuild = await buildBrowserArtifacts({
browserDir: "chrome",
artifactPrefix: "vot-extension-chrome",
fileExtension: ".zip",
backgroundSrc: "background.js",
headers,
version,
includeWorld,
buildManifest: buildManifestChrome,
});
}
if (shouldBuildFirefox) {
firefoxBuild = await buildBrowserArtifacts({
browserDir: "firefox",
artifactPrefix: "vot-extension-firefox",
fileExtension: ".xpi",
backgroundSrc: "background-ff.js",
headers,
version,
includeWorld,
buildManifest: buildManifestFirefox,
afterPackage: async () =>
writeFirefoxUpdatesManifest({
version,
addonId: getFirefoxAddonId(),
}),
});
}
await verifyExtensionOutputs(target);
console.log("Extension build complete:");
if (chromeBuild) {
console.log(`- Chrome: ${chromeBuild.outDir}`);
console.log(` - Package: ${chromeBuild.packagePath}`);
if (chromeBuild.removedPackages.length) {
console.log(
` - Removed old packages: ${chromeBuild.removedPackages.join(", ")}`,
);
}
}
if (firefoxBuild) {
console.log(`- Firefox: ${firefoxBuild.outDir}`);
console.log(` - Package: ${firefoxBuild.packagePath}`);
console.log(` - Updates: ${firefoxBuild.updatesPath}`);
if (firefoxBuild.removedPackages.length) {
console.log(
` - Removed old packages: ${firefoxBuild.removedPackages.join(", ")}`,
);
}
}
}
export async function cleanupExtensionTmpDir(): Promise<void> {
await fs.rm(outTmp, { recursive: true, force: true });
}