feat: wire gui instance start and delete confirmation

This commit is contained in:
QiuSW
2026-07-22 17:02:59 +08:00
parent 46578c8743
commit 176e3cdad2
12 changed files with 600 additions and 59 deletions
+99 -2
View File
@@ -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
}
+65
View File
@@ -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 }