feat: define browser domain and application contracts

This commit is contained in:
QiuSW
2026-07-22 14:53:39 +08:00
parent f8aaaeda21
commit a5260496f3
7 changed files with 255 additions and 30 deletions
+55
View File
@@ -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
+117
View File
@@ -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")
)
+44
View File
@@ -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)
}
}
}