feat: add local cdp startup and external association

This commit is contained in:
QiuSW
2026-07-25 15:53:31 +08:00
parent 972c637716
commit 4063b851eb
17 changed files with 845 additions and 158 deletions
+25 -12
View File
@@ -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
+10
View File
@@ -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)
}
}
}
+182
View File
@@ -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
}
}
+95
View File
@@ -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)
}
}
+6
View File
@@ -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)
}
+19
View File
@@ -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`)
+25 -12
View File
@@ -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)
}
}
}
+15 -10
View File
@@ -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,
}
}
+17 -1
View File
@@ -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
View File
@@ -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))
}
+44 -9
View File
@@ -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