diff --git a/cmd/chub/main.go b/cmd/chub/main.go index 2b5b834..0616e80 100644 --- a/cmd/chub/main.go +++ b/cmd/chub/main.go @@ -10,6 +10,7 @@ import ( "os" "path/filepath" "strings" + "time" "chub/internal/domain" "chub/internal/platform/browser" @@ -106,6 +107,7 @@ 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) shell.OnPathSearch(func(ctx context.Context, field ui.PathField, current string) (string, error) { switch field { case ui.PathChromeExecutable: @@ -138,12 +140,16 @@ func runWindow(logger *slog.Logger) { shell.SetSettings(ui.SettingsState{ChromePath: saved.Settings.ChromePath, EdgePath: saved.Settings.EdgePath, DefaultDir: saved.Settings.DefaultDir, LogDir: saved.Settings.LogDir, 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: string(item.Launch.Kind), UserDataDir: item.Launch.UserDataDir, Status: "已退出"}) + 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} - go saveSettings(logger, store, saved) + go saveSettings(logger, store, configSnapshot(saved)) + }) + shell.OnInstancesChanged(func(rows []ui.InstanceRow) { + saved.Instances = mergeInstanceConfig(saved.Instances, rows) + go saveSettings(logger, store, configSnapshot(saved)) }) } } @@ -170,3 +176,94 @@ func saveSettings(logger *slog.Logger, store *config.Store, value config.File) { logger.Error("save settings", "error", err) } } + +type executableResolver interface { + Resolve(context.Context, domain.BrowserKind, string) (string, error) +} + +type processLauncher interface { + Start(context.Context, domain.LaunchSpec) (browser.ProcessHandle, error) +} + +type instanceStarter struct { + launcher processLauncher + resolver executableResolver +} + +func (s instanceStarter) Start(ctx context.Context, row ui.InstanceRow, settings ui.SettingsState) (int, error) { + kind, err := browserKind(row.Browser) + if err != nil { + return 0, err + } + if s.launcher == nil || s.resolver == nil { + return 0, errors.New("浏览器启动服务尚未准备好") + } + configuredExecutable := settings.ChromePath + if kind == domain.BrowserEdge { + configuredExecutable = settings.EdgePath + } + executable, err := s.resolver.Resolve(ctx, kind, configuredExecutable) + if err != nil { + return 0, fmt.Errorf("无法找到%s可执行文件:%w", browserLabel(kind), err) + } + handle, err := s.launcher.Start(ctx, domain.LaunchSpec{ + Kind: kind, + Executable: executable, + UserDataDir: row.UserDataDir, + TargetURL: row.TargetURL, + }) + if err != nil { + return 0, err + } + return handle.PID(), nil +} + +func browserKind(value string) (domain.BrowserKind, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "chrome": + return domain.BrowserChrome, nil + case "edge": + return domain.BrowserEdge, nil + default: + return "", fmt.Errorf("不支持的浏览器类型:%s", value) + } +} + +func browserLabel(kind domain.BrowserKind) string { + if kind == domain.BrowserEdge { + return "Edge" + } + return "Chrome" +} + +func mergeInstanceConfig(existing []config.Instance, rows []ui.InstanceRow) []config.Instance { + byID := make(map[string]config.Instance, len(existing)) + for _, item := range existing { + byID[item.ID] = item + } + updated := make([]config.Instance, 0, len(rows)) + for _, row := range rows { + kind, err := browserKind(row.Browser) + if err != nil { + continue + } + item := byID[row.ID] + item.ID = row.ID + item.Name = row.Name + item.Launch.Kind = kind + item.Launch.UserDataDir = row.UserDataDir + item.Launch.TargetURL = row.TargetURL + item.UpdatedAt = time.Now() + updated = append(updated, item) + } + return updated +} + +func configSnapshot(value config.File) config.File { + clone := value + clone.Instances = append([]config.Instance(nil), value.Instances...) + for i := range clone.Instances { + clone.Instances[i].Launch.ExtraArgs = append([]string(nil), clone.Instances[i].Launch.ExtraArgs...) + } + return clone +} diff --git a/cmd/chub/main_test.go b/cmd/chub/main_test.go index b1ef725..504fe2e 100644 --- a/cmd/chub/main_test.go +++ b/cmd/chub/main_test.go @@ -2,8 +2,14 @@ package main import ( "bytes" + "context" "strings" "testing" + + "chub/internal/domain" + "chub/internal/platform/browser" + "chub/internal/platform/config" + "chub/internal/ui" ) func TestRunCLIRejectsUnknownCommandWithStableCode(t *testing.T) { @@ -12,3 +18,62 @@ func TestRunCLIRejectsUnknownCommandWithStableCode(t *testing.T) { t.Fatalf("code=%d output=%s", code, out.String()) } } + +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) + } + 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 != "" { + t.Fatalf("launch spec = %#v", launcher.spec) + } +} + +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"}}) + if len(updated) != 1 { + t.Fatalf("instances = %d, want 1", len(updated)) + } + got := updated[0] + if got.Name != "新名称" || got.Launch.Kind != domain.BrowserEdge || got.Launch.UserDataDir != `C:\profiles\new` || got.Launch.TargetURL != "https://example.com" { + t.Fatalf("updated instance = %#v", got) + } + if got.Launch.ProfileDirectory != "Default" || got.Launch.ProxyServer != "https://proxy.local:8443" || len(got.Launch.ExtraArgs) != 1 { + t.Fatalf("existing launch options were lost: %#v", got.Launch) + } +} + +type fakeExecutableResolver struct { + path string + kind domain.BrowserKind + configured string +} + +func (r *fakeExecutableResolver) Resolve(_ context.Context, kind domain.BrowserKind, configured string) (string, error) { + r.kind = kind + r.configured = configured + return r.path, nil +} + +type fakeProcessLauncher struct { + spec domain.LaunchSpec + handle browser.ProcessHandle +} + +func (l *fakeProcessLauncher) Start(_ context.Context, spec domain.LaunchSpec) (browser.ProcessHandle, error) { + l.spec = spec + return l.handle, nil +} + +type fakeProcessHandle struct{ pid int } + +func (p fakeProcessHandle) PID() int { return p.pid } + +func (fakeProcessHandle) Wait(context.Context) (int, error) { return 0, nil } diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 8cbb587..d8bb6a6 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -36,7 +36,7 @@ | ID | 任务 | 依赖 | 状态 | | --- | --- | --- | --- | -| T-301 | 接入 GUI 启动操作与实例删除确认 | T-204,T-208 | DOING | +| T-301 | 接入 GUI 启动操作与实例删除确认 | T-204,T-208 | DONE | ## Backlog diff --git a/docs/api.md b/docs/api.md index c248dc4..a5a7ee4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -4,7 +4,7 @@ MVP 首先提供本地 Go application service 和 CLI;是否增加 loopback HT ## 核心类型 -代码权威类型位于 `internal/domain/browser.go`:`BrowserKind`、`LaunchSpec`、`InstanceStatus`、`InstanceView`、`BrowserEvent` 和稳定错误码。`LaunchSpec.Normalize()` 负责当前已确定的 browser kind、绝对 user data dir 和 http/https URL 校验。 +代码权威类型位于 `internal/domain/browser.go`:`BrowserKind`、`LaunchSpec`、`InstanceStatus`、`InstanceView`、`BrowserEvent` 和稳定错误码。`LaunchSpec.Normalize()` 负责当前已确定的 browser kind、绝对 user data dir,以及非空时的 http/https URL 校验;空 URL 表示打开浏览器默认页。 ## Application service diff --git a/docs/current-state.md b/docs/current-state.md index 5ccad08..4641a54 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -3,11 +3,11 @@ ## 快照 - 日期:2026-07-22 -- 阶段:Phase 3 真实实例操作(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 已完成 -- UI:Gio 双页 Shell 使用左侧“实例/设置”导航;实例列表固定显示实例名称、浏览器类型、用户数据目录、状态和启动;四个设置路径有独立异步可取消搜索,切换页面后输入和任务状态保留;新建实例表单使用 Label、Windows 原生目录选择器和 Chrome/Edge RadioButton;CLI `list/events` 已可用,`start/stop/restart` 等待 BrowserManager adapter +- 阶段:Phase 3 真实实例操作(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 - 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目 -- blocker:无;T-301 正在接入 GUI 的真实启动操作、实例配置同步和删除确认。 +- blocker:无;下一步可接入关闭、重启、退出状态回传和 CLI BrowserManager adapter。 ## 当前目录 diff --git a/docs/tasks/T-301.md b/docs/tasks/T-301.md index f84c513..ce3c821 100644 --- a/docs/tasks/T-301.md +++ b/docs/tasks/T-301.md @@ -3,7 +3,7 @@ id: T-301 title: 接入 GUI 启动操作与实例删除确认 phase: 3 deps: [T-204, T-208] -status: DOING +status: DONE created: 2026-07-22 owner: codex --- @@ -30,8 +30,8 @@ owner: codex ## 执行记录 -- 状态:DOING -- 变更:待实施。 -- 验证:待实施。 +- 状态:DONE +- 变更:Gio Shell 新增异步启动命令、行级“启动中/运行中/启动失败”状态和重复提交防护;删除图标改为窗口级确认弹层。组合根将启动命令接到 `browser.NewOSLauncher` 与浏览器发现器;新建/删除实例同步保存配置,保留已有 profile、代理和额外参数。空 URL 现在表示不传 URL 参数,浏览器打开默认页。 +- 验证:`gofmt`、`go test ./...`、`go vet ./...`、`scripts/smoke-browser.ps1`(真实 Chrome/Edge)和 `scripts/smoke-windows.ps1` 均通过。 - 阻塞:无。 -- 残余风险:实际浏览器启动将受本机可执行文件、目录权限和 profile 占用状态影响。 +- 残余风险:实际浏览器启动仍受本机可执行文件、目录权限和 profile 占用状态影响;关闭、重启、退出状态回传和 CLI BrowserManager adapter 尚未接入。 diff --git a/internal/domain/browser.go b/internal/domain/browser.go index 35e635b..bdcf072 100644 --- a/internal/domain/browser.go +++ b/internal/domain/browser.go @@ -50,15 +50,18 @@ func (s LaunchSpec) Normalize() (LaunchSpec, error) { return LaunchSpec{}, fmt.Errorf("%w: user data dir must be absolute", ErrInvalidLaunchSpec) } target := strings.TrimSpace(s.TargetURL) - parsed, err := url.Parse(target) - if err != nil || parsed.Host == "" || parsed.User != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") { - return LaunchSpec{}, fmt.Errorf("%w: target URL must be absolute http or https", ErrInvalidLaunchSpec) + if target != "" { + parsed, err := url.Parse(target) + if err != nil || parsed.Host == "" || parsed.User != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return LaunchSpec{}, fmt.Errorf("%w: target URL must be absolute http or https", ErrInvalidLaunchSpec) + } + target = parsed.String() } normalized := s normalized.Executable = strings.TrimSpace(s.Executable) normalized.UserDataDir = filepath.Clean(userDataDir) normalized.ProfileDirectory = strings.TrimSpace(s.ProfileDirectory) - normalized.TargetURL = parsed.String() + normalized.TargetURL = target normalized.ProxyServer = strings.TrimSpace(s.ProxyServer) normalized.ExtraArgs = append([]string(nil), s.ExtraArgs...) return normalized, nil diff --git a/internal/domain/browser_test.go b/internal/domain/browser_test.go index 5ac4bcf..1f49f58 100644 --- a/internal/domain/browser_test.go +++ b/internal/domain/browser_test.go @@ -42,3 +42,14 @@ func TestLaunchSpecNormalizeRejectsUnsafeValues(t *testing.T) { } } } + +func TestLaunchSpecNormalizeAllowsAnEmptyTargetURL(t *testing.T) { + profile := filepath.Join(t.TempDir(), "profile") + normalized, err := (LaunchSpec{Kind: BrowserChrome, UserDataDir: profile}).Normalize() + if err != nil { + t.Fatal(err) + } + if normalized.TargetURL != "" { + t.Fatalf("target URL = %q, want empty", normalized.TargetURL) + } +} diff --git a/internal/platform/browser/launch.go b/internal/platform/browser/launch.go index 6ee176a..98c8088 100644 --- a/internal/platform/browser/launch.go +++ b/internal/platform/browser/launch.go @@ -116,7 +116,10 @@ func BuildArgs(spec domain.LaunchSpec) ([]string, error) { } args = append(args, extra) } - return append(args, normalized.TargetURL), nil + if normalized.TargetURL != "" { + args = append(args, normalized.TargetURL) + } + return args, nil } func normalizeProxy(value string) (string, error) { diff --git a/internal/platform/browser/launch_test.go b/internal/platform/browser/launch_test.go index 810063c..646c630 100644 --- a/internal/platform/browser/launch_test.go +++ b/internal/platform/browser/launch_test.go @@ -48,6 +48,18 @@ func TestBuildArgsRejectsProxyCredentialsAndUnsupportedValues(t *testing.T) { } } +func TestBuildArgsAllowsDefaultBrowserPage(t *testing.T) { + profile := filepath.Join(t.TempDir(), "profile") + args, err := BuildArgs(domain.LaunchSpec{Kind: domain.BrowserChrome, UserDataDir: profile}) + if err != nil { + t.Fatal(err) + } + want := []string{"--user-data-dir=" + filepath.Clean(profile), "--no-first-run", "--disable-default-apps"} + if !reflect.DeepEqual(args, want) { + t.Fatalf("args = %#v, want %#v", args, want) + } +} + func TestDiscovererResolvesConfiguredAndStandardPaths(t *testing.T) { root := t.TempDir() chrome := filepath.Join(root, `Google\Chrome\Application\chrome.exe`) diff --git a/internal/ui/shell.go b/internal/ui/shell.go index 7ac22f6..e1131c0 100644 --- a/internal/ui/shell.go +++ b/internal/ui/shell.go @@ -4,9 +4,13 @@ import ( "context" "errors" "fmt" + "image" "image/color" + "strings" "gioui.org/layout" + "gioui.org/op/clip" + "gioui.org/op/paint" "gioui.org/unit" "gioui.org/widget" "gioui.org/widget/material" @@ -47,6 +51,20 @@ 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 instanceStartState struct { + request uint64 + running bool +} + +type instanceStartResult struct { + id string + request uint64 + pid int + err error +} + type pathSearchState struct { request uint64 cancel context.CancelFunc @@ -79,6 +97,7 @@ type InstanceRow struct { Name string Browser string UserDataDir string + TargetURL string Status string } @@ -126,20 +145,28 @@ type Shell struct { pathFeedback string formFeedback string - list widget.List - rows []InstanceRow - startClicks map[string]*widget.Clickable - deleteClicks map[string]*widget.Clickable - nextInstance uint64 - instanceFeedback string - onSave func(SettingsState) - pathSearcher PathSearcher - invalidate func() - searches map[PathField]*pathSearchState - searchResults chan pathSearchResult - directoryChooser DirectoryChooser - directoryPick directoryPickState - directoryResults chan directoryPickResult + list widget.List + rows []InstanceRow + startClicks map[string]*widget.Clickable + deleteClicks map[string]*widget.Clickable + deleteConfirm widget.Clickable + deleteCancel widget.Clickable + deleteBlocker widget.Clickable + startStates map[string]*instanceStartState + startResults chan instanceStartResult + nextInstance uint64 + instanceFeedback string + pendingDeleteID string + onSave func(SettingsState) + onInstancesChanged func([]InstanceRow) + instanceStarter InstanceStarter + pathSearcher PathSearcher + invalidate func() + searches map[PathField]*pathSearchState + searchResults chan pathSearchResult + directoryChooser DirectoryChooser + directoryPick directoryPickState + directoryResults chan directoryPickResult } func NewShell(theme *material.Theme) *Shell { @@ -153,7 +180,7 @@ func NewShell(theme *material.Theme) *Shell { PathEdgeExecutable: {}, PathDefaultUserData: {}, PathLogDirectory: {}, - }, startClicks: make(map[string]*widget.Clickable), deleteClicks: make(map[string]*widget.Clickable), nextInstance: 4, searchResults: make(chan pathSearchResult, 8), directoryResults: make(chan directoryPickResult, 1)} + }, startClicks: make(map[string]*widget.Clickable), deleteClicks: make(map[string]*widget.Clickable), startStates: make(map[string]*instanceStartState), nextInstance: 4, startResults: make(chan instanceStartResult, 8), searchResults: make(chan pathSearchResult, 8), directoryResults: make(chan directoryPickResult, 1)} s.chromePath.SetText(`C:\Program Files\Google\Chrome\Application\chrome.exe`) s.edgePath.SetText(`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`) s.dataDir.SetText(`C:\Users\Public\chub\profiles`) @@ -165,9 +192,6 @@ func NewShell(theme *material.Theme) *Shell { } func (s *Shell) SetInstances(rows []InstanceRow) { - if len(rows) == 0 { - return - } s.rows = append([]InstanceRow(nil), rows...) for i := range s.rows { if s.rows[i].ID == "" { @@ -187,6 +211,13 @@ func (s *Shell) SetSettings(value SettingsState) { func (s *Shell) OnSave(fn func(SettingsState)) { s.onSave = fn } +func (s *Shell) OnInstancesChanged(fn func([]InstanceRow)) { s.onInstancesChanged = fn } + +func (s *Shell) OnStartInstance(starter InstanceStarter, invalidate func()) { + s.instanceStarter = starter + s.invalidate = invalidate +} + func (s *Shell) OnPathSearch(searcher PathSearcher, invalidate func()) { s.pathSearcher = searcher s.invalidate = invalidate @@ -200,6 +231,30 @@ func (s *Shell) OnChooseDirectory(chooser DirectoryChooser, invalidate func()) { func (s *Shell) Layout(gtx layout.Context) layout.Dimensions { s.consumeSearchResults() s.consumeDirectoryResults() + s.consumeStartResults() + if s.pendingDeleteID != "" { + s.consumeDeleteConfirmation(gtx) + } else { + s.consumeControls(gtx) + } + mainLayout := func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, + layout.Rigid(s.sidebar), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return layout.Inset{Top: unit.Dp(24), Bottom: unit.Dp(24), Left: unit.Dp(28), Right: unit.Dp(28)}.Layout(gtx, s.content) + }), + ) + } + if s.pendingDeleteID == "" { + return mainLayout(gtx) + } + return layout.Stack{Alignment: layout.Center}.Layout(gtx, + layout.Expanded(mainLayout), + layout.Stacked(s.deleteConfirmation), + ) +} + +func (s *Shell) consumeControls(gtx layout.Context) { for s.instancesClick.Clicked(gtx) { s.page = pageInstances } @@ -210,14 +265,7 @@ func (s *Shell) Layout(gtx layout.Context) layout.Dimensions { s.page = pageCreate } for s.createClick.Clicked(gtx) { - if name := s.instanceName.Text(); name != "" { - s.nextInstance++ - s.rows = append(s.rows, InstanceRow{ID: fmt.Sprintf("instance-%d", s.nextInstance), Name: name, Browser: browserDisplay(s.browserKind.Value), UserDataDir: s.instanceDir.Text(), Status: "已退出"}) - s.page = pageInstances - s.formFeedback = "" - } else { - s.formFeedback = "请输入实例名称后再创建。" - } + s.createInstanceFromForm() } for s.instanceDirPick.Clicked(gtx) { s.chooseInstanceDirectory() @@ -232,15 +280,9 @@ func (s *Shell) Layout(gtx layout.Context) layout.Dimensions { } for _, row := range append([]InstanceRow(nil), s.rows...) { for s.deleteClickFor(row.ID).Clicked(gtx) { - s.deleteInstance(row.ID) + s.requestDelete(row.ID) } } - return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, - layout.Rigid(s.sidebar), - layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { - return layout.Inset{Top: unit.Dp(24), Bottom: unit.Dp(24), Left: unit.Dp(28), Right: unit.Dp(28)}.Layout(gtx, s.content) - }), - ) } func (s *Shell) sidebar(gtx layout.Context) layout.Dimensions { @@ -345,14 +387,81 @@ func (s *Shell) instanceIconButton(click *widget.Clickable, icon *widget.Icon, d } } +func (s *Shell) deleteConfirmation(gtx layout.Context) layout.Dimensions { + row, ok := s.instanceRow(s.pendingDeleteID) + if !ok { + s.pendingDeleteID = "" + return layout.Dimensions{} + } + gtx.Constraints.Min = gtx.Constraints.Max + return s.deleteBlocker.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + defer clip.Rect{Max: gtx.Constraints.Min}.Push(gtx.Ops).Pop() + paint.Fill(gtx.Ops, color.NRGBA{R: 0, G: 0, B: 0, A: 92}) + return layout.Center.Layout(gtx, s.deleteConfirmationCard(row)) + }) +} + +func (s *Shell) deleteConfirmationCard(row InstanceRow) layout.Widget { + return func(gtx layout.Context) layout.Dimensions { + if maxWidth := gtx.Dp(420); gtx.Constraints.Max.X > maxWidth { + gtx.Constraints.Max.X = maxWidth + } + return layout.Background{}.Layout(gtx, + func(gtx layout.Context) layout.Dimensions { + rect := image.Rectangle{Max: gtx.Constraints.Min} + defer clip.UniformRRect(rect, gtx.Dp(8)).Push(gtx.Ops).Pop() + paint.Fill(gtx.Ops, s.theme.Palette.Bg) + return layout.Dimensions{Size: gtx.Constraints.Min} + }, + func(gtx layout.Context) layout.Dimensions { + return layout.Inset{Top: unit.Dp(20), Right: unit.Dp(24), Bottom: unit.Dp(20), Left: unit.Dp(24)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(material.H6(s.theme, "删除实例?").Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(material.Body1(s.theme, fmt.Sprintf("确定删除实例“%s”?", row.Name)).Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(material.Caption(s.theme, "不会删除 User Data Dir;若浏览器仍在运行,它会继续运行。").Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(18)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(s.deleteCancelButton), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(s.deleteConfirmButton), + ) + }), + ) + }) + }, + ) + } +} + +func (s *Shell) deleteCancelButton(gtx layout.Context) layout.Dimensions { + style := material.Button(s.theme, &s.deleteCancel, "取消") + style.Background = color.NRGBA{R: 230, G: 232, B: 235, A: 255} + style.Color = s.theme.Palette.Fg + return style.Layout(gtx) +} + +func (s *Shell) deleteConfirmButton(gtx layout.Context) layout.Dimensions { + style := material.Button(s.theme, &s.deleteConfirm, "删除") + style.Background = color.NRGBA{R: 188, G: 51, B: 51, A: 255} + style.Color = color.NRGBA{R: 255, G: 255, B: 255, A: 255} + return style.Layout(gtx) +} + func statusLabel(theme *material.Theme, status string) layout.Widget { style := material.Label(theme, unit.Sp(13), status) - style.Color = map[string]color.NRGBA{ + 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}, - }[status] + }[status]; ok { + style.Color = color + } return style.Layout } @@ -488,6 +597,31 @@ func (s *Shell) instanceDirField(gtx layout.Context) layout.Dimensions { ) } +func (s *Shell) createInstanceFromForm() { + name := strings.TrimSpace(s.instanceName.Text()) + if name == "" { + s.formFeedback = "请输入实例名称后再创建。" + return + } + userDataDir := strings.TrimSpace(s.instanceDir.Text()) + if userDataDir == "" { + s.formFeedback = "请选择或输入 User Data Dir 后再创建。" + return + } + s.nextInstance++ + s.rows = append(s.rows, InstanceRow{ + ID: fmt.Sprintf("instance-%d", s.nextInstance), + Name: name, + Browser: browserDisplay(s.browserKind.Value), + UserDataDir: userDataDir, + TargetURL: strings.TrimSpace(s.instanceURL.Text()), + Status: "已退出", + }) + s.page = pageInstances + s.formFeedback = "" + s.notifyInstancesChanged() +} + func (s *Shell) togglePathSearch(field PathField) { state := s.searches[field] if state == nil { @@ -610,14 +744,100 @@ func (s *Shell) deleteClickFor(id string) *widget.Clickable { } func (s *Shell) requestStart(id string) { - for _, row := range s.rows { - if row.ID != id { - continue - } - s.instanceFeedback = fmt.Sprintf("%s 的启动命令等待 BrowserManager 接入。", row.Name) + row, ok := s.instanceRow(id) + if !ok { + s.instanceFeedback = "找不到要启动的实例。" return } - s.instanceFeedback = "找不到要启动的实例。" + state := s.startStates[id] + if state != nil && state.running { + s.instanceFeedback = fmt.Sprintf("%s 正在启动,请稍候。", row.Name) + return + } + if s.instanceStarter == nil { + s.instanceFeedback = "浏览器启动服务尚未准备好。" + return + } + if state == nil { + state = &instanceStartState{} + s.startStates[id] = state + } + state.request++ + request := state.request + 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} + if s.invalidate != nil { + s.invalidate() + } + }() +} + +func (s *Shell) consumeStartResults() { + for { + select { + case result := <-s.startResults: + state := s.startStates[result.id] + if state == nil || !state.running || result.request != state.request { + continue + } + state.running = false + row, exists := s.instanceRow(result.id) + if !exists { + continue + } + if result.err != nil { + 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) + default: + return + } + } +} + +func (s *Shell) requestDelete(id string) { + row, ok := s.instanceRow(id) + if !ok { + s.instanceFeedback = "找不到要删除的实例。" + return + } + if state := s.startStates[id]; state != nil && state.running { + s.instanceFeedback = fmt.Sprintf("%s 正在启动,暂时不能删除。", row.Name) + return + } + s.pendingDeleteID = id +} + +func (s *Shell) consumeDeleteConfirmation(gtx layout.Context) { + for s.deleteBlocker.Clicked(gtx) { + } + for s.deleteCancel.Clicked(gtx) { + s.cancelDelete() + } + for s.deleteConfirm.Clicked(gtx) { + s.confirmDelete() + } +} + +func (s *Shell) cancelDelete() { + if row, ok := s.instanceRow(s.pendingDeleteID); ok { + s.instanceFeedback = fmt.Sprintf("已取消删除实例“%s”。", row.Name) + } + s.pendingDeleteID = "" +} + +func (s *Shell) confirmDelete() { + id := s.pendingDeleteID + s.pendingDeleteID = "" + s.deleteInstance(id) } func (s *Shell) deleteInstance(id string) { @@ -628,12 +848,49 @@ func (s *Shell) deleteInstance(id string) { s.rows = append(s.rows[:i], s.rows[i+1:]...) delete(s.startClicks, id) delete(s.deleteClicks, id) + delete(s.startStates, id) s.instanceFeedback = fmt.Sprintf("已删除实例“%s”;其 User Data Dir 未被删除。", row.Name) + s.notifyInstancesChanged() return } s.instanceFeedback = "找不到要删除的实例。" } +func (s *Shell) instanceRow(id string) (InstanceRow, bool) { + for _, row := range s.rows { + if row.ID == id { + return row, true + } + } + return InstanceRow{}, false +} + +func (s *Shell) setInstanceStatus(id, status string) bool { + for i := range s.rows { + if s.rows[i].ID == id { + s.rows[i].Status = status + return true + } + } + 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) notifyInstancesChanged() { + if s.onInstancesChanged != nil { + s.onInstancesChanged(append([]InstanceRow(nil), s.rows...)) + } +} + func mustIcon(data []byte) *widget.Icon { icon, err := widget.NewIcon(data) if err != nil { diff --git a/internal/ui/shell_test.go b/internal/ui/shell_test.go index ce7ee7b..4087200 100644 --- a/internal/ui/shell_test.go +++ b/internal/ui/shell_test.go @@ -2,6 +2,7 @@ package ui import ( "context" + "sync/atomic" "testing" "time" @@ -90,6 +91,98 @@ func TestShellDeletesOnlyTheRequestedInstance(t *testing.T) { } } +func TestShellDeletesOnlyAfterConfirmation(t *testing.T) { + shell := NewShell(material.NewTheme()) + target := shell.rows[1] + changes := 0 + shell.OnInstancesChanged(func([]InstanceRow) { changes++ }) + + shell.requestDelete(target.ID) + if shell.pendingDeleteID != target.ID { + t.Fatalf("pending delete = %q, want %q", shell.pendingDeleteID, target.ID) + } + if len(shell.rows) != 4 { + t.Fatalf("instance count changed before confirmation: %d", len(shell.rows)) + } + shell.cancelDelete() + if shell.pendingDeleteID != "" || len(shell.rows) != 4 || changes != 0 { + t.Fatalf("cancel delete state = pending %q, rows %d, changes %d", shell.pendingDeleteID, len(shell.rows), changes) + } + + shell.requestDelete(target.ID) + shell.confirmDelete() + if shell.pendingDeleteID != "" || len(shell.rows) != 3 || changes != 1 { + t.Fatalf("confirm delete state = pending %q, rows %d, changes %d", shell.pendingDeleteID, len(shell.rows), changes) + } +} + +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) { + started <- row + return 4242, nil + }, nil) + + shell.requestStart(target.ID) + if row, ok := shell.instanceRow(target.ID); !ok || row.Status != "启动中" { + t.Fatalf("start status = %#v, want 启动中", row) + } + select { + case got := <-started: + if got.ID != target.ID { + t.Fatalf("started instance = %q, want %q", got.ID, target.ID) + } + case <-time.After(time.Second): + t.Fatal("start callback was not invoked") + } + var result instanceStartResult + select { + case result = <-shell.startResults: + case <-time.After(time.Second): + t.Fatal("start result was not produced") + } + shell.startResults <- result + shell.consumeStartResults() + if row, ok := shell.instanceRow(target.ID); !ok || row.Status != "运行中" { + t.Fatalf("completed start status = %#v, want 运行中", row) + } +} + +func TestShellDoesNotStartTheSameInstanceTwiceWhilePending(t *testing.T) { + shell := NewShell(material.NewTheme()) + target := shell.rows[0] + entered := make(chan struct{}, 1) + release := make(chan struct{}) + var calls atomic.Int32 + shell.OnStartInstance(func(_ context.Context, _ InstanceRow, _ SettingsState) (int, error) { + calls.Add(1) + entered <- struct{}{} + <-release + return 4242, nil + }, nil) + + shell.requestStart(target.ID) + shell.requestStart(target.ID) + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("start callback was not invoked") + } + if calls.Load() != 1 { + t.Fatalf("start calls = %d, want 1", calls.Load()) + } + close(release) + select { + case result := <-shell.startResults: + shell.startResults <- result + shell.consumeStartResults() + case <-time.After(time.Second): + t.Fatal("start result was not produced") + } +} + func TestShellCancelsOnlyTheActivePathSearch(t *testing.T) { shell := NewShell(material.NewTheme()) started := make(chan struct{}, 1)