feat: notify when managed browsers exit

This commit is contained in:
QiuSW
2026-07-25 18:23:33 +08:00
parent f5b5e95223
commit eeaf4c7826
9 changed files with 647 additions and 92 deletions
+9 -7
View File
@@ -133,13 +133,15 @@ const (
)
type BrowserEvent struct {
Kind EventKind
InstanceID string
Status InstanceStatus
PID int
ExitCode *int
ErrorCode ErrorCode
At time.Time
Kind EventKind
InstanceID string
LaunchGeneration uint64
Status InstanceStatus
PID int
ExitCode *int
ExpectedStop bool
ErrorCode ErrorCode
At time.Time
}
type ErrorCode string
+297 -57
View File
@@ -10,6 +10,7 @@ import (
"path/filepath"
"strconv"
"strings"
"sync"
"chub/internal/domain"
"gioui.org/io/key"
@@ -75,11 +76,22 @@ type InstanceStartOutcome struct {
Warning string
}
type InstanceStarter func(context.Context, InstanceRow, SettingsState) (InstanceStartOutcome, error)
// ManagedInstanceExit is delivered by the application adapter after the root
// process of a browser started by this Chub session exits. It contains only
// DTO data; UI code never receives a platform process handle.
type ManagedInstanceExit struct {
InstanceID string
LaunchGeneration uint64
PID int
ExitCode int
ExpectedStop bool
}
type InstanceStarter func(context.Context, InstanceRow, SettingsState, uint64) (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
type InstanceStopper func(context.Context, InstanceRow, uint64) 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
@@ -134,6 +146,18 @@ type instanceRefreshResult struct {
err error
}
type managedExitKey struct {
instanceID string
launchGeneration uint64
pid int
}
type unexpectedExitNotice struct {
instanceID string
name string
browser string
}
type pathSearchState struct {
request uint64
cancel context.CancelFunc
@@ -277,43 +301,53 @@ type Shell struct {
pendingProxyDelete string
proxyDeleteFocus bool
list widget.List
rows []InstanceRow
selectedInstanceID string
rowClicks map[string]*widget.Clickable
startClicks map[string]*widget.Clickable
editClicks map[string]*widget.Clickable
deleteClicks map[string]*widget.Clickable
deleteConfirm widget.Clickable
deleteCancel widget.Clickable
deleteBlocker widget.Clickable
startStates map[string]*instanceStartState
startResults chan instanceStartResult
stopStates map[string]*instanceStopState
stopResults chan instanceStopResult
refreshClickState instanceRefreshState
refreshResults chan instanceRefreshResult
nextInstance uint64
instanceFeedback string
pendingDeleteID string
editingID string
editOriginal InstanceRow
pendingEditDiscard bool
editFocusPending bool
focusRestoreID string
onSave func(SettingsState)
onInstancesChanged func([]InstanceRow)
onProxiesChanged func([]ProxyOption)
instanceStarter InstanceStarter
instanceStopper InstanceStopper
instanceRefresher InstanceRefresher
pathSearcher PathSearcher
invalidate func()
searches map[PathField]*pathSearchState
searchResults chan pathSearchResult
directoryChooser DirectoryChooser
directoryPick directoryPickState
directoryResults chan directoryPickResult
list widget.List
rows []InstanceRow
selectedInstanceID string
rowClicks map[string]*widget.Clickable
startClicks map[string]*widget.Clickable
editClicks map[string]*widget.Clickable
deleteClicks map[string]*widget.Clickable
deleteConfirm widget.Clickable
deleteCancel widget.Clickable
deleteBlocker widget.Clickable
startStates map[string]*instanceStartState
startResults chan instanceStartResult
stopStates map[string]*instanceStopState
stopResults chan instanceStopResult
refreshClickState instanceRefreshState
refreshResults chan instanceRefreshResult
managedExitMu sync.Mutex
managedExitResults []ManagedInstanceExit
pendingManagedExit map[managedExitKey]ManagedInstanceExit
unexpectedExits []unexpectedExitNotice
unexpectedExitOpen bool
unexpectedExitAck widget.Clickable
unexpectedExitBlocker widget.Clickable
unexpectedExitFocus bool
unexpectedExitFocusID string
nextInstance uint64
instanceFeedback string
pendingDeleteID string
editingID string
editOriginal InstanceRow
pendingEditDiscard bool
editFocusPending bool
focusRestoreID string
focusStartID string
onSave func(SettingsState)
onInstancesChanged func([]InstanceRow)
onProxiesChanged func([]ProxyOption)
instanceStarter InstanceStarter
instanceStopper InstanceStopper
instanceRefresher InstanceRefresher
pathSearcher PathSearcher
invalidate func()
searches map[PathField]*pathSearchState
searchResults chan pathSearchResult
directoryChooser DirectoryChooser
directoryPick directoryPickState
directoryResults chan directoryPickResult
}
func NewShell(theme *material.Theme) *Shell {
@@ -327,7 +361,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), 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)}
}, rowClicks: make(map[string]*widget.Clickable), startClicks: make(map[string]*widget.Clickable), editClicks: make(map[string]*widget.Clickable), deleteClicks: make(map[string]*widget.Clickable), proxyPickerChoices: make(map[string]*widget.Clickable), startStates: make(map[string]*instanceStartState), stopStates: make(map[string]*instanceStopState), pendingManagedExit: make(map[managedExitKey]ManagedInstanceExit), 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`)
@@ -407,6 +441,19 @@ func (s *Shell) OnStopInstance(stopper InstanceStopper, invalidate func()) {
s.invalidate = invalidate
}
// ReportManagedExit is safe for the application's single per-instance monitor
// goroutine. The event is consumed during the next UI frame and therefore does
// not perform layout or mutate widgets from a background thread. The monitor
// that reports this result is responsible for invalidating its window.
func (s *Shell) ReportManagedExit(result ManagedInstanceExit) {
if result.InstanceID == "" || result.LaunchGeneration == 0 || result.PID <= 0 {
return
}
s.managedExitMu.Lock()
s.managedExitResults = append(s.managedExitResults, result)
s.managedExitMu.Unlock()
}
func (s *Shell) OnRefreshInstances(refresher InstanceRefresher, invalidate func()) {
s.instanceRefresher = refresher
s.invalidate = invalidate
@@ -428,8 +475,12 @@ func (s *Shell) Layout(gtx layout.Context) layout.Dimensions {
s.consumeStartResults()
s.consumeStopResults()
s.consumeRefreshResults()
s.consumeManagedExitResults()
s.presentUnexpectedExitIfReady()
s.consumeKeyboard(gtx)
if s.pendingDeleteID != "" {
if s.unexpectedExitOpen {
s.consumeUnexpectedExitDialog(gtx)
} else if s.pendingDeleteID != "" {
s.consumeDeleteConfirmation(gtx)
} else if s.pendingProxyDelete != "" {
s.consumeProxyDeleteConfirmation(gtx)
@@ -438,6 +489,7 @@ func (s *Shell) Layout(gtx layout.Context) layout.Dimensions {
} else {
s.consumeControls(gtx)
}
s.presentUnexpectedExitIfReady()
mainLayout := func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(s.sidebar),
@@ -446,9 +498,15 @@ func (s *Shell) Layout(gtx layout.Context) layout.Dimensions {
}),
)
}
if s.pendingDeleteID == "" && s.pendingProxyDelete == "" && s.editingID == "" && !s.proxyPicker.open {
if !s.unexpectedExitOpen && s.pendingDeleteID == "" && s.pendingProxyDelete == "" && s.editingID == "" && !s.proxyPicker.open {
return mainLayout(gtx)
}
if s.unexpectedExitOpen {
return layout.Stack{Alignment: layout.Center}.Layout(gtx,
layout.Expanded(mainLayout),
layout.Stacked(s.unexpectedExitDialog),
)
}
if s.pendingDeleteID != "" {
return layout.Stack{Alignment: layout.Center}.Layout(gtx,
layout.Expanded(mainLayout),
@@ -546,7 +604,9 @@ func (s *Shell) consumeKeyboard(gtx layout.Context) {
}
switch keyEvent.Name {
case key.NameEscape:
if s.pendingProxyDelete != "" {
if s.unexpectedExitOpen {
s.dismissUnexpectedExitNotice()
} else if s.pendingProxyDelete != "" {
s.cancelProxyDelete()
} else if s.proxyPicker.open {
s.proxyPicker.open = false
@@ -919,18 +979,24 @@ 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 {
mode := instanceActionFor(row)
startClick := s.startClickFor(row.ID)
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.startClickFor(row.ID), actionIcon, actionLabel, s.theme.Palette.ContrastBg, mode.enabled)),
dims := layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(s.instanceIconButton(startClick, actionIcon, actionLabel, s.theme.Palette.ContrastBg, mode.enabled)),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(s.instanceIconButton(s.editClickFor(row.ID), instanceEditIcon, "编辑 "+row.Name, s.theme.Palette.ContrastBg)),
layout.Rigid(layout.Spacer{Width: unit.Dp(10)}.Layout),
layout.Rigid(s.instanceIconButton(s.deleteClickFor(row.ID), instanceDeleteIcon, "删除 "+row.Name, color.NRGBA{R: 188, G: 51, B: 51, A: 255})),
)
if s.focusStartID == row.ID {
gtx.Execute(key.FocusCmd{Tag: startClick})
s.focusStartID = ""
}
return dims
})
}
}
@@ -1270,6 +1336,79 @@ func (s *Shell) proxyDeleteConfirmButton(gtx layout.Context) layout.Dimensions {
return style.Layout(gtx)
}
func (s *Shell) unexpectedExitDialog(gtx layout.Context) layout.Dimensions {
if !s.unexpectedExitOpen || len(s.unexpectedExits) == 0 {
return layout.Dimensions{}
}
gtx.Constraints.Min = gtx.Constraints.Max
return s.unexpectedExitBlocker.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
defer clip.Rect{Max: gtx.Constraints.Min}.Push(gtx.Ops).Pop()
paint.Fill(gtx.Ops, color.NRGBA{R: 0, G: 0, B: 0, A: 92})
return layout.Center.Layout(gtx, s.unexpectedExitCard)
})
}
func (s *Shell) unexpectedExitCard(gtx layout.Context) layout.Dimensions {
if maxWidth := gtx.Dp(480); gtx.Constraints.Max.X > maxWidth {
gtx.Constraints.Max.X = maxWidth
}
title := "浏览器已退出"
message := "检测到以下由 Chub 启动的浏览器已退出。"
if len(s.unexpectedExits) == 1 {
notice := s.unexpectedExits[0]
message = fmt.Sprintf("检测到“%s”的 %s 已退出。", notice.name, notice.browser)
} else {
title = fmt.Sprintf("%d 个浏览器已退出", len(s.unexpectedExits))
}
return layout.Background{}.Layout(gtx,
func(gtx layout.Context) layout.Dimensions {
rect := image.Rectangle{Max: gtx.Constraints.Min}
defer clip.UniformRRect(rect, gtx.Dp(8)).Push(gtx.Ops).Pop()
paint.Fill(gtx.Ops, s.theme.Palette.Bg)
return layout.Dimensions{Size: gtx.Constraints.Min}
},
func(gtx layout.Context) layout.Dimensions {
return layout.Inset{Top: unit.Dp(20), Right: unit.Dp(24), Bottom: unit.Dp(20), Left: unit.Dp(24)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
children := []layout.FlexChild{
layout.Rigid(material.H6(s.theme, title).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(material.Body1(s.theme, message).Layout),
}
if len(s.unexpectedExits) > 1 {
for index, notice := range s.unexpectedExits {
if index == 5 {
children = append(children,
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(material.Caption(s.theme, fmt.Sprintf("另有 %d 个实例已退出。", len(s.unexpectedExits)-index)).Layout),
)
break
}
notice := notice
children = append(children,
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(material.Body2(s.theme, fmt.Sprintf("• %s · %s", notice.name, notice.browser)).Layout),
)
}
}
children = append(children,
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(material.Caption(s.theme, "实例配置和 User Data Dir 未被删除。关闭提示后可重新启动。").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(18)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.E.Layout(gtx, material.Button(s.theme, &s.unexpectedExitAck, "知道了").Layout)
}),
)
dims := layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
if s.unexpectedExitFocus {
gtx.Execute(key.FocusCmd{Tag: &s.unexpectedExitAck})
s.unexpectedExitFocus = false
}
return dims
})
},
)
}
func statusLabel(theme *material.Theme, status string) layout.Widget {
style := material.Label(theme, unit.Sp(13), status)
style.Color = theme.Palette.Fg
@@ -1957,7 +2096,8 @@ func (s *Shell) requestStop(id string) {
s.instanceFeedback = fmt.Sprintf("%s 正在停止,请稍候。", row.Name)
return
}
if s.instanceStopper == nil {
stopper := s.instanceStopper
if stopper == nil {
s.instanceFeedback = "浏览器停止服务尚未准备好。"
return
}
@@ -1968,14 +2108,19 @@ func (s *Shell) requestStop(id string) {
state.request++
request := state.request
state.running = true
launchGeneration := uint64(0)
if started := s.startStates[id]; started != nil {
launchGeneration = started.request
}
state.previousStatus = row.Status
s.setInstanceStatus(id, "停止中")
s.instanceFeedback = fmt.Sprintf("正在请求 %s 正常退出…", row.Name)
invalidate := s.invalidate
go func() {
err := s.instanceStopper(context.Background(), row)
err := stopper(context.Background(), row, launchGeneration)
s.stopResults <- instanceStopResult{id: id, request: request, err: err}
if s.invalidate != nil {
s.invalidate()
if invalidate != nil {
invalidate()
}
}()
}
@@ -1995,7 +2140,8 @@ func (s *Shell) requestStart(id string) {
s.instanceFeedback = fmt.Sprintf("%s 正在停止,请等待退出后再启动。", row.Name)
return
}
if s.instanceStarter == nil {
starter := s.instanceStarter
if starter == nil {
s.instanceFeedback = "浏览器启动服务尚未准备好。"
return
}
@@ -2013,11 +2159,12 @@ func (s *Shell) requestStart(id string) {
state.running = true
s.setInstanceStatus(id, "启动中")
s.instanceFeedback = fmt.Sprintf("正在启动 %s…", row.Name)
invalidate := s.invalidate
go func() {
outcome, err := s.instanceStarter(context.Background(), row, settings)
outcome, err := starter(context.Background(), row, settings, request)
s.startResults <- instanceStartResult{id: id, request: request, outcome: outcome, err: err}
if s.invalidate != nil {
s.invalidate()
if invalidate != nil {
invalidate()
}
}()
}
@@ -2089,6 +2236,9 @@ func (s *Shell) consumeStartResults() {
status = "运行中(调试不可用)"
}
s.setInstanceRuntime(result.id, status, result.outcome.PID, result.outcome.RemoteDebugPort, "chub_registry")
if s.applyPendingManagedExit(result.id, result.request, result.outcome.PID) {
continue
}
if result.outcome.Warning != "" {
s.instanceFeedback = fmt.Sprintf("%s 已启动(PID %d),但%s。", row.Name, result.outcome.PID, result.outcome.Warning)
} else {
@@ -2105,7 +2255,8 @@ func (s *Shell) requestRefresh() {
s.instanceFeedback = "实例状态正在刷新,请稍候。"
return
}
if s.instanceRefresher == nil {
refresher := s.instanceRefresher
if refresher == nil {
s.instanceFeedback = "实例状态刷新服务尚未准备好。"
return
}
@@ -2118,11 +2269,12 @@ func (s *Shell) requestRefresh() {
request := s.refreshClickState.request
s.refreshClickState.running = true
s.instanceFeedback = "正在刷新已保存实例的状态…"
invalidate := s.invalidate
go func() {
results, err := s.instanceRefresher(context.Background(), rows)
results, err := refresher(context.Background(), rows)
s.refreshResults <- instanceRefreshResult{request: request, snapshots: snapshots, results: results, err: err}
if s.invalidate != nil {
s.invalidate()
if invalidate != nil {
invalidate()
}
}()
}
@@ -2172,6 +2324,66 @@ func (s *Shell) consumeRefreshResults() {
}
}
func (s *Shell) consumeManagedExitResults() {
s.managedExitMu.Lock()
results := s.managedExitResults
s.managedExitResults = nil
s.managedExitMu.Unlock()
for _, result := range results {
key := managedExitKey{instanceID: result.InstanceID, launchGeneration: result.LaunchGeneration, pid: result.PID}
row, exists := s.instanceRow(result.InstanceID)
if !exists {
continue
}
state := s.startStates[result.InstanceID]
if state != nil && state.running && state.request == result.LaunchGeneration && row.Status == "启动中" {
s.pendingManagedExit[key] = result
continue
}
s.applyManagedExit(result)
}
}
func (s *Shell) applyPendingManagedExit(id string, launchGeneration uint64, pid int) bool {
key := managedExitKey{instanceID: id, launchGeneration: launchGeneration, pid: pid}
result, exists := s.pendingManagedExit[key]
if !exists {
return false
}
delete(s.pendingManagedExit, key)
return s.applyManagedExit(result)
}
func (s *Shell) applyManagedExit(result ManagedInstanceExit) bool {
row, exists := s.instanceRow(result.InstanceID)
if !exists || row.PID != result.PID {
return false
}
state := s.startStates[result.InstanceID]
if state == nil || state.request != result.LaunchGeneration {
return false
}
if row.Status != "运行中" && row.Status != "运行中(调试不可用)" && row.Status != "停止中" {
return false
}
s.setInstanceRuntime(result.InstanceID, "已退出", 0, 0, "")
if result.ExpectedStop || row.Status == "停止中" {
s.instanceFeedback = fmt.Sprintf("%s 已正常退出。", row.Name)
return true
}
for _, notice := range s.unexpectedExits {
if notice.instanceID == row.ID {
return true
}
}
s.unexpectedExits = append(s.unexpectedExits, unexpectedExitNotice{instanceID: row.ID, name: row.Name, browser: row.Browser})
if s.unexpectedExitFocusID == "" {
s.unexpectedExitFocusID = row.ID
}
s.instanceFeedback = fmt.Sprintf("检测到“%s”的 %s 已退出,可重新启动。", row.Name, row.Browser)
return true
}
func instanceRefreshFingerprint(row InstanceRow) string {
return strings.Join([]string{row.ID, row.Name, row.Browser, row.UserDataDir, row.TargetURL, row.ProxyID}, "\x00")
}
@@ -2215,6 +2427,34 @@ func (s *Shell) consumeProxyDeleteConfirmation(gtx layout.Context) {
}
}
func (s *Shell) consumeUnexpectedExitDialog(gtx layout.Context) {
for s.unexpectedExitBlocker.Clicked(gtx) {
}
for s.unexpectedExitAck.Clicked(gtx) {
s.dismissUnexpectedExitNotice()
}
}
func (s *Shell) hasOtherModal() bool {
return s.pendingDeleteID != "" || s.pendingProxyDelete != "" || s.editingID != "" || s.pendingEditDiscard || s.proxyPicker.open
}
func (s *Shell) presentUnexpectedExitIfReady() {
if s.unexpectedExitOpen || len(s.unexpectedExits) == 0 || s.hasOtherModal() {
return
}
s.unexpectedExitOpen = true
s.unexpectedExitFocus = true
}
func (s *Shell) dismissUnexpectedExitNotice() {
s.unexpectedExitOpen = false
s.unexpectedExitFocus = false
s.unexpectedExits = nil
s.focusStartID = s.unexpectedExitFocusID
s.unexpectedExitFocusID = ""
}
func (s *Shell) cancelDelete() {
if row, ok := s.instanceRow(s.pendingDeleteID); ok {
s.instanceFeedback = fmt.Sprintf("已取消删除实例“%s”。", row.Name)
+101 -8
View File
@@ -129,7 +129,7 @@ func TestShellStartsInstanceAsynchronouslyAndAppliesResult(t *testing.T) {
shell := NewShell(material.NewTheme())
target := shell.rows[0]
started := make(chan InstanceRow, 1)
shell.OnStartInstance(func(_ context.Context, row InstanceRow, _ SettingsState) (InstanceStartOutcome, error) {
shell.OnStartInstance(func(_ context.Context, row InstanceRow, _ SettingsState, _ uint64) (InstanceStartOutcome, error) {
started <- row
return InstanceStartOutcome{PID: 4242, RemoteDebugPort: 9666}, nil
}, nil)
@@ -165,7 +165,7 @@ func TestShellDoesNotStartTheSameInstanceTwiceWhilePending(t *testing.T) {
entered := make(chan struct{}, 1)
release := make(chan struct{})
var calls atomic.Int32
shell.OnStartInstance(func(_ context.Context, _ InstanceRow, _ SettingsState) (InstanceStartOutcome, error) {
shell.OnStartInstance(func(_ context.Context, _ InstanceRow, _ SettingsState, _ uint64) (InstanceStartOutcome, error) {
calls.Add(1)
entered <- struct{}{}
<-release
@@ -196,7 +196,7 @@ func TestShellStopsManagedInstanceAsynchronouslyAndRestoresStartAction(t *testin
shell := NewShell(material.NewTheme())
target := shell.rows[0]
stopped := make(chan InstanceRow, 1)
shell.OnStopInstance(func(_ context.Context, row InstanceRow) error {
shell.OnStopInstance(func(_ context.Context, row InstanceRow, _ uint64) error {
stopped <- row
return nil
}, nil)
@@ -233,7 +233,7 @@ func TestShellDoesNotStopTheSameInstanceTwiceOrExternalInstance(t *testing.T) {
entered := make(chan struct{}, 1)
release := make(chan struct{})
var calls atomic.Int32
shell.OnStopInstance(func(context.Context, InstanceRow) error {
shell.OnStopInstance(func(context.Context, InstanceRow, uint64) error {
calls.Add(1)
entered <- struct{}{}
<-release
@@ -260,8 +260,8 @@ func TestShellDoesNotStopTheSameInstanceTwiceOrExternalInstance(t *testing.T) {
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) {
shell.OnStopInstance(func(context.Context, InstanceRow, uint64) error { externalStops.Add(1); return nil }, nil)
shell.OnStartInstance(func(context.Context, InstanceRow, SettingsState, uint64) (InstanceStartOutcome, error) {
return InstanceStartOutcome{External: true, Source: "browser_message_window"}, nil
}, nil)
shell.requestInstanceAction(external.ID)
@@ -273,7 +273,7 @@ func TestShellDoesNotStopTheSameInstanceTwiceOrExternalInstance(t *testing.T) {
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.OnStopInstance(func(context.Context, InstanceRow, uint64) error { return errors.New("permission denied") }, nil)
shell.requestInstanceAction(target.ID)
var result instanceStopResult
select {
@@ -358,7 +358,7 @@ func TestShellNormalizesRemoteDebugStartPort(t *testing.T) {
func TestShellMarksExternalAssociationWithoutManagingLifecycle(t *testing.T) {
shell := NewShell(material.NewTheme())
target := shell.rows[2]
shell.OnStartInstance(func(_ context.Context, _ InstanceRow, _ SettingsState) (InstanceStartOutcome, error) {
shell.OnStartInstance(func(_ context.Context, _ InstanceRow, _ SettingsState, _ uint64) (InstanceStartOutcome, error) {
return InstanceStartOutcome{PID: 16108, RemoteDebugPort: 9668, External: true, Source: "browser_message_window"}, nil
}, nil)
@@ -624,3 +624,96 @@ func TestShellSavesProxyFromAddressPicker(t *testing.T) {
t.Fatalf("created proxy = %#v, selected %q, changes %d", shell.proxies, shell.settingsProxyID, changes)
}
}
func TestShellMarksUnexpectedManagedExitAndRestoresStartFocus(t *testing.T) {
shell := NewShell(material.NewTheme())
target := shell.rows[0]
shell.startStates[target.ID] = &instanceStartState{request: 7}
shell.ReportManagedExit(ManagedInstanceExit{InstanceID: target.ID, LaunchGeneration: 7, PID: target.PID, ExitCode: 0})
shell.consumeManagedExitResults()
shell.presentUnexpectedExitIfReady()
row, ok := shell.instanceRow(target.ID)
if !ok || row.Status != "已退出" || row.PID != 0 || row.RemoteDebugPort != 0 || row.OccupancySource != "" || !shell.unexpectedExitOpen || len(shell.unexpectedExits) != 1 {
t.Fatalf("unexpected exit state = row %#v, dialog %v, notices %#v", row, shell.unexpectedExitOpen, shell.unexpectedExits)
}
if mode := instanceActionFor(row); !mode.enabled || mode.stop || mode.label != "启动" {
t.Fatalf("action after unexpected exit = %#v", mode)
}
shell.dismissUnexpectedExitNotice()
if shell.unexpectedExitOpen || len(shell.unexpectedExits) != 0 || shell.focusStartID != target.ID {
t.Fatalf("dismissed unexpected exit state = open %v, notices %#v, focus %q", shell.unexpectedExitOpen, shell.unexpectedExits, shell.focusStartID)
}
}
func TestShellDoesNotNotifyExpectedOrStaleManagedExit(t *testing.T) {
shell := NewShell(material.NewTheme())
target := shell.rows[0]
shell.startStates[target.ID] = &instanceStartState{request: 3}
shell.ReportManagedExit(ManagedInstanceExit{InstanceID: target.ID, LaunchGeneration: 2, PID: target.PID, ExitCode: 0})
shell.consumeManagedExitResults()
if row, _ := shell.instanceRow(target.ID); row.Status != "运行中" || len(shell.unexpectedExits) != 0 {
t.Fatalf("stale exit changed row = %#v, notices %#v", row, shell.unexpectedExits)
}
shell.ReportManagedExit(ManagedInstanceExit{InstanceID: target.ID, LaunchGeneration: 3, PID: target.PID, ExitCode: 0, ExpectedStop: true})
shell.consumeManagedExitResults()
shell.presentUnexpectedExitIfReady()
if row, _ := shell.instanceRow(target.ID); row.Status != "已退出" || len(shell.unexpectedExits) != 0 || shell.unexpectedExitOpen {
t.Fatalf("expected exit state = row %#v, notices %#v, dialog %v", row, shell.unexpectedExits, shell.unexpectedExitOpen)
}
}
func TestShellAppliesManagedExitThatArrivesDuringStart(t *testing.T) {
shell := NewShell(material.NewTheme())
target := shell.rows[0]
target.Status = "启动中"
target.PID = 0
target.RemoteDebugPort = 0
shell.rows[0] = target
shell.startStates[target.ID] = &instanceStartState{request: 5, running: true}
shell.ReportManagedExit(ManagedInstanceExit{InstanceID: target.ID, LaunchGeneration: 5, PID: 5123, ExitCode: 1})
shell.consumeManagedExitResults()
if len(shell.pendingManagedExit) != 1 {
t.Fatalf("pending managed exits = %#v", shell.pendingManagedExit)
}
shell.startStates[target.ID].running = false
shell.setInstanceRuntime(target.ID, "运行中", 5123, 9666, "chub_registry")
if !shell.applyPendingManagedExit(target.ID, 5, 5123) {
t.Fatal("pending managed exit was not applied")
}
shell.presentUnexpectedExitIfReady()
if row, _ := shell.instanceRow(target.ID); row.Status != "已退出" || !shell.unexpectedExitOpen {
t.Fatalf("quick exit after start = row %#v, dialog %v", row, shell.unexpectedExitOpen)
}
}
func TestShellAggregatesManagedExitNoticesUntilOtherModalCloses(t *testing.T) {
shell := NewShell(material.NewTheme())
shell.SetInstances([]InstanceRow{
{ID: "chrome", Name: "运营 Chrome", Browser: "Chrome", UserDataDir: t.TempDir(), PID: 4001, RemoteDebugPort: 9666, OccupancySource: "chub_registry", Status: "运行中"},
{ID: "edge", Name: "审核 Edge", Browser: "Edge", UserDataDir: t.TempDir(), PID: 4002, RemoteDebugPort: 9667, OccupancySource: "chub_registry", Status: "运行中"},
})
shell.startStates["chrome"] = &instanceStartState{request: 1}
shell.startStates["edge"] = &instanceStartState{request: 2}
shell.editingID = "editing"
shell.ReportManagedExit(ManagedInstanceExit{InstanceID: "chrome", LaunchGeneration: 1, PID: 4001})
shell.ReportManagedExit(ManagedInstanceExit{InstanceID: "edge", LaunchGeneration: 2, PID: 4002})
shell.consumeManagedExitResults()
shell.presentUnexpectedExitIfReady()
if shell.unexpectedExitOpen || len(shell.unexpectedExits) != 2 {
t.Fatalf("queued notices = open %v, notices %#v", shell.unexpectedExitOpen, shell.unexpectedExits)
}
shell.editingID = ""
shell.presentUnexpectedExitIfReady()
if !shell.unexpectedExitOpen || len(shell.unexpectedExits) != 2 {
t.Fatalf("aggregated notices = open %v, notices %#v", shell.unexpectedExitOpen, shell.unexpectedExits)
}
}