feat: wire gui instance start and delete confirmation
This commit is contained in:
+99
-2
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@
|
||||
|
||||
| ID | 任务 | 依赖 | 状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| T-301 | 接入 GUI 启动操作与实例删除确认 | T-204,T-208 | DOING |
|
||||
| T-301 | 接入 GUI 启动操作与实例删除确认 | T-204,T-208 | DONE |
|
||||
|
||||
## Backlog
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
## 当前目录
|
||||
|
||||
|
||||
+5
-5
@@ -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 尚未接入。
|
||||
|
||||
@@ -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)
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`)
|
||||
|
||||
+284
-27
@@ -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
|
||||
}
|
||||
|
||||
@@ -130,9 +149,17 @@ type Shell struct {
|
||||
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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user