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
+159 -8
View File
@@ -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