feat: toggle managed instance start and stop

This commit is contained in:
QiuSW
2026-07-25 17:10:20 +08:00
parent 3a3c92c875
commit b4e16fa708
7 changed files with 390 additions and 16 deletions
+71
View File
@@ -121,6 +121,7 @@ func runWindow(logger *slog.Logger) {
portAllocator: cdp, portAllocator: cdp,
proxyResolver: proxies, proxyResolver: proxies,
}.Start, window.Invalidate) }.Start, window.Invalidate)
shell.OnStopInstance(instanceStopper{launcher: launcher, managedProfiles: launcher}.Stop, window.Invalidate)
shell.OnRefreshInstances(instanceStatusRefresher{ shell.OnRefreshInstances(instanceStatusRefresher{
managedProfiles: launcher, managedProfiles: launcher,
externalProfiles: externalProfiles, externalProfiles: externalProfiles,
@@ -263,6 +264,76 @@ type managedProfileInspector interface {
InspectProfile(context.Context, string) (browser.ProfileUse, error) InspectProfile(context.Context, string) (browser.ProfileUse, error)
} }
type profileStopper interface {
StopProfile(context.Context, string, bool) error
}
// instanceStopper is deliberately scoped to Chub's in-memory registry. It
// requests graceful exit only, then waits for that registry entry to disappear;
// it never enumerates or terminates browsers by process name.
type instanceStopper struct {
launcher profileStopper
managedProfiles managedProfileInspector
waitTimeout time.Duration
waitInterval time.Duration
}
func (s instanceStopper) Stop(ctx context.Context, row ui.InstanceRow) error {
if row.Status != "运行中" && row.Status != "运行中(调试不可用)" {
return errors.New("实例当前不是可停止的 Chub 托管状态")
}
if row.OccupancySource != "" && row.OccupancySource != "chub_registry" {
return domain.ErrIdentityMismatch
}
if s.launcher == nil || s.managedProfiles == nil {
return errors.New("浏览器停止服务尚未准备好")
}
use, err := s.managedProfiles.InspectProfile(ctx, row.UserDataDir)
if err != nil {
return fmt.Errorf("检查 Chub 实例身份失败:%w", err)
}
if !use.Occupied {
return domain.ErrInstanceNotFound
}
if use.Source != "chub_registry" || (row.PID > 0 && use.PID > 0 && row.PID != use.PID) {
return domain.ErrIdentityMismatch
}
if err := s.launcher.StopProfile(ctx, row.UserDataDir, false); err != nil {
return err
}
timeout := s.waitTimeout
if timeout <= 0 {
timeout = 8 * time.Second
}
interval := s.waitInterval
if interval <= 0 {
interval = 100 * time.Millisecond
}
waitCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
for {
use, err = s.managedProfiles.InspectProfile(waitCtx, row.UserDataDir)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return errors.New("浏览器未在等待时间内退出;可稍后刷新状态")
}
return fmt.Errorf("检查浏览器退出状态失败:%w", err)
}
if !use.Occupied {
return nil
}
timer := time.NewTimer(interval)
select {
case <-waitCtx.Done():
if !timer.Stop() {
<-timer.C
}
return errors.New("浏览器未在等待时间内退出;可稍后刷新状态")
case <-timer.C:
}
}
}
type instanceStarter struct { type instanceStarter struct {
launcher processLauncher launcher processLauncher
resolver executableResolver resolver executableResolver
+54
View File
@@ -6,6 +6,7 @@ import (
"errors" "errors"
"strings" "strings"
"testing" "testing"
"time"
"chub/internal/domain" "chub/internal/domain"
"chub/internal/platform/browser" "chub/internal/platform/browser"
@@ -54,6 +55,37 @@ func TestInstanceStarterResolvesSelectedProxyAtLaunch(t *testing.T) {
} }
} }
func TestInstanceStopperGracefullyStopsOnlyVerifiedManagedProfile(t *testing.T) {
managed := &fakeManagedStopper{use: browser.ProfileUse{Occupied: true, PID: 4242, Source: "chub_registry"}, releaseOnStop: true}
stopper := instanceStopper{launcher: managed, managedProfiles: managed, waitTimeout: time.Second, waitInterval: time.Millisecond}
err := stopper.Stop(context.Background(), ui.InstanceRow{ID: "managed", Status: "运行中", PID: 4242, OccupancySource: "chub_registry", UserDataDir: `C:\profiles\managed`})
if err != nil || managed.stopCalls != 1 || managed.force || managed.userDataDir != `C:\profiles\managed` {
t.Fatalf("Stop() = %v, calls=%d force=%v dir=%q", err, managed.stopCalls, managed.force, managed.userDataDir)
}
}
func TestInstanceStopperRejectsMismatchedOrExternalProfiles(t *testing.T) {
managed := &fakeManagedStopper{use: browser.ProfileUse{Occupied: true, PID: 4243, Source: "chub_registry"}}
stopper := instanceStopper{launcher: managed, managedProfiles: managed, waitTimeout: 10 * time.Millisecond, waitInterval: time.Millisecond}
err := stopper.Stop(context.Background(), ui.InstanceRow{Status: "运行中", PID: 4242, OccupancySource: "chub_registry", UserDataDir: `C:\profiles\managed`})
if !errors.Is(err, domain.ErrIdentityMismatch) || managed.stopCalls != 0 {
t.Fatalf("mismatch Stop() = %v, calls=%d", err, managed.stopCalls)
}
err = stopper.Stop(context.Background(), ui.InstanceRow{Status: "运行中", PID: 4243, OccupancySource: "browser_message_window", UserDataDir: `C:\profiles\managed`})
if !errors.Is(err, domain.ErrIdentityMismatch) || managed.stopCalls != 0 {
t.Fatalf("external Stop() = %v, calls=%d", err, managed.stopCalls)
}
}
func TestInstanceStopperDoesNotEscalateWhenGracefulCloseTimesOut(t *testing.T) {
managed := &fakeManagedStopper{use: browser.ProfileUse{Occupied: true, PID: 4242, Source: "chub_registry"}}
stopper := instanceStopper{launcher: managed, managedProfiles: managed, waitTimeout: 10 * time.Millisecond, waitInterval: time.Millisecond}
err := stopper.Stop(context.Background(), ui.InstanceRow{Status: "运行中", PID: 4242, OccupancySource: "chub_registry", UserDataDir: `C:\profiles\managed`})
if err == nil || managed.stopCalls != 1 || managed.force {
t.Fatalf("timeout Stop() = %v, calls=%d force=%v", err, managed.stopCalls, managed.force)
}
}
func TestInstanceStarterAssociatesVerifiedExternalProfileWithoutLaunching(t *testing.T) { func TestInstanceStarterAssociatesVerifiedExternalProfileWithoutLaunching(t *testing.T) {
launcher := &fakeProcessLauncher{handle: fakeProcessHandle{pid: 4242}} launcher := &fakeProcessLauncher{handle: fakeProcessHandle{pid: 4242}}
remote := &fakeRemoteDebugInspector{endpoint: browser.RemoteDebugEndpoint{Port: 9668}} remote := &fakeRemoteDebugInspector{endpoint: browser.RemoteDebugEndpoint{Port: 9668}}
@@ -187,6 +219,28 @@ func (i fakeManagedProfileInspector) InspectProfile(context.Context, string) (br
return i.use, nil return i.use, nil
} }
type fakeManagedStopper struct {
use browser.ProfileUse
stopCalls int
force bool
userDataDir string
releaseOnStop bool
}
func (s *fakeManagedStopper) InspectProfile(context.Context, string) (browser.ProfileUse, error) {
return s.use, nil
}
func (s *fakeManagedStopper) StopProfile(_ context.Context, userDataDir string, force bool) error {
s.stopCalls++
s.force = force
s.userDataDir = userDataDir
if s.releaseOnStop {
s.use.Occupied = false
}
return nil
}
type fakeExternalProfileInspector struct { type fakeExternalProfileInspector struct {
use browser.ProfileUse use browser.ProfileUse
err error err error
+1 -1
View File
@@ -40,7 +40,7 @@
| T-302 | 本地 CDP 启动、外部关联与实例端口展示 | T-102,T-301 | DONE | | T-302 | 本地 CDP 启动、外部关联与实例端口展示 | T-102,T-301 | DONE |
| T-303 | 实例状态刷新与编辑对话框 | T-302 | DONE | | T-303 | 实例状态刷新与编辑对话框 | T-302 | DONE |
| T-304 | 代理配置库、实例选择与安全启动参数传递 | T-303 | DONE | | T-304 | 代理配置库、实例选择与安全启动参数传递 | T-303 | DONE |
| T-305 | GUI 受管实例优雅停止与启停状态机 | T-304 | DOING | | T-305 | GUI 受管实例优雅停止与启停状态机 | T-304 | DONE |
## Backlog ## Backlog
+3 -3
View File
@@ -3,11 +3,11 @@
## 快照 ## 快照
- 日期:2026-07-25 - 日期:2026-07-25
- 阶段:Phase 3 真实实例操作(T-305 进行中;T-304、T-303、T-302、T-301 已完成;T-201 至 T-208 已完成) - 阶段:Phase 3 真实实例操作(T-305、T-304、T-303、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-303 已完成 - 代码:已建立 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-303 已完成
- UI:Gio 双页 Shell 使用左侧“实例/设置”导航;实例页以等宽“新建实例 / 刷新实例状态”命令区开始,窄内容区自动堆叠。列表固定显示实例名称、浏览器类型、用户数据目录、调试端口、状态和紧凑编辑/启动/删除图标,并以外层边框和表头分隔组织。刷新通过后台回调只检查已保存实例,以配置快照丢弃编辑或删除后的过期结果;它结合 Chub registry、指定 profile 的外部占用证据和 loopback CDP 端点更新状态,但不扫描、接管或关闭其他 Chrome/Edge。双击、Enter 或编辑图标打开实例编辑弹层,支持名称、浏览器类型、User Data Dir、启动 URL、代理选择和只读实际端口;保存保留未公开启动选项,Escape 对脏表单先请求确认,活跃/外部关联实例锁定身份约束字段。设置页可维护名称加无认证端点的代理库,删除仍被实例引用的代理会被拒绝。启动通过异步回调接到 Windows 浏览器启动器:未占用目录从已保存的起始端口(空值默认 9666)选择 loopback CDP 端口,并在启动时按已选代理 ID 解析最新的 `--proxy-server` 参数;同目录外部浏览器只有在 `DevToolsActivePort` 与 CDP 端点可验证时才显示“外部已关联”,且不会接管其生命周期。删除使用确认弹层,只删除 Chub 实例配置而不删除 User Data Dir。新建、编辑和删除实例会保存到本地配置;四个设置路径有独立异步可取消搜索,切换页面后输入和任务状态保留;新建实例表单使用 Label、Windows 原生目录选择器和 Chrome/Edge RadioButton;CLI `list/events` 已可用,`start/stop/restart` 等待 BrowserManager adapter - UI:Gio 双页 Shell 使用左侧“实例/设置”导航;实例页以等宽“新建实例 / 刷新实例状态”命令区开始,窄内容区自动堆叠。列表固定显示实例名称、浏览器类型、用户数据目录、调试端口、状态和紧凑编辑/启动/停止/删除图标,并以外层边框和表头分隔组织。主操作在已退出时启动,在 Chub 托管运行或调试不可用时显示停止,在启动/停止中禁用,在外部关联、外部占用或未知占用时仅重新检测;优雅停止通过后台 adapter 验证 registry 的 PID/profile 身份并等待其释放,绝不按进程名关闭或接管外部 Chrome/Edge。刷新通过后台回调只检查已保存实例,以配置快照丢弃编辑或删除后的过期结果;它结合 Chub registry、指定 profile 的外部占用证据和 loopback CDP 端点更新状态,但不扫描、接管或关闭其他 Chrome/Edge。双击、Enter 或编辑图标打开实例编辑弹层,支持名称、浏览器类型、User Data Dir、启动 URL、代理选择和只读实际端口;保存保留未公开启动选项,Escape 对脏表单先请求确认,活跃/外部关联实例锁定身份约束字段。设置页可维护名称加无认证端点的代理库,删除仍被实例引用的代理会被拒绝。启动通过异步回调接到 Windows 浏览器启动器:未占用目录从已保存的起始端口(空值默认 9666)选择 loopback CDP 端口,并在启动时按已选代理 ID 解析最新的 `--proxy-server` 参数;同目录外部浏览器只有在 `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`,尚未复制或接入本项目 - 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目
- blocker:无;下一步完成已注册实例的 GUI 优雅停止、停止结果等待和启停图标状态回传(T-305)。 - blocker:无;Phase 3 的当前 GUI 启动、代理、刷新、编辑、删除和受管实例优雅停止已闭环。下一步可按 Backlog 决策 CLI BrowserManager adapter、重启/强制关闭确认或 CDP tab/window 管理。
## 当前目录 ## 当前目录
+4 -4
View File
@@ -3,7 +3,7 @@ id: T-305
title: GUI 受管实例优雅停止与启停状态机 title: GUI 受管实例优雅停止与启停状态机
phase: 3 phase: 3
deps: [T-304] deps: [T-304]
status: DOING status: DONE
created: 2026-07-25 created: 2026-07-25
owner: codex owner: codex
--- ---
@@ -29,7 +29,7 @@ owner: codex
## 执行记录 ## 执行记录
- 状态:DOING - 状态:DONE
- 变更:待实现。 - 变更:实例主操作现在按状态显示启动、停止、重新检测或禁用图标。受管运行/调试不可用实例点击停止后进入“停止中”,后台 adapter 先检查 Chub registry 来源和 PID,再调用 `StopProfile(userDataDir, false)`,等待 registry 释放后清空 PID/端口并恢复启动图标。超时、身份不匹配或权限失败保持原运行状态并给出反馈;外部关联、外部占用和未知占用只允许重新检测。停止期间拒绝重复启停和删除。
- 验证:待执行。 - 验证:`gofmt -w cmd/chub/main.go internal/ui/shell.go cmd/chub/main_test.go internal/ui/shell_test.go`、`go test ./...`、`go vet ./...`、`go build -o build/chub.exe ./cmd/chub`、`scripts/smoke-browser.ps1`(真实 Chrome/Edge)和 `scripts/smoke-windows.ps1` 均通过。
- 阻塞:无。 - 阻塞:无。
+159 -8
View File
@@ -41,6 +41,7 @@ const (
var ( var (
instanceStartIcon = mustIcon(icons.AVPlayArrow) instanceStartIcon = mustIcon(icons.AVPlayArrow)
instanceStopIcon = mustIcon(icons.AVStop)
instanceEditIcon = mustIcon(icons.EditorModeEdit) instanceEditIcon = mustIcon(icons.EditorModeEdit)
instanceDeleteIcon = mustIcon(icons.ActionDelete) instanceDeleteIcon = mustIcon(icons.ActionDelete)
) )
@@ -76,6 +77,10 @@ type InstanceStartOutcome struct {
type InstanceStarter func(context.Context, InstanceRow, SettingsState) (InstanceStartOutcome, error) type InstanceStarter func(context.Context, InstanceRow, SettingsState) (InstanceStartOutcome, error)
// InstanceStopper requests graceful shutdown only for the supplied configured
// instance. Implementations must reject external or unverified processes.
type InstanceStopper func(context.Context, InstanceRow) error
// InstanceRefreshResult carries a read-only status check for one configured // InstanceRefreshResult carries a read-only status check for one configured
// instance. The UI applies it only while the row's editable fields still match // instance. The UI applies it only while the row's editable fields still match
// the snapshot passed to the refresher. // the snapshot passed to the refresher.
@@ -105,6 +110,18 @@ type instanceStartResult struct {
err error err error
} }
type instanceStopState struct {
request uint64
running bool
previousStatus string
}
type instanceStopResult struct {
id string
request uint64
err error
}
type instanceRefreshState struct { type instanceRefreshState struct {
request uint64 request uint64
running bool running bool
@@ -268,6 +285,8 @@ type Shell struct {
deleteBlocker widget.Clickable deleteBlocker widget.Clickable
startStates map[string]*instanceStartState startStates map[string]*instanceStartState
startResults chan instanceStartResult startResults chan instanceStartResult
stopStates map[string]*instanceStopState
stopResults chan instanceStopResult
refreshClickState instanceRefreshState refreshClickState instanceRefreshState
refreshResults chan instanceRefreshResult refreshResults chan instanceRefreshResult
nextInstance uint64 nextInstance uint64
@@ -282,6 +301,7 @@ type Shell struct {
onInstancesChanged func([]InstanceRow) onInstancesChanged func([]InstanceRow)
onProxiesChanged func([]ProxyOption) onProxiesChanged func([]ProxyOption)
instanceStarter InstanceStarter instanceStarter InstanceStarter
instanceStopper InstanceStopper
instanceRefresher InstanceRefresher instanceRefresher InstanceRefresher
pathSearcher PathSearcher pathSearcher PathSearcher
invalidate func() invalidate func()
@@ -303,7 +323,7 @@ func NewShell(theme *material.Theme) *Shell {
PathEdgeExecutable: {}, PathEdgeExecutable: {},
PathDefaultUserData: {}, PathDefaultUserData: {},
PathLogDirectory: {}, PathLogDirectory: {},
}, rowClicks: make(map[string]*widget.Clickable), startClicks: make(map[string]*widget.Clickable), editClicks: make(map[string]*widget.Clickable), deleteClicks: make(map[string]*widget.Clickable), proxyEditClicks: make(map[string]*widget.Clickable), proxyDeleteClicks: make(map[string]*widget.Clickable), proxyPickerChoices: make(map[string]*widget.Clickable), startStates: make(map[string]*instanceStartState), nextInstance: 4, startResults: make(chan instanceStartResult, 8), refreshResults: make(chan instanceRefreshResult, 1), searchResults: make(chan pathSearchResult, 8), directoryResults: make(chan directoryPickResult, 1)} }, rowClicks: make(map[string]*widget.Clickable), startClicks: make(map[string]*widget.Clickable), editClicks: make(map[string]*widget.Clickable), deleteClicks: make(map[string]*widget.Clickable), proxyEditClicks: make(map[string]*widget.Clickable), proxyDeleteClicks: make(map[string]*widget.Clickable), proxyPickerChoices: make(map[string]*widget.Clickable), startStates: make(map[string]*instanceStartState), stopStates: make(map[string]*instanceStopState), nextInstance: 4, startResults: make(chan instanceStartResult, 8), stopResults: make(chan instanceStopResult, 8), refreshResults: make(chan instanceRefreshResult, 1), searchResults: make(chan pathSearchResult, 8), directoryResults: make(chan directoryPickResult, 1)}
s.chromePath.SetText(`C:\Program Files\Google\Chrome\Application\chrome.exe`) s.chromePath.SetText(`C:\Program Files\Google\Chrome\Application\chrome.exe`)
s.edgePath.SetText(`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`) s.edgePath.SetText(`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`)
s.dataDir.SetText(`C:\Users\Public\chub\profiles`) s.dataDir.SetText(`C:\Users\Public\chub\profiles`)
@@ -374,6 +394,11 @@ func (s *Shell) OnStartInstance(starter InstanceStarter, invalidate func()) {
s.invalidate = invalidate s.invalidate = invalidate
} }
func (s *Shell) OnStopInstance(stopper InstanceStopper, invalidate func()) {
s.instanceStopper = stopper
s.invalidate = invalidate
}
func (s *Shell) OnRefreshInstances(refresher InstanceRefresher, invalidate func()) { func (s *Shell) OnRefreshInstances(refresher InstanceRefresher, invalidate func()) {
s.instanceRefresher = refresher s.instanceRefresher = refresher
s.invalidate = invalidate s.invalidate = invalidate
@@ -393,6 +418,7 @@ func (s *Shell) Layout(gtx layout.Context) layout.Dimensions {
s.consumeSearchResults() s.consumeSearchResults()
s.consumeDirectoryResults() s.consumeDirectoryResults()
s.consumeStartResults() s.consumeStartResults()
s.consumeStopResults()
s.consumeRefreshResults() s.consumeRefreshResults()
s.consumeKeyboard(gtx) s.consumeKeyboard(gtx)
if s.pendingDeleteID != "" { if s.pendingDeleteID != "" {
@@ -476,7 +502,7 @@ func (s *Shell) consumeControls(gtx layout.Context) {
} }
} }
for s.startClickFor(row.ID).Clicked(gtx) { for s.startClickFor(row.ID).Clicked(gtx) {
s.requestStart(row.ID) s.requestInstanceAction(row.ID)
} }
for s.editClickFor(row.ID).Clicked(gtx) { for s.editClickFor(row.ID).Clicked(gtx) {
s.beginEdit(row.ID) s.beginEdit(row.ID)
@@ -592,7 +618,7 @@ func (s *Shell) beginEdit(id string) {
func (s *Shell) editLocked(row InstanceRow) bool { func (s *Shell) editLocked(row InstanceRow) bool {
switch row.Status { switch row.Status {
case "启动中", "运行中", "运行中(调试不可用)", "外部已关联": case "启动中", "停止中", "运行中", "运行中(调试不可用)", "外部已关联":
return true return true
default: default:
return false return false
@@ -874,14 +900,16 @@ func remoteDebugPortText(port int) string {
func (s *Shell) instanceActionButtons(row InstanceRow) layout.Widget { func (s *Shell) instanceActionButtons(row InstanceRow) layout.Widget {
return func(gtx layout.Context) layout.Dimensions { return func(gtx layout.Context) layout.Dimensions {
return layout.E.Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.E.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
actionLabel := "启动 " + row.Name mode := instanceActionFor(row)
if row.Status == "外部已关联" { actionLabel := mode.label + " " + row.Name
actionLabel = "重新检测 " + row.Name actionIcon := instanceStartIcon
if mode.stop {
actionIcon = instanceStopIcon
} }
return layout.Flex{Alignment: layout.Middle}.Layout(gtx, return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(s.instanceIconButton(s.editClickFor(row.ID), instanceEditIcon, "编辑 "+row.Name, s.theme.Palette.ContrastBg)), layout.Rigid(s.instanceIconButton(s.editClickFor(row.ID), instanceEditIcon, "编辑 "+row.Name, s.theme.Palette.ContrastBg)),
layout.Rigid(layout.Spacer{Width: unit.Dp(2)}.Layout), layout.Rigid(layout.Spacer{Width: unit.Dp(2)}.Layout),
layout.Rigid(s.instanceIconButton(s.startClickFor(row.ID), instanceStartIcon, actionLabel, s.theme.Palette.ContrastBg)), layout.Rigid(s.instanceIconButton(s.startClickFor(row.ID), actionIcon, actionLabel, s.theme.Palette.ContrastBg, mode.enabled)),
layout.Rigid(layout.Spacer{Width: unit.Dp(4)}.Layout), 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})), layout.Rigid(s.instanceIconButton(s.deleteClickFor(row.ID), instanceDeleteIcon, "删除 "+row.Name, color.NRGBA{R: 188, G: 51, B: 51, A: 255})),
) )
@@ -889,8 +917,32 @@ func (s *Shell) instanceActionButtons(row InstanceRow) layout.Widget {
} }
} }
func (s *Shell) instanceIconButton(click *widget.Clickable, icon *widget.Icon, description string, iconColor color.NRGBA) layout.Widget { type instanceActionMode struct {
label string
stop bool
enabled bool
}
func instanceActionFor(row InstanceRow) instanceActionMode {
switch row.Status {
case "运行中", "运行中(调试不可用)":
return instanceActionMode{label: "停止", stop: true, enabled: true}
case "启动中":
return instanceActionMode{label: "启动中", enabled: false}
case "停止中":
return instanceActionMode{label: "停止中", stop: true, enabled: false}
case "外部已关联", "外部占用", "未知占用":
return instanceActionMode{label: "重新检测", enabled: true}
default:
return instanceActionMode{label: "启动", enabled: true}
}
}
func (s *Shell) instanceIconButton(click *widget.Clickable, icon *widget.Icon, description string, iconColor color.NRGBA, enabled ...bool) layout.Widget {
return func(gtx layout.Context) layout.Dimensions { return func(gtx layout.Context) layout.Dimensions {
if len(enabled) > 0 && !enabled[0] {
gtx = gtx.Disabled()
}
style := material.IconButton(s.theme, click, icon, description) style := material.IconButton(s.theme, click, icon, description)
style.Size = unit.Dp(16) style.Size = unit.Dp(16)
style.Inset = layout.UniformInset(unit.Dp(5)) style.Inset = layout.UniformInset(unit.Dp(5))
@@ -1136,6 +1188,7 @@ func statusLabel(theme *material.Theme, status string) layout.Widget {
if color, ok := map[string]color.NRGBA{ if color, ok := map[string]color.NRGBA{
"运行中": {R: 24, G: 125, B: 78, A: 255}, "运行中": {R: 24, G: 125, B: 78, A: 255},
"启动中": {R: 175, G: 105, B: 0, A: 255}, "启动中": {R: 175, G: 105, B: 0, A: 255},
"停止中": {R: 175, G: 105, B: 0, A: 255},
"启动失败": {R: 188, G: 51, B: 51, A: 255}, "启动失败": {R: 188, G: 51, B: 51, A: 255},
"运行中(调试不可用)": {R: 175, G: 105, B: 0, A: 255}, "运行中(调试不可用)": {R: 175, G: 105, B: 0, A: 255},
"外部已关联": {R: 146, G: 93, B: 0, A: 255}, "外部已关联": {R: 146, G: 93, B: 0, A: 255},
@@ -1791,6 +1844,66 @@ func (s *Shell) notifyProxiesChanged() {
} }
} }
func (s *Shell) requestInstanceAction(id string) {
row, ok := s.instanceRow(id)
if !ok {
s.instanceFeedback = "找不到要操作的实例。"
return
}
mode := instanceActionFor(row)
if !mode.enabled {
s.instanceFeedback = fmt.Sprintf("%s%s,请稍候。", row.Name, mode.label)
return
}
if mode.stop {
s.requestStop(id)
return
}
s.requestStart(id)
}
func (s *Shell) requestStop(id string) {
row, ok := s.instanceRow(id)
if !ok {
s.instanceFeedback = "找不到要停止的实例。"
return
}
if row.Status != "运行中" && row.Status != "运行中(调试不可用)" {
s.instanceFeedback = fmt.Sprintf("%s 当前不能由 Chub 停止。", row.Name)
return
}
if row.OccupancySource != "" && row.OccupancySource != "chub_registry" {
s.instanceFeedback = fmt.Sprintf("%s 不是 Chub 托管实例,不能停止。", row.Name)
return
}
state := s.stopStates[id]
if state != nil && state.running {
s.instanceFeedback = fmt.Sprintf("%s 正在停止,请稍候。", row.Name)
return
}
if s.instanceStopper == nil {
s.instanceFeedback = "浏览器停止服务尚未准备好。"
return
}
if state == nil {
state = &instanceStopState{}
s.stopStates[id] = state
}
state.request++
request := state.request
state.running = true
state.previousStatus = row.Status
s.setInstanceStatus(id, "停止中")
s.instanceFeedback = fmt.Sprintf("正在请求 %s 正常退出…", row.Name)
go func() {
err := s.instanceStopper(context.Background(), row)
s.stopResults <- instanceStopResult{id: id, request: request, err: err}
if s.invalidate != nil {
s.invalidate()
}
}()
}
func (s *Shell) requestStart(id string) { func (s *Shell) requestStart(id string) {
row, ok := s.instanceRow(id) row, ok := s.instanceRow(id)
if !ok { if !ok {
@@ -1802,6 +1915,10 @@ func (s *Shell) requestStart(id string) {
s.instanceFeedback = fmt.Sprintf("%s 正在启动,请稍候。", row.Name) s.instanceFeedback = fmt.Sprintf("%s 正在启动,请稍候。", row.Name)
return return
} }
if stop := s.stopStates[id]; stop != nil && stop.running {
s.instanceFeedback = fmt.Sprintf("%s 正在停止,请等待退出后再启动。", row.Name)
return
}
if s.instanceStarter == nil { if s.instanceStarter == nil {
s.instanceFeedback = "浏览器启动服务尚未准备好。" s.instanceFeedback = "浏览器启动服务尚未准备好。"
return return
@@ -1829,6 +1946,35 @@ func (s *Shell) requestStart(id string) {
}() }()
} }
func (s *Shell) consumeStopResults() {
for {
select {
case result := <-s.stopResults:
state := s.stopStates[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 {
if row.Status == "停止中" {
s.setInstanceRuntime(result.id, state.previousStatus, row.PID, row.RemoteDebugPort, row.OccupancySource)
}
s.instanceFeedback = fmt.Sprintf("停止 %s 失败:%v", row.Name, result.err)
continue
}
s.setInstanceRuntime(result.id, "已退出", 0, 0, "")
s.focusRestoreID = result.id
s.instanceFeedback = fmt.Sprintf("%s 已正常退出。", row.Name)
default:
return
}
}
}
func (s *Shell) consumeStartResults() { func (s *Shell) consumeStartResults() {
for { for {
select { select {
@@ -1964,6 +2110,10 @@ func (s *Shell) requestDelete(id string) {
s.instanceFeedback = fmt.Sprintf("%s 正在启动,暂时不能删除。", row.Name) s.instanceFeedback = fmt.Sprintf("%s 正在启动,暂时不能删除。", row.Name)
return return
} }
if state := s.stopStates[id]; state != nil && state.running {
s.instanceFeedback = fmt.Sprintf("%s 正在停止,暂时不能删除。", row.Name)
return
}
s.pendingDeleteID = id s.pendingDeleteID = id
} }
@@ -2002,6 +2152,7 @@ func (s *Shell) deleteInstance(id string) {
delete(s.editClicks, id) delete(s.editClicks, id)
delete(s.deleteClicks, id) delete(s.deleteClicks, id)
delete(s.startStates, id) delete(s.startStates, id)
delete(s.stopStates, id)
s.instanceFeedback = fmt.Sprintf("已删除实例“%s”;其 User Data Dir 未被删除。", row.Name) s.instanceFeedback = fmt.Sprintf("已删除实例“%s”;其 User Data Dir 未被删除。", row.Name)
s.notifyInstancesChanged() s.notifyInstancesChanged()
return return
+98
View File
@@ -2,6 +2,7 @@ package ui
import ( import (
"context" "context"
"errors"
"path/filepath" "path/filepath"
"strings" "strings"
"sync/atomic" "sync/atomic"
@@ -191,6 +192,103 @@ func TestShellDoesNotStartTheSameInstanceTwiceWhilePending(t *testing.T) {
} }
} }
func TestShellStopsManagedInstanceAsynchronouslyAndRestoresStartAction(t *testing.T) {
shell := NewShell(material.NewTheme())
target := shell.rows[0]
stopped := make(chan InstanceRow, 1)
shell.OnStopInstance(func(_ context.Context, row InstanceRow) error {
stopped <- row
return nil
}, nil)
shell.requestInstanceAction(target.ID)
if row, ok := shell.instanceRow(target.ID); !ok || row.Status != "停止中" {
t.Fatalf("stop status = %#v, want 停止中", row)
}
select {
case got := <-stopped:
if got.ID != target.ID || got.Status != "运行中" {
t.Fatalf("stopped row = %#v", got)
}
case <-time.After(time.Second):
t.Fatal("stop callback was not invoked")
}
var result instanceStopResult
select {
case result = <-shell.stopResults:
case <-time.After(time.Second):
t.Fatal("stop result was not produced")
}
shell.stopResults <- result
shell.consumeStopResults()
row, ok := shell.instanceRow(target.ID)
if !ok || row.Status != "已退出" || row.PID != 0 || row.RemoteDebugPort != 0 || instanceActionFor(row).stop {
t.Fatalf("completed stop row = %#v", row)
}
}
func TestShellDoesNotStopTheSameInstanceTwiceOrExternalInstance(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.OnStopInstance(func(context.Context, InstanceRow) error {
calls.Add(1)
entered <- struct{}{}
<-release
return nil
}, nil)
shell.requestInstanceAction(target.ID)
shell.requestInstanceAction(target.ID)
select {
case <-entered:
case <-time.After(time.Second):
t.Fatal("stop callback was not invoked")
}
if calls.Load() != 1 {
t.Fatalf("stop calls = %d, want 1", calls.Load())
}
close(release)
select {
case result := <-shell.stopResults:
shell.stopResults <- result
shell.consumeStopResults()
case <-time.After(time.Second):
t.Fatal("stop result was not produced")
}
external := shell.rows[2]
var externalStops atomic.Int32
shell.OnStopInstance(func(context.Context, InstanceRow) error { externalStops.Add(1); return nil }, nil)
shell.OnStartInstance(func(context.Context, InstanceRow, SettingsState) (InstanceStartOutcome, error) {
return InstanceStartOutcome{External: true, Source: "browser_message_window"}, nil
}, nil)
shell.requestInstanceAction(external.ID)
if externalStops.Load() != 0 {
t.Fatal("external association used stop callback")
}
}
func TestShellRestoresManagedStatusAfterStopFailure(t *testing.T) {
shell := NewShell(material.NewTheme())
target := shell.rows[0]
shell.OnStopInstance(func(context.Context, InstanceRow) error { return errors.New("permission denied") }, nil)
shell.requestInstanceAction(target.ID)
var result instanceStopResult
select {
case result = <-shell.stopResults:
case <-time.After(time.Second):
t.Fatal("stop result was not produced")
}
shell.stopResults <- result
shell.consumeStopResults()
row, ok := shell.instanceRow(target.ID)
if !ok || row.Status != "运行中" || !strings.Contains(shell.instanceFeedback, "失败") {
t.Fatalf("failed stop row = %#v, feedback=%q", row, shell.instanceFeedback)
}
}
func TestShellCancelsOnlyTheActivePathSearch(t *testing.T) { func TestShellCancelsOnlyTheActivePathSearch(t *testing.T) {
shell := NewShell(material.NewTheme()) shell := NewShell(material.NewTheme())
started := make(chan struct{}, 1) started := make(chan struct{}, 1)