2026-07-22 14:53:39 +08:00
|
|
|
package domain
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
2026-07-25 17:04:26 +08:00
|
|
|
"net"
|
2026-07-22 14:53:39 +08:00
|
|
|
"net/url"
|
|
|
|
|
"path/filepath"
|
2026-07-25 17:04:26 +08:00
|
|
|
"strconv"
|
2026-07-22 14:53:39 +08:00
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type BrowserKind string
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
BrowserChrome BrowserKind = "chrome"
|
|
|
|
|
BrowserEdge BrowserKind = "edge"
|
2026-07-25 15:53:31 +08:00
|
|
|
|
|
|
|
|
DefaultRemoteDebugPort = 9666
|
|
|
|
|
MinRemoteDebugPort = 1024
|
|
|
|
|
MaxRemoteDebugPort = 65535
|
2026-07-22 14:53:39 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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
|
2026-07-25 15:53:31 +08:00
|
|
|
RemoteDebugPort int
|
2026-07-22 14:53:39 +08:00
|
|
|
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)
|
|
|
|
|
}
|
2026-07-25 15:53:31 +08:00
|
|
|
if s.RemoteDebugPort != 0 && !ValidRemoteDebugPort(s.RemoteDebugPort) {
|
|
|
|
|
return LaunchSpec{}, fmt.Errorf("%w: remote debugging port must be between %d and %d", ErrInvalidLaunchSpec, MinRemoteDebugPort, MaxRemoteDebugPort)
|
|
|
|
|
}
|
2026-07-22 14:53:39 +08:00
|
|
|
target := strings.TrimSpace(s.TargetURL)
|
2026-07-22 17:02:59 +08:00
|
|
|
if target != "" {
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
target = parsed.String()
|
2026-07-22 14:53:39 +08:00
|
|
|
}
|
|
|
|
|
normalized := s
|
|
|
|
|
normalized.Executable = strings.TrimSpace(s.Executable)
|
|
|
|
|
normalized.UserDataDir = filepath.Clean(userDataDir)
|
|
|
|
|
normalized.ProfileDirectory = strings.TrimSpace(s.ProfileDirectory)
|
2026-07-22 17:02:59 +08:00
|
|
|
normalized.TargetURL = target
|
2026-07-25 17:04:26 +08:00
|
|
|
proxy, err := NormalizeProxyServer(s.ProxyServer)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return LaunchSpec{}, err
|
|
|
|
|
}
|
|
|
|
|
normalized.ProxyServer = proxy
|
2026-07-22 14:53:39 +08:00
|
|
|
normalized.ExtraArgs = append([]string(nil), s.ExtraArgs...)
|
|
|
|
|
return normalized, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 17:04:26 +08:00
|
|
|
// NormalizeProxyServer accepts only non-authenticated Chromium proxy endpoints.
|
|
|
|
|
// Keeping this validation in domain makes configuration and process launch share
|
|
|
|
|
// the same security boundary: no proxy userinfo can reach disk or command args.
|
|
|
|
|
func NormalizeProxyServer(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", ErrInvalidLaunchSpec)
|
|
|
|
|
}
|
|
|
|
|
scheme := strings.ToLower(parsed.Scheme)
|
|
|
|
|
if scheme != "http" && scheme != "https" && scheme != "socks4" && scheme != "socks5" {
|
|
|
|
|
return "", fmt.Errorf("%w: unsupported proxy scheme", ErrInvalidLaunchSpec)
|
|
|
|
|
}
|
|
|
|
|
port, err := strconv.Atoi(parsed.Port())
|
|
|
|
|
if err != nil || port < 1 || port > 65535 {
|
|
|
|
|
return "", fmt.Errorf("%w: proxy port out of range", ErrInvalidLaunchSpec)
|
|
|
|
|
}
|
|
|
|
|
return scheme + "://" + net.JoinHostPort(strings.ToLower(parsed.Hostname()), strconv.Itoa(port)), nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 12:09:54 +08:00
|
|
|
// NormalizeProxyName returns a non-empty display name for a locally saved
|
|
|
|
|
// proxy profile. It deliberately carries no endpoint or credentials semantics.
|
|
|
|
|
func NormalizeProxyName(value string) (string, error) {
|
|
|
|
|
name := strings.TrimSpace(value)
|
|
|
|
|
if name == "" {
|
|
|
|
|
return "", fmt.Errorf("%w: proxy name is required", ErrInvalidLaunchSpec)
|
|
|
|
|
}
|
|
|
|
|
return name, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 15:53:31 +08:00
|
|
|
func ValidRemoteDebugPort(port int) bool {
|
|
|
|
|
return port >= MinRemoteDebugPort && port <= MaxRemoteDebugPort
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-22 14:53:39 +08:00
|
|
|
type InstanceView struct {
|
2026-07-25 15:53:31 +08:00
|
|
|
ID string
|
|
|
|
|
Kind BrowserKind
|
|
|
|
|
Executable string
|
|
|
|
|
PID int
|
|
|
|
|
RemoteDebugPort int
|
|
|
|
|
UserDataDir string
|
|
|
|
|
Profile string
|
|
|
|
|
TargetURL string
|
|
|
|
|
Status InstanceStatus
|
|
|
|
|
ExitCode *int
|
|
|
|
|
StartedAt time.Time
|
|
|
|
|
ChangedAt time.Time
|
|
|
|
|
IdentityNote string
|
2026-07-22 14:53:39 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type EventKind string
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
EventBrowserStarted EventKind = "browser.started"
|
|
|
|
|
EventBrowserStatusChanged EventKind = "browser.status.changed"
|
|
|
|
|
EventBrowserExited EventKind = "browser.exited"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type BrowserEvent struct {
|
2026-07-25 18:23:33 +08:00
|
|
|
Kind EventKind
|
|
|
|
|
InstanceID string
|
|
|
|
|
LaunchGeneration uint64
|
|
|
|
|
Status InstanceStatus
|
|
|
|
|
PID int
|
|
|
|
|
ExitCode *int
|
|
|
|
|
ExpectedStop bool
|
|
|
|
|
ErrorCode ErrorCode
|
|
|
|
|
At time.Time
|
2026-07-22 14:53:39 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
)
|