feat: add local cdp startup and external association

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