Files
2025-11-30 09:05:55 +08:00

275 lines
7.9 KiB
JavaScript
Executable File

#!/usr/bin/env node
import { spawn } from "node:child_process";
import { mkdir, rm, cp } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import net from "node:net";
import puppeteer from "puppeteer-core";
import {
DEFAULT_PORT,
createLogger,
delay,
expandPath,
fail,
findExistingPath,
parseArgs,
printJSON,
resolveBrowserConnection,
waitFor,
normalizeNumber,
pathExists,
} from "./config.js";
const args = parseArgs(process.argv.slice(2), {
boolean: ["json", "quiet"],
string: ["profile", "profile-path", "chrome-path", "chromePath", "host", "user-data-dir", "ws"],
number: ["port", "timeout"],
alias: {
j: "json",
q: "quiet",
},
defaults: {
port: DEFAULT_PORT,
timeout: 15000,
},
});
const jsonOutput = Boolean(args.json);
const logger = createLogger({ quiet: Boolean(args.quiet), json: jsonOutput });
const port = normalizeNumber(args.port, DEFAULT_PORT);
const timeout = normalizeNumber(args.timeout, 15000);
const host = args.host ?? "127.0.0.1";
const userDataDir = expandPath(
args["user-data-dir"] ?? join(homedir(), ".cache", "browser-tools", `profile-${port}`),
);
await ensurePortAvailable({ port, host });
const chromeExecutable = await resolveChromeExecutable(args);
if (!chromeExecutable) {
fail(
"Unable to find Chrome/Chromium executable. Provide --chrome-path or set CHROME_PATH/PUPPETEER_EXECUTABLE_PATH.",
{ json: jsonOutput },
);
}
let profileSource = await resolveProfileSource(args);
let copiedProfile = false;
try {
await rm(userDataDir, { recursive: true, force: true });
if (profileSource) {
logger.info(`⏳ Copying profile from ${profileSource}...`);
await cp(profileSource, userDataDir, { recursive: true });
copiedProfile = true;
} else {
await mkdir(userDataDir, { recursive: true });
}
} catch (error) {
logger.error(`Failed to prepare user data dir: ${error.message}`);
fail("Failed to prepare profile directory", { json: jsonOutput });
}
const chromeArgs = buildChromeArguments({ port, userDataDir });
logger.info(`🚀 Launching Chrome at ${chromeExecutable} on port ${port}...`);
const chromeProcess = spawn(chromeExecutable, chromeArgs, {
detached: true,
stdio: "ignore",
});
chromeProcess.unref();
logger.info("⏳ Waiting for DevTools endpoint...");
const connectionOptions = resolveBrowserConnection({ port, host, ws: args.ws });
const connected = await waitFor(async () => {
try {
const browser = await puppeteer.connect({
...connectionOptions,
timeout: 2000,
});
await browser.disconnect();
return true;
} catch {
return false;
}
}, { timeout, interval: 500 });
if (!connected) {
fail(
`Failed to connect to Chrome on ${connectionOptions.browserWSEndpoint ?? connectionOptions.browserURL}`,
{ json: jsonOutput },
);
}
logger.info(`✅ Chrome listening on ${connectionOptions.browserWSEndpoint ?? connectionOptions.browserURL}`);
const result = {
ok: true,
port,
userDataDir,
chromePath: chromeExecutable,
profile: copiedProfile ? profileSource : null,
};
if (jsonOutput) {
printJSON(result);
} else {
process.stdout.write(`${JSON.stringify(result)}\n`);
logger.info(
`✓ Chrome started on ${connectionOptions.browserWSEndpoint ?? connectionOptions.browserURL}`,
);
}
async function ensurePortAvailable({ port: portToCheck, host: hostToCheck }) {
await new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", (error) => {
if (error.code === "EADDRINUSE") {
reject(
new Error(
`Port ${portToCheck} is already in use. If Chrome is already running, reconnect with other tools or close it first.`,
),
);
} else {
reject(error);
}
});
server.once("listening", () => {
server.close(() => resolve());
});
server.listen(portToCheck, hostToCheck);
}).catch((error) => {
fail(error.message, { json: jsonOutput });
});
// small delay to allow OS to release the port after closing the test server
await delay(50);
}
function buildChromeArguments({ port: portValue, userDataDir: dir }) {
return [
`--remote-debugging-port=${portValue}`,
`--user-data-dir=${dir}`,
"--no-first-run",
"--no-default-browser-check",
"--disable-background-networking",
"--disable-sync",
"--metrics-recording-only",
"--enable-automation",
];
}
async function resolveChromeExecutable(parsed) {
const providedPath = parsed["chrome-path"] ?? parsed.chromePath ?? process.env.CHROME_PATH;
const puppeteerPath = process.env.PUPPETEER_EXECUTABLE_PATH;
const platformCandidates = getDefaultChromeCandidates();
return findExistingPath([
providedPath && expandPath(providedPath),
puppeteerPath && expandPath(puppeteerPath),
...platformCandidates,
]);
}
async function resolveProfileSource(parsed) {
const raw = parsed.profile ?? parsed["profile-path"];
if (raw === undefined || raw === false) return null;
const normalized = typeof raw === "string" ? raw.trim() : "";
if (normalized && normalized.toLowerCase() !== "default") {
const expanded = expandPath(normalized);
if (await pathExists(expanded)) return expanded;
logger.warn(`⚠️ Profile path not found: ${expanded}`);
return null;
}
const defaultPath = await resolveDefaultProfileRoot();
if (!defaultPath) {
logger.warn("⚠️ No default Chrome profile detected; starting with a fresh profile.");
return null;
}
return defaultPath;
}
async function resolveDefaultProfileRoot() {
const candidates = getDefaultProfileCandidates();
for (const candidate of candidates) {
if (!candidate) continue;
const expanded = expandPath(candidate);
if (await pathExists(expanded)) {
return expanded;
}
}
return null;
}
function getDefaultChromeCandidates() {
const platform = process.platform;
const home = homedir();
if (platform === "darwin") {
return [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
];
}
if (platform === "linux") {
return [
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/chromium-browser",
"/usr/bin/chromium",
join(home, "snap", "chromium", "current", "usr", "lib", "chromium-browser", "chromium-browser"),
];
}
if (platform === "win32") {
const localAppData = process.env.LOCALAPPDATA;
const programFiles = process.env["PROGRAMFILES(X86)"] ?? process.env.PROGRAMFILES;
return [
localAppData && join(localAppData, "Google", "Chrome", "Application", "chrome.exe"),
programFiles && join(programFiles, "Google", "Chrome", "Application", "chrome.exe"),
];
}
return [];
}
function getDefaultProfileCandidates() {
const platform = process.platform;
if (platform === "darwin") {
return [
"~/Library/Application Support/Google/Chrome",
"~/Library/Application Support/Chromium",
];
}
if (platform === "linux") {
return [
"~/.config/google-chrome",
"~/.config/chromium",
];
}
if (platform === "win32") {
const localAppData = process.env.LOCALAPPDATA;
if (!localAppData) return [];
return [join(localAppData, "Google", "Chrome", "User Data")];
}
return [];
}