package browsercfg import ( "context" "fmt" "net/http" "os" "os/exec" "path/filepath" "runtime" "strings" "time" ) // probeHTTPTimeout 是 CDP 存活探测的单次超时(2s)。探测问的是「有没有在听」,不是 // 「页面能不能加载」,所以给得短:连不上就是连不上,不该拖住启动路径。 const probeHTTPTimeout = 2 * time.Second // Source 标识浏览器从哪来(探测优先级见 design/28 §8 B1)。 type Source string // 来源取值。SourceNone 用于不可用(Usable=false)的结论。 const ( // SourceNone 表示没有可用来源。 SourceNone Source = "" // SourceLocalChrome 表示本机 Chrome/Chromium 可执行文件(默认有头)。 SourceLocalChrome Source = "local-chrome" // SourceCDP 表示一个已在响应 CDP 的端点(容器,或本机手动起的实例)。 SourceCDP Source = "cdp" ) // Availability 是可用性判定的结论。**不可用也是结论**:Usable=false 时 Reason 说明分类 // 原因、Detail 给出可排查的细节,上层据此如实降级(不注册工具)与记日志。 type Availability struct { // Usable 为 true 表示探测到可用来源。 Usable bool // Source 是选定的来源;不可用时为 SourceNone。 Source Source // Executable 是选定的本机可执行文件(Source=SourceLocalChrome 时有值)。 Executable string // Endpoint 是选定的 CDP 端点(Source=SourceCDP 时有值)。 Endpoint string // Headless 是本次判定采用的档位(来自配置,默认有头)。 Headless bool // Reason 是分类原因码(可用时恒为 ReasonUsable)。 Reason Reason // Detail 是可排查的补充说明(含具体路径/端点/错误),进日志与面板,**不进提示词**。 Detail string } // Prober 执行可用性探测。字段可注入,使单测无需真的装浏览器 / 起容器;零接线:除 // CheckCDP 会发一次出站 HTTP GET 外,它不建立任何长连接、不启动任何进程。 type Prober struct { // LookPath 在 PATH 上查找可执行文件(默认 exec.LookPath)。 LookPath func(string) (string, error) // Stat 检查候选绝对路径(默认 os.Stat)。 Stat func(string) (os.FileInfo, error) // Getenv 读系统环境(默认 os.Getenv),只用于定位 Windows 的安装目录。 Getenv func(string) string // GOOS 决定候选路径表(默认 runtime.GOOS)。 GOOS string // CheckCDP 探测一个 CDP 端点,返回 nil 表示可达(默认对 /json/version 发 GET)。 CheckCDP func(ctx context.Context, endpoint string) error } // NewProber 返回接好真实现的 Prober。 func NewProber() *Prober { return (&Prober{}).withDefaults() } // withDefaults 给零值字段补上真实现,使 `&Prober{}` 也安全(照 transcode.Limits 的口径: // 零值 = 全默认)。测试只覆写关心的那几个字段即可。 func (p *Prober) withDefaults() *Prober { if p.LookPath == nil { p.LookPath = exec.LookPath } if p.Stat == nil { p.Stat = os.Stat } if p.Getenv == nil { p.Getenv = os.Getenv } if p.GOOS == "" { p.GOOS = runtime.GOOS } if p.CheckCDP == nil { p.CheckCDP = checkCDP } return p } // Resolve 判定浏览器是否可用并给出分类原因。探测优先级(design/28 §8 B1): // // 1. 配置**显式**给出的来源(executable / cdpEndpoint)—— 面板是权威:连不上就如实报 // 对应原因,**不偷偷回退**到自动探测(design/28 §6 坑 5:失败要如实报); // 2. 无显式来源 ⇒ 本机 Chrome/Chromium 可执行文件; // 3. 仍无 ⇒ 已在跑的 CDP 端点(容器,或本机手动实例); // 4. 都没有 ⇒ Usable=false、Reason=no_source —— 上层据此**不注册工具**,不假装可用。 // // cfg 为 nil 表示没有配置文件(Load 返回 nil, nil),直接走 2→3→4。p 为 nil 时用真实现。 func Resolve(ctx context.Context, cfg *Config, p *Prober) Availability { if p == nil { p = &Prober{} } p = p.withDefaults() headless := cfg != nil && cfg.Headless if cfg != nil { if exe := strings.TrimSpace(cfg.Executable); exe != "" { return resolveConfiguredExecutable(p, exe, headless) } if endpoint := strings.TrimSpace(cfg.CDPEndpoint); endpoint != "" { return resolveConfiguredEndpoint(ctx, p, endpoint, headless) } } return resolveByProbe(ctx, p, headless) } // resolveConfiguredExecutable 处理配置显式给的浏览器可执行文件:不存在即 // Reason=executable_missing(不做「换一个」的猜测——面板写错了就该让人看见)。 func resolveConfiguredExecutable(p *Prober, exe string, headless bool) Availability { if !p.isExecutable(exe) { return Availability{ Headless: headless, Reason: ReasonExecutableMissing, Detail: fmt.Sprintf("配置的 executable 不存在或不可执行:%s", exe), } } return Availability{ Usable: true, Source: SourceLocalChrome, Executable: exe, Headless: headless, Reason: ReasonUsable, Detail: "配置指定的本机浏览器:" + exe, } } // resolveConfiguredEndpoint 处理配置显式给的 CDP 端点:连不上即 Reason=cdp_unreachable, // 绝不悄悄改用本机浏览器(否则「我配的容器没生效」会变成静默的语义漂移)。 func resolveConfiguredEndpoint(ctx context.Context, p *Prober, endpoint string, headless bool) Availability { if err := p.CheckCDP(ctx, endpoint); err != nil { return Availability{ Headless: headless, Reason: ReasonCDPUnreachable, Detail: fmt.Sprintf("配置的 cdpEndpoint 不可达:%s(%v)", endpoint, err), } } return Availability{ Usable: true, Source: SourceCDP, Endpoint: endpoint, Headless: headless, Reason: ReasonUsable, Detail: "配置指定的 CDP 端点:" + endpoint, } } // resolveByProbe 按优先级自动探测:本机可执行文件 → 已在跑的 CDP 端点 → 都没有。 func resolveByProbe(ctx context.Context, p *Prober, headless bool) Availability { if exe, ok := p.FindLocalBrowser(); ok { return Availability{ Usable: true, Source: SourceLocalChrome, Executable: exe, Headless: headless, Reason: ReasonUsable, Detail: "自动探测到的本机浏览器:" + exe, } } if err := p.CheckCDP(ctx, DefaultCDPEndpoint); err == nil { return Availability{ Usable: true, Source: SourceCDP, Endpoint: DefaultCDPEndpoint, Headless: headless, Reason: ReasonUsable, Detail: "自动探测到在跑的 CDP 端点:" + DefaultCDPEndpoint, } } return Availability{ Headless: headless, Reason: ReasonNoSource, Detail: fmt.Sprintf("本机未发现 Chrome/Chromium,%s 也没有在跑的 CDP 端点", DefaultCDPEndpoint), } } // FindLocalBrowser 按「PATH 上的常见命令名 → 各平台固定安装位置」查找本机 Chrome/Chromium, // 返回第一个可执行文件。找不到返回 ("", false)。 func (p *Prober) FindLocalBrowser() (string, bool) { for _, name := range lookPathNames(p.GOOS) { if path, err := p.LookPath(name); err == nil && strings.TrimSpace(path) != "" { return path, true } } for _, path := range absoluteCandidates(p.GOOS, p.Getenv) { if p.isExecutable(path) { return path, true } } return "", false } // isExecutable 判定 path 是否是一个可执行文件:存在、不是目录,且在非 Windows 平台上有 // 执行位(Windows 不看执行位)。 func (p *Prober) isExecutable(path string) bool { info, err := p.Stat(path) if err != nil || info == nil || info.IsDir() { return false } if p.GOOS == "windows" { return true } return info.Mode().Perm()&0o111 != 0 } // checkCDP 对 `/json/version` 发一次出站 GET:CDP 端点活着就会回版本 JSON。 // 它是**出站**请求,只连调用方给的地址(默认 127.0.0.1),不新开任何监听端口(AGENTS §9)。 func checkCDP(ctx context.Context, endpoint string) error { base := strings.TrimRight(strings.TrimSpace(endpoint), "/") req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/json/version", nil) if err != nil { return fmt.Errorf("browsercfg: 构造探测请求: %w", err) } client := &http.Client{Timeout: probeHTTPTimeout} resp, err := client.Do(req) if err != nil { return fmt.Errorf("browsercfg: 探测 %s: %w", endpoint, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("browsercfg: 探测 %s: HTTP %d", endpoint, resp.StatusCode) } return nil } // lookPathNames 是各平台 PATH 上常见的浏览器命令名。 func lookPathNames(goos string) []string { switch goos { case "windows": return []string{"chrome.exe", "chrome", "chromium.exe", "chromium"} case "darwin": return []string{"google-chrome", "chromium", "chrome"} default: return []string{"google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome"} } } // absoluteCandidates 是各平台**不在 PATH 上**的固定安装位置。 func absoluteCandidates(goos string, getenv func(string) string) []string { switch goos { case "windows": var out []string for _, key := range []string{"ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"} { if base := strings.TrimSpace(getenv(key)); base != "" { out = append(out, filepath.Join(base, "Google", "Chrome", "Application", "chrome.exe"), filepath.Join(base, "Chromium", "Application", "chrome.exe"), ) } } return out case "darwin": return []string{ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium", } default: return []string{ "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser", "/snap/bin/chromium", "/usr/bin/chrome", } } }