diff options
Diffstat (limited to 'src/profile/browser-process.ts')
| -rw-r--r-- | src/profile/browser-process.ts | 371 |
1 files changed, 371 insertions, 0 deletions
diff --git a/src/profile/browser-process.ts b/src/profile/browser-process.ts new file mode 100644 index 0000000..c0f6178 --- /dev/null +++ b/src/profile/browser-process.ts @@ -0,0 +1,371 @@ +/** + * Identifying the browser process the harness is attached to. + * + * Two questions, deliberately kept apart: + * + * 1. Is a Chromium-family browser RUNNING? (`detectRunningBrowser().running`) + * Only live-process evidence may answer this — an installed-but-closed + * browser must read as "not running", which is why the registry lookup + * below is never consulted here. + * + * 2. Which binary do we invoke to open a window for a chosen profile? + * (`resolveBrowserExecutable()`) Chromium's ProcessSingleton hands a second + * launch's argv to the already-running browser instead of starting another + * one — Windows via WM_COPYDATA (process_singleton_win.cc), Linux and macOS + * via the socket in the user-data-dir (process_singleton_posix.cc, which is + * compiled on macOS too). Here an installed path is a fine fallback. + * + * Whether the running browser carries an explicit `--user-data-dir` matters: + * ProcessSingleton keys on that directory, so passing a path that differs from + * the running browser's by case or normalisation would start a SECOND browser + * rather than delegating. The flag is forwarded only when the running process + * already has one. + * + * Every probe is best-effort; when nothing can be determined, profile launching + * degrades to a clear message rather than guessing. + * + * Windows note: `wmic` is deliberately unused — Microsoft removed it by default + * in Windows 11 24H2 and entirely in 25H2. PowerShell CIM is the supported + * replacement, with `tasklist` for liveness and the documented App Paths + * registry key for install location. + */ + +import { execFile } from "node:child_process"; +import { access, readFile, readlink } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { browserNameForUserDataDir } from "./paths"; + +// execFile, not exec: arguments are passed as an array, with no shell involved. +const run = promisify(execFile); +const EXEC_TIMEOUT_MS = 5_000; + +export type RunningBrowser = { + /** True when a Chromium-family browser process is live. */ + readonly running: boolean; + /** Absolute path to the running browser's binary, when determinable. */ + readonly exePath?: string; + /** Value of an explicit --user-data-dir on the running process, if any. */ + readonly explicitUserDataDir?: string; +}; + +/** Chromium-family process names, lowercase, as they appear per platform. */ +const LINUX_COMMS: ReadonlyArray<string> = [ + "chrome", "chromium", "chromium-browser", "msedge", "microsoft-edge", + "google-chrome", "brave", "brave-browser", +]; +const MAC_BROWSER_NAMES: ReadonlyArray<string> = [ + "google chrome", "chromium", "microsoft edge", "brave browser", +]; +const WIN_IMAGE_NAMES: ReadonlyArray<string> = ["chrome.exe", "msedge.exe", "brave.exe"]; + +/** + * Chromium tags every child process with `--type=<role>`; only the browser + * process omits it or carries `--type=browser`. Matching the flag (rather than + * words like "renderer" anywhere in the string) keeps a user whose install path + * contains such a word from being filtered out. + */ +const hasChildProcessType = (commandLine: string): boolean => { + const lower = commandLine.toLowerCase(); + return lower.includes("--type=") && !lower.includes("--type=browser"); +}; + +/** + * macOS names its child processes in the executable path itself ("Google Chrome + * Helper (Renderer)", crashpad, updaters), so the process NAME must be filtered + * too — the same exclusions the previous setup.ts check applied. + */ +const isMacChildProcessName = (comm: string): boolean => { + const lower = comm.toLowerCase(); + return ( + lower.includes("helper") || + lower.includes("renderer") || + lower.includes("crashpad") || + lower.includes(" gpu") || + lower.includes("updater") + ); +}; + +/** One running browser process, as much as the platform will tell us. */ +export type BrowserCandidate = { + readonly exePath?: string; + readonly explicitUserDataDir?: string; +}; + +/** Path comparison that tolerates separator and case differences per platform. */ +const samePath = (a: string, b: string): boolean => { + const norm = (p: string): string => { + const unified = p.replace(/\\/g, "/").replace(/\/+$/, ""); + return process.platform === "linux" ? unified : unified.toLowerCase(); + }; + return norm(a) === norm(b); +}; + +/** + * Rank a candidate against the user-data-dir the harness is actually connected + * to. Several Chromium browsers can run at once (Chrome plus Brave, or a second + * instance on a custom dir), and launching a profile window into the wrong one + * would open a window the harness cannot see — while disturbing a browser the + * user never pointed us at. + */ +export const rankBrowserCandidate = (candidate: BrowserCandidate, preferUserDataDir?: string): number => { + if (!preferUserDataDir) return 1; + if (candidate.explicitUserDataDir) { + // An explicit dir is decisive in both directions: exact match wins, and a + // different one means this is definitively another browser instance. + return samePath(candidate.explicitUserDataDir, preferUserDataDir) ? 4 : 0; + } + // No flag: the process uses its default dir. Prefer the one whose executable + // belongs to the same browser family as the target directory. + const family = browserNameForUserDataDir(preferUserDataDir).toLowerCase(); + const exe = (candidate.exePath ?? "").toLowerCase(); + if (!exe) return 1; + if (family === "brave" && exe.includes("brave")) return 3; + if (family === "edge" && (exe.includes("edge") || exe.includes("msedge"))) return 3; + if (family === "chromium" && exe.includes("chromium")) return 3; + if (family === "chrome" && exe.includes("chrome") && !exe.includes("chromium")) return 3; + return 1; +}; + +/** Extract `--user-data-dir=<path>` from a command line, quoted or bare. */ +export const parseUserDataDirFlag = (commandLine: string): string | undefined => { + const match = /--user-data-dir=(?:"([^"]*)"|'([^']*)'|(\S+))/.exec(commandLine); + if (!match) return undefined; + const value = match[1] ?? match[2] ?? match[3]; + return value && value.length > 0 ? value : undefined; +}; + +// ── Linux ────────────────────────────────────────────────────────────────── + +const collectLinux = async (): Promise<ReadonlyArray<BrowserCandidate>> => { + let pids: string[]; + try { + const { stdout } = await run("ps", ["-A", "-o", "pid=,comm="], { timeout: EXEC_TIMEOUT_MS }); + pids = stdout + .split("\n") + .map((line) => line.trim()) + .flatMap((line) => { + const [pid, comm] = line.split(/\s+/, 2); + if (!pid || !comm) return []; + return LINUX_COMMS.includes(comm.toLowerCase()) ? [pid] : []; + }); + } catch { + return []; + } + + const candidates: BrowserCandidate[] = []; + for (const pid of pids) { + let cmdline: string; + try { + // /proc/<pid>/cmdline is NUL-separated argv. + cmdline = (await readFile(`/proc/${pid}/cmdline`, "utf8")).replace(/\0/g, " ").trim(); + } catch { + continue; + } + if (!cmdline || hasChildProcessType(cmdline)) continue; + let exePath: string | undefined; + try { + exePath = await readlink(`/proc/${pid}/exe`); + } catch { + // /proc/<pid>/exe is unreadable for processes we don't own; argv[0] is + // the next best thing. + exePath = cmdline.split(" ")[0]; + } + const explicit = parseUserDataDirFlag(cmdline); + candidates.push({ + ...(exePath ? { exePath } : {}), + ...(explicit ? { explicitUserDataDir: explicit } : {}), + }); + } + return candidates; +}; + +// ── macOS ────────────────────────────────────────────────────────────────── + +const collectDarwin = async (): Promise<ReadonlyArray<BrowserCandidate>> => { + let lines: string[]; + try { + // BSD ps prints the full executable path for `comm`, unlike Linux. + const { stdout } = await run("ps", ["-A", "-ww", "-o", "pid=,comm="], { timeout: EXEC_TIMEOUT_MS }); + lines = stdout.split("\n").map((l) => l.trim()).filter(Boolean); + } catch { + return []; + } + + const candidates: BrowserCandidate[] = []; + for (const line of lines) { + const spaceAt = line.indexOf(" "); + if (spaceAt < 0) continue; + const pid = line.slice(0, spaceAt); + const comm = line.slice(spaceAt + 1).trim(); + const lower = comm.toLowerCase(); + if (!MAC_BROWSER_NAMES.some((name) => lower.includes(name))) continue; + if (isMacChildProcessName(comm)) continue; + + let args = ""; + try { + const { stdout } = await run("ps", ["-ww", "-o", "args=", "-p", pid], { timeout: EXEC_TIMEOUT_MS }); + args = stdout.trim(); + } catch { + // args are optional — only the --user-data-dir probe needs them + } + if (args && hasChildProcessType(args)) continue; + const explicit = args ? parseUserDataDirFlag(args) : undefined; + candidates.push({ + exePath: comm, + ...(explicit ? { explicitUserDataDir: explicit } : {}), + }); + } + return candidates; +}; + +// ── Windows ──────────────────────────────────────────────────────────────── + +const POWERSHELL_QUERY = [ + "$ErrorActionPreference='SilentlyContinue';", + "Get-CimInstance Win32_Process", + "-Filter \"Name='chrome.exe' or Name='msedge.exe' or Name='brave.exe'\"", + "| Select-Object ExecutablePath,CommandLine", + "| ConvertTo-Json -Compress", +].join(" "); + +type WinProcess = { ExecutablePath?: string | null; CommandLine?: string | null }; + +const collectWin32 = async (): Promise<ReadonlyArray<BrowserCandidate>> => { + try { + const { stdout } = await run( + "powershell.exe", + ["-NoProfile", "-NonInteractive", "-Command", POWERSHELL_QUERY], + { timeout: EXEC_TIMEOUT_MS, windowsHide: true }, + ); + const trimmed = stdout.trim(); + if (trimmed) { + const parsed: unknown = JSON.parse(trimmed); + // ConvertTo-Json emits an object for a single row, an array for many. + const rows: WinProcess[] = Array.isArray(parsed) ? (parsed as WinProcess[]) : [parsed as WinProcess]; + const candidates = rows + .filter((row) => !hasChildProcessType(row.CommandLine ?? "")) + .map((row): BrowserCandidate => { + const explicit = parseUserDataDirFlag(row.CommandLine ?? ""); + return { + ...(row.ExecutablePath ? { exePath: row.ExecutablePath } : {}), + ...(explicit ? { explicitUserDataDir: explicit } : {}), + }; + }); + if (candidates.length > 0) return candidates; + } + } catch { + // PowerShell missing or blocked by policy — fall through to tasklist. + } + + // Liveness-only fallback: tasklist still ships on every supported Windows. + // It reveals no path, so the candidate is a bare "something is running". + try { + const { stdout } = await run("tasklist.exe", [], { timeout: EXEC_TIMEOUT_MS, windowsHide: true }); + const lower = stdout.toLowerCase(); + if (WIN_IMAGE_NAMES.some((n) => lower.includes(n))) return [{}]; + } catch { + // best-effort + } + return []; +}; + +/** Installed-browser locations to try when the live process reveals no path. */ +const winInstallCandidates = (): ReadonlyArray<string> => { + const roots = [ + process.env["PROGRAMFILES"], + process.env["PROGRAMFILES(X86)"], + process.env["LOCALAPPDATA"], + ].filter((r): r is string => typeof r === "string" && r.length > 0); + const suffixes = [ + "Google\\Chrome\\Application\\chrome.exe", + "Google\\Chrome Beta\\Application\\chrome.exe", + "Google\\Chrome SxS\\Application\\chrome.exe", + "Chromium\\Application\\chrome.exe", + "BraveSoftware\\Brave-Browser\\Application\\brave.exe", + "Microsoft\\Edge\\Application\\msedge.exe", + ]; + return roots.flatMap((root) => suffixes.map((suffix) => join(root, suffix))); +}; + +/** Query the Shell's App Paths registration for a browser's install location. */ +const winRegistryExecutable = async (): Promise<string | undefined> => { + for (const image of WIN_IMAGE_NAMES) { + for (const hive of ["HKLM", "HKCU"]) { + try { + const { stdout } = await run( + "reg.exe", + ["query", `${hive}\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\${image}`, "/ve"], + { timeout: EXEC_TIMEOUT_MS, windowsHide: true }, + ); + const match = /REG_[A-Z_]+\s+(.+)$/m.exec(stdout.trim()); + const path = match?.[1]?.trim(); + if (path) return path; + } catch { + // key absent for this browser/hive — try the next + } + } + } + return undefined; +}; + +// ── Public API ───────────────────────────────────────────────────────────── + +/** + * Live-process facts about the running Chromium-family browser. `running` is + * derived only from process evidence, never from an install location. + * + * `preferUserDataDir` should be the dir the harness is connected to; with more + * than one browser running it decides which process is described. + */ +export const detectRunningBrowser = async (preferUserDataDir?: string): Promise<RunningBrowser> => { + const candidates = + process.platform === "darwin" + ? await collectDarwin() + : process.platform === "win32" + ? await collectWin32() + : await collectLinux(); + if (candidates.length === 0) return { running: false }; + + let best = candidates[0] as BrowserCandidate; + let bestRank = rankBrowserCandidate(best, preferUserDataDir); + for (const candidate of candidates.slice(1)) { + const rank = rankBrowserCandidate(candidate, preferUserDataDir); + if (rank > bestRank) { + best = candidate; + bestRank = rank; + } + } + + return { + running: true, + ...(best.exePath ? { exePath: best.exePath } : {}), + ...(best.explicitUserDataDir ? { explicitUserDataDir: best.explicitUserDataDir } : {}), + }; +}; + +/** True when a Chromium-family browser process is running. */ +export const isBrowserRunning = async (): Promise<boolean> => (await detectRunningBrowser()).running; + +/** + * The binary to invoke when opening a window for a chosen profile: the running + * browser's own executable when known, otherwise a documented install location. + * Undefined when nothing usable is found. + */ +export const resolveBrowserExecutable = async (running?: RunningBrowser): Promise<string | undefined> => { + const detected = running ?? (await detectRunningBrowser()); + if (detected.exePath) return detected.exePath; + if (process.platform !== "win32") return undefined; + + const fromRegistry = await winRegistryExecutable(); + if (fromRegistry) return fromRegistry; + for (const candidate of winInstallCandidates()) { + try { + await access(candidate); + return candidate; + } catch { + // not installed here + } + } + return undefined; +}; |
