Files
homarr/scripts/release-v2-qa/zoom.mts
T

173 lines
5.1 KiB
TypeScript

import { lstat, mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { assertSafeZoomOutput } from "./safety.mts";
const allowedFactorStrings = new Set(["0.8", "1", "1.25", "2"]);
interface ZoomOptions {
origin: string;
factor: number;
outputDirectory: string;
}
const usage = () => {
console.log("Usage: zoom.mts --origin <http(s)-origin> --factor <0.8|1|1.25|2> --output <new-absolute-directory>");
};
const validateOrigin = (value: string) => {
let url: URL;
try {
url = new URL(value);
} catch {
throw new Error("--origin must be a valid HTTP(S) origin");
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error("--origin must use http: or https:");
}
if (url.username || url.password) {
throw new Error("--origin must not contain credentials");
}
if (url.pathname !== "/" || url.search || url.hash) {
throw new Error("--origin must not contain a path, query, or fragment");
}
return url.origin;
};
const validateOutputDirectory = async (value: string) => {
if (!path.isAbsolute(value)) {
throw new Error("--output must be an absolute path to a new directory");
}
const outputDirectory = await assertSafeZoomOutput(value, { allowMissing: true });
let outputExists = true;
try {
await lstat(outputDirectory);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
outputExists = false;
}
if (outputExists) throw new Error(`--output must not already exist: ${outputDirectory}`);
return outputDirectory;
};
export const parseZoomOptions = async (rawArguments: string[]): Promise<ZoomOptions> => {
const args = rawArguments.filter((argument) => argument !== "--");
let origin: string | undefined;
let factorString: string | undefined;
let output: string | undefined;
for (let index = 0; index < args.length; index += 1) {
const argument = args[index];
const value = args[index + 1];
if (argument === "--origin" && value) {
origin = value;
index += 1;
continue;
}
if (argument === "--factor" && value) {
factorString = value;
index += 1;
continue;
}
if (argument === "--output" && value) {
output = value;
index += 1;
continue;
}
throw new Error(`Unknown or incomplete argument: ${argument ?? "<missing>"}`);
}
if (!origin) throw new Error("--origin is required");
if (!factorString) throw new Error("--factor is required");
if (!allowedFactorStrings.has(factorString)) {
throw new Error("--factor must be one of 0.8, 1, 1.25, or 2");
}
if (!output) throw new Error("--output is required");
return {
origin: validateOrigin(origin),
factor: Number(factorString),
outputDirectory: await validateOutputDirectory(output),
};
};
export const createZoomExtensionContent = (origin: string, factor: number) => {
const manifest = {
manifest_version: 3,
name: "Homarr release v2 native zoom",
version: "1.0.0",
permissions: ["tabs"],
host_permissions: [`${origin}/*`],
background: { service_worker: "background.js" },
};
const background = `const TARGET_ORIGIN = ${JSON.stringify(origin)};
const ZOOM_FACTOR = ${JSON.stringify(factor)};
function applyZoom(tab) {
if (tab?.id == null || !tab.url) return;
let origin;
try {
origin = new URL(tab.url).origin;
} catch {
return;
}
if (origin !== TARGET_ORIGIN) return;
chrome.tabs.setZoom(tab.id, ZOOM_FACTOR, () => {
void chrome.runtime.lastError;
});
}
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
if (changeInfo.status !== "complete") return;
applyZoom({ ...tab, id: tabId });
});
chrome.tabs.query({}, (tabs) => {
tabs.forEach(applyZoom);
});
`;
return {
manifest: `${JSON.stringify(manifest, null, 2)}\n`,
background,
};
};
export const generateZoomExtension = async (options: ZoomOptions) => {
const content = createZoomExtensionContent(options.origin, options.factor);
await assertSafeZoomOutput(options.outputDirectory, { allowMissing: true });
await mkdir(options.outputDirectory, { mode: 0o700 });
await assertSafeZoomOutput(options.outputDirectory);
await Promise.all([
writeFile(path.join(options.outputDirectory, "manifest.json"), content.manifest, { flag: "wx", mode: 0o600 }),
writeFile(path.join(options.outputDirectory, "background.js"), content.background, { flag: "wx", mode: 0o600 }),
]);
};
const main = async () => {
const rawArguments = process.argv.slice(2);
if (rawArguments.includes("--help") || rawArguments.includes("-h")) {
usage();
return;
}
const options = await parseZoomOptions(rawArguments);
await generateZoomExtension(options);
console.log(`Generated release-v2 native zoom extension`);
console.log(`Origin: ${options.origin}`);
console.log(`Factor: ${options.factor}`);
console.log(`Output: ${options.outputDirectory}`);
};
const isDirectExecution = process.argv[1]
? pathToFileURL(path.resolve(process.argv[1])).href === pathToFileURL(fileURLToPath(import.meta.url)).href
: false;
if (isDirectExecution) await main();