feat: add local cdp startup and external association
This commit is contained in:
+72
-17
@@ -107,7 +107,16 @@ func runWindow(logger *slog.Logger) {
|
||||
directoryPicker := files.NewDirectoryPicker()
|
||||
shell.OnChooseDirectory(directoryPicker.ChooseDirectory, window.Invalidate)
|
||||
discoverer := browser.NewDiscoverer()
|
||||
shell.OnStartInstance(instanceStarter{launcher: browser.NewOSLauncher(), resolver: discoverer}.Start, window.Invalidate)
|
||||
launcher := browser.NewOSLauncher()
|
||||
cdp := browser.NewCDPInspector()
|
||||
shell.OnStartInstance(instanceStarter{
|
||||
launcher: launcher,
|
||||
resolver: discoverer,
|
||||
managedProfiles: launcher,
|
||||
externalProfiles: browser.NewWindowsProfileInspector(),
|
||||
remoteDebug: cdp,
|
||||
portAllocator: cdp,
|
||||
}.Start, window.Invalidate)
|
||||
shell.OnPathSearch(func(ctx context.Context, field ui.PathField, current string) (string, error) {
|
||||
switch field {
|
||||
case ui.PathChromeExecutable:
|
||||
@@ -137,14 +146,14 @@ func runWindow(logger *slog.Logger) {
|
||||
if path, err := config.DefaultPath(); err == nil {
|
||||
if store, err := config.New(path); err == nil {
|
||||
if saved, err := store.Load(); err == nil {
|
||||
shell.SetSettings(ui.SettingsState{ChromePath: saved.Settings.ChromePath, EdgePath: saved.Settings.EdgePath, DefaultDir: saved.Settings.DefaultDir, LogDir: saved.Settings.LogDir, CloseOnExit: saved.Settings.CloseOnExit})
|
||||
shell.SetSettings(ui.SettingsState{ChromePath: saved.Settings.ChromePath, EdgePath: saved.Settings.EdgePath, DefaultDir: saved.Settings.DefaultDir, LogDir: saved.Settings.LogDir, RemoteDebugStartPort: saved.Settings.RemoteDebugStartPort, CloseOnExit: saved.Settings.CloseOnExit})
|
||||
rows := make([]ui.InstanceRow, 0, len(saved.Instances))
|
||||
for _, item := range saved.Instances {
|
||||
rows = append(rows, ui.InstanceRow{ID: item.ID, Name: item.Name, Browser: browserLabel(item.Launch.Kind), UserDataDir: item.Launch.UserDataDir, TargetURL: item.Launch.TargetURL, Status: "已退出"})
|
||||
}
|
||||
shell.SetInstances(rows)
|
||||
shell.OnSave(func(value ui.SettingsState) {
|
||||
saved.Settings = config.Settings{ChromePath: value.ChromePath, EdgePath: value.EdgePath, DefaultDir: value.DefaultDir, LogDir: value.LogDir, CloseOnExit: value.CloseOnExit}
|
||||
saved.Settings = config.Settings{ChromePath: value.ChromePath, EdgePath: value.EdgePath, DefaultDir: value.DefaultDir, LogDir: value.LogDir, RemoteDebugStartPort: value.RemoteDebugStartPort, CloseOnExit: value.CloseOnExit}
|
||||
go saveSettings(logger, store, configSnapshot(saved))
|
||||
})
|
||||
shell.OnInstancesChanged(func(rows []ui.InstanceRow) {
|
||||
@@ -185,18 +194,53 @@ type processLauncher interface {
|
||||
Start(context.Context, domain.LaunchSpec) (browser.ProcessHandle, error)
|
||||
}
|
||||
|
||||
type instanceStarter struct {
|
||||
launcher processLauncher
|
||||
resolver executableResolver
|
||||
type managedProfileInspector interface {
|
||||
InspectProfile(context.Context, string) (browser.ProfileUse, error)
|
||||
}
|
||||
|
||||
func (s instanceStarter) Start(ctx context.Context, row ui.InstanceRow, settings ui.SettingsState) (int, error) {
|
||||
type instanceStarter struct {
|
||||
launcher processLauncher
|
||||
resolver executableResolver
|
||||
managedProfiles managedProfileInspector
|
||||
externalProfiles browser.ExternalProfileInspector
|
||||
remoteDebug browser.RemoteDebugEndpointInspector
|
||||
portAllocator browser.RemoteDebugPortAllocator
|
||||
}
|
||||
|
||||
func (s instanceStarter) Start(ctx context.Context, row ui.InstanceRow, settings ui.SettingsState) (ui.InstanceStartOutcome, error) {
|
||||
kind, err := browserKind(row.Browser)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return ui.InstanceStartOutcome{}, err
|
||||
}
|
||||
if s.launcher == nil || s.resolver == nil {
|
||||
return 0, errors.New("浏览器启动服务尚未准备好")
|
||||
if s.launcher == nil || s.resolver == nil || s.remoteDebug == nil || s.portAllocator == nil {
|
||||
return ui.InstanceStartOutcome{}, errors.New("浏览器启动服务尚未准备好")
|
||||
}
|
||||
if s.managedProfiles != nil {
|
||||
use, inspectErr := s.managedProfiles.InspectProfile(ctx, row.UserDataDir)
|
||||
if inspectErr != nil {
|
||||
return ui.InstanceStartOutcome{}, fmt.Errorf("检查 Chub 实例状态失败:%w", inspectErr)
|
||||
}
|
||||
if use.Occupied {
|
||||
endpoint, _ := s.remoteDebug.InspectRemoteDebugEndpoint(ctx, kind, row.UserDataDir)
|
||||
return ui.InstanceStartOutcome{PID: use.PID, RemoteDebugPort: endpoint.Port, Source: use.Source}, &browser.ProfileOccupiedError{UserDataDir: row.UserDataDir, PID: use.PID}
|
||||
}
|
||||
}
|
||||
if s.externalProfiles != nil {
|
||||
use, inspectErr := s.externalProfiles.InspectProfile(ctx, kind, row.UserDataDir, 0)
|
||||
if inspectErr != nil {
|
||||
return ui.InstanceStartOutcome{Source: "unknown"}, fmt.Errorf("检查外部浏览器占用失败:%w", inspectErr)
|
||||
}
|
||||
endpoint, endpointErr := s.remoteDebug.InspectRemoteDebugEndpoint(ctx, kind, row.UserDataDir)
|
||||
if endpointErr == nil {
|
||||
source := use.Source
|
||||
if source == "" {
|
||||
source = "devtools_active_port"
|
||||
}
|
||||
return ui.InstanceStartOutcome{PID: use.PID, RemoteDebugPort: endpoint.Port, External: true, Source: source}, nil
|
||||
}
|
||||
if use.Occupied {
|
||||
return ui.InstanceStartOutcome{PID: use.PID, Source: use.Source}, &browser.ProfileOccupiedError{UserDataDir: row.UserDataDir, PID: use.PID}
|
||||
}
|
||||
}
|
||||
configuredExecutable := settings.ChromePath
|
||||
if kind == domain.BrowserEdge {
|
||||
@@ -204,18 +248,29 @@ func (s instanceStarter) Start(ctx context.Context, row ui.InstanceRow, settings
|
||||
}
|
||||
executable, err := s.resolver.Resolve(ctx, kind, configuredExecutable)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("无法找到%s可执行文件:%w", browserLabel(kind), err)
|
||||
return ui.InstanceStartOutcome{}, fmt.Errorf("无法找到%s可执行文件:%w", browserLabel(kind), err)
|
||||
}
|
||||
port, err := s.portAllocator.FindAvailableRemoteDebugPort(ctx, settings.RemoteDebugStartPort)
|
||||
if err != nil {
|
||||
return ui.InstanceStartOutcome{}, fmt.Errorf("无法分配本地调试端口:%w", err)
|
||||
}
|
||||
handle, err := s.launcher.Start(ctx, domain.LaunchSpec{
|
||||
Kind: kind,
|
||||
Executable: executable,
|
||||
UserDataDir: row.UserDataDir,
|
||||
TargetURL: row.TargetURL,
|
||||
Kind: kind,
|
||||
Executable: executable,
|
||||
UserDataDir: row.UserDataDir,
|
||||
RemoteDebugPort: port,
|
||||
TargetURL: row.TargetURL,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return ui.InstanceStartOutcome{}, err
|
||||
}
|
||||
return handle.PID(), nil
|
||||
endpointCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
endpoint, endpointErr := s.remoteDebug.WaitForRemoteDebugPort(endpointCtx, kind, port)
|
||||
if endpointErr != nil {
|
||||
return ui.InstanceStartOutcome{PID: handle.PID(), Warning: "本地调试端点不可用"}, nil
|
||||
}
|
||||
return ui.InstanceStartOutcome{PID: handle.PID(), RemoteDebugPort: endpoint.Port, Source: "chub_registry"}, nil
|
||||
}
|
||||
|
||||
func browserKind(value string) (domain.BrowserKind, error) {
|
||||
|
||||
+94
-5
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -22,19 +23,60 @@ func TestRunCLIRejectsUnknownCommandWithStableCode(t *testing.T) {
|
||||
func TestInstanceStarterBuildsLaunchSpecFromInstanceAndSettings(t *testing.T) {
|
||||
launcher := &fakeProcessLauncher{handle: fakeProcessHandle{pid: 4242}}
|
||||
resolver := &fakeExecutableResolver{path: `C:\Browser\msedge.exe`}
|
||||
starter := instanceStarter{launcher: launcher, resolver: resolver}
|
||||
pid, err := starter.Start(context.Background(), ui.InstanceRow{ID: "edge-a", Browser: "Edge", UserDataDir: `C:\profiles\edge-a`}, ui.SettingsState{EdgePath: `C:\Configured\msedge.exe`})
|
||||
if err != nil || pid != 4242 {
|
||||
t.Fatalf("Start() = %d, %v", pid, err)
|
||||
remote := &fakeRemoteDebugInspector{endpoint: browser.RemoteDebugEndpoint{Port: 9666}, inspectErr: browser.ErrRemoteDebugEndpointUnavailable}
|
||||
starter := instanceStarter{launcher: launcher, resolver: resolver, managedProfiles: fakeManagedProfileInspector{}, externalProfiles: fakeExternalProfileInspector{}, remoteDebug: remote, portAllocator: remote}
|
||||
outcome, err := starter.Start(context.Background(), ui.InstanceRow{ID: "edge-a", Browser: "Edge", UserDataDir: `C:\profiles\edge-a`}, ui.SettingsState{EdgePath: `C:\Configured\msedge.exe`, RemoteDebugStartPort: 9666})
|
||||
if err != nil || outcome.PID != 4242 || outcome.RemoteDebugPort != 9666 {
|
||||
t.Fatalf("Start() = %#v, %v", outcome, err)
|
||||
}
|
||||
if resolver.kind != domain.BrowserEdge || resolver.configured != `C:\Configured\msedge.exe` {
|
||||
t.Fatalf("resolver input = %s, %q", resolver.kind, resolver.configured)
|
||||
}
|
||||
if launcher.spec.Kind != domain.BrowserEdge || launcher.spec.Executable != resolver.path || launcher.spec.TargetURL != "" {
|
||||
if launcher.spec.Kind != domain.BrowserEdge || launcher.spec.Executable != resolver.path || launcher.spec.TargetURL != "" || launcher.spec.RemoteDebugPort != 9666 {
|
||||
t.Fatalf("launch spec = %#v", launcher.spec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceStarterAssociatesVerifiedExternalProfileWithoutLaunching(t *testing.T) {
|
||||
launcher := &fakeProcessLauncher{handle: fakeProcessHandle{pid: 4242}}
|
||||
remote := &fakeRemoteDebugInspector{endpoint: browser.RemoteDebugEndpoint{Port: 9668}}
|
||||
starter := instanceStarter{
|
||||
launcher: launcher,
|
||||
resolver: &fakeExecutableResolver{path: `C:\Browser\chrome.exe`},
|
||||
managedProfiles: fakeManagedProfileInspector{},
|
||||
externalProfiles: fakeExternalProfileInspector{use: browser.ProfileUse{Occupied: true, PID: 16108, Source: browser.ProfileSourceMessageWindow}},
|
||||
remoteDebug: remote,
|
||||
portAllocator: remote,
|
||||
}
|
||||
outcome, err := starter.Start(context.Background(), ui.InstanceRow{ID: "chrome-a", Browser: "Chrome", UserDataDir: `C:\profiles\chrome-a`}, ui.SettingsState{RemoteDebugStartPort: 9666})
|
||||
if err != nil || !outcome.External || outcome.PID != 16108 || outcome.RemoteDebugPort != 9668 || outcome.Source != browser.ProfileSourceMessageWindow {
|
||||
t.Fatalf("Start() = %#v, %v", outcome, err)
|
||||
}
|
||||
if launcher.calls != 0 {
|
||||
t.Fatalf("external association launched %d processes", launcher.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceStarterRejectsUnverifiedExternalProfile(t *testing.T) {
|
||||
launcher := &fakeProcessLauncher{handle: fakeProcessHandle{pid: 4242}}
|
||||
remote := &fakeRemoteDebugInspector{inspectErr: browser.ErrRemoteDebugEndpointUnavailable}
|
||||
starter := instanceStarter{
|
||||
launcher: launcher,
|
||||
resolver: &fakeExecutableResolver{path: `C:\Browser\chrome.exe`},
|
||||
managedProfiles: fakeManagedProfileInspector{},
|
||||
externalProfiles: fakeExternalProfileInspector{use: browser.ProfileUse{Occupied: true, PID: 16108, Source: browser.ProfileSourceLock}},
|
||||
remoteDebug: remote,
|
||||
portAllocator: remote,
|
||||
}
|
||||
_, err := starter.Start(context.Background(), ui.InstanceRow{ID: "chrome-a", Browser: "Chrome", UserDataDir: `C:\profiles\chrome-a`}, ui.SettingsState{RemoteDebugStartPort: 9666})
|
||||
if !errors.Is(err, domain.ErrProfileOccupied) {
|
||||
t.Fatalf("Start() error = %v", err)
|
||||
}
|
||||
if launcher.calls != 0 {
|
||||
t.Fatalf("unverified external profile launched %d processes", launcher.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeInstanceConfigPreservesExistingLaunchOptions(t *testing.T) {
|
||||
existing := []config.Instance{{ID: "one", Name: "旧名称", Launch: domain.LaunchSpec{Kind: domain.BrowserChrome, UserDataDir: `C:\profiles\old`, ProfileDirectory: "Default", ProxyServer: "https://proxy.local:8443", ExtraArgs: []string{"--window-size=1280,900"}}}}
|
||||
updated := mergeInstanceConfig(existing, []ui.InstanceRow{{ID: "one", Name: "新名称", Browser: "Edge", UserDataDir: `C:\profiles\new`, TargetURL: "https://example.com"}})
|
||||
@@ -65,10 +107,12 @@ func (r *fakeExecutableResolver) Resolve(_ context.Context, kind domain.BrowserK
|
||||
type fakeProcessLauncher struct {
|
||||
spec domain.LaunchSpec
|
||||
handle browser.ProcessHandle
|
||||
calls int
|
||||
}
|
||||
|
||||
func (l *fakeProcessLauncher) Start(_ context.Context, spec domain.LaunchSpec) (browser.ProcessHandle, error) {
|
||||
l.spec = spec
|
||||
l.calls++
|
||||
return l.handle, nil
|
||||
}
|
||||
|
||||
@@ -77,3 +121,48 @@ type fakeProcessHandle struct{ pid int }
|
||||
func (p fakeProcessHandle) PID() int { return p.pid }
|
||||
|
||||
func (fakeProcessHandle) Wait(context.Context) (int, error) { return 0, nil }
|
||||
|
||||
type fakeManagedProfileInspector struct{ use browser.ProfileUse }
|
||||
|
||||
func (i fakeManagedProfileInspector) InspectProfile(context.Context, string) (browser.ProfileUse, error) {
|
||||
return i.use, nil
|
||||
}
|
||||
|
||||
type fakeExternalProfileInspector struct {
|
||||
use browser.ProfileUse
|
||||
err error
|
||||
}
|
||||
|
||||
func (i fakeExternalProfileInspector) InspectProfile(context.Context, domain.BrowserKind, string, int) (browser.ProfileUse, error) {
|
||||
return i.use, i.err
|
||||
}
|
||||
|
||||
type fakeRemoteDebugInspector struct {
|
||||
endpoint browser.RemoteDebugEndpoint
|
||||
inspectErr error
|
||||
waitErr error
|
||||
port int
|
||||
}
|
||||
|
||||
func (i *fakeRemoteDebugInspector) InspectRemoteDebugEndpoint(context.Context, domain.BrowserKind, string) (browser.RemoteDebugEndpoint, error) {
|
||||
return i.endpoint, i.inspectErr
|
||||
}
|
||||
|
||||
func (i *fakeRemoteDebugInspector) WaitForRemoteDebugEndpoint(context.Context, domain.BrowserKind, string) (browser.RemoteDebugEndpoint, error) {
|
||||
return i.endpoint, i.waitErr
|
||||
}
|
||||
|
||||
func (i *fakeRemoteDebugInspector) InspectRemoteDebugPort(context.Context, domain.BrowserKind, int) (browser.RemoteDebugEndpoint, error) {
|
||||
return i.endpoint, i.inspectErr
|
||||
}
|
||||
|
||||
func (i *fakeRemoteDebugInspector) WaitForRemoteDebugPort(context.Context, domain.BrowserKind, int) (browser.RemoteDebugEndpoint, error) {
|
||||
return i.endpoint, i.waitErr
|
||||
}
|
||||
|
||||
func (i *fakeRemoteDebugInspector) FindAvailableRemoteDebugPort(_ context.Context, start int) (int, error) {
|
||||
if i.port != 0 {
|
||||
return i.port, nil
|
||||
}
|
||||
return start, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user