472 lines
14 KiB
TypeScript
472 lines
14 KiB
TypeScript
import fs from "fs";
|
||
import os from "os";
|
||
import path from "path";
|
||
import { spawn } from "child_process";
|
||
import {
|
||
Browser,
|
||
Cache,
|
||
computeExecutablePath,
|
||
detectBrowserPlatform,
|
||
install,
|
||
} from "@puppeteer/browsers";
|
||
import { getCacheDir, resourceBaseDir } from "./constants";
|
||
import { isWindowsStoreInstall } from "./export-msix-runtime";
|
||
import { safeError, safeLog } from "./safe-console";
|
||
|
||
/** Must match the Chrome revision expected by the bundled presentation-export runtime. */
|
||
const EXPORT_CHROME_BUILD_ID =
|
||
process.env.EXPORT_CHROME_BUILD_ID?.trim() || "146.0.7680.76";
|
||
const BUNDLED_CHROMIUM_MANIFEST = "presenton-runtime.json";
|
||
|
||
type BundledChromiumManifest = {
|
||
browser?: string;
|
||
buildId?: string;
|
||
platform?: string;
|
||
nodePlatform?: string;
|
||
arch?: string;
|
||
executable?: string;
|
||
};
|
||
|
||
export type ChromiumInstallProgress = {
|
||
phase: "downloading" | "installing" | "done" | "error";
|
||
percent?: number;
|
||
message?: string;
|
||
};
|
||
|
||
function resolvePuppeteerCacheRoot(): string {
|
||
const configured = process.env.PUPPETEER_CACHE_DIR?.trim();
|
||
if (configured) {
|
||
return path.resolve(configured);
|
||
}
|
||
return path.join(os.homedir(), ".cache", "puppeteer");
|
||
}
|
||
|
||
export function getBundledExportChromiumCacheRoot(): string {
|
||
return path.join(resourceBaseDir, "resources", "chromium");
|
||
}
|
||
|
||
function readBundledChromiumManifest(): BundledChromiumManifest | null {
|
||
const manifestPath = path.join(getBundledExportChromiumCacheRoot(), BUNDLED_CHROMIUM_MANIFEST);
|
||
try {
|
||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as BundledChromiumManifest;
|
||
if (manifest.browser && manifest.browser !== Browser.CHROME) {
|
||
return null;
|
||
}
|
||
if (manifest.buildId && manifest.buildId !== EXPORT_CHROME_BUILD_ID) {
|
||
return null;
|
||
}
|
||
if (manifest.nodePlatform && manifest.nodePlatform !== process.platform) {
|
||
return null;
|
||
}
|
||
if (manifest.arch && manifest.arch !== process.arch) {
|
||
return null;
|
||
}
|
||
if (!manifest.executable) {
|
||
return null;
|
||
}
|
||
return manifest;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function resolveManifestBundledExportChromiumPath(): string | null {
|
||
const manifest = readBundledChromiumManifest();
|
||
if (!manifest?.executable) {
|
||
return null;
|
||
}
|
||
const executablePath = path.join(getBundledExportChromiumCacheRoot(), manifest.executable);
|
||
return isMaterializedChromiumComplete(executablePath) ? executablePath : null;
|
||
}
|
||
|
||
function resolveExportChromeInstallOptions(cacheDir = resolvePuppeteerCacheRoot()):
|
||
| { browser: Browser.CHROME; buildId: string; cacheDir: string; platform: NonNullable<ReturnType<typeof detectBrowserPlatform>> }
|
||
| null {
|
||
const platform = detectBrowserPlatform();
|
||
if (!platform) {
|
||
return null;
|
||
}
|
||
return {
|
||
browser: Browser.CHROME,
|
||
buildId: EXPORT_CHROME_BUILD_ID,
|
||
cacheDir,
|
||
platform,
|
||
};
|
||
}
|
||
|
||
/** Pre–Chrome-for-Testing cache layouts still present on some machines. */
|
||
function getLegacyExecutableRelativePaths(): string[] {
|
||
if (process.platform === "win32") {
|
||
return [
|
||
path.join("chrome-win64", "chrome.exe"),
|
||
path.join("chrome-win32", "chrome.exe"),
|
||
];
|
||
}
|
||
if (process.platform === "darwin") {
|
||
return [
|
||
path.join("chrome-mac", "Chromium.app", "Contents", "MacOS", "Chromium"),
|
||
path.join("chrome-mac-arm64", "Chromium.app", "Contents", "MacOS", "Chromium"),
|
||
path.join("chrome-mac-x64", "Chromium.app", "Contents", "MacOS", "Chromium"),
|
||
];
|
||
}
|
||
return [path.join("chrome-linux64", "chrome")];
|
||
}
|
||
|
||
function resolveLegacyInstalledExportChromiumPath(): string | null {
|
||
const chromeBaseDir = path.join(resolvePuppeteerCacheRoot(), "chrome");
|
||
let revisionDirs: string[] = [];
|
||
try {
|
||
revisionDirs = fs
|
||
.readdirSync(chromeBaseDir, { withFileTypes: true })
|
||
.filter((entry) => entry.isDirectory())
|
||
.map((entry) => path.join(chromeBaseDir, entry.name));
|
||
} catch {
|
||
return null;
|
||
}
|
||
|
||
const legacyRelativePaths = getLegacyExecutableRelativePaths();
|
||
for (const revisionDir of revisionDirs) {
|
||
for (const relativePath of legacyRelativePaths) {
|
||
const executablePath = path.join(revisionDir, relativePath);
|
||
if (fs.existsSync(executablePath)) {
|
||
return executablePath;
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export function resolveInstalledExportChromiumPath(): string | null {
|
||
const manifestBundledPath = resolveManifestBundledExportChromiumPath();
|
||
if (manifestBundledPath) {
|
||
return manifestBundledPath;
|
||
}
|
||
|
||
const bundledOptions = resolveExportChromeInstallOptions(getBundledExportChromiumCacheRoot());
|
||
if (bundledOptions) {
|
||
const bundledExpectedPath = computeExecutablePath(bundledOptions);
|
||
if (fs.existsSync(bundledExpectedPath)) {
|
||
return bundledExpectedPath;
|
||
}
|
||
|
||
const bundledCache = new Cache(bundledOptions.cacheDir);
|
||
for (const installed of bundledCache.getInstalledBrowsers()) {
|
||
if (installed.browser !== Browser.CHROME || installed.buildId !== bundledOptions.buildId) {
|
||
continue;
|
||
}
|
||
if (fs.existsSync(installed.executablePath)) {
|
||
return installed.executablePath;
|
||
}
|
||
}
|
||
}
|
||
|
||
const options = resolveExportChromeInstallOptions();
|
||
if (options) {
|
||
const expectedPath = computeExecutablePath(options);
|
||
if (fs.existsSync(expectedPath)) {
|
||
return expectedPath;
|
||
}
|
||
|
||
const cache = new Cache(options.cacheDir);
|
||
for (const installed of cache.getInstalledBrowsers()) {
|
||
if (installed.browser !== Browser.CHROME || installed.buildId !== options.buildId) {
|
||
continue;
|
||
}
|
||
if (fs.existsSync(installed.executablePath)) {
|
||
return installed.executablePath;
|
||
}
|
||
}
|
||
}
|
||
|
||
return resolveLegacyInstalledExportChromiumPath();
|
||
}
|
||
|
||
export function isExportChromiumAvailable(): boolean {
|
||
return Boolean(resolveInstalledExportChromiumPath());
|
||
}
|
||
|
||
function isPathUnderWindowsApps(filePath: string): boolean {
|
||
return /\\windowsapps\\/i.test(filePath);
|
||
}
|
||
|
||
function getMsixChromiumCacheRoot(): string {
|
||
return path.join(getCacheDir(), "msix-export-chromium", EXPORT_CHROME_BUILD_ID);
|
||
}
|
||
|
||
/**
|
||
* MSIX/APPX installs keep the app under Program Files\\WindowsApps. Chrome cannot
|
||
* reliably launch from that read-only package, so copy the browser folder to user cache.
|
||
*/
|
||
async function materializeBundledChromiumForMsix(bundledExePath: string): Promise<string> {
|
||
const browserDir = path.dirname(bundledExePath);
|
||
const revisionDir = path.dirname(browserDir);
|
||
const revisionName = path.basename(revisionDir);
|
||
const cacheRoot = getMsixChromiumCacheRoot();
|
||
const destRevisionDir = path.join(cacheRoot, "chrome", revisionName);
|
||
const destExe = path.join(destRevisionDir, path.basename(browserDir), path.basename(bundledExePath));
|
||
const stampPath = path.join(cacheRoot, ".source-revision-dir");
|
||
const sourceStamp = `${revisionDir}\n${await getDirectoryMtimeFingerprint(revisionDir)}`;
|
||
|
||
if (isMaterializedChromiumComplete(destExe)) {
|
||
try {
|
||
if ((await fs.promises.readFile(stampPath, "utf8")).trim() === sourceStamp.trim()) {
|
||
return destExe;
|
||
}
|
||
} catch {
|
||
// Stale cache; recopy below.
|
||
}
|
||
}
|
||
|
||
safeLog(
|
||
"[Chromium] Copying bundled Chrome for Microsoft Store install:",
|
||
destRevisionDir
|
||
);
|
||
await fs.promises.rm(cacheRoot, { recursive: true, force: true });
|
||
await fs.promises.mkdir(path.dirname(destRevisionDir), { recursive: true });
|
||
await fs.promises.cp(revisionDir, destRevisionDir, { recursive: true });
|
||
await fs.promises.writeFile(stampPath, sourceStamp, "utf8");
|
||
|
||
if (!isMaterializedChromiumComplete(destExe)) {
|
||
throw new Error(`Chrome executable missing after MSIX materialization: ${destExe}`);
|
||
}
|
||
return destExe;
|
||
}
|
||
|
||
function isMaterializedChromiumComplete(executablePath: string): boolean {
|
||
if (!fs.existsSync(executablePath)) {
|
||
return false;
|
||
}
|
||
if (process.platform !== "win32") {
|
||
return true;
|
||
}
|
||
|
||
const chromeDir = path.dirname(executablePath);
|
||
return ["chrome.dll", "icudtl.dat"].every((fileName) =>
|
||
fs.existsSync(path.join(chromeDir, fileName))
|
||
);
|
||
}
|
||
|
||
async function getDirectoryMtimeFingerprint(directory: string): Promise<string> {
|
||
let newestMtime = 0;
|
||
let fileCount = 0;
|
||
const visit = async (current: string) => {
|
||
const entries = await fs.promises.readdir(current, { withFileTypes: true });
|
||
for (const entry of entries) {
|
||
const fullPath = path.join(current, entry.name);
|
||
if (entry.isDirectory()) {
|
||
await visit(fullPath);
|
||
continue;
|
||
}
|
||
const stat = await fs.promises.stat(fullPath);
|
||
newestMtime = Math.max(newestMtime, stat.mtimeMs);
|
||
fileCount += 1;
|
||
}
|
||
};
|
||
await visit(directory);
|
||
return `${fileCount}:${newestMtime}`;
|
||
}
|
||
|
||
function verifyChromiumCanStart(executablePath: string): Promise<void> {
|
||
return new Promise((resolve, reject) => {
|
||
const probe = spawn(
|
||
executablePath,
|
||
[
|
||
"--headless=new",
|
||
"--disable-gpu",
|
||
"--disable-dev-shm-usage",
|
||
"--no-sandbox",
|
||
"--no-first-run",
|
||
"--disable-extensions",
|
||
"about:blank",
|
||
],
|
||
{
|
||
stdio: "ignore",
|
||
windowsHide: process.platform === "win32",
|
||
}
|
||
);
|
||
|
||
let settled = false;
|
||
const timeout = setTimeout(() => {
|
||
if (settled) return;
|
||
settled = true;
|
||
probe.kill();
|
||
resolve();
|
||
}, 3000);
|
||
|
||
probe.once("error", (error) => {
|
||
if (settled) return;
|
||
settled = true;
|
||
clearTimeout(timeout);
|
||
reject(error);
|
||
});
|
||
|
||
probe.once("exit", (code) => {
|
||
if (settled) return;
|
||
settled = true;
|
||
clearTimeout(timeout);
|
||
code === 0
|
||
? resolve()
|
||
: reject(new Error(`Chrome probe exited with code ${code ?? "unknown"}`));
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Resolves a Chrome binary path that can actually be spawned (writable on MSIX/APPX).
|
||
*/
|
||
export async function resolveLaunchableExportChromiumPath(): Promise<string | null> {
|
||
const installed = resolveInstalledExportChromiumPath();
|
||
if (!installed) {
|
||
return null;
|
||
}
|
||
|
||
const mustMaterialize =
|
||
isWindowsStoreInstall() || isPathUnderWindowsApps(installed);
|
||
if (!mustMaterialize) {
|
||
return installed;
|
||
}
|
||
|
||
try {
|
||
const materializedPath = await materializeBundledChromiumForMsix(installed);
|
||
await verifyChromiumCanStart(materializedPath);
|
||
return materializedPath;
|
||
} catch (error) {
|
||
safeError("[Chromium] Failed to prepare Chrome for Microsoft Store export", error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export async function removeBrokenExportChromiumCaches(): Promise<number> {
|
||
const cacheDir = resolvePuppeteerCacheRoot();
|
||
const cache = new Cache(cacheDir);
|
||
let removedCount = 0;
|
||
|
||
for (const installed of cache.getInstalledBrowsers()) {
|
||
if (installed.browser !== Browser.CHROME) {
|
||
continue;
|
||
}
|
||
if (fs.existsSync(installed.executablePath)) {
|
||
continue;
|
||
}
|
||
try {
|
||
await fs.promises.rm(installed.path, { recursive: true, force: true });
|
||
removedCount += 1;
|
||
safeLog(`[Chromium] Removed broken cache: ${installed.path}`);
|
||
} catch {
|
||
// Best effort cleanup only.
|
||
}
|
||
}
|
||
|
||
const chromeBaseDir = path.join(cacheDir, "chrome");
|
||
const legacyRelativePaths = getLegacyExecutableRelativePaths();
|
||
let revisionDirs: string[] = [];
|
||
try {
|
||
revisionDirs = fs
|
||
.readdirSync(chromeBaseDir, { withFileTypes: true })
|
||
.filter((entry) => entry.isDirectory())
|
||
.map((entry) => path.join(chromeBaseDir, entry.name));
|
||
} catch {
|
||
return removedCount;
|
||
}
|
||
|
||
for (const revisionDir of revisionDirs) {
|
||
const hasLegacyExecutable = legacyRelativePaths.some((relativePath) =>
|
||
fs.existsSync(path.join(revisionDir, relativePath))
|
||
);
|
||
if (hasLegacyExecutable) {
|
||
continue;
|
||
}
|
||
|
||
const basename = path.basename(revisionDir);
|
||
if (basename.includes("-")) {
|
||
continue;
|
||
}
|
||
|
||
try {
|
||
await fs.promises.rm(revisionDir, { recursive: true, force: true });
|
||
removedCount += 1;
|
||
safeLog(`[Chromium] Removed broken cache: ${revisionDir}`);
|
||
} catch {
|
||
// Best effort cleanup only.
|
||
}
|
||
}
|
||
|
||
return removedCount;
|
||
}
|
||
|
||
function formatMegabytes(bytes: number): string {
|
||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||
}
|
||
|
||
export async function installExportChromium(
|
||
onProgress?: (progress: ChromiumInstallProgress) => void
|
||
): Promise<void> {
|
||
const removed = await removeBrokenExportChromiumCaches();
|
||
if (removed > 0) {
|
||
onProgress?.({
|
||
phase: "installing",
|
||
message: `Removed ${removed} incomplete Chromium download${removed === 1 ? "" : "s"}.`,
|
||
});
|
||
}
|
||
|
||
if (isExportChromiumAvailable()) {
|
||
onProgress?.({ phase: "done", percent: 100, message: "Chromium is already installed." });
|
||
return;
|
||
}
|
||
|
||
const options = resolveExportChromeInstallOptions();
|
||
if (!options) {
|
||
throw new Error(`Unsupported platform for Chromium export runtime: ${process.platform}-${process.arch}`);
|
||
}
|
||
|
||
await fs.promises.mkdir(options.cacheDir, { recursive: true });
|
||
|
||
onProgress?.({
|
||
phase: "downloading",
|
||
percent: 0,
|
||
message: `Downloading Chromium ${options.buildId}…`,
|
||
});
|
||
|
||
let lastLoggedPercent = -1;
|
||
await install({
|
||
...options,
|
||
downloadProgressCallback(downloadedBytes, totalBytes) {
|
||
if (totalBytes <= 0) {
|
||
return;
|
||
}
|
||
const percent = Math.min(99, Math.floor((downloadedBytes / totalBytes) * 100));
|
||
if (percent === lastLoggedPercent) {
|
||
return;
|
||
}
|
||
lastLoggedPercent = percent;
|
||
onProgress?.({
|
||
phase: "downloading",
|
||
percent,
|
||
message: `${formatMegabytes(downloadedBytes)} / ${formatMegabytes(totalBytes)}`,
|
||
});
|
||
},
|
||
});
|
||
|
||
if (!isExportChromiumAvailable()) {
|
||
const expectedPath = computeExecutablePath(options);
|
||
throw new Error(
|
||
`Chromium download finished but chrome executable was not found at ${expectedPath}. Check your network connection and try again.`
|
||
);
|
||
}
|
||
|
||
onProgress?.({
|
||
phase: "done",
|
||
percent: 100,
|
||
message: `Chromium ready (${resolveInstalledExportChromiumPath()})`,
|
||
});
|
||
}
|
||
|
||
export async function ensureExportChromiumReady(): Promise<boolean> {
|
||
await removeBrokenExportChromiumCaches();
|
||
if (isExportChromiumAvailable()) {
|
||
return true;
|
||
}
|
||
await installExportChromium();
|
||
return isExportChromiumAvailable();
|
||
}
|