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