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