/** * Browser discovery and selection (Chrome and Edge only). * * Product boundary, frozen by the owner: BRB supports exactly two browsers, * Google Chrome and Microsoft Edge. Everything else -- Firefox, Safari, Brave, * Vivaldi, Opera, Arc, arbitrary Chromium derivatives, Electron apps -- is * deliberately not supported, and this module does not scan for, suggest, or * fall back to any of them. * * Two rules shape the logic: * * - Automatic selection is Chrome first, Edge second, and it happens without * asking the user anything. * - A resolved selection is sticky. Once a machine resolved to Edge, a Chrome * installed later must NOT silently take over: the Edge profile holds the * ChatGPT login, so switching would look like a surprise logout. Sticky * resolution survives until the chosen executable is gone. * * The module is pure: filesystem access is injected (`exists`), so every rule * below is testable without a browser. */ import { existsSync } from "node:fs"; import { basename } from "node:path"; import { POSIX_CANDIDATES, WINDOWS_CANDIDATES, defaultProfileDir } from "./launcher.ts"; /** The only browsers BRB supports. */ export type BrowserKind = "chrome" | "edge"; /** The configured choice: a pinned kind, or `auto`. */ export type BrowserChoiceLike = "auto" | BrowserKind; /** * Capability of the automation browser on this machine. * * `BLOCKED` exists because "installed" and "usable" are different facts: an * Edge whose remote debugging is disabled by enterprise policy is installed and * supported, yet cannot be driven. Reporting it as NOT_FOUND would be a wrong * attribution, which is the failure mode this project treats as most expensive. */ export type BrowserCapability = "USABLE" | "NOT_FOUND" | "UNSUPPORTED" | "BLOCKED"; export type BrowserSource = "explicit" | "configured" | "resolved" | "known-path"; export interface BrowserCandidate { kind: BrowserKind; executablePath: string; source: BrowserSource; } export interface BrowserDiscovery { capability: BrowserCapability; candidate?: BrowserCandidate; /** Set when the capability is UNSUPPORTED, so the caller can explain why. */ rejected?: { executablePath: string; family: string; reason: string }; /** True when the user pinned a browser or an explicit executable path. */ pinned: boolean; notes: string[]; } export interface DiscoveryInput { browser?: BrowserChoiceLike; browserExecutable?: string; resolvedBrowser?: BrowserKind; resolvedExecutable?: string; /** Override the per-platform known installation paths (used by tests). */ knownPaths?: Partial>; } export interface DiscoveryDeps { exists?: (path: string) => boolean; } /** Automatic priority: Chrome first, then Edge. */ export const AUTO_PRIORITY: BrowserKind[] = ["chrome", "edge"]; interface FamilyPattern { re: RegExp; kind?: BrowserKind; family: string; } /** * Executable-name families. Order matters: the supported two are matched first, * then the browsers we explicitly refuse to guess about. */ const FAMILIES: FamilyPattern[] = [ { re: /^chrome(\.exe)?$/i, kind: "chrome", family: "chrome" }, { re: /^google chrome$/i, kind: "chrome", family: "chrome" }, { re: /^msedge(\.exe)?$/i, kind: "edge", family: "edge" }, { re: /^microsoft edge$/i, kind: "edge", family: "edge" }, { re: /^firefox(\.exe)?$/i, family: "firefox" }, { re: /^safari$/i, family: "safari" }, { re: /^brave(\.exe)?$/i, family: "brave" }, { re: /^vivaldi(\.exe)?$/i, family: "vivaldi" }, { re: /^(opera|launcher)(\.exe)?$/i, family: "opera" }, { re: /^arc(\.exe)?$/i, family: "arc" }, { re: /^chromium(\.exe)?$/i, family: "chromium" }, { re: /^msedgewebview2(\.exe)?$/i, family: "edge-webview" }, { re: /^(thorium|yandex|librewolf|waterfox|browser)(\.exe)?$/i, family: "other-chromium" }, ]; export interface ClassifiedExecutable { tier: "supported" | "unsupported"; kind?: BrowserKind; family: string; } /** * Classify one executable path by its file name. * * A non-standard *installation directory* is fine -- what matters is the * executable name, which is how a Chrome installed under a custom path is still * recognised as Chrome. */ export function classifyExecutable(executablePath: string): ClassifiedExecutable { const name = basename(executablePath.trim()).trim(); for (const pattern of FAMILIES) { if (pattern.re.test(name)) { return pattern.kind ? { tier: "supported", kind: pattern.kind, family: pattern.family } : { tier: "unsupported", family: pattern.family }; } } return { tier: "unsupported", family: "unknown" }; } /** Known installation paths for a kind on this platform. */ export function knownPathsFor(kind: BrowserKind, override?: Partial>): string[] { if (override?.[kind]) return override[kind]!; const table = process.platform === "win32" ? WINDOWS_CANDIDATES : POSIX_CANDIDATES; return table[kind]; } function unsupportedNote(family: string): string { return `BRB 只支持 Google Chrome 与 Microsoft Edge;检测到 ${family},不做兼容、不扫描、不回退。`; } /** * Decide which automation browser this machine should use. * * Precedence: explicit executable > pinned kind > sticky resolution > auto * (Chrome, then Edge). A pinned choice never falls back to the other browser. */ export function discoverBrowser(input: DiscoveryInput = {}, deps: DiscoveryDeps = {}): BrowserDiscovery { const exists = deps.exists ?? existsSync; const notes: string[] = []; const explicit = input.browserExecutable?.trim(); if (explicit) { if (!exists(explicit)) { notes.push(`显式指定的浏览器可执行文件不存在:${explicit}`); return { capability: "NOT_FOUND", pinned: true, notes }; } const classified = classifyExecutable(explicit); if (classified.tier === "unsupported") { notes.push(unsupportedNote(classified.family)); return { capability: "UNSUPPORTED", rejected: { executablePath: explicit, family: classified.family, reason: unsupportedNote(classified.family), }, pinned: true, notes, }; } notes.push("使用用户显式指定的浏览器。"); return { capability: "USABLE", candidate: { kind: classified.kind!, executablePath: explicit, source: "explicit" }, pinned: true, notes, }; } const choice: BrowserChoiceLike = input.browser ?? "auto"; if (choice !== "auto") { const candidate = knownPathsFor(choice, input.knownPaths).find((path) => exists(path)); if (!candidate) { notes.push(`已显式指定 ${choice},但未在其标准安装路径找到;不自动回退到另一个浏览器。`); return { capability: "NOT_FOUND", pinned: true, notes }; } notes.push(`使用已显式指定的浏览器:${choice}。`); return { capability: "USABLE", candidate: { kind: choice, executablePath: candidate, source: "configured" }, pinned: true, notes, }; } const stickyKind = input.resolvedBrowser; const stickyExecutable = input.resolvedExecutable?.trim(); if (stickyKind && stickyExecutable && exists(stickyExecutable)) { notes.push(`沿用此前已解析的浏览器:${stickyKind}(不因后装的另一个浏览器静默切换)。`); return { capability: "USABLE", candidate: { kind: stickyKind, executablePath: stickyExecutable, source: "resolved" }, pinned: false, notes, }; } for (const kind of AUTO_PRIORITY) { const candidate = knownPathsFor(kind, input.knownPaths).find((path) => exists(path)); if (!candidate) continue; notes.push(kind === "chrome" ? "自动选择 Chrome(优先级最高)。" : "未找到 Chrome,自动回退 Edge。"); return { capability: "USABLE", candidate: { kind, executablePath: candidate, source: "known-path" }, pinned: false, notes, }; } notes.push("未找到 Chrome 或 Edge。BRB 只支持这两个浏览器,系统默认浏览器不会自动替代。"); return { capability: "NOT_FOUND", pinned: false, notes }; } export interface ResolvedSelection { resolvedBrowser?: BrowserKind; resolvedExecutable?: string; } /** * The selection to persist after a discovery run. * * Only a USABLE candidate is persisted; a machine with no usable browser keeps * whatever it had, so a temporarily missing browser does not erase the sticky * choice. */ export function resolveSelection(previous: ResolvedSelection, discovery: BrowserDiscovery): ResolvedSelection { if (discovery.capability !== "USABLE" || !discovery.candidate) return previous; return { resolvedBrowser: discovery.candidate.kind, resolvedExecutable: discovery.candidate.executablePath }; } /** * The profile directory to actually launch with. * * Chrome keeps `.pi-chrome-profile` exactly where it always was, so an existing * ChatGPT login survives untouched; Edge gets its own directory. A user who set * a custom `profileDir` keeps it for both browsers. */ export function effectiveProfileDir(configured: string, kind: BrowserKind): string { const chromeDefault = defaultProfileDir("chrome"); const edgeDefault = defaultProfileDir("edge"); if (configured === chromeDefault || configured === edgeDefault) { return kind === "edge" ? edgeDefault : chromeDefault; } return configured; }