feat: add browser argument builder and discovery

This commit is contained in:
QiuSW
2026-07-22 14:55:35 +08:00
parent a5260496f3
commit cf828aec21
6 changed files with 285 additions and 3 deletions
+1 -1
View File
@@ -8,7 +8,7 @@
| --- | --- | --- | --- |
| T-001 | 建立 Go module、命令入口、日志和测试基座 | - | TODO |
| T-002 | 固化 BrowserDefinition、LaunchSpec、错误和事件合约 | T-001 | DONE |
| T-003 | 实现可测试的参数构造和 Chrome/Edge 发现 | T-002 | TODO |
| T-003 | 实现可测试的参数构造和 Chrome/Edge 发现 | T-002 | DONE |
## Phase 1:进程核心
+2
View File
@@ -20,6 +20,8 @@ type BrowserManager interface {
`Stop(force=false)` 只执行优雅关闭;强制关闭必须由明确用户动作或 CLI `--force` 触发。
参数数组由 `internal/platform/browser.BuildArgs` 生成;Chrome/Edge executable 由同包 `Discoverer.Resolve` 解析。二者都不接收 shell 命令行字符串。
## CLI 目标形状
```text
+2 -2
View File
@@ -4,10 +4,10 @@
- 日期:2026-07-22
- 阶段:文档与交互原型准备
- 代码:已建立 Go module `chub`、`cmd/chub` 入口、logging 测试基座和浏览器 domain/application 合约,T-001/T-002 已完成
- 代码:已建立 Go module `chub`、`cmd/chub` 入口、logging 测试基座、浏览器 domain/application 合约和 Chrome/Edge 参数/发现模块,T-001 至 T-003 已完成
- UI:尚未实现;P0 页面需要先制作 HTML 原型
- 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目
- blocker:无;下一个任务为 T-003
- blocker:无;下一个任务为 T-101
## 当前目录
+36
View File
@@ -0,0 +1,36 @@
---
id: T-003
title: 实现 Chrome/Edge 参数构造与可执行文件发现
phase: 0
deps: [T-002]
status: DONE
created: 2026-07-22
owner: codex
---
## 需求与背景
不同浏览器的 executable 路径不同,但 Chromium 启动参数需要保持统一、可测试且不允许用户额外参数覆盖实例身份边界。
## 方案与边界
- `internal/platform/browser` 提供 Chrome/Edge executable discovery。
- `BuildArgs` 只生成参数数组,不创建 shell 命令字符串。
- user data dir、profile、proxy 和 remote debugging 相关参数由工具控制;额外参数只能是非空 `--flag`,不能覆盖受控项。
- 本任务不启动真实进程,不实现 Windows API 进程树。
## 验收要点
- Chrome 和 Edge 标准安装路径自动发现测试通过。
- 配置路径优先于自动发现,非法路径被拒绝。
- 代理拒绝 userinfo、非法 scheme、非法端口和路径参数。
- 额外参数不能覆盖 user data dir、profile、代理或 remote debugging 参数。
- `go test ./...`、`go vet ./...` 通过。
## 执行记录
- 状态:DONE
- 变更:新增浏览器发现器、参数构造器、代理校验、额外参数边界和注入式测试。
- 验证:`gofmt`、`go test ./...`、`go vet ./...` 均通过。
- 阻塞:无。
- 残余风险:标准安装路径和 Windows 文件权限需在真实 Chrome/Edge smoke 中继续验证。
+154
View File
@@ -0,0 +1,154 @@
package browser
import (
"context"
"errors"
"fmt"
"io/fs"
"net"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"chub/internal/domain"
)
var (
ErrExecutableNotFound = errors.New("browser executable was not found")
ErrUnsafeArgument = errors.New("browser argument is controlled or unsafe")
)
type Discoverer struct {
getenv func(string) string
stat func(string) (fs.FileInfo, error)
}
func NewDiscoverer() *Discoverer { return &Discoverer{getenv: os.Getenv, stat: os.Stat} }
func (d *Discoverer) Resolve(ctx context.Context, kind domain.BrowserKind, configured string) (string, error) {
if err := ctx.Err(); err != nil {
return "", err
}
if d == nil || d.getenv == nil || d.stat == nil || !kind.Valid() {
return "", ErrExecutableNotFound
}
if configured = strings.TrimSpace(configured); configured != "" {
if path, ok := d.executable(configured); ok {
return path, nil
}
return "", fmt.Errorf("%w: configured path is unavailable", ErrExecutableNotFound)
}
seen := make(map[string]struct{})
for _, candidate := range d.candidates(kind) {
cleaned := filepath.Clean(candidate)
key := strings.ToLower(cleaned)
if candidate == "" {
continue
}
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
if path, ok := d.executable(cleaned); ok {
return path, nil
}
}
return "", ErrExecutableNotFound
}
func (d *Discoverer) candidates(kind domain.BrowserKind) []string {
var suffixes []string
switch kind {
case domain.BrowserChrome:
suffixes = []string{`Google\Chrome\Application\chrome.exe`}
case domain.BrowserEdge:
suffixes = []string{`Microsoft\Edge\Application\msedge.exe`}
}
var candidates []string
for _, root := range []string{"PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"} {
for _, suffix := range suffixes {
candidates = append(candidates, filepath.Join(d.getenv(root), suffix))
}
}
return candidates
}
func (d *Discoverer) executable(path string) (string, bool) {
cleaned := filepath.Clean(strings.TrimSpace(path))
if cleaned == "." || !filepath.IsAbs(cleaned) {
return "", false
}
info, err := d.stat(cleaned)
if err != nil || info.IsDir() || !info.Mode().IsRegular() {
return "", false
}
return cleaned, true
}
func BuildArgs(spec domain.LaunchSpec) ([]string, error) {
normalized, err := spec.Normalize()
if err != nil {
return nil, err
}
proxy, err := normalizeProxy(normalized.ProxyServer)
if err != nil {
return nil, err
}
args := []string{
"--user-data-dir=" + normalized.UserDataDir,
"--no-first-run",
"--disable-default-apps",
}
if normalized.ProfileDirectory != "" {
args = append(args, "--profile-directory="+normalized.ProfileDirectory)
}
if normalized.Headless {
args = append(args, "--headless=new")
}
if proxy != "" {
args = append(args, "--proxy-server="+proxy)
}
for _, extra := range normalized.ExtraArgs {
if err := validateExtraArg(extra); err != nil {
return nil, err
}
args = append(args, extra)
}
return append(args, normalized.TargetURL), nil
}
func normalizeProxy(value string) (string, error) {
proxy := strings.TrimSpace(value)
if proxy == "" {
return "", nil
}
parsed, err := url.Parse(proxy)
if err != nil || parsed.User != nil || parsed.Hostname() == "" || parsed.Port() == "" || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
return "", fmt.Errorf("%w: proxy must be scheme://host:port without credentials", domain.ErrInvalidLaunchSpec)
}
scheme := strings.ToLower(parsed.Scheme)
if scheme != "http" && scheme != "https" && scheme != "socks4" && scheme != "socks5" {
return "", fmt.Errorf("%w: unsupported proxy scheme", domain.ErrInvalidLaunchSpec)
}
port, err := strconv.Atoi(parsed.Port())
if err != nil || port < 1 || port > 65535 {
return "", fmt.Errorf("%w: proxy port out of range", domain.ErrInvalidLaunchSpec)
}
return scheme + "://" + net.JoinHostPort(strings.ToLower(parsed.Hostname()), strconv.Itoa(port)), nil
}
func validateExtraArg(value string) error {
arg := strings.TrimSpace(value)
if arg == "" || !strings.HasPrefix(arg, "--") {
return fmt.Errorf("%w: extra arguments must be non-empty flags", ErrUnsafeArgument)
}
name := strings.ToLower(strings.SplitN(arg[2:], "=", 2)[0])
for _, controlled := range []string{"user-data-dir", "profile-directory", "proxy-server", "remote-debugging-address", "remote-debugging-port"} {
if name == controlled {
return fmt.Errorf("%w: --%s is controlled by LaunchSpec", ErrUnsafeArgument, controlled)
}
}
return nil
}
+90
View File
@@ -0,0 +1,90 @@
package browser
import (
"context"
"errors"
"io/fs"
"path/filepath"
"reflect"
"testing"
"time"
"chub/internal/domain"
)
func TestBuildArgsUsesControlledArgumentsAndRejectsOverrides(t *testing.T) {
profile := filepath.Join(t.TempDir(), "profile one")
args, err := BuildArgs(domain.LaunchSpec{
Kind: domain.BrowserChrome,
UserDataDir: profile,
ProfileDirectory: "Default",
TargetURL: "https://example.com/?a=1&b=2",
ProxyServer: "HTTPS://Proxy.Local:8443",
Headless: true,
ExtraArgs: []string{"--window-size=1280,900"},
})
if err != nil {
t.Fatalf("BuildArgs() error = %v", err)
}
want := []string{"--user-data-dir=" + filepath.Clean(profile), "--no-first-run", "--disable-default-apps", "--profile-directory=Default", "--headless=new", "--proxy-server=https://proxy.local:8443", "--window-size=1280,900", "https://example.com/?a=1&b=2"}
if !reflect.DeepEqual(args, want) {
t.Fatalf("args = %#v, want %#v", args, want)
}
for _, unsafe := range []string{"--user-data-dir=C:\\other", "--remote-debugging-port=9222", "not-a-flag"} {
_, err := BuildArgs(domain.LaunchSpec{Kind: domain.BrowserEdge, UserDataDir: profile, TargetURL: "https://example.com", ExtraArgs: []string{unsafe}})
if !errors.Is(err, ErrUnsafeArgument) {
t.Errorf("extra arg %q error = %v", unsafe, err)
}
}
}
func TestBuildArgsRejectsProxyCredentialsAndUnsupportedValues(t *testing.T) {
base := domain.LaunchSpec{Kind: domain.BrowserChrome, UserDataDir: filepath.Join(t.TempDir(), "profile"), TargetURL: "https://example.com"}
for _, proxy := range []string{"http://user@127.0.0.1:8080", "ftp://127.0.0.1:21", "http://127.0.0.1:70000", "http://127.0.0.1:8080/path"} {
base.ProxyServer = proxy
if _, err := BuildArgs(base); !errors.Is(err, domain.ErrInvalidLaunchSpec) {
t.Errorf("proxy %q error = %v", proxy, err)
}
}
}
func TestDiscovererResolvesConfiguredAndStandardPaths(t *testing.T) {
root := t.TempDir()
chrome := filepath.Join(root, `Google\Chrome\Application\chrome.exe`)
edge := filepath.Join(root, `Microsoft\Edge\Application\msedge.exe`)
files := map[string]fs.FileInfo{chrome: fakeFileInfo{}, edge: fakeFileInfo{}}
discoverer := &Discoverer{getenv: func(key string) string {
if key == "PROGRAMFILES" {
return root
}
return ""
}, stat: func(path string) (fs.FileInfo, error) {
info, ok := files[path]
if !ok {
return nil, fs.ErrNotExist
}
return info, nil
}}
for _, test := range []struct {
kind domain.BrowserKind
want string
}{{domain.BrowserChrome, chrome}, {domain.BrowserEdge, edge}} {
got, err := discoverer.Resolve(context.Background(), test.kind, "")
if err != nil || got != test.want {
t.Errorf("Resolve(%s) = %q, %v; want %q", test.kind, got, err, test.want)
}
}
got, err := discoverer.Resolve(context.Background(), domain.BrowserChrome, chrome)
if err != nil || got != chrome {
t.Fatalf("configured Resolve() = %q, %v", got, err)
}
}
type fakeFileInfo struct{}
func (fakeFileInfo) Name() string { return "browser.exe" }
func (fakeFileInfo) Size() int64 { return 1 }
func (fakeFileInfo) Mode() fs.FileMode { return 0o755 }
func (fakeFileInfo) ModTime() time.Time { return time.Time{} }
func (fakeFileInfo) IsDir() bool { return false }
func (fakeFileInfo) Sys() any { return nil }