From 4063b851eb31803443a09a411eeee20840d83ee9 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 25 Jul 2026 15:53:31 +0800 Subject: [PATCH] feat: add local cdp startup and external association --- cmd/chub/main.go | 89 ++++- cmd/chub/main_test.go | 99 +++++- docs/04-architecture.md | 2 +- docs/06-tasks.md | 2 +- docs/current-state.md | 9 +- docs/tasks/T-302.md | 12 +- internal/domain/browser.go | 37 ++- internal/domain/browser_test.go | 10 + internal/platform/browser/cdp.go | 182 +++++++++++ internal/platform/browser/cdp_test.go | 95 ++++++ internal/platform/browser/launch.go | 6 + internal/platform/browser/launch_test.go | 19 ++ .../platform/browser/smoke_windows_test.go | 37 ++- internal/platform/config/store.go | 25 +- internal/platform/config/store_test.go | 18 +- internal/ui/shell.go | 308 +++++++++++++----- internal/ui/shell_test.go | 53 ++- 17 files changed, 845 insertions(+), 158 deletions(-) create mode 100644 internal/platform/browser/cdp.go create mode 100644 internal/platform/browser/cdp_test.go diff --git a/cmd/chub/main.go b/cmd/chub/main.go index 0616e80..63561ca 100644 --- a/cmd/chub/main.go +++ b/cmd/chub/main.go @@ -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) { diff --git a/cmd/chub/main_test.go b/cmd/chub/main_test.go index 504fe2e..618625c 100644 --- a/cmd/chub/main_test.go +++ b/cmd/chub/main_test.go @@ -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 +} diff --git a/docs/04-architecture.md b/docs/04-architecture.md index d7c6d8b..9a494f4 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -30,7 +30,7 @@ UI/CLI 不直接操作 `exec.Cmd`、Win32 handle 或数据库。Application 层 `CREATED -> STARTING -> RUNNING -> STOPPING -> EXITED`;启动失败为 `FAILED`,无法确认的外部实例为 `UNKNOWN`。 -- Start:规范化路径和参数,先检查 user data dir。未占用时从配置的起始端口选择可用 loopback 端口,注册 pending 后以受控的 `--remote-debugging-address=127.0.0.1` 和 `--remote-debugging-port` 启动,并以 `DevToolsActivePort` 与 `/json/version` 验证实际端点。 +- Start:规范化路径和参数,先检查 user data dir。未占用时从配置的起始端口选择可用 loopback 端口,注册 pending 后以受控的 `--remote-debugging-address=127.0.0.1` 和 `--remote-debugging-port` 启动,并直接请求该已知端口的 `/json/version` 验证实际端点。 - 外部关联:目录被占用时只检查该目录中的 `DevToolsActivePort` 和其 loopback `/json/version`;端点与浏览器类型均匹配才报告 `EXTERNAL_ASSOCIATED`,不注册、关闭、重启或强制终止该 PID。无法验证时保持 `UNKNOWN`/占用并拒绝启动。 - Stop:校验身份,优先窗口关闭/浏览器协议,超时后经明确授权强制终止。 - Force stop:使用实例 Job Object 或已校验进程树,不按名称全局终止。 diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 14f2406..fbecb5c 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -37,7 +37,7 @@ | ID | 任务 | 依赖 | 状态 | | --- | --- | --- | --- | | T-301 | 接入 GUI 启动操作与实例删除确认 | T-204,T-208 | DONE | -| T-302 | 本地 CDP 启动、外部关联与实例端口展示 | T-102,T-301 | DOING | +| T-302 | 本地 CDP 启动、外部关联与实例端口展示 | T-102,T-301 | DONE | ## Backlog diff --git a/docs/current-state.md b/docs/current-state.md index b8da99c..e980bb0 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -3,12 +3,11 @@ ## 快照 - 日期:2026-07-25 -- 阶段:Phase 3 真实实例操作(T-302 进行中;T-301 已完成;T-201 至 T-208 已完成) -- 代码:已建立 Go module `chub`、`cmd/chub` 入口、logging 测试基座、浏览器 domain/application 合约、Chrome/Edge 参数/发现模块、启动 registry、Windows 身份/占用检查、优雅关闭、Job Object、真实 Chrome/Edge smoke、JSON 配置存储、启动恢复、CLI JSON 合约、应用内事件总线、UI 状态测试和 Windows smoke 脚本,T-001 至 T-003、T-101 至 T-104、T-201 至 T-208、T-301 已完成 -- UI:Gio 双页 Shell 使用左侧“实例/设置”导航;实例列表固定显示实例名称、浏览器类型、用户数据目录、状态和紧凑启动/删除图标。启动通过异步回调接到 Windows 浏览器启动器,并显示启动中、运行中或失败;删除使用确认弹层,只删除 Chub 实例配置而不删除 User Data Dir。新建和删除实例会保存到本地配置;四个设置路径有独立异步可取消搜索,切换页面后输入和任务状态保留;新建实例表单使用 Label、Windows 原生目录选择器和 Chrome/Edge RadioButton;CLI `list/events` 已可用,`start/stop/restart` 等待 BrowserManager adapter +- 阶段:Phase 3 真实实例操作(T-302、T-301 已完成;T-201 至 T-208 已完成) +- 代码:已建立 Go module `chub`、`cmd/chub` 入口、logging 测试基座、浏览器 domain/application 合约、Chrome/Edge 参数/发现模块、启动 registry、Windows 身份/占用检查、loopback CDP 端口分配与端点校验、优雅关闭、Job Object、真实 Chrome/Edge smoke、JSON 配置存储、启动恢复、CLI JSON 合约、应用内事件总线、UI 状态测试和 Windows smoke 脚本,T-001 至 T-003、T-101 至 T-104、T-201 至 T-208、T-301、T-302 已完成 +- UI:Gio 双页 Shell 使用左侧“实例/设置”导航;实例列表固定显示实例名称、浏览器类型、用户数据目录、调试端口、状态和紧凑启动/删除图标,并以外层边框和表头分隔组织。启动通过异步回调接到 Windows 浏览器启动器:未占用目录从已保存的起始端口(空值默认 9666)选择 loopback CDP 端口;同目录外部浏览器只有在 `DevToolsActivePort` 与 CDP 端点可验证时才显示“外部已关联”,且不会接管其生命周期。删除使用确认弹层,只删除 Chub 实例配置而不删除 User Data Dir。新建和删除实例会保存到本地配置;四个设置路径有独立异步可取消搜索,切换页面后输入和任务状态保留;新建实例表单使用 Label、Windows 原生目录选择器和 Chrome/Edge RadioButton;CLI `list/events` 已可用,`start/stop/restart` 等待 BrowserManager adapter - 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目 -- 当前任务:T-302 正在为托管实例接入 loopback CDP 端口分配、同目录外部浏览器的只读关联、实例列表端口字段和设置中的远程调试起始端口;外部关联不接管进程生命周期。 -- blocker:无;T-302 完成后可继续接入关闭、重启、退出状态回传和 CLI BrowserManager adapter。 +- blocker:无;下一步可接入关闭、重启、退出状态回传和 CLI BrowserManager adapter。 ## 当前目录 diff --git a/docs/tasks/T-302.md b/docs/tasks/T-302.md index 45028ea..8f390ac 100644 --- a/docs/tasks/T-302.md +++ b/docs/tasks/T-302.md @@ -3,7 +3,7 @@ id: T-302 title: 本地 CDP 启动、外部关联与实例端口展示 phase: 3 deps: [T-102, T-301] -status: DOING +status: DONE created: 2026-07-25 owner: codex --- @@ -17,7 +17,7 @@ Chub 需要为新启动的 Chrome/Edge 分配仅限 loopback 的 remote debuggin ## 方案与边界 - `LaunchSpec` 将 remote debugging port 作为受控领域字段;`BuildArgs` 固定生成 `--remote-debugging-address=127.0.0.1` 和 `--remote-debugging-port=`,额外参数继续不得覆盖。 -- 平台端从保存的起始端口开始,在最多 100 个端口范围中探测可用候选;启动后等待 `DevToolsActivePort` 并请求 loopback `/json/version`,只有验证成功才把该端口写入运行时结果。端口探测与浏览器绑定之间存在竞争时,不自动杀死或重复启动浏览器。 +- 平台端从保存的起始端口开始,在最多 100 个端口范围中探测可用候选;启动后直接请求该已知 loopback 端口的 `/json/version`,只有验证成功才把该端口写入运行时结果。端口探测与浏览器绑定之间存在竞争时,不自动杀死或重复启动浏览器。 - 启动前先合并 Chub launcher registry 与 Windows 外部 profile inspector 结果。目录被外部占用时,只读取该目录的 `DevToolsActivePort`,不扫描全系统 Chrome/Edge 进程或端口。 - 外部端点验证失败、端口文件过期、权限不足或浏览器类型不匹配时,保守报告外部/未知占用并拒绝启动;不得借此接管外部 PID。 - Gio 设置增加带 Label 和帮助文本的“远程调试起始端口”数字输入;空值在保存时归一化为 `9666`,有效值为 `1024` 至 `65535`。 @@ -36,8 +36,8 @@ Chub 需要为新启动的 Chrome/Edge 分配仅限 loopback 的 remote debuggin ## 执行记录 -- 状态:DOING -- 变更:已建立需求、架构、API、交互、原型和任务契约;待实现。 -- 验证:文档待实现验证。 +- 状态:DONE +- 变更:`LaunchSpec` 新增受控 remote debugging port 并固定生成 loopback 参数;设置持久化并恢复起始端口(空值/旧配置默认 9666);新增端口递增、`DevToolsActivePort` 解析和 `/json/version` 浏览器类型校验。GUI 启动链路会拒绝未验证的外部占用、关联可验证的外部实例、展示 PID/端口/来源,并将端口作为列表边框内的独立字段。真实 smoke 以已知端口校验 Chrome/Edge CDP,并在任意失败路径按 profile 清理测试进程。 +- 验证:`gofmt`、`go test ./...`、`go vet ./...`、`go build -o build/chub.exe ./cmd/chub`、`scripts/smoke-browser.ps1`(真实 Chrome/Edge loopback CDP)和 `scripts/smoke-windows.ps1` 均通过。 - 阻塞:无。 -- 残余风险:Chrome 默认用户目录可能忽略 remote debugging 开关;端口预探测存在不可消除的短暂竞争,最终以端点验证为准。 +- 残余风险:Chrome 默认用户目录可能忽略 remote debugging 开关;端口预探测存在不可消除的短暂竞争,最终以端点验证为准。外部浏览器未留下 `DevToolsActivePort` 时,Chub 不会猜测或扫描端口以避免把其他 profile 的 CDP 端点错误关联,只报告外部占用。 diff --git a/internal/domain/browser.go b/internal/domain/browser.go index bdcf072..c59fe90 100644 --- a/internal/domain/browser.go +++ b/internal/domain/browser.go @@ -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 diff --git a/internal/domain/browser_test.go b/internal/domain/browser_test.go index 1f49f58..942cedb 100644 --- a/internal/domain/browser_test.go +++ b/internal/domain/browser_test.go @@ -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) + } + } +} diff --git a/internal/platform/browser/cdp.go b/internal/platform/browser/cdp.go new file mode 100644 index 0000000..20dac2e --- /dev/null +++ b/internal/platform/browser/cdp.go @@ -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 + } +} diff --git a/internal/platform/browser/cdp_test.go b/internal/platform/browser/cdp_test.go new file mode 100644 index 0000000..38668e0 --- /dev/null +++ b/internal/platform/browser/cdp_test.go @@ -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) + } +} diff --git a/internal/platform/browser/launch.go b/internal/platform/browser/launch.go index 98c8088..4f165e2 100644 --- a/internal/platform/browser/launch.go +++ b/internal/platform/browser/launch.go @@ -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) } diff --git a/internal/platform/browser/launch_test.go b/internal/platform/browser/launch_test.go index 646c630..39edb13 100644 --- a/internal/platform/browser/launch_test.go +++ b/internal/platform/browser/launch_test.go @@ -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`) diff --git a/internal/platform/browser/smoke_windows_test.go b/internal/platform/browser/smoke_windows_test.go index d0fe750..5bbf326 100644 --- a/internal/platform/browser/smoke_windows_test.go +++ b/internal/platform/browser/smoke_windows_test.go @@ -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) - } - } } diff --git a/internal/platform/config/store.go b/internal/platform/config/store.go index c3330d6..04af41f 100644 --- a/internal/platform/config/store.go +++ b/internal/platform/config/store.go @@ -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, } } diff --git a/internal/platform/config/store_test.go b/internal/platform/config/store_test.go index 14f1bf2..5ee48bc 100644 --- a/internal/platform/config/store_test.go +++ b/internal/platform/config/store_test.go @@ -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 { diff --git a/internal/ui/shell.go b/internal/ui/shell.go index e1131c0..d95a134 100644 --- a/internal/ui/shell.go +++ b/internal/ui/shell.go @@ -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)) } diff --git a/internal/ui/shell_test.go b/internal/ui/shell_test.go index 4087200..d0f52df 100644 --- a/internal/ui/shell_test.go +++ b/internal/ui/shell_test.go @@ -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