feat: notify when managed browsers exit
This commit is contained in:
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user