feat: add local cdp startup and external association
This commit is contained in:
+25
-12
@@ -14,6 +14,10 @@ type BrowserKind string
|
||||
const (
|
||||
BrowserChrome BrowserKind = "chrome"
|
||||
BrowserEdge BrowserKind = "edge"
|
||||
|
||||
DefaultRemoteDebugPort = 9666
|
||||
MinRemoteDebugPort = 1024
|
||||
MaxRemoteDebugPort = 65535
|
||||
)
|
||||
|
||||
func (k BrowserKind) Valid() bool { return k == BrowserChrome || k == BrowserEdge }
|
||||
@@ -35,6 +39,7 @@ type LaunchSpec struct {
|
||||
Executable string
|
||||
UserDataDir string
|
||||
ProfileDirectory string
|
||||
RemoteDebugPort int
|
||||
TargetURL string
|
||||
ProxyServer string
|
||||
Headless bool
|
||||
@@ -49,6 +54,9 @@ func (s LaunchSpec) Normalize() (LaunchSpec, error) {
|
||||
if userDataDir == "" || !filepath.IsAbs(userDataDir) {
|
||||
return LaunchSpec{}, fmt.Errorf("%w: user data dir must be absolute", ErrInvalidLaunchSpec)
|
||||
}
|
||||
if s.RemoteDebugPort != 0 && !ValidRemoteDebugPort(s.RemoteDebugPort) {
|
||||
return LaunchSpec{}, fmt.Errorf("%w: remote debugging port must be between %d and %d", ErrInvalidLaunchSpec, MinRemoteDebugPort, MaxRemoteDebugPort)
|
||||
}
|
||||
target := strings.TrimSpace(s.TargetURL)
|
||||
if target != "" {
|
||||
parsed, err := url.Parse(target)
|
||||
@@ -67,19 +75,24 @@ func (s LaunchSpec) Normalize() (LaunchSpec, error) {
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func ValidRemoteDebugPort(port int) bool {
|
||||
return port >= MinRemoteDebugPort && port <= MaxRemoteDebugPort
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
type EventKind string
|
||||
|
||||
@@ -53,3 +53,13 @@ func TestLaunchSpecNormalizeAllowsAnEmptyTargetURL(t *testing.T) {
|
||||
t.Fatalf("target URL = %q, want empty", normalized.TargetURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchSpecNormalizeRejectsInvalidRemoteDebugPort(t *testing.T) {
|
||||
profile := filepath.Join(t.TempDir(), "profile")
|
||||
for _, port := range []int{1, MinRemoteDebugPort - 1, MaxRemoteDebugPort + 1} {
|
||||
_, err := (LaunchSpec{Kind: BrowserChrome, UserDataDir: profile, RemoteDebugPort: port}).Normalize()
|
||||
if !errors.Is(err, ErrInvalidLaunchSpec) {
|
||||
t.Errorf("port %d error = %v", port, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chub/internal/domain"
|
||||
)
|
||||
|
||||
const remoteDebugPortAttempts = 100
|
||||
|
||||
var (
|
||||
ErrRemoteDebugPortUnavailable = errors.New("remote debugging port is unavailable")
|
||||
ErrRemoteDebugEndpointUnavailable = errors.New("remote debugging endpoint is unavailable")
|
||||
)
|
||||
|
||||
type RemoteDebugEndpoint struct {
|
||||
Port int
|
||||
Browser string
|
||||
}
|
||||
|
||||
type RemoteDebugEndpointInspector interface {
|
||||
InspectRemoteDebugEndpoint(context.Context, domain.BrowserKind, string) (RemoteDebugEndpoint, error)
|
||||
WaitForRemoteDebugEndpoint(context.Context, domain.BrowserKind, string) (RemoteDebugEndpoint, error)
|
||||
InspectRemoteDebugPort(context.Context, domain.BrowserKind, int) (RemoteDebugEndpoint, error)
|
||||
WaitForRemoteDebugPort(context.Context, domain.BrowserKind, int) (RemoteDebugEndpoint, error)
|
||||
}
|
||||
|
||||
type RemoteDebugPortAllocator interface {
|
||||
FindAvailableRemoteDebugPort(context.Context, int) (int, error)
|
||||
}
|
||||
|
||||
type CDPInspector struct {
|
||||
readFile func(string) ([]byte, error)
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewCDPInspector() *CDPInspector {
|
||||
return &CDPInspector{
|
||||
readFile: os.ReadFile,
|
||||
client: &http.Client{Transport: &http.Transport{Proxy: nil}},
|
||||
}
|
||||
}
|
||||
|
||||
func (i *CDPInspector) InspectRemoteDebugEndpoint(ctx context.Context, kind domain.BrowserKind, userDataDir string) (RemoteDebugEndpoint, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return RemoteDebugEndpoint{}, err
|
||||
}
|
||||
if i == nil || i.readFile == nil || i.client == nil || !kind.Valid() {
|
||||
return RemoteDebugEndpoint{}, ErrRemoteDebugEndpointUnavailable
|
||||
}
|
||||
directory := strings.TrimSpace(userDataDir)
|
||||
if directory == "" || !filepath.IsAbs(directory) {
|
||||
return RemoteDebugEndpoint{}, fmt.Errorf("%w: user data dir must be absolute", domain.ErrInvalidLaunchSpec)
|
||||
}
|
||||
data, err := i.readFile(filepath.Join(filepath.Clean(directory), "DevToolsActivePort"))
|
||||
if err != nil {
|
||||
return RemoteDebugEndpoint{}, fmt.Errorf("%w: read active port", ErrRemoteDebugEndpointUnavailable)
|
||||
}
|
||||
port, err := parseDevToolsActivePort(data)
|
||||
if err != nil {
|
||||
return RemoteDebugEndpoint{}, err
|
||||
}
|
||||
return i.InspectRemoteDebugPort(ctx, kind, port)
|
||||
}
|
||||
|
||||
func (i *CDPInspector) InspectRemoteDebugPort(ctx context.Context, kind domain.BrowserKind, port int) (RemoteDebugEndpoint, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return RemoteDebugEndpoint{}, err
|
||||
}
|
||||
if i == nil || i.client == nil || !kind.Valid() || !domain.ValidRemoteDebugPort(port) {
|
||||
return RemoteDebugEndpoint{}, ErrRemoteDebugEndpointUnavailable
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://127.0.0.1:"+strconv.Itoa(port)+"/json/version", nil)
|
||||
if err != nil {
|
||||
return RemoteDebugEndpoint{}, fmt.Errorf("%w: build version request", ErrRemoteDebugEndpointUnavailable)
|
||||
}
|
||||
response, err := i.client.Do(request)
|
||||
if err != nil {
|
||||
return RemoteDebugEndpoint{}, fmt.Errorf("%w: query version", ErrRemoteDebugEndpointUnavailable)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return RemoteDebugEndpoint{}, fmt.Errorf("%w: version status %d", ErrRemoteDebugEndpointUnavailable, response.StatusCode)
|
||||
}
|
||||
var version struct {
|
||||
Browser string `json:"Browser"`
|
||||
}
|
||||
if err := json.NewDecoder(response.Body).Decode(&version); err != nil {
|
||||
return RemoteDebugEndpoint{}, fmt.Errorf("%w: decode version", ErrRemoteDebugEndpointUnavailable)
|
||||
}
|
||||
if !browserVersionMatchesKind(version.Browser, kind) {
|
||||
return RemoteDebugEndpoint{}, fmt.Errorf("%w: browser kind mismatch", ErrRemoteDebugEndpointUnavailable)
|
||||
}
|
||||
return RemoteDebugEndpoint{Port: port, Browser: version.Browser}, nil
|
||||
}
|
||||
|
||||
func (i *CDPInspector) WaitForRemoteDebugEndpoint(ctx context.Context, kind domain.BrowserKind, userDataDir string) (RemoteDebugEndpoint, error) {
|
||||
return waitForRemoteDebugEndpoint(ctx, func(ctx context.Context) (RemoteDebugEndpoint, error) {
|
||||
return i.InspectRemoteDebugEndpoint(ctx, kind, userDataDir)
|
||||
})
|
||||
}
|
||||
|
||||
func (i *CDPInspector) WaitForRemoteDebugPort(ctx context.Context, kind domain.BrowserKind, port int) (RemoteDebugEndpoint, error) {
|
||||
return waitForRemoteDebugEndpoint(ctx, func(ctx context.Context) (RemoteDebugEndpoint, error) {
|
||||
return i.InspectRemoteDebugPort(ctx, kind, port)
|
||||
})
|
||||
}
|
||||
|
||||
func waitForRemoteDebugEndpoint(ctx context.Context, inspect func(context.Context) (RemoteDebugEndpoint, error)) (RemoteDebugEndpoint, error) {
|
||||
const pollInterval = 50 * time.Millisecond
|
||||
for {
|
||||
endpoint, err := inspect(ctx)
|
||||
if err == nil {
|
||||
return endpoint, nil
|
||||
}
|
||||
if !errors.Is(err, ErrRemoteDebugEndpointUnavailable) {
|
||||
return RemoteDebugEndpoint{}, err
|
||||
}
|
||||
timer := time.NewTimer(pollInterval)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return RemoteDebugEndpoint{}, fmt.Errorf("%w: %v", ErrRemoteDebugEndpointUnavailable, ctx.Err())
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (i *CDPInspector) FindAvailableRemoteDebugPort(ctx context.Context, start int) (int, error) {
|
||||
if i == nil || !domain.ValidRemoteDebugPort(start) {
|
||||
return 0, fmt.Errorf("%w: start port must be between %d and %d", ErrRemoteDebugPortUnavailable, domain.MinRemoteDebugPort, domain.MaxRemoteDebugPort)
|
||||
}
|
||||
for offset := 0; offset < remoteDebugPortAttempts && start+offset <= domain.MaxRemoteDebugPort; offset++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
port := start + offset
|
||||
listener, err := net.Listen("tcp4", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_ = listener.Close()
|
||||
return port, nil
|
||||
}
|
||||
return 0, fmt.Errorf("%w: no free port in %d candidates starting at %d", ErrRemoteDebugPortUnavailable, remoteDebugPortAttempts, start)
|
||||
}
|
||||
|
||||
func parseDevToolsActivePort(data []byte) (int, error) {
|
||||
lines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n")
|
||||
if len(lines) < 2 {
|
||||
return 0, fmt.Errorf("%w: malformed active port file", ErrRemoteDebugEndpointUnavailable)
|
||||
}
|
||||
port, err := strconv.Atoi(strings.TrimSpace(lines[0]))
|
||||
if err != nil || !domain.ValidRemoteDebugPort(port) {
|
||||
return 0, fmt.Errorf("%w: malformed active port", ErrRemoteDebugEndpointUnavailable)
|
||||
}
|
||||
if strings.TrimSpace(lines[1]) == "" {
|
||||
return 0, fmt.Errorf("%w: missing browser endpoint", ErrRemoteDebugEndpointUnavailable)
|
||||
}
|
||||
return port, nil
|
||||
}
|
||||
|
||||
func browserVersionMatchesKind(browser string, kind domain.BrowserKind) bool {
|
||||
switch kind {
|
||||
case domain.BrowserChrome:
|
||||
return strings.HasPrefix(browser, "Chrome/") || strings.HasPrefix(browser, "HeadlessChrome/")
|
||||
case domain.BrowserEdge:
|
||||
return strings.HasPrefix(browser, "Edg/") || strings.HasPrefix(browser, "HeadlessEdg/")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"chub/internal/domain"
|
||||
)
|
||||
|
||||
func TestCDPInspectorValidatesEndpointFromProfileFile(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/json/version" {
|
||||
t.Fatalf("path = %q", r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"Browser":"Chrome/136.0.0.0"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
parsed, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, portText, err := net.SplitHostPort(parsed.Host)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
profile := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(profile, "DevToolsActivePort"), []byte(portText+"\n/devtools/browser/test\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
endpoint, err := NewCDPInspector().InspectRemoteDebugEndpoint(context.Background(), domain.BrowserChrome, profile)
|
||||
if err != nil {
|
||||
t.Fatalf("InspectRemoteDebugEndpoint() error = %v", err)
|
||||
}
|
||||
port, _ := strconv.Atoi(portText)
|
||||
if endpoint.Port != port || endpoint.Browser != "Chrome/136.0.0.0" {
|
||||
t.Fatalf("endpoint = %#v", endpoint)
|
||||
}
|
||||
direct, err := NewCDPInspector().InspectRemoteDebugPort(context.Background(), domain.BrowserChrome, port)
|
||||
if err != nil || direct.Port != port {
|
||||
t.Fatalf("InspectRemoteDebugPort() = %#v, %v", direct, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCDPInspectorRejectsMismatchedAndMalformedEndpoints(t *testing.T) {
|
||||
profile := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(profile, "DevToolsActivePort"), []byte("not-a-port\n/devtools/browser/test\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := NewCDPInspector().InspectRemoteDebugEndpoint(context.Background(), domain.BrowserChrome, profile); !errors.Is(err, ErrRemoteDebugEndpointUnavailable) {
|
||||
t.Fatalf("malformed endpoint error = %v", err)
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"Browser":"Edg/136.0.0.0"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
parsed, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, portText, err := net.SplitHostPort(parsed.Host)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(profile, "DevToolsActivePort"), []byte(portText+"\n/devtools/browser/test\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := NewCDPInspector().InspectRemoteDebugEndpoint(context.Background(), domain.BrowserChrome, profile); !errors.Is(err, ErrRemoteDebugEndpointUnavailable) {
|
||||
t.Fatalf("mismatched endpoint error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCDPInspectorFindsNextAvailablePort(t *testing.T) {
|
||||
listener, err := net.Listen("tcp4", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
start := listener.Addr().(*net.TCPAddr).Port
|
||||
port, err := NewCDPInspector().FindAvailableRemoteDebugPort(context.Background(), start)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if port != start+1 {
|
||||
t.Fatalf("port = %d, want %d", port, start+1)
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,12 @@ func BuildArgs(spec domain.LaunchSpec) ([]string, error) {
|
||||
"--no-first-run",
|
||||
"--disable-default-apps",
|
||||
}
|
||||
if normalized.RemoteDebugPort > 0 {
|
||||
args = append(args,
|
||||
"--remote-debugging-address=127.0.0.1",
|
||||
"--remote-debugging-port="+strconv.Itoa(normalized.RemoteDebugPort),
|
||||
)
|
||||
}
|
||||
if normalized.ProfileDirectory != "" {
|
||||
args = append(args, "--profile-directory="+normalized.ProfileDirectory)
|
||||
}
|
||||
|
||||
@@ -60,6 +60,25 @@ func TestBuildArgsAllowsDefaultBrowserPage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildArgsAddsLoopbackRemoteDebuggingArguments(t *testing.T) {
|
||||
profile := filepath.Join(t.TempDir(), "profile")
|
||||
args, err := BuildArgs(domain.LaunchSpec{Kind: domain.BrowserChrome, UserDataDir: profile, RemoteDebugPort: 9666})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"--remote-debugging-address=127.0.0.1", "--remote-debugging-port=9666"} {
|
||||
found := false
|
||||
for _, arg := range args {
|
||||
if arg == want {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("args = %#v, missing %q", args, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscovererResolvesConfiguredAndStandardPaths(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
chrome := filepath.Join(root, `Google\Chrome\Application\chrome.exe`)
|
||||
|
||||
@@ -15,6 +15,7 @@ func TestRealChromiumSmoke(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
discoverer := NewDiscoverer()
|
||||
launcher := NewOSLauncher()
|
||||
cdp := NewCDPInspector()
|
||||
type running struct {
|
||||
kind domain.BrowserKind
|
||||
dir string
|
||||
@@ -31,7 +32,24 @@ func TestRealChromiumSmoke(t *testing.T) {
|
||||
t.Fatalf("resolve %s: %v", kind, err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
handle, err := launcher.Start(ctx, domain.LaunchSpec{Kind: kind, Executable: executable, UserDataDir: dir, TargetURL: "https://example.com", Headless: true, ExtraArgs: []string{"--disable-gpu"}})
|
||||
var handle ProcessHandle
|
||||
t.Cleanup(func() {
|
||||
if handle == nil {
|
||||
return
|
||||
}
|
||||
if err := launcher.StopProfile(ctx, dir, true); err != nil && !errors.Is(err, domain.ErrInstanceNotFound) {
|
||||
t.Errorf("cleanup force stop %s: %v", kind, err)
|
||||
return
|
||||
}
|
||||
waitCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
_, _ = handle.Wait(waitCtx)
|
||||
cancel()
|
||||
})
|
||||
port, err := cdp.FindAvailableRemoteDebugPort(ctx, domain.DefaultRemoteDebugPort)
|
||||
if err != nil {
|
||||
t.Fatalf("allocate %s CDP port: %v", kind, err)
|
||||
}
|
||||
handle, err = launcher.Start(ctx, domain.LaunchSpec{Kind: kind, Executable: executable, UserDataDir: dir, RemoteDebugPort: port, TargetURL: "https://example.com", Headless: true, ExtraArgs: []string{"--disable-gpu"}})
|
||||
if err != nil {
|
||||
t.Fatalf("start %s: %v", kind, err)
|
||||
}
|
||||
@@ -39,6 +57,12 @@ func TestRealChromiumSmoke(t *testing.T) {
|
||||
if handle.PID() <= 0 {
|
||||
t.Fatalf("%s returned invalid PID %d", kind, handle.PID())
|
||||
}
|
||||
endpointCtx, cancelEndpoint := context.WithTimeout(ctx, 10*time.Second)
|
||||
endpoint, endpointErr := cdp.WaitForRemoteDebugPort(endpointCtx, kind, port)
|
||||
cancelEndpoint()
|
||||
if endpointErr != nil || endpoint.Port != port {
|
||||
t.Fatalf("%s CDP endpoint = %+v, %v; want port %d", kind, endpoint, endpointErr, port)
|
||||
}
|
||||
use, err := launcher.InspectProfile(ctx, dir)
|
||||
if err != nil || !use.Occupied || use.PID != handle.PID() {
|
||||
t.Fatalf("%s registry = %+v, %v", kind, use, err)
|
||||
@@ -62,15 +86,4 @@ func TestRealChromiumSmoke(t *testing.T) {
|
||||
if processes[0].handle.PID() == processes[1].handle.PID() {
|
||||
t.Fatalf("Chrome and Edge shared PID %d", processes[0].handle.PID())
|
||||
}
|
||||
for _, item := range processes {
|
||||
if err := launcher.StopProfile(ctx, item.dir, true); err != nil {
|
||||
t.Fatalf("force stop %s: %v", item.kind, err)
|
||||
}
|
||||
waitCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
code, waitErr := item.handle.Wait(waitCtx)
|
||||
cancel()
|
||||
if waitErr != nil {
|
||||
t.Logf("%s force stop returned expected process wait error: code=%d error=%v", item.kind, code, waitErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,11 +14,12 @@ import (
|
||||
const currentVersion = 1
|
||||
|
||||
type Settings struct {
|
||||
ChromePath string `json:"chromePath"`
|
||||
EdgePath string `json:"edgePath"`
|
||||
DefaultDir string `json:"defaultUserDataDir"`
|
||||
LogDir string `json:"logDir"`
|
||||
CloseOnExit bool `json:"closeOnExit"`
|
||||
ChromePath string `json:"chromePath"`
|
||||
EdgePath string `json:"edgePath"`
|
||||
DefaultDir string `json:"defaultUserDataDir"`
|
||||
LogDir string `json:"logDir"`
|
||||
RemoteDebugStartPort int `json:"remoteDebugStartPort"`
|
||||
CloseOnExit bool `json:"closeOnExit"`
|
||||
}
|
||||
|
||||
type Instance struct {
|
||||
@@ -71,16 +72,20 @@ func (s *Store) Load() (File, error) {
|
||||
if result.Version != currentVersion {
|
||||
return File{}, fmt.Errorf("unsupported config version %d", result.Version)
|
||||
}
|
||||
if result.Settings.RemoteDebugStartPort == 0 {
|
||||
result.Settings.RemoteDebugStartPort = domain.DefaultRemoteDebugPort
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func DefaultSettings() Settings {
|
||||
return Settings{
|
||||
ChromePath: `C:\Program Files\Google\Chrome\Application\chrome.exe`,
|
||||
EdgePath: `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`,
|
||||
DefaultDir: `C:\Users\Public\chub\profiles`,
|
||||
LogDir: `C:\Users\Public\chub\logs`,
|
||||
CloseOnExit: true,
|
||||
ChromePath: `C:\Program Files\Google\Chrome\Application\chrome.exe`,
|
||||
EdgePath: `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`,
|
||||
DefaultDir: `C:\Users\Public\chub\profiles`,
|
||||
LogDir: `C:\Users\Public\chub\logs`,
|
||||
RemoteDebugStartPort: domain.DefaultRemoteDebugPort,
|
||||
CloseOnExit: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
@@ -13,7 +14,7 @@ func TestStoreRoundTripAndCreatesPrivateFile(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := File{Settings: Settings{ChromePath: "chrome.exe", CloseOnExit: true}, Instances: []Instance{{ID: "a", Name: "运营", Launch: domain.LaunchSpec{Kind: domain.BrowserChrome, UserDataDir: `C:\profiles\a`}}}}
|
||||
want := File{Settings: Settings{ChromePath: "chrome.exe", RemoteDebugStartPort: domain.DefaultRemoteDebugPort, CloseOnExit: true}, Instances: []Instance{{ID: "a", Name: "运营", Launch: domain.LaunchSpec{Kind: domain.BrowserChrome, UserDataDir: `C:\profiles\a`}}}}
|
||||
if err := store.Save(want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -26,6 +27,21 @@ func TestStoreRoundTripAndCreatesPrivateFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreAddsDefaultRemoteDebugPortForExistingConfig(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"version":1,"settings":{},"instances":[]}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, err := New(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := store.Load()
|
||||
if err != nil || got.Settings.RemoteDebugStartPort != domain.DefaultRemoteDebugPort {
|
||||
t.Fatalf("settings = %#v, error = %v", got.Settings, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreMissingFileReturnsDefaults(t *testing.T) {
|
||||
store, err := New(filepath.Join(t.TempDir(), "config.json"))
|
||||
if err != nil {
|
||||
|
||||
+229
-79
@@ -6,8 +6,10 @@ import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"chub/internal/domain"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
@@ -26,11 +28,12 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
instanceNameColumnWeight = 24
|
||||
instanceBrowserColumnWeight = 12
|
||||
instanceDirectoryColumnWeight = 38
|
||||
instanceStatusColumnWeight = 12
|
||||
instanceActionColumnWeight = 14
|
||||
instanceNameColumnWeight = 20
|
||||
instanceBrowserColumnWeight = 10
|
||||
instanceDirectoryColumnWeight = 32
|
||||
instancePortColumnWeight = 10
|
||||
instanceStatusColumnWeight = 13
|
||||
instanceActionColumnWeight = 15
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -51,7 +54,15 @@ type PathSearcher func(context.Context, PathField, string) (string, error)
|
||||
|
||||
type DirectoryChooser func(context.Context) (string, error)
|
||||
|
||||
type InstanceStarter func(context.Context, InstanceRow, SettingsState) (int, error)
|
||||
type InstanceStartOutcome struct {
|
||||
PID int
|
||||
RemoteDebugPort int
|
||||
External bool
|
||||
Source string
|
||||
Warning string
|
||||
}
|
||||
|
||||
type InstanceStarter func(context.Context, InstanceRow, SettingsState) (InstanceStartOutcome, error)
|
||||
|
||||
type instanceStartState struct {
|
||||
request uint64
|
||||
@@ -61,7 +72,7 @@ type instanceStartState struct {
|
||||
type instanceStartResult struct {
|
||||
id string
|
||||
request uint64
|
||||
pid int
|
||||
outcome InstanceStartOutcome
|
||||
err error
|
||||
}
|
||||
|
||||
@@ -93,20 +104,24 @@ type directoryPickResult struct {
|
||||
// InstanceRow is the read-only view model used by the first Gio shell.
|
||||
// Runtime data will replace these fixtures when the application service is wired.
|
||||
type InstanceRow struct {
|
||||
ID string
|
||||
Name string
|
||||
Browser string
|
||||
UserDataDir string
|
||||
TargetURL string
|
||||
Status string
|
||||
ID string
|
||||
Name string
|
||||
Browser string
|
||||
UserDataDir string
|
||||
TargetURL string
|
||||
PID int
|
||||
RemoteDebugPort int
|
||||
OccupancySource string
|
||||
Status string
|
||||
}
|
||||
|
||||
type SettingsState struct {
|
||||
ChromePath string
|
||||
EdgePath string
|
||||
DefaultDir string
|
||||
LogDir string
|
||||
CloseOnExit bool
|
||||
ChromePath string
|
||||
EdgePath string
|
||||
DefaultDir string
|
||||
LogDir string
|
||||
RemoteDebugStartPort int
|
||||
CloseOnExit bool
|
||||
}
|
||||
|
||||
// Shell owns every interactive Gio widget. Keeping this state outside Layout
|
||||
@@ -133,17 +148,18 @@ type Shell struct {
|
||||
saveClick widget.Clickable
|
||||
cancelClick widget.Clickable
|
||||
|
||||
chromePath widget.Editor
|
||||
edgePath widget.Editor
|
||||
dataDir widget.Editor
|
||||
logDir widget.Editor
|
||||
closeOnExit widget.Bool
|
||||
instanceName widget.Editor
|
||||
instanceDir widget.Editor
|
||||
instanceURL widget.Editor
|
||||
browserKind widget.Enum
|
||||
pathFeedback string
|
||||
formFeedback string
|
||||
chromePath widget.Editor
|
||||
edgePath widget.Editor
|
||||
dataDir widget.Editor
|
||||
logDir widget.Editor
|
||||
remoteDebugPort widget.Editor
|
||||
closeOnExit widget.Bool
|
||||
instanceName widget.Editor
|
||||
instanceDir widget.Editor
|
||||
instanceURL widget.Editor
|
||||
browserKind widget.Enum
|
||||
pathFeedback string
|
||||
formFeedback string
|
||||
|
||||
list widget.List
|
||||
rows []InstanceRow
|
||||
@@ -171,9 +187,9 @@ type Shell struct {
|
||||
|
||||
func NewShell(theme *material.Theme) *Shell {
|
||||
s := &Shell{theme: theme, rows: []InstanceRow{
|
||||
{ID: "demo-operations", Name: "运营主账号", Browser: "Chrome", UserDataDir: `C:\Users\Public\chub\profiles\operations`, Status: "运行中"},
|
||||
{ID: "demo-operations", Name: "运营主账号", Browser: "Chrome", UserDataDir: `C:\Users\Public\chub\profiles\operations`, PID: 18420, RemoteDebugPort: 9666, Status: "运行中"},
|
||||
{ID: "demo-ads", Name: "广告投放", Browser: "Edge", UserDataDir: `C:\Users\Public\chub\profiles\ads`, Status: "启动中"},
|
||||
{ID: "demo-assets", Name: "素材采集", Browser: "Chrome", UserDataDir: `C:\Users\Public\chub\profiles\assets`, Status: "外部占用"},
|
||||
{ID: "demo-assets", Name: "素材采集", Browser: "Chrome", UserDataDir: `C:\Users\Public\chub\profiles\assets`, PID: 16108, RemoteDebugPort: 9668, OccupancySource: "browser_message_window", Status: "外部已关联"},
|
||||
{ID: "demo-backup", Name: "备用环境", Browser: "Edge", UserDataDir: `C:\Users\Public\chub\profiles\backup`, Status: "已退出"},
|
||||
}, searches: map[PathField]*pathSearchState{
|
||||
PathChromeExecutable: {},
|
||||
@@ -185,6 +201,7 @@ func NewShell(theme *material.Theme) *Shell {
|
||||
s.edgePath.SetText(`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`)
|
||||
s.dataDir.SetText(`C:\Users\Public\chub\profiles`)
|
||||
s.logDir.SetText(`C:\Users\Public\chub\logs`)
|
||||
s.remoteDebugPort.SetText(strconv.Itoa(domain.DefaultRemoteDebugPort))
|
||||
s.closeOnExit.Value = true
|
||||
s.browserKind.Value = "chrome"
|
||||
s.list.Axis = layout.Vertical
|
||||
@@ -206,6 +223,11 @@ func (s *Shell) SetSettings(value SettingsState) {
|
||||
s.edgePath.SetText(value.EdgePath)
|
||||
s.dataDir.SetText(value.DefaultDir)
|
||||
s.logDir.SetText(value.LogDir)
|
||||
port := value.RemoteDebugStartPort
|
||||
if port == 0 {
|
||||
port = domain.DefaultRemoteDebugPort
|
||||
}
|
||||
s.remoteDebugPort.SetText(strconv.Itoa(port))
|
||||
s.closeOnExit.Value = value.CloseOnExit
|
||||
}
|
||||
|
||||
@@ -323,39 +345,77 @@ func (s *Shell) instances(gtx layout.Context) layout.Dimensions {
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(18)}.Layout),
|
||||
layout.Rigid(material.Button(s.theme, &s.newClick, "新建实例").Layout),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout),
|
||||
layout.Rigid(s.instanceHeader),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
return material.List(s.theme, &s.list).Layout(gtx, len(s.rows), func(gtx layout.Context, i int) layout.Dimensions {
|
||||
row := s.rows[i]
|
||||
return layout.Inset{Right: unit.Dp(4), Bottom: unit.Dp(10)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Flexed(instanceNameColumnWeight, material.Body1(s.theme, row.Name).Layout),
|
||||
layout.Flexed(instanceBrowserColumnWeight, material.Body2(s.theme, row.Browser).Layout),
|
||||
layout.Flexed(instanceDirectoryColumnWeight, pathCell(s.theme, row.UserDataDir)),
|
||||
layout.Flexed(instanceStatusColumnWeight, statusLabel(s.theme, row.Status)),
|
||||
layout.Flexed(instanceActionColumnWeight, s.instanceActionButtons(row)),
|
||||
)
|
||||
})
|
||||
})
|
||||
}),
|
||||
layout.Flexed(1, s.instanceTable),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(material.Caption(s.theme, s.instanceFeedback).Layout),
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Shell) instanceTable(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Background{}.Layout(gtx,
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
rect := image.Rectangle{Max: gtx.Constraints.Min}
|
||||
if rect.Empty() {
|
||||
return layout.Dimensions{Size: rect.Max}
|
||||
}
|
||||
paint.FillShape(gtx.Ops, color.NRGBA{R: 199, G: 207, B: 218, A: 255}, clip.UniformRRect(rect, gtx.Dp(8)).Op(gtx.Ops))
|
||||
inner := rect.Inset(gtx.Dp(1))
|
||||
if !inner.Empty() {
|
||||
paint.FillShape(gtx.Ops, s.theme.Palette.Bg, clip.UniformRRect(inner, gtx.Dp(7)).Op(gtx.Ops))
|
||||
}
|
||||
return layout.Dimensions{Size: rect.Max}
|
||||
},
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Inset{Top: unit.Dp(8), Right: unit.Dp(10), Bottom: unit.Dp(8), Left: unit.Dp(10)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(s.instanceHeader),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
return material.List(s.theme, &s.list).Layout(gtx, len(s.rows), func(gtx layout.Context, i int) layout.Dimensions {
|
||||
row := s.rows[i]
|
||||
return layout.Inset{Bottom: unit.Dp(8)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Flexed(instanceNameColumnWeight, material.Body1(s.theme, row.Name).Layout),
|
||||
layout.Flexed(instanceBrowserColumnWeight, material.Body2(s.theme, row.Browser).Layout),
|
||||
layout.Flexed(instanceDirectoryColumnWeight, pathCell(s.theme, row.UserDataDir)),
|
||||
layout.Flexed(instancePortColumnWeight, remoteDebugPortCell(s.theme, row.RemoteDebugPort)),
|
||||
layout.Flexed(instanceStatusColumnWeight, statusLabel(s.theme, row.Status)),
|
||||
layout.Flexed(instanceActionColumnWeight, s.instanceActionButtons(row)),
|
||||
)
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Shell) instanceHeader(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Inset{Right: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Flexed(instanceNameColumnWeight, material.Caption(s.theme, "实例名称").Layout),
|
||||
layout.Flexed(instanceBrowserColumnWeight, material.Caption(s.theme, "浏览器类型").Layout),
|
||||
layout.Flexed(instanceDirectoryColumnWeight, material.Caption(s.theme, "用户数据目录").Layout),
|
||||
layout.Flexed(instanceStatusColumnWeight, material.Caption(s.theme, "状态").Layout),
|
||||
layout.Flexed(instanceActionColumnWeight, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.E.Layout(gtx, material.Caption(s.theme, "操作").Layout)
|
||||
}),
|
||||
)
|
||||
})
|
||||
return layout.Background{}.Layout(gtx,
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
rect := image.Rectangle{Max: gtx.Constraints.Min}
|
||||
if rect.Dy() > 0 {
|
||||
line := image.Rect(rect.Min.X, rect.Max.Y-gtx.Dp(1), rect.Max.X, rect.Max.Y)
|
||||
paint.FillShape(gtx.Ops, color.NRGBA{R: 220, G: 226, B: 235, A: 255}, clip.Rect{Min: line.Min, Max: line.Max}.Op())
|
||||
}
|
||||
return layout.Dimensions{Size: rect.Max}
|
||||
},
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Flexed(instanceNameColumnWeight, material.Caption(s.theme, "实例名称").Layout),
|
||||
layout.Flexed(instanceBrowserColumnWeight, material.Caption(s.theme, "浏览器类型").Layout),
|
||||
layout.Flexed(instanceDirectoryColumnWeight, material.Caption(s.theme, "用户数据目录").Layout),
|
||||
layout.Flexed(instancePortColumnWeight, material.Caption(s.theme, "调试端口").Layout),
|
||||
layout.Flexed(instanceStatusColumnWeight, material.Caption(s.theme, "状态").Layout),
|
||||
layout.Flexed(instanceActionColumnWeight, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.E.Layout(gtx, material.Caption(s.theme, "操作").Layout)
|
||||
}),
|
||||
)
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func pathCell(theme *material.Theme, path string) layout.Widget {
|
||||
@@ -364,11 +424,23 @@ func pathCell(theme *material.Theme, path string) layout.Widget {
|
||||
return style.Layout
|
||||
}
|
||||
|
||||
func remoteDebugPortCell(theme *material.Theme, port int) layout.Widget {
|
||||
label := "—"
|
||||
if port > 0 {
|
||||
label = strconv.Itoa(port)
|
||||
}
|
||||
return material.Body2(theme, label).Layout
|
||||
}
|
||||
|
||||
func (s *Shell) instanceActionButtons(row InstanceRow) layout.Widget {
|
||||
return func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.E.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
actionLabel := "启动 " + row.Name
|
||||
if row.Status == "外部已关联" {
|
||||
actionLabel = "重新检测 " + row.Name
|
||||
}
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(s.instanceIconButton(s.startClickFor(row.ID), instanceStartIcon, "启动 "+row.Name, s.theme.Palette.ContrastBg)),
|
||||
layout.Rigid(s.instanceIconButton(s.startClickFor(row.ID), instanceStartIcon, actionLabel, s.theme.Palette.ContrastBg)),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(s.instanceIconButton(s.deleteClickFor(row.ID), instanceDeleteIcon, "删除 "+row.Name, color.NRGBA{R: 188, G: 51, B: 51, A: 255})),
|
||||
)
|
||||
@@ -454,11 +526,14 @@ func statusLabel(theme *material.Theme, status string) layout.Widget {
|
||||
style := material.Label(theme, unit.Sp(13), status)
|
||||
style.Color = theme.Palette.Fg
|
||||
if color, ok := map[string]color.NRGBA{
|
||||
"运行中": {R: 24, G: 125, B: 78, A: 255},
|
||||
"启动中": {R: 175, G: 105, B: 0, A: 255},
|
||||
"启动失败": {R: 188, G: 51, B: 51, A: 255},
|
||||
"外部占用": {R: 190, G: 75, B: 35, A: 255},
|
||||
"已退出": {R: 100, G: 105, B: 115, A: 255},
|
||||
"运行中": {R: 24, G: 125, B: 78, A: 255},
|
||||
"启动中": {R: 175, G: 105, B: 0, A: 255},
|
||||
"启动失败": {R: 188, G: 51, B: 51, A: 255},
|
||||
"运行中(调试不可用)": {R: 175, G: 105, B: 0, A: 255},
|
||||
"外部已关联": {R: 146, G: 93, B: 0, A: 255},
|
||||
"外部占用": {R: 190, G: 75, B: 35, A: 255},
|
||||
"未知占用": {R: 190, G: 75, B: 35, A: 255},
|
||||
"已退出": {R: 100, G: 105, B: 115, A: 255},
|
||||
}[status]; ok {
|
||||
style.Color = color
|
||||
}
|
||||
@@ -491,8 +566,14 @@ func (s *Shell) settings(gtx layout.Context) layout.Dimensions {
|
||||
s.togglePathSearch(PathLogDirectory)
|
||||
}
|
||||
for s.saveClick.Clicked(gtx) {
|
||||
settings, err := s.settingsState()
|
||||
if err != nil {
|
||||
s.pathFeedback = err.Error()
|
||||
continue
|
||||
}
|
||||
s.remoteDebugPort.SetText(strconv.Itoa(settings.RemoteDebugStartPort))
|
||||
if s.onSave != nil {
|
||||
s.onSave(SettingsState{ChromePath: s.chromePath.Text(), EdgePath: s.edgePath.Text(), DefaultDir: s.dataDir.Text(), LogDir: s.logDir.Text(), CloseOnExit: s.closeOnExit.Value})
|
||||
s.onSave(settings)
|
||||
}
|
||||
}
|
||||
for s.cancelClick.Clicked(gtx) {
|
||||
@@ -510,6 +591,8 @@ func (s *Shell) settings(gtx layout.Context) layout.Dimensions {
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
||||
layout.Rigid(s.pathField(PathLogDirectory, "日志目录", &s.logDir, &s.logPick, &s.logSearch)),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(s.remoteDebugPortField),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(material.Caption(s.theme, s.pathFeedback).Layout),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
||||
layout.Rigid(material.CheckBox(s.theme, &s.closeOnExit, "退出时关闭托管浏览器").Layout),
|
||||
@@ -524,6 +607,15 @@ func (s *Shell) settings(gtx layout.Context) layout.Dimensions {
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Shell) remoteDebugPortField(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(material.Body2(s.theme, "远程调试起始端口").Layout),
|
||||
layout.Rigid(material.Caption(s.theme, "留空时使用 9666;仅绑定 127.0.0.1,启动时顺序尝试下一个可用端口").Layout),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(material.Editor(s.theme, &s.remoteDebugPort, "例如:9666").Layout),
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Shell) pathField(field PathField, label string, editor *widget.Editor, pick, search *widget.Clickable) layout.Widget {
|
||||
return func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||
@@ -758,6 +850,11 @@ func (s *Shell) requestStart(id string) {
|
||||
s.instanceFeedback = "浏览器启动服务尚未准备好。"
|
||||
return
|
||||
}
|
||||
settings, err := s.settingsState()
|
||||
if err != nil {
|
||||
s.instanceFeedback = err.Error()
|
||||
return
|
||||
}
|
||||
if state == nil {
|
||||
state = &instanceStartState{}
|
||||
s.startStates[id] = state
|
||||
@@ -767,10 +864,9 @@ func (s *Shell) requestStart(id string) {
|
||||
state.running = true
|
||||
s.setInstanceStatus(id, "启动中")
|
||||
s.instanceFeedback = fmt.Sprintf("正在启动 %s…", row.Name)
|
||||
settings := s.settingsState()
|
||||
go func() {
|
||||
pid, err := s.instanceStarter(context.Background(), row, settings)
|
||||
s.startResults <- instanceStartResult{id: id, request: request, pid: pid, err: err}
|
||||
outcome, err := s.instanceStarter(context.Background(), row, settings)
|
||||
s.startResults <- instanceStartResult{id: id, request: request, outcome: outcome, err: err}
|
||||
if s.invalidate != nil {
|
||||
s.invalidate()
|
||||
}
|
||||
@@ -791,12 +887,35 @@ func (s *Shell) consumeStartResults() {
|
||||
continue
|
||||
}
|
||||
if result.err != nil {
|
||||
s.setInstanceStatus(result.id, "启动失败")
|
||||
s.instanceFeedback = fmt.Sprintf("启动 %s 失败:%v", row.Name, result.err)
|
||||
if errors.Is(result.err, domain.ErrProfileOccupied) {
|
||||
if result.outcome.Source == "chub_registry" {
|
||||
s.setInstanceRuntime(result.id, "运行中", result.outcome.PID, result.outcome.RemoteDebugPort, result.outcome.Source)
|
||||
s.instanceFeedback = fmt.Sprintf("%s 已由 Chub 启动,未重复创建浏览器。", row.Name)
|
||||
} else {
|
||||
s.setInstanceRuntime(result.id, "外部占用", result.outcome.PID, result.outcome.RemoteDebugPort, result.outcome.Source)
|
||||
s.instanceFeedback = fmt.Sprintf("%s 的 User Data Dir 已被外部浏览器占用,未再次启动。", row.Name)
|
||||
}
|
||||
} else {
|
||||
s.setInstanceStatus(result.id, "启动失败")
|
||||
s.instanceFeedback = fmt.Sprintf("启动 %s 失败:%v", row.Name, result.err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
s.setInstanceStatus(result.id, "运行中")
|
||||
s.instanceFeedback = fmt.Sprintf("已启动 %s(PID %d)。", row.Name, result.pid)
|
||||
if result.outcome.External {
|
||||
s.setInstanceRuntime(result.id, "外部已关联", result.outcome.PID, result.outcome.RemoteDebugPort, result.outcome.Source)
|
||||
s.instanceFeedback = fmt.Sprintf("已关联外部 %s(PID %d,端口 %d);不会接管其生命周期。", row.Name, result.outcome.PID, result.outcome.RemoteDebugPort)
|
||||
continue
|
||||
}
|
||||
status := "运行中"
|
||||
if result.outcome.Warning != "" {
|
||||
status = "运行中(调试不可用)"
|
||||
}
|
||||
s.setInstanceRuntime(result.id, status, result.outcome.PID, result.outcome.RemoteDebugPort, "chub_registry")
|
||||
if result.outcome.Warning != "" {
|
||||
s.instanceFeedback = fmt.Sprintf("%s 已启动(PID %d),但%s。", row.Name, result.outcome.PID, result.outcome.Warning)
|
||||
} else {
|
||||
s.instanceFeedback = fmt.Sprintf("已启动 %s(PID %d,端口 %d)。", row.Name, result.outcome.PID, result.outcome.RemoteDebugPort)
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
@@ -875,14 +994,44 @@ func (s *Shell) setInstanceStatus(id, status string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Shell) settingsState() SettingsState {
|
||||
return SettingsState{
|
||||
ChromePath: s.chromePath.Text(),
|
||||
EdgePath: s.edgePath.Text(),
|
||||
DefaultDir: s.dataDir.Text(),
|
||||
LogDir: s.logDir.Text(),
|
||||
CloseOnExit: s.closeOnExit.Value,
|
||||
func (s *Shell) setInstanceRuntime(id, status string, pid, remoteDebugPort int, source string) bool {
|
||||
for i := range s.rows {
|
||||
if s.rows[i].ID == id {
|
||||
s.rows[i].Status = status
|
||||
s.rows[i].PID = pid
|
||||
s.rows[i].RemoteDebugPort = remoteDebugPort
|
||||
s.rows[i].OccupancySource = source
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Shell) settingsState() (SettingsState, error) {
|
||||
port, err := normalizeRemoteDebugStartPort(s.remoteDebugPort.Text())
|
||||
if err != nil {
|
||||
return SettingsState{}, err
|
||||
}
|
||||
return SettingsState{
|
||||
ChromePath: s.chromePath.Text(),
|
||||
EdgePath: s.edgePath.Text(),
|
||||
DefaultDir: s.dataDir.Text(),
|
||||
LogDir: s.logDir.Text(),
|
||||
RemoteDebugStartPort: port,
|
||||
CloseOnExit: s.closeOnExit.Value,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeRemoteDebugStartPort(value string) (int, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return domain.DefaultRemoteDebugPort, nil
|
||||
}
|
||||
port, err := strconv.Atoi(value)
|
||||
if err != nil || !domain.ValidRemoteDebugPort(port) {
|
||||
return 0, fmt.Errorf("远程调试起始端口必须在 %d 到 %d 之间", domain.MinRemoteDebugPort, domain.MaxRemoteDebugPort)
|
||||
}
|
||||
return port, nil
|
||||
}
|
||||
|
||||
func (s *Shell) notifyInstancesChanged() {
|
||||
@@ -964,4 +1113,5 @@ func (s *Shell) resetSettings() {
|
||||
s.edgePath.SetText(`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`)
|
||||
s.dataDir.SetText(`C:\Users\Public\chub\profiles`)
|
||||
s.logDir.SetText(`C:\Users\Public\chub\logs`)
|
||||
s.remoteDebugPort.SetText(strconv.Itoa(domain.DefaultRemoteDebugPort))
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ func TestShellStartsWithSemanticInstanceStatuses(t *testing.T) {
|
||||
for _, row := range shell.rows {
|
||||
seen[row.Status] = true
|
||||
}
|
||||
for _, status := range []string{"运行中", "启动中", "外部占用", "已退出"} {
|
||||
for _, status := range []string{"运行中", "启动中", "外部已关联", "已退出"} {
|
||||
if !seen[status] {
|
||||
t.Fatalf("status %q is missing", status)
|
||||
}
|
||||
@@ -51,7 +51,7 @@ func TestShellInstanceRowsContainRequiredColumnsAndIndependentActionControls(t *
|
||||
}
|
||||
|
||||
func TestInstanceListColumnWeightsPrioritizeDirectoryAndCompactAction(t *testing.T) {
|
||||
got := instanceNameColumnWeight + instanceBrowserColumnWeight + instanceDirectoryColumnWeight + instanceStatusColumnWeight + instanceActionColumnWeight
|
||||
got := instanceNameColumnWeight + instanceBrowserColumnWeight + instanceDirectoryColumnWeight + instancePortColumnWeight + instanceStatusColumnWeight + instanceActionColumnWeight
|
||||
if got != 100 {
|
||||
t.Fatalf("instance column weights = %d, want 100", got)
|
||||
}
|
||||
@@ -120,9 +120,9 @@ func TestShellStartsInstanceAsynchronouslyAndAppliesResult(t *testing.T) {
|
||||
shell := NewShell(material.NewTheme())
|
||||
target := shell.rows[0]
|
||||
started := make(chan InstanceRow, 1)
|
||||
shell.OnStartInstance(func(_ context.Context, row InstanceRow, _ SettingsState) (int, error) {
|
||||
shell.OnStartInstance(func(_ context.Context, row InstanceRow, _ SettingsState) (InstanceStartOutcome, error) {
|
||||
started <- row
|
||||
return 4242, nil
|
||||
return InstanceStartOutcome{PID: 4242, RemoteDebugPort: 9666}, nil
|
||||
}, nil)
|
||||
|
||||
shell.requestStart(target.ID)
|
||||
@@ -145,7 +145,7 @@ func TestShellStartsInstanceAsynchronouslyAndAppliesResult(t *testing.T) {
|
||||
}
|
||||
shell.startResults <- result
|
||||
shell.consumeStartResults()
|
||||
if row, ok := shell.instanceRow(target.ID); !ok || row.Status != "运行中" {
|
||||
if row, ok := shell.instanceRow(target.ID); !ok || row.Status != "运行中" || row.PID != 4242 || row.RemoteDebugPort != 9666 {
|
||||
t.Fatalf("completed start status = %#v, want 运行中", row)
|
||||
}
|
||||
}
|
||||
@@ -156,11 +156,11 @@ func TestShellDoesNotStartTheSameInstanceTwiceWhilePending(t *testing.T) {
|
||||
entered := make(chan struct{}, 1)
|
||||
release := make(chan struct{})
|
||||
var calls atomic.Int32
|
||||
shell.OnStartInstance(func(_ context.Context, _ InstanceRow, _ SettingsState) (int, error) {
|
||||
shell.OnStartInstance(func(_ context.Context, _ InstanceRow, _ SettingsState) (InstanceStartOutcome, error) {
|
||||
calls.Add(1)
|
||||
entered <- struct{}{}
|
||||
<-release
|
||||
return 4242, nil
|
||||
return InstanceStartOutcome{PID: 4242, RemoteDebugPort: 9666}, nil
|
||||
}, nil)
|
||||
|
||||
shell.requestStart(target.ID)
|
||||
@@ -230,12 +230,47 @@ func TestShellAppliesLatestPathSearchResult(t *testing.T) {
|
||||
|
||||
func TestShellSettingsCanBeRestored(t *testing.T) {
|
||||
shell := NewShell(material.NewTheme())
|
||||
shell.SetSettings(SettingsState{ChromePath: "chrome", EdgePath: "edge", DefaultDir: "profiles", LogDir: "logs", CloseOnExit: false})
|
||||
if shell.chromePath.Text() != "chrome" || shell.edgePath.Text() != "edge" || shell.dataDir.Text() != "profiles" || shell.logDir.Text() != "logs" || shell.closeOnExit.Value {
|
||||
shell.SetSettings(SettingsState{ChromePath: "chrome", EdgePath: "edge", DefaultDir: "profiles", LogDir: "logs", RemoteDebugStartPort: 9777, CloseOnExit: false})
|
||||
if shell.chromePath.Text() != "chrome" || shell.edgePath.Text() != "edge" || shell.dataDir.Text() != "profiles" || shell.logDir.Text() != "logs" || shell.remoteDebugPort.Text() != "9777" || shell.closeOnExit.Value {
|
||||
t.Fatalf("settings were not restored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellNormalizesRemoteDebugStartPort(t *testing.T) {
|
||||
shell := NewShell(material.NewTheme())
|
||||
shell.remoteDebugPort.SetText("")
|
||||
settings, err := shell.settingsState()
|
||||
if err != nil || settings.RemoteDebugStartPort != 9666 {
|
||||
t.Fatalf("empty port settings = %#v, error = %v", settings, err)
|
||||
}
|
||||
shell.remoteDebugPort.SetText("80")
|
||||
if _, err := shell.settingsState(); err == nil {
|
||||
t.Fatal("invalid remote debug port was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellMarksExternalAssociationWithoutManagingLifecycle(t *testing.T) {
|
||||
shell := NewShell(material.NewTheme())
|
||||
target := shell.rows[2]
|
||||
shell.OnStartInstance(func(_ context.Context, _ InstanceRow, _ SettingsState) (InstanceStartOutcome, error) {
|
||||
return InstanceStartOutcome{PID: 16108, RemoteDebugPort: 9668, External: true, Source: "browser_message_window"}, nil
|
||||
}, nil)
|
||||
|
||||
shell.requestStart(target.ID)
|
||||
var result instanceStartResult
|
||||
select {
|
||||
case result = <-shell.startResults:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("external association result was not produced")
|
||||
}
|
||||
shell.startResults <- result
|
||||
shell.consumeStartResults()
|
||||
row, ok := shell.instanceRow(target.ID)
|
||||
if !ok || row.Status != "外部已关联" || row.PID != 16108 || row.RemoteDebugPort != 9668 || row.OccupancySource != "browser_message_window" {
|
||||
t.Fatalf("external association = %#v", row)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellKeepsSettingsStateAcrossPageSwitch(t *testing.T) {
|
||||
shell := NewShell(material.NewTheme())
|
||||
shell.page = pageSettings
|
||||
|
||||
Reference in New Issue
Block a user