import { spawn, type ChildProcess } from "node:child_process"; import { constants } from "node:fs"; import { access, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { delimiter, isAbsolute, join, sep } from "node:path"; import { env, platform } from "node:process"; export type ViewerWindowMode = "app" | "browser" | "none"; export interface ViewerWindow { mode: ViewerWindowMode; /** Best-effort liveness; browser mode cannot track an external browser tab. */ isAlive: () => boolean; close: () => Promise; } /** * Chromium-family browsers support a chromeless "app mode" window * (`--app=`). Combined with an isolated `--user-data-dir`, this gives the * artifact viewer its own clean, minimal GUI window instead of a tab in the * user's default browser — without any native dependency or build step. * * Falls back to the default browser, then to nothing, when no Chromium-family * browser is available. * * Mode precedence: `PI_ARTIFACTS_VIEWER` env override > the `preferred` * argument (a persisted user setting) > the built-in default (`app`). The * binary used for app mode can be overridden with `PI_ARTIFACTS_BROWSER`. */ export async function openViewerWindow( url: string, preferred?: ViewerWindowMode, ): Promise { const mode = resolveViewerMode(preferred); if (mode === "none") { return { mode: "none", isAlive: () => false, close: async () => {} }; } if (mode === "browser") { return openDefaultBrowser(url); } const browser = await resolveBrowser(); if (browser) { const appWindow = await launchAppWindow(browser, url); if (appWindow) { return appWindow; } } return openDefaultBrowser(url); } function resolveViewerMode(preferred?: ViewerWindowMode): ViewerWindowMode { const fromEnv = env.PI_ARTIFACTS_VIEWER; if (fromEnv === "browser" || fromEnv === "app" || fromEnv === "none") { return fromEnv; } return preferred ?? "app"; } export function buildAppWindowArgs(url: string, profileDir: string): string[] { return [ `--app=${url}`, `--user-data-dir=${profileDir}`, "--no-first-run", "--no-default-browser-check", "--new-window", ]; } const CHROMIUM_CANDIDATES: Record = { darwin: [ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", ], win32: [ join( env.PROGRAMFILES ?? "C:\\Program Files", "Google\\Chrome\\Application\\chrome.exe", ), join( env["PROGRAMFILES(X86)"] ?? "C:\\Program Files (x86)", "Google\\Chrome\\Application\\chrome.exe", ), join( env["PROGRAMFILES(X86)"] ?? "C:\\Program Files (x86)", "Microsoft\\Edge\\Application\\msedge.exe", ), ], linux: [ "google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge", "brave-browser", ], }; async function resolveBrowser(): Promise { const override = env.PI_ARTIFACTS_BROWSER; if (override) { return (await resolveCandidate(override)) ?? undefined; } const candidates = CHROMIUM_CANDIDATES[platform] ?? CHROMIUM_CANDIDATES.linux; for (const candidate of candidates ?? []) { const resolved = await resolveCandidate(candidate); if (resolved) { return resolved; } } return undefined; } async function resolveCandidate( candidate: string, ): Promise { if ( isAbsolute(candidate) || candidate.includes(sep) || candidate.includes("/") ) { return (await isExecutable(candidate)) ? candidate : undefined; } const dirs = (env.PATH ?? "").split(delimiter); for (const dir of dirs) { if (!dir) { continue; } const full = join(dir, candidate); if (await isExecutable(full)) { return full; } } return undefined; } async function isExecutable(path: string): Promise { try { await access(path, constants.X_OK); return true; } catch { return false; } } async function launchAppWindow( browser: string, url: string, ): Promise { let profileDir: string | undefined; try { profileDir = await mkdtemp(join(tmpdir(), "pi-artifacts-viewer-")); const child = spawn(browser, buildAppWindowArgs(url, profileDir), { stdio: "ignore", detached: false, }); // `spawn()` can fail asynchronously. Do not report app mode until the // process has actually started. await waitForSpawn(child); let exited = child.exitCode !== null; const dir = profileDir; let cleanupPromise: Promise | undefined; const cleanup = () => { cleanupPromise ??= rm(dir, { recursive: true, force: true }).catch( () => {}, ); return cleanupPromise; }; child.on("error", () => {}); child.on("exit", () => { exited = true; void cleanup(); }); if (exited) { void cleanup(); } return { mode: "app", isAlive: () => !exited && child.exitCode === null, close: async () => { try { if (!exited) { child.kill(); await waitForExit(child, 1_000); } if (!exited) { child.kill("SIGKILL"); await waitForExit(child, 500); } } catch { // best effort } await cleanup(); }, }; } catch { if (profileDir) { await rm(profileDir, { recursive: true, force: true }).catch(() => {}); } return undefined; } } async function waitForExit( child: ChildProcess, timeoutMs: number, ): Promise { if (child.exitCode !== null) { return; } await new Promise((resolve) => { const done = () => { clearTimeout(timer); child.off("exit", done); resolve(); }; const timer = setTimeout(done, timeoutMs); child.once("exit", done); }); } async function waitForSpawn(child: ChildProcess): Promise { await new Promise((resolve, reject) => { const spawned = () => { child.off("error", failed); resolve(); }; const failed = (error: Error) => { child.off("spawn", spawned); reject(error); }; child.once("spawn", spawned); child.once("error", failed); }); } async function openDefaultBrowser(url: string): Promise { const command = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open"; const args = platform === "win32" ? ["/c", "start", "", url] : [url]; try { const child = spawn(command, args, { stdio: "ignore", detached: true }); await waitForSpawn(child); child.unref(); return { mode: "browser", isAlive: () => true, close: async () => {}, }; } catch { return { mode: "none", isAlive: () => false, close: async () => {} }; } }