feat: define browser domain and application contracts
This commit is contained in:
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
| ID | 任务 | 依赖 | 状态 |
|
| ID | 任务 | 依赖 | 状态 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| T-001 | 建立 Go module、命令入口、日志和测试基座 | - | TODO |
|
| 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 |
|
| T-003 | 实现可测试的参数构造和 Chrome/Edge 发现 | T-002 | TODO |
|
||||||
|
|
||||||
## Phase 1:进程核心
|
## Phase 1:进程核心
|
||||||
|
|||||||
+1
-27
@@ -4,33 +4,7 @@ MVP 首先提供本地 Go application service 和 CLI;是否增加 loopback HT
|
|||||||
|
|
||||||
## 核心类型
|
## 核心类型
|
||||||
|
|
||||||
```go
|
代码权威类型位于 `internal/domain/browser.go`:`BrowserKind`、`LaunchSpec`、`InstanceStatus`、`InstanceView`、`BrowserEvent` 和稳定错误码。`LaunchSpec.Normalize()` 负责当前已确定的 browser kind、绝对 user data dir 和 http/https URL 校验。
|
||||||
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
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Application service
|
## Application service
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
|
|
||||||
- 日期:2026-07-22
|
- 日期: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 原型
|
- UI:尚未实现;P0 页面需要先制作 HTML 原型
|
||||||
- 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目
|
- 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目
|
||||||
- blocker:无;下一个任务为 T-002
|
- blocker:无;下一个任务为 T-003
|
||||||
|
|
||||||
## 当前目录
|
## 当前目录
|
||||||
|
|
||||||
|
|||||||
@@ -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。
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user