真机复现(我做的):同 profile 再起一次 chrome.exe ⇒ Chrome **把命令委托给已有 实例并 exit 0**,stderr 逐字「正在现有的浏览器会话中打开。」⇒ **不产生新的 DevToolsActivePort** ⇒ 我们的等待只能超时 ⇒ 浏览器降级档整体不可用。 修法:启动前加一跳**探测**,三档各自出路 —— - 端口记录缺席 / 端口没人听 ⇒ 正常启动(后者先把**陈记录**清掉:那是启动时写下 的记录、不是锁,删一条指向死端口的记录不会伤到任何活实例); - 端口在听 + `/json/version` 有合法响应 ⇒ **复用它**(INFO `browser debug endpoint reused`),**不再起第二个**; - 端口在听但握不上手 ⇒ **如实报错**(保留现场,既不起进程也不删记录); - 真被占用且拿不到端点 ⇒ 报错带 **Chrome 原话**(`withChromeOutput`)+ 可操作处置。 🔴 红线(写进错误文案与注释):**绝不杀别人的浏览器**、不发信号抢 profile、 不新开监听端口;`ErrLaunch` / `errors.Is` 口径未破。 验收(主会话复跑 + 亲自变异):browser 包与 plugins/tools/io 两包 build/vet/test 全绿;我用 -overlay 摘掉「复用」那一支 ⇒ TestLocalChromeAdoptsLiveEndpoint 与 TestDial_ReusesLiveEndpointAndDoesNotSpawnSecondProcess 变红。子代理另跑了真机 端到端(临时 profile):会话 1 拿端口 51732 → 裸起第二次 chrome(exit 0)→ 会话 2 **复用同一端口、未起第二个实例**。 ⚠️ 已知未修(如实):Chrome 那句委托提示是**控制台代码页(GBK)字节**,我们的 UTF-8 日志里会显示成乱码(字节保真、人看不了);彻底解决要引编码转换,另定。 ⚠️ 判据③的取舍:陈记录 +端口恰被别的程序复用时,会持续报错直到人工删 `DevToolsActivePort`(错误文案已写明处置);**未加"报错后自动清理重试"**。
379 lines
19 KiB
Go
379 lines
19 KiB
Go
package browser
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
|
||
)
|
||
|
||
// 启动参数与就绪等待的具名常量(AGENTS §10:不出现裸魔法数字)。
|
||
const (
|
||
// devToolsPortFile 是 Chromium 在 `--remote-debugging-port=0` 时写回端口号的
|
||
// 文件名(profile 目录下):首行端口、次行 browser_guid
|
||
// (reference/browser-stealth/10 §2.4,依据 content/browser/devtools/devtools_http_handler.cc)。
|
||
devToolsPortFile = "DevToolsActivePort"
|
||
|
||
// portPollInterval 是「等调试端口就绪」的轮询间隔:冷启动通常 0.3~1.5s,
|
||
// 100ms 的粒度既不迟钝也不白烧 CPU。
|
||
portPollInterval = 100 * time.Millisecond
|
||
|
||
// defaultLang 是启动时声明的 UI 语言。口径:与「本机 / 出口 IP 所在地」对齐
|
||
// (design/28 §2 指纹一致性);写死 zh-CN 是本部署的实情,配置面(browser.json)
|
||
// 尚未开放语言项 —— 见本片报告「未做」。
|
||
defaultLang = "zh-CN"
|
||
)
|
||
|
||
// adoptProbeTimeout 是「要不要复用已在跑的实例」这一次探测的超时(也是远端端点探活
|
||
// 用的那一档):它先问「端口有没有在听」,再问 `/json/version` 答不答话,两问都给得短 ——
|
||
// 这是启动前的判定,不该拖住启动路径。
|
||
const adoptProbeTimeout = 2 * time.Second
|
||
|
||
// 启动失败的**分类标记**:几种情形必须在错误消息里一眼可分(design/28 §7 判据 4
|
||
// 「如实标注」/ §6 坑 5「失败要如实报」)——
|
||
//
|
||
// ① 浏览器进程自己退了(参数被拒 / 委托给已跑实例 / profile 锁被占);
|
||
// ② 我们自己的等待窗口用完(DevToolsActivePort 没出现);
|
||
// ③ **调用方的预算先没了**:浏览器没坏,只是没时间了;
|
||
// ④ 上一轮记的调试端口**在听、但不是可用的 CDP 端点**(握手失败)—— 它既不是「进程退出」
|
||
// 也不是「端口没出现」,必须单独说,因为它有**自己的**处置方式(见 endpointBrokenHint)。
|
||
//
|
||
// 哨兵仍然只有 ErrLaunch 一个(`errors.Is` 口径不变),这些标记是消息里可机读的那一段。
|
||
const (
|
||
// launchMarkerProcessExited 标记 ①。
|
||
launchMarkerProcessExited = "浏览器进程立刻退出"
|
||
// launchMarkerPortTimeout 标记 ②。
|
||
launchMarkerPortTimeout = "等 DevToolsActivePort 超时"
|
||
// launchMarkerBudget 标记 ③。
|
||
launchMarkerBudget = "调用方预算耗尽"
|
||
// launchMarkerEndpointBroken 标记 ④:端口在听却握不上手。它与「陈文件」(端口没人听)
|
||
// 是**两回事**:陈文件可以安全清掉后照常启动,这里只能如实报 —— 静默忽略它去另起一个
|
||
// 进程,换来的只是「委托给已跑实例后立刻退出」,把原因糊掉。
|
||
launchMarkerEndpointBroken = "调试端口在听但不是可用的 CDP 端点"
|
||
)
|
||
|
||
const (
|
||
// chromeOutputDrainWait 是「进程退出/被杀之后,把它已经写进管道的输出收干净」的
|
||
// 等待上限:诊断不该反过来拖住失败路径。
|
||
chromeOutputDrainWait = 500 * time.Millisecond
|
||
// launchDeadlineSlack 容忍「调用方的截止时间与我们这一次等待的截止时间几乎同时」
|
||
// 这种不可分情形:差值在它之内时按**我们自己等端口超时**报(此刻的事实正是这一次
|
||
// 等待用满了配置的 timeout,真机常态如此:外层 openSession 与本次等待同源同值)。
|
||
launchDeadlineSlack = 250 * time.Millisecond
|
||
)
|
||
|
||
// dialer 产出一个可用的 CDP HTTP 端点(`http://host:port`)与停止函数。它把
|
||
// 「启动本机浏览器」与「连接已跑端点」两条路统一成同一个会话装配入口。
|
||
type dialer interface {
|
||
// Dial 返回端点;stop 释放本次会话占用的资源(本机路径 = 杀掉进程,容器路径 = 空操作)。
|
||
Dial(ctx context.Context) (endpoint string, stop func(), err error)
|
||
// Describe 返回一句人类可读的来源描述(进日志与工具结果,不进提示词模板)。
|
||
Describe() string
|
||
// Local 报告这个端点是不是**我们自己的本机浏览器**:只有它为 true 时,会话收尾
|
||
// 才允许关闭浏览器。容器/手动实例(remoteCDP)是别人的,绝不代关。
|
||
Local() bool
|
||
}
|
||
|
||
// localChrome 是主路径:在**宿主真桌面**上起一个真 Chromium(默认有头)。
|
||
//
|
||
// 启动参数按 `DevDocs/reference/browser-stealth/10 §2.1` 的白/黑名单落地:
|
||
// - **要传**:`--user-data-dir`、`--window-size`、`--lang`、`--no-first-run`、
|
||
// `--no-default-browser-check`、`--disable-blink-features=AutomationControlled`、
|
||
// `--remote-debugging-port=0`;
|
||
// - **不传**:`--enable-automation`(会点亮「受自动化控制」,且 Puppeteer 默认就传它)、
|
||
// `--disable-extensions` / `--disable-default-apps` /
|
||
// `--disable-component-extensions-with-background-pages` / `--disable-component-update` /
|
||
// `--disable-popup-blocking`(Playwright/Puppeteer 的默认参数,真浏览器不会那样)、
|
||
// 以及一切 `--headless*`(有头就是不能有它)。
|
||
//
|
||
// 未传 `--remote-debugging-pipe`(reference/10 §2.4 的首选)是**平台限制**而不是取舍:
|
||
// 它要求把 CDP 通道给到子进程的 fd 3/4,而 Go 的 `exec.Cmd.ExtraFiles` **在 Windows
|
||
// 上不支持**。因此 Windows 主路径用次选 `--remote-debugging-port=0`(内核分配随机
|
||
// 回环端口,从 profile 目录的 DevToolsActivePort 读回):**仍是我们出站连它**,
|
||
// 不开任何对外监听端口(AGENTS §9)。
|
||
type localChrome struct {
|
||
exe string
|
||
profileDir string
|
||
headless bool
|
||
viewportW int
|
||
viewportH int
|
||
timeout time.Duration
|
||
log *slog.Logger
|
||
}
|
||
|
||
// Describe 实现 dialer。
|
||
func (l *localChrome) Describe() string {
|
||
mode := "headful"
|
||
if l.headless {
|
||
mode = "headless"
|
||
}
|
||
return fmt.Sprintf("本机浏览器 %s(%s)", l.exe, mode)
|
||
}
|
||
|
||
// Local 实现 dialer:本机浏览器归我们管,会话收尾可以(也应该)关掉它。
|
||
func (l *localChrome) Local() bool { return true }
|
||
|
||
// Dial 拉起浏览器并等调试端口就绪。任何一步失败都返回带分类原因的 ErrLaunch。
|
||
//
|
||
// 四条**先于启动**的判定(前三条都是为了让「上一次会话留下的浏览器」不至于把功能卡死):
|
||
//
|
||
// 1. **预算已耗尽就不起进程**:ctx 已经结束(这一轮的工具预算用完 / 会话被取消)时
|
||
// 立刻返回「预算耗尽」分类,**不 spawn** —— 起了也只是生一个会被马上杀掉的窗口,
|
||
// 并把「没时间了」伪装成「浏览器起不来」(真机踩过:窗口上跳出浏览器的抱怨,日志
|
||
// 里却像浏览器坏了)。判定做在**启动之前**,因为它要省掉的正是那次启动。
|
||
// 2. **能复用就复用**(`probeExistingEndpoint`):profile 目录的 DevToolsActivePort 还在、
|
||
// 端口**确实在听**、`/json/version` 也**真有合法响应** ⇒ 直接出站连它,**不再起第二个**。
|
||
// 这不是优化,是**正确性**:同一个 user-data-dir 上起第二个实例时 Chromium 会把手上的
|
||
// 命令**委托给已在跑的那个**然后自己退出(真机实测:新进程 exit 0、不写新的
|
||
// DevToolsActivePort,stderr 只有一句「正在现有的浏览器会话中打开。」),于是新进程永远
|
||
// 不写端口 ⇒ 不复用的话,这一档会一直报启动失败,直到人工杀掉那个进程。复用还正好贴合
|
||
// design/28 §2「profile 长期保留、任务间复用」。
|
||
// 3. **端口在听、却握不上手 ⇒ 如实报错**(`launchMarkerEndpointBroken`):绝不「忽略掉它、
|
||
// 自己起一个」—— 占着这个端口的多半就是同一个 profile 的浏览器,起进程只会让新进程委托
|
||
// 出去后立刻退出,把一句本就查不明白的失败再糊一层。
|
||
// 4. **陈的端口记录先清掉**(`clearStalePortFile`):只在真的要走启动路径时才清;不清的话,
|
||
// 上一轮的端口会被当成本次结果(理由见那个方法的注释)。
|
||
//
|
||
// 子进程的 stdout/stderr **接进缓冲**(`chromeOutput`):启动失败时把浏览器自己的话与本次
|
||
// argv 一起报进**日志**,并把浏览器原话附进**返回的错误**(`withChromeOutput`)—— 用户侧看到的
|
||
// 工具结果就是从这条错误来的。成功时只报字节数(Debug),不把 Chrome 的常规输出刷进日志。
|
||
//
|
||
// 停止函数只对**本次启动**的进程有效(复用来的实例不归我们杀):进程退出后 Chrome 的
|
||
// 渲染进程随之退出,因此进程级 Kill 足够。
|
||
func (l *localChrome) Dial(ctx context.Context) (string, func(), error) {
|
||
if strings.TrimSpace(l.exe) == "" {
|
||
return "", nil, fmt.Errorf("%w: 未指定可执行文件", ErrLaunch)
|
||
}
|
||
if err := ctx.Err(); err != nil {
|
||
return "", nil, budgetLaunchErr(ctx)
|
||
}
|
||
endpoint, err := l.probeExistingEndpoint(ctx)
|
||
if err != nil {
|
||
// 复用不了、又不许起第二个:如实报(错误里已带处置建议),一条 WARN 留现场。
|
||
l.log.Warn("browser launch blocked by unusable debug endpoint",
|
||
"profile", l.profileDir, "error", err)
|
||
return "", nil, err
|
||
}
|
||
if endpoint != "" {
|
||
l.log.Info("browser debug endpoint reused",
|
||
"endpoint", endpoint,
|
||
"profile", l.profileDir,
|
||
"detail", "复用已存在的调试端点(同 profile 的浏览器仍在跑,不再起第二个)")
|
||
return endpoint, func() {}, nil
|
||
}
|
||
if err := os.MkdirAll(l.profileDir, 0o700); err != nil {
|
||
return "", nil, fmt.Errorf("%w: 建 profile 目录 %s: %v", ErrLaunch, l.profileDir, err)
|
||
}
|
||
if err := l.clearStalePortFile(); err != nil {
|
||
return "", nil, err
|
||
}
|
||
|
||
args := l.launchArgs()
|
||
cmd := exec.Command(l.exe, args...)
|
||
out := &chromeOutput{}
|
||
cmd.Stdout = out
|
||
cmd.Stderr = out
|
||
if err := cmd.Start(); err != nil {
|
||
return "", nil, fmt.Errorf("%w: 启动 %s: %v", ErrLaunch, l.exe, err)
|
||
}
|
||
exited := make(chan struct{})
|
||
go func() {
|
||
defer close(exited)
|
||
// 用 cmd.Wait(而不是 cmd.Process.Wait):它会等两个输出复制协程收工,
|
||
// 于是 exited 关闭时缓冲里就是子进程说过的**全部**话(下面要在失败时上报它)。
|
||
_ = cmd.Wait()
|
||
}()
|
||
port, err := l.waitDevToolsPort(ctx, exited)
|
||
if err != nil {
|
||
stopProcess(cmd)
|
||
l.waitExited(exited)
|
||
l.log.Warn("browser launch failed",
|
||
"exe", l.exe,
|
||
"argv", strings.Join(args, " "),
|
||
"chrome_output", out.preview(),
|
||
"chrome_output_bytes", out.totalBytes(),
|
||
"error", err)
|
||
return "", nil, withChromeOutput(err, out.preview())
|
||
}
|
||
l.log.Debug("browser launched",
|
||
"exe", l.exe, "port", port, "profile", l.profileDir, "chrome_output_bytes", out.totalBytes())
|
||
return fmt.Sprintf("http://127.0.0.1:%d", port), func() { stopProcess(cmd) }, nil
|
||
}
|
||
|
||
// waitExited 等子进程真正收尾(管道里的在途字节落进缓冲)再上报:Kill 之后 Wait 还要
|
||
// 一点点时间,不等就会漏掉最后几行 —— 而最后几行往往正是原因。等不到就照现有内容报,
|
||
// 不把失败路径拖住。
|
||
func (l *localChrome) waitExited(exited <-chan struct{}) {
|
||
timer := time.NewTimer(chromeOutputDrainWait)
|
||
defer timer.Stop()
|
||
select {
|
||
case <-exited:
|
||
case <-timer.C:
|
||
}
|
||
}
|
||
|
||
// budgetLaunchErr 是 ③「调用方预算耗尽」的**唯一文案**。它必须读起来像「没时间了」,
|
||
// 而不是像「浏览器坏了」:取消与截止时间到分别点明(两者都是调用方自己的事)。
|
||
func budgetLaunchErr(ctx context.Context) error {
|
||
detail := "上下文截止时间到(这一轮的工具预算已耗尽)"
|
||
if errors.Is(ctx.Err(), context.Canceled) {
|
||
detail = "上下文被取消(会话/这一轮已结束)"
|
||
}
|
||
return fmt.Errorf("%w: %s:%s,浏览器未启动完成", ErrLaunch, launchMarkerBudget, detail)
|
||
}
|
||
|
||
// 启动前的三条判定与它们的处置文案在 adopt.go:复用活端点(probeExistingEndpoint)、
|
||
// 清掉陈的端口记录(clearStalePortFile)、端点不可用时的如实上报(withChromeOutput 与两条 hint)。
|
||
|
||
// launchArgs 组装启动参数(**顺序稳定、可断言**:单元测试钉住白/黑名单)。
|
||
func (l *localChrome) launchArgs() []string {
|
||
args := []string{
|
||
// profile 必传:内含 cookie / 登录态,且是「并发上限 1」的锁载体(design/28 §3)。
|
||
"--user-data-dir=" + l.profileDir,
|
||
// 内核分配随机回环端口;端口从 DevToolsActivePort 读回(见本类型文档)。
|
||
"--remote-debugging-port=0",
|
||
// 非浏览器 WS 客户端在部分 Chrome 版本上会被 DevTools 的 Origin 校验拒绝;
|
||
// reference/07 §4 的命令行里同样有它。它只放宽「谁能连这个回环调试端点」,
|
||
// 不开任何对外服务面。
|
||
"--remote-allow-origins=*",
|
||
"--no-first-run",
|
||
"--no-default-browser-check",
|
||
fmt.Sprintf("--window-size=%d,%d", l.viewportW, l.viewportH),
|
||
"--lang=" + defaultLang,
|
||
// 兜底防线:真正的关键是**不传** `--enable-automation`(见类型文档)。
|
||
"--disable-blink-features=AutomationControlled",
|
||
"about:blank",
|
||
}
|
||
if l.headless {
|
||
// 只在面板显式降级时出现;默认档(有头)**绝不传**任何 `--headless*`。
|
||
args = append([]string{"--headless=new"}, args...)
|
||
}
|
||
return args
|
||
}
|
||
|
||
// waitDevToolsPort 等 DevToolsActivePort 出现并解析出端口号。三条出口都要**说清原因**,
|
||
// 而且三者互不冒充(design/28 §7 判据 4「如实标注」):
|
||
//
|
||
// ① 浏览器进程自己退了(多半是 profile 锁被占 / 浏览器已在运行)——错误里带
|
||
// launchMarkerProcessExited **与可操作的处置建议**(profileBusyHint);子进程说的原话
|
||
// 由 Dial 追加进这条错误(withChromeOutput),用户侧因此看得见 Chromium 那句
|
||
// 「正在现有的浏览器会话中打开。」;
|
||
// ② 我们自己的等待窗口用完 —— 带 launchMarkerPortTimeout;
|
||
// ③ 调用方的预算先没了(ctx 结束)—— 带 launchMarkerBudget,**不**说浏览器坏。
|
||
//
|
||
// ② 与 ③ 的分辨靠两者的截止时间:ctx 的截止时间**明显早于**我们这一次等待的截止时间
|
||
// 时,结束它的是调用方;几乎同时到点时按 ② 报(那是这一次等待真的用满了配置的 timeout,
|
||
// 也是真机常态:外层 openSession 的预算与本次等待同源同值)。
|
||
//
|
||
// 还有一条**不走这里**的出口:启动前探到「端口在听却握不上手」时,Dial 会在起进程之前就
|
||
// 报 launchMarkerEndpointBroken(见 adopt.go)—— 那种情形根本不该起这个进程。
|
||
func (l *localChrome) waitDevToolsPort(ctx context.Context, exited <-chan struct{}) (int, error) {
|
||
path := filepath.Join(l.profileDir, devToolsPortFile)
|
||
launchDeadline := time.Now().Add(l.timeout)
|
||
deadline := time.NewTimer(l.timeout)
|
||
defer deadline.Stop()
|
||
ticker := time.NewTicker(portPollInterval)
|
||
defer ticker.Stop()
|
||
for {
|
||
if port, ok := readDevToolsPort(path); ok {
|
||
return port, nil
|
||
}
|
||
select {
|
||
case <-exited:
|
||
// 浏览器进程自己退了,端口不会再出现 —— **立刻**报,不把超时白等完。
|
||
// 最常见的原因是同一 profile 已被另一个实例占用:Chromium 把命令**委托给已在跑的
|
||
// 那个**之后自己退出(真机实测 stderr 原话「正在现有的浏览器会话中打开。」)、新
|
||
// 进程不写 DevToolsActivePort。这里给可操作建议,但不碰别人的进程。
|
||
return 0, fmt.Errorf("%w: %s。%s", ErrLaunch, launchMarkerProcessExited, profileBusyHint(l.profileDir))
|
||
case <-ctx.Done():
|
||
if dl, ok := ctx.Deadline(); !ok || dl.Add(launchDeadlineSlack).Before(launchDeadline) {
|
||
return 0, budgetLaunchErr(ctx)
|
||
}
|
||
return 0, l.portTimeoutErr()
|
||
case <-deadline.C:
|
||
return 0, l.portTimeoutErr()
|
||
case <-ticker.C:
|
||
}
|
||
}
|
||
}
|
||
|
||
// portTimeoutErr 是 ②「我们自己的等待窗口用完」的唯一文案(两条出口共用,口径只有一处)。
|
||
func (l *localChrome) portTimeoutErr() error {
|
||
return fmt.Errorf("%w: %s(%s)", ErrLaunch, launchMarkerPortTimeout, l.timeout)
|
||
}
|
||
|
||
// readDevToolsPort 读端口文件的首行;文件还没出现 / 还没写完 / 内容不合法都返回 false
|
||
// (交给调用方继续轮询,而不是把「正在写」误判成「配错」)。
|
||
func readDevToolsPort(path string) (int, bool) {
|
||
raw, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return 0, false
|
||
}
|
||
line, _, _ := strings.Cut(strings.TrimSpace(string(raw)), "\n")
|
||
port, err := strconv.Atoi(strings.TrimSpace(line))
|
||
if err != nil || port <= 0 || port > 65535 {
|
||
return 0, false
|
||
}
|
||
return port, true
|
||
}
|
||
|
||
// remoteCDP 是次选路径:连接一个**已在跑**的 CDP 端点(容器 + Xvfb + 真 Chromium,
|
||
// 或主人手动起的实例)。它是纯出站客户端,不启动任何进程、不监听任何端口。
|
||
type remoteCDP struct{ endpoint string }
|
||
|
||
// Describe 实现 dialer。
|
||
func (r *remoteCDP) Describe() string { return "已跑的 CDP 端点 " + r.endpoint }
|
||
|
||
// Local 实现 dialer:容器/手动实例**不是我们的**,会话收尾绝不代它关闭。
|
||
func (r *remoteCDP) Local() bool { return false }
|
||
|
||
// Dial 先探活再交出端点;连不上就如实报,不偷偷回退本机(design/28 §6 坑 5)。
|
||
func (r *remoteCDP) Dial(ctx context.Context) (string, func(), error) {
|
||
endpoint := strings.TrimRight(strings.TrimSpace(r.endpoint), "/")
|
||
if endpoint == "" {
|
||
return "", nil, fmt.Errorf("%w: CDP 端点为空白", ErrUnavailable)
|
||
}
|
||
var version struct {
|
||
Browser string `json:"Browser"`
|
||
}
|
||
if err := httpGetJSON(ctx, endpoint+"/json/version", &version); err != nil {
|
||
return "", nil, fmt.Errorf("%w: %v", ErrUnavailable, err)
|
||
}
|
||
return endpoint, func() {}, nil
|
||
}
|
||
|
||
// stopProcess 结束本进程拉起的浏览器(保留为具名函数:Kill 的语义在这一处写清楚,
|
||
// 调用点不必解释;Wait 由 Dial 里的等待协程负责,这里只 Kill,避免双重 Wait)。
|
||
// Windows 上渲染进程随浏览器进程一起退,因此进程级 Kill 足够。
|
||
func stopProcess(cmd *exec.Cmd) {
|
||
if cmd == nil || cmd.Process == nil {
|
||
return
|
||
}
|
||
_ = cmd.Process.Kill()
|
||
}
|
||
|
||
// newDialer 按「配置显式端点 → 配置显式可执行文件 → 本机探测结果」组装 dialer。
|
||
// 可用性判定由 browsercfg.Resolve 做(优先级单一出处),这里只把结论映射成具体 dialer。
|
||
func newDialer(avail browsercfg.Availability, cfg browsercfg.Config, log *slog.Logger) dialer {
|
||
if e := strings.TrimSpace(avail.Endpoint); e != "" {
|
||
return &remoteCDP{endpoint: e}
|
||
}
|
||
return &localChrome{
|
||
exe: strings.TrimSpace(avail.Executable),
|
||
profileDir: cfg.ProfileDir,
|
||
headless: cfg.Headless,
|
||
viewportW: cfg.ViewportWidth,
|
||
viewportH: cfg.ViewportHeight,
|
||
timeout: cfg.Timeout(),
|
||
log: log,
|
||
}
|
||
}
|