feat: toggle managed instance start and stop
This commit is contained in:
+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