diff --git a/docs/06-tasks.md b/docs/06-tasks.md index d0a23b5..a9076cd 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -7,7 +7,7 @@ | ID | 任务 | 依赖 | 状态 | | --- | --- | --- | --- | | T-001 | 建立 Go module、命令入口、日志和测试基座 | - | TODO | -| T-002 | 固化 BrowserDefinition、LaunchSpec、错误和事件合约 | T-001 | TODO | +| T-002 | 固化 BrowserDefinition、LaunchSpec、错误和事件合约 | T-001 | DONE | | T-003 | 实现可测试的参数构造和 Chrome/Edge 发现 | T-002 | TODO | ## Phase 1:进程核心 diff --git a/docs/api.md b/docs/api.md index 7b6aa4f..11708bf 100644 --- a/docs/api.md +++ b/docs/api.md @@ -4,33 +4,7 @@ MVP 首先提供本地 Go application service 和 CLI;是否增加 loopback HT ## 核心类型 -```go -type BrowserKind string -const ( - BrowserChrome BrowserKind = "chrome" - BrowserEdge BrowserKind = "edge" -) - -type LaunchSpec struct { - Kind BrowserKind - Executable string - UserDataDir string - ProfileDirectory string - TargetURL string - ProxyServer string - Headless bool - ExtraArgs []string -} - -type InstanceView struct { - ID string - Kind BrowserKind - PID int - UserDataDir string - Status string - ExitCode *int -} -``` +代码权威类型位于 `internal/domain/browser.go`:`BrowserKind`、`LaunchSpec`、`InstanceStatus`、`InstanceView`、`BrowserEvent` 和稳定错误码。`LaunchSpec.Normalize()` 负责当前已确定的 browser kind、绝对 user data dir 和 http/https URL 校验。 ## Application service diff --git a/docs/current-state.md b/docs/current-state.md index 0ab2992..e25f30e 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -4,10 +4,10 @@ - 日期:2026-07-22 - 阶段:文档与交互原型准备 -- 代码:已建立 Go module `chub`、`cmd/chub` 入口和 logging 测试基座,T-001 已完成 +- 代码:已建立 Go module `chub`、`cmd/chub` 入口、logging 测试基座和浏览器 domain/application 合约,T-001/T-002 已完成 - UI:尚未实现;P0 页面需要先制作 HTML 原型 - 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目 -- blocker:无;下一个任务为 T-002 +- blocker:无;下一个任务为 T-003 ## 当前目录 diff --git a/docs/tasks/T-002.md b/docs/tasks/T-002.md new file mode 100644 index 0000000..442ebbd --- /dev/null +++ b/docs/tasks/T-002.md @@ -0,0 +1,35 @@ +--- +id: T-002 +title: 固化浏览器领域、启动配置、实例状态和事件合约 +phase: 0 +deps: [T-001] +status: DONE +created: 2026-07-22 +owner: codex +--- + +## 需求与背景 + +UI 原型和 API 文档需要稳定的 Go 类型,后续平台适配不能自行定义浏览器类型、状态和错误语义。 + +## 方案与边界 + +- `internal/domain/browser.go` 定义浏览器类型、启动配置、实例视图、状态、事件和稳定错误码。 +- `LaunchSpec.Normalize()` 只做基础安全校验:浏览器类型、绝对 user data dir、无 userinfo 的 http/https URL和字段规范化。 +- `internal/application/browser_manager.go` 定义 BrowserManager、进程启动、进程身份、profile 检查和事件端口。 +- 本任务不启动真实浏览器,不实现 Windows API 和实例 registry。 + +## 验收要点 + +- `go test ./...` 通过。 +- `go vet ./...` 通过。 +- 非法浏览器类型、相对 user data dir、非 http/https URL 和 URL userinfo 被拒绝。 +- 业务层不依赖 `os/exec` 或 Windows handle。 + +## 执行记录 + +- 状态:DONE +- 变更:新增 domain/application 浏览器合约、Normalize 校验和回归测试,API 文档指向代码权威类型。 +- 验证:`gofmt`、`go test ./...`、`go vet ./...` 均通过。 +- 阻塞:无。 +- 残余风险:代理 URL 和额外参数的完整校验留给 T-003。 diff --git a/internal/application/browser_manager.go b/internal/application/browser_manager.go new file mode 100644 index 0000000..59c1223 --- /dev/null +++ b/internal/application/browser_manager.go @@ -0,0 +1,55 @@ +package application + +import ( + "context" + "time" + + "chub/internal/domain" +) + +type BrowserManager interface { + Start(context.Context, domain.LaunchSpec) (domain.InstanceView, error) + List(context.Context) ([]domain.InstanceView, error) + Get(context.Context, string) (domain.InstanceView, error) + Stop(context.Context, string, bool) error + Restart(context.Context, string) (domain.InstanceView, error) +} + +type EventSink func(domain.BrowserEvent) + +type ProcessHandle interface { + PID() int + Wait(context.Context) (int, error) +} + +type ProcessLauncher interface { + Start(context.Context, domain.LaunchSpec) (ProcessHandle, error) +} + +type ExecutableResolver interface { + Resolve(context.Context, domain.BrowserKind, string) (string, error) +} + +type ProcessIdentity struct { + PID int + Executable string + CommandLine string + UserDataDir string +} + +type ProcessInspector interface { + Inspect(context.Context, int) (ProcessIdentity, error) + Alive(context.Context, int) (bool, error) +} + +type ProfileUse struct { + Occupied bool + PID int + Source string +} + +type ProfileInspector interface { + Inspect(context.Context, string) (ProfileUse, error) +} + +type Clock func() time.Time diff --git a/internal/domain/browser.go b/internal/domain/browser.go new file mode 100644 index 0000000..35e635b --- /dev/null +++ b/internal/domain/browser.go @@ -0,0 +1,117 @@ +package domain + +import ( + "errors" + "fmt" + "net/url" + "path/filepath" + "strings" + "time" +) + +type BrowserKind string + +const ( + BrowserChrome BrowserKind = "chrome" + BrowserEdge BrowserKind = "edge" +) + +func (k BrowserKind) Valid() bool { return k == BrowserChrome || k == BrowserEdge } + +type InstanceStatus string + +const ( + StatusCreated InstanceStatus = "created" + StatusStarting InstanceStatus = "starting" + StatusRunning InstanceStatus = "running" + StatusStopping InstanceStatus = "stopping" + StatusExited InstanceStatus = "exited" + StatusFailed InstanceStatus = "failed" + StatusUnknown InstanceStatus = "unknown" +) + +type LaunchSpec struct { + Kind BrowserKind + Executable string + UserDataDir string + ProfileDirectory string + TargetURL string + ProxyServer string + Headless bool + ExtraArgs []string +} + +func (s LaunchSpec) Normalize() (LaunchSpec, error) { + if !s.Kind.Valid() { + return LaunchSpec{}, fmt.Errorf("%w: unsupported browser kind", ErrInvalidLaunchSpec) + } + userDataDir := strings.TrimSpace(s.UserDataDir) + if userDataDir == "" || !filepath.IsAbs(userDataDir) { + return LaunchSpec{}, fmt.Errorf("%w: user data dir must be absolute", ErrInvalidLaunchSpec) + } + target := strings.TrimSpace(s.TargetURL) + parsed, err := url.Parse(target) + if err != nil || parsed.Host == "" || parsed.User != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return LaunchSpec{}, fmt.Errorf("%w: target URL must be absolute http or https", ErrInvalidLaunchSpec) + } + normalized := s + normalized.Executable = strings.TrimSpace(s.Executable) + normalized.UserDataDir = filepath.Clean(userDataDir) + normalized.ProfileDirectory = strings.TrimSpace(s.ProfileDirectory) + normalized.TargetURL = parsed.String() + normalized.ProxyServer = strings.TrimSpace(s.ProxyServer) + normalized.ExtraArgs = append([]string(nil), s.ExtraArgs...) + return normalized, nil +} + +type InstanceView struct { + ID string + Kind BrowserKind + Executable string + PID int + UserDataDir string + Profile string + TargetURL string + Status InstanceStatus + ExitCode *int + StartedAt time.Time + ChangedAt time.Time + IdentityNote string +} + +type EventKind string + +const ( + EventBrowserStarted EventKind = "browser.started" + EventBrowserStatusChanged EventKind = "browser.status.changed" + EventBrowserExited EventKind = "browser.exited" +) + +type BrowserEvent struct { + Kind EventKind + InstanceID string + Status InstanceStatus + PID int + ExitCode *int + ErrorCode ErrorCode + At time.Time +} + +type ErrorCode string + +const ( + ErrorInvalidLaunchSpec ErrorCode = "invalid_launch_spec" + ErrorProfileOccupied ErrorCode = "profile_occupied" + ErrorExecutableNotFound ErrorCode = "executable_not_found" + ErrorInstanceNotFound ErrorCode = "instance_not_found" + ErrorIdentityMismatch ErrorCode = "identity_mismatch" + ErrorProcessPermission ErrorCode = "process_permission" + ErrorProcessStart ErrorCode = "process_start_failed" +) + +var ( + ErrInvalidLaunchSpec = errors.New("invalid browser launch specification") + ErrProfileOccupied = errors.New("browser user data dir is occupied") + ErrInstanceNotFound = errors.New("browser instance was not found") + ErrIdentityMismatch = errors.New("browser process identity mismatch") +) diff --git a/internal/domain/browser_test.go b/internal/domain/browser_test.go new file mode 100644 index 0000000..5ac4bcf --- /dev/null +++ b/internal/domain/browser_test.go @@ -0,0 +1,44 @@ +package domain + +import ( + "errors" + "path/filepath" + "testing" +) + +func TestLaunchSpecNormalizeCanonicalizesSafeFields(t *testing.T) { + profile := filepath.Join(t.TempDir(), "profile") + normalized, err := (LaunchSpec{ + Kind: BrowserChrome, + Executable: " chrome.exe ", + UserDataDir: profile + string(filepath.Separator), + ProfileDirectory: " Default ", + TargetURL: " https://example.com/path ", + ExtraArgs: []string{"--no-first-run"}, + }).Normalize() + if err != nil { + t.Fatalf("Normalize() error = %v", err) + } + if normalized.Executable != "chrome.exe" || normalized.UserDataDir != filepath.Clean(profile) || normalized.ProfileDirectory != "Default" || normalized.TargetURL != "https://example.com/path" { + t.Fatalf("normalized spec = %#v", normalized) + } + normalized.ExtraArgs[0] = "changed" + if normalized.ExtraArgs[0] == "" { + t.Fatal("extra args unexpectedly empty") + } +} + +func TestLaunchSpecNormalizeRejectsUnsafeValues(t *testing.T) { + base := LaunchSpec{Kind: BrowserEdge, UserDataDir: filepath.Join(t.TempDir(), "profile"), TargetURL: "https://example.com"} + cases := []LaunchSpec{ + {UserDataDir: base.UserDataDir, TargetURL: base.TargetURL}, + {Kind: base.Kind, UserDataDir: "relative", TargetURL: base.TargetURL}, + {Kind: base.Kind, UserDataDir: base.UserDataDir, TargetURL: "file:///tmp/test"}, + {Kind: base.Kind, UserDataDir: base.UserDataDir, TargetURL: "https://user@example.com"}, + } + for i, spec := range cases { + if _, err := spec.Normalize(); !errors.Is(err, ErrInvalidLaunchSpec) { + t.Errorf("case %d error = %v, want ErrInvalidLaunchSpec", i, err) + } + } +}