feat: toggle managed instance start and stop
This commit is contained in:
@@ -121,6 +121,7 @@ func runWindow(logger *slog.Logger) {
|
||||
portAllocator: cdp,
|
||||
proxyResolver: proxies,
|
||||
}.Start, window.Invalidate)
|
||||
shell.OnStopInstance(instanceStopper{launcher: launcher, managedProfiles: launcher}.Stop, window.Invalidate)
|
||||
shell.OnRefreshInstances(instanceStatusRefresher{
|
||||
managedProfiles: launcher,
|
||||
externalProfiles: externalProfiles,
|
||||
@@ -263,6 +264,76 @@ type managedProfileInspector interface {
|
||||
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 {
|
||||
launcher processLauncher
|
||||
resolver executableResolver
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chub/internal/domain"
|
||||
"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) {
|
||||
launcher := &fakeProcessLauncher{handle: fakeProcessHandle{pid: 4242}}
|
||||
remote := &fakeRemoteDebugInspector{endpoint: browser.RemoteDebugEndpoint{Port: 9668}}
|
||||
@@ -187,6 +219,28 @@ func (i fakeManagedProfileInspector) InspectProfile(context.Context, string) (br
|
||||
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 {
|
||||
use browser.ProfileUse
|
||||
err error
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@
|
||||
| T-302 | 本地 CDP 启动、外部关联与实例端口展示 | T-102,T-301 | DONE |
|
||||
| T-303 | 实例状态刷新与编辑对话框 | T-302 | DONE |
|
||||
| T-304 | 代理配置库、实例选择与安全启动参数传递 | T-303 | DONE |
|
||||
| T-305 | GUI 受管实例优雅停止与启停状态机 | T-304 | DOING |
|
||||
| T-305 | GUI 受管实例优雅停止与启停状态机 | T-304 | DONE |
|
||||
|
||||
## Backlog
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
## 快照
|
||||
|
||||
- 日期: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 已完成
|
||||
- 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`,尚未复制或接入本项目
|
||||
- blocker:无;下一步完成已注册实例的 GUI 优雅停止、停止结果等待和启停图标状态回传(T-305)。
|
||||
- blocker:无;Phase 3 的当前 GUI 启动、代理、刷新、编辑、删除和受管实例优雅停止已闭环。下一步可按 Backlog 决策 CLI BrowserManager adapter、重启/强制关闭确认或 CDP tab/window 管理。
|
||||
|
||||
## 当前目录
|
||||
|
||||
|
||||
+4
-4
@@ -3,7 +3,7 @@ id: T-305
|
||||
title: GUI 受管实例优雅停止与启停状态机
|
||||
phase: 3
|
||||
deps: [T-304]
|
||||
status: DOING
|
||||
status: DONE
|
||||
created: 2026-07-25
|
||||
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
@@ -41,6 +41,7 @@ const (
|
||||
|
||||
var (
|
||||
instanceStartIcon = mustIcon(icons.AVPlayArrow)
|
||||
instanceStopIcon = mustIcon(icons.AVStop)
|
||||
instanceEditIcon = mustIcon(icons.EditorModeEdit)
|
||||
instanceDeleteIcon = mustIcon(icons.ActionDelete)
|
||||
)
|
||||
@@ -76,6 +77,10 @@ type InstanceStartOutcome struct {
|
||||
|
||||
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
|
||||
// instance. The UI applies it only while the row's editable fields still match
|
||||
// the snapshot passed to the refresher.
|
||||
@@ -105,6 +110,18 @@ type instanceStartResult struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type instanceStopState struct {
|
||||
request uint64
|
||||
running bool
|
||||
previousStatus string
|
||||
}
|
||||
|
||||
type instanceStopResult struct {
|
||||
id string
|
||||
request uint64
|
||||
err error
|
||||
}
|
||||
|
||||
type instanceRefreshState struct {
|
||||
request uint64
|
||||
running bool
|
||||
@@ -268,6 +285,8 @@ type Shell struct {
|
||||
deleteBlocker widget.Clickable
|
||||
startStates map[string]*instanceStartState
|
||||
startResults chan instanceStartResult
|
||||
stopStates map[string]*instanceStopState
|
||||
stopResults chan instanceStopResult
|
||||
refreshClickState instanceRefreshState
|
||||
refreshResults chan instanceRefreshResult
|
||||
nextInstance uint64
|
||||
@@ -282,6 +301,7 @@ type Shell struct {
|
||||
onInstancesChanged func([]InstanceRow)
|
||||
onProxiesChanged func([]ProxyOption)
|
||||
instanceStarter InstanceStarter
|
||||
instanceStopper InstanceStopper
|
||||
instanceRefresher InstanceRefresher
|
||||
pathSearcher PathSearcher
|
||||
invalidate func()
|
||||
@@ -303,7 +323,7 @@ func NewShell(theme *material.Theme) *Shell {
|
||||
PathEdgeExecutable: {},
|
||||
PathDefaultUserData: {},
|
||||
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.edgePath.SetText(`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`)
|
||||
s.dataDir.SetText(`C:\Users\Public\chub\profiles`)
|
||||
@@ -374,6 +394,11 @@ func (s *Shell) OnStartInstance(starter InstanceStarter, invalidate func()) {
|
||||
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()) {
|
||||
s.instanceRefresher = refresher
|
||||
s.invalidate = invalidate
|
||||
@@ -393,6 +418,7 @@ func (s *Shell) Layout(gtx layout.Context) layout.Dimensions {
|
||||
s.consumeSearchResults()
|
||||
s.consumeDirectoryResults()
|
||||
s.consumeStartResults()
|
||||
s.consumeStopResults()
|
||||
s.consumeRefreshResults()
|
||||
s.consumeKeyboard(gtx)
|
||||
if s.pendingDeleteID != "" {
|
||||
@@ -476,7 +502,7 @@ func (s *Shell) consumeControls(gtx layout.Context) {
|
||||
}
|
||||
}
|
||||
for s.startClickFor(row.ID).Clicked(gtx) {
|
||||
s.requestStart(row.ID)
|
||||
s.requestInstanceAction(row.ID)
|
||||
}
|
||||
for s.editClickFor(row.ID).Clicked(gtx) {
|
||||
s.beginEdit(row.ID)
|
||||
@@ -592,7 +618,7 @@ func (s *Shell) beginEdit(id string) {
|
||||
|
||||
func (s *Shell) editLocked(row InstanceRow) bool {
|
||||
switch row.Status {
|
||||
case "启动中", "运行中", "运行中(调试不可用)", "外部已关联":
|
||||
case "启动中", "停止中", "运行中", "运行中(调试不可用)", "外部已关联":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -874,14 +900,16 @@ func remoteDebugPortText(port int) string {
|
||||
func (s *Shell) instanceActionButtons(row InstanceRow) layout.Widget {
|
||||
return func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.E.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
actionLabel := "启动 " + row.Name
|
||||
if row.Status == "外部已关联" {
|
||||
actionLabel = "重新检测 " + row.Name
|
||||
mode := instanceActionFor(row)
|
||||
actionLabel := mode.label + " " + row.Name
|
||||
actionIcon := instanceStartIcon
|
||||
if mode.stop {
|
||||
actionIcon = instanceStopIcon
|
||||
}
|
||||
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(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(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 {
|
||||
if len(enabled) > 0 && !enabled[0] {
|
||||
gtx = gtx.Disabled()
|
||||
}
|
||||
style := material.IconButton(s.theme, click, icon, description)
|
||||
style.Size = unit.Dp(16)
|
||||
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{
|
||||
"运行中": {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: 188, G: 51, B: 51, A: 255},
|
||||
"运行中(调试不可用)": {R: 175, G: 105, 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) {
|
||||
row, ok := s.instanceRow(id)
|
||||
if !ok {
|
||||
@@ -1802,6 +1915,10 @@ func (s *Shell) requestStart(id string) {
|
||||
s.instanceFeedback = fmt.Sprintf("%s 正在启动,请稍候。", row.Name)
|
||||
return
|
||||
}
|
||||
if stop := s.stopStates[id]; stop != nil && stop.running {
|
||||
s.instanceFeedback = fmt.Sprintf("%s 正在停止,请等待退出后再启动。", row.Name)
|
||||
return
|
||||
}
|
||||
if s.instanceStarter == nil {
|
||||
s.instanceFeedback = "浏览器启动服务尚未准备好。"
|
||||
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() {
|
||||
for {
|
||||
select {
|
||||
@@ -1964,6 +2110,10 @@ func (s *Shell) requestDelete(id string) {
|
||||
s.instanceFeedback = fmt.Sprintf("%s 正在启动,暂时不能删除。", row.Name)
|
||||
return
|
||||
}
|
||||
if state := s.stopStates[id]; state != nil && state.running {
|
||||
s.instanceFeedback = fmt.Sprintf("%s 正在停止,暂时不能删除。", row.Name)
|
||||
return
|
||||
}
|
||||
s.pendingDeleteID = id
|
||||
}
|
||||
|
||||
@@ -2002,6 +2152,7 @@ func (s *Shell) deleteInstance(id string) {
|
||||
delete(s.editClicks, id)
|
||||
delete(s.deleteClicks, id)
|
||||
delete(s.startStates, id)
|
||||
delete(s.stopStates, id)
|
||||
s.instanceFeedback = fmt.Sprintf("已删除实例“%s”;其 User Data Dir 未被删除。", row.Name)
|
||||
s.notifyInstancesChanged()
|
||||
return
|
||||
|
||||
@@ -2,6 +2,7 @@ package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"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) {
|
||||
shell := NewShell(material.NewTheme())
|
||||
started := make(chan struct{}, 1)
|
||||
|
||||
Reference in New Issue
Block a user