feat: support explicit instance data deletion

This commit is contained in:
QiuSW
2026-07-27 23:16:11 +08:00
parent e400172d04
commit cab7e6ac7b
12 changed files with 670 additions and 28 deletions
+182 -16
View File
@@ -100,6 +100,10 @@ type InstanceStarter func(context.Context, InstanceRow, SettingsState, uint64) (
// instance. Implementations must reject external or unverified processes.
type InstanceStopper func(context.Context, InstanceRow, uint64) error
// InstanceDataDeleter removes the explicitly selected User Data Dir only after
// its implementation has revalidated browser occupancy and path boundaries.
type InstanceDataDeleter 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.
@@ -154,6 +158,13 @@ type instanceStopResult struct {
err error
}
type instanceDataDeleteResult struct {
id string
request uint64
fingerprint string
err error
}
type instanceRefreshState struct {
request uint64
running bool
@@ -363,6 +374,12 @@ type Shell struct {
deleteConfirm widget.Clickable
deleteCancel widget.Clickable
deleteBlocker widget.Clickable
deleteData widget.Bool
deleteDataRunning bool
deleteDataRequest uint64
deleteDataFeedback string
deleteDataFocus bool
deleteDataResults chan instanceDataDeleteResult
startStates map[string]*instanceStartState
startResults chan instanceStartResult
stopStates map[string]*instanceStopState
@@ -407,6 +424,7 @@ type Shell struct {
onProxiesChanged func([]ProxyOption)
instanceStarter InstanceStarter
instanceStopper InstanceStopper
instanceDataDeleter InstanceDataDeleter
instanceRefresher InstanceRefresher
instanceTabInspector InstanceTabInspector
pathSearcher PathSearcher
@@ -432,7 +450,7 @@ func NewShell(theme *material.Theme) *Shell {
PathEdgeExecutable: {},
PathDefaultUserData: {},
PathLogDirectory: {},
}, pathFieldFeedback: make(map[PathField]string), 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), tabsResults: make(chan instanceTabsResult, 1), searchResults: make(chan pathSearchResult, 8), executableResults: make(chan executablePickResult, 1), directoryResults: make(chan directoryPickResult, 1)}
}, pathFieldFeedback: make(map[PathField]string), 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), deleteDataResults: make(chan instanceDataDeleteResult, 1), refreshResults: make(chan instanceRefreshResult, 1), tabsResults: make(chan instanceTabsResult, 1), searchResults: make(chan pathSearchResult, 8), executableResults: make(chan executablePickResult, 1), 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`)
@@ -535,6 +553,11 @@ func (s *Shell) OnStopInstance(stopper InstanceStopper, invalidate func()) {
s.invalidate = invalidate
}
func (s *Shell) OnDeleteInstanceData(deleter InstanceDataDeleter, invalidate func()) {
s.instanceDataDeleter = deleter
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
@@ -579,6 +602,7 @@ func (s *Shell) Layout(gtx layout.Context) layout.Dimensions {
s.consumeDirectoryResults()
s.consumeStartResults()
s.consumeStopResults()
s.consumeInstanceDataDeleteResults()
s.consumeRefreshResults()
s.consumeTabsResults()
s.consumeManagedExitResults()
@@ -736,6 +760,8 @@ func (s *Shell) consumeKeyboard(gtx layout.Context) {
case key.NameEscape:
if s.unexpectedExitOpen {
s.dismissUnexpectedExitNotice()
} else if s.pendingDeleteID != "" && !s.deleteDataRunning {
s.cancelDelete()
} else if s.pendingProxyDelete != "" {
s.cancelProxyDelete()
} else if s.proxyPicker.open {
@@ -1674,27 +1700,74 @@ func (s *Shell) deleteConfirmationCard(row InstanceRow) layout.Widget {
},
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 {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
children := []layout.FlexChild{
layout.Rigid(material.H6(s.theme, "删除实例?").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(material.Body1(s.theme, fmt.Sprintf("确定删除实例“%s”?", row.Name)).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.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.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(s.deleteCancelButton),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(s.deleteConfirmButton),
)
}),
)
layout.Rigid(material.Caption(s.theme, "默认只删除 Chub 实例配置;User Data Dir 会保留。").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
}
if s.deleteDataRunning {
children = append(children,
layout.Rigid(s.instanceFormFeedback("正在删除 User Data Dir 数据,请勿关闭应用…")),
)
} else {
children = append(children,
layout.Rigid(s.deleteDataOption(row)),
layout.Rigid(layout.Spacer{Height: unit.Dp(18)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(s.deleteCancelButton),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(s.deleteConfirmButton),
)
}),
)
}
dims := layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
if s.deleteDataFocus && !s.deleteDataRunning {
gtx.Execute(key.FocusCmd{Tag: &s.deleteCancel})
s.deleteDataFocus = false
}
return dims
})
},
)
}
}
func (s *Shell) deleteDataOption(row InstanceRow) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
canDeleteData := s.instanceDataDeletionAllowed(row)
children := []layout.FlexChild{
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !canDeleteData {
gtx = gtx.Disabled()
}
return material.CheckBox(s.theme, &s.deleteData, "同时删除 User Data Dir 中的数据").Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
}
if !canDeleteData {
children = append(children, layout.Rigid(material.Caption(s.theme, "浏览器正在运行、启动/停止或占用;请先停止并刷新状态后再删除数据。当前仍可只删除实例配置。").Layout))
} else if s.deleteData.Value {
children = append(children, layout.Rigid(s.instanceFormFeedback(fmt.Sprintf("将永久删除 User Data Dir:\n%s\n此操作无法恢复;执行前会再次检查浏览器占用。", row.UserDataDir))))
}
if s.deleteDataFeedback != "" {
children = append(children,
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(s.instanceFormFeedback(s.deleteDataFeedback)),
)
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
}
}
func (s *Shell) instanceDataDeletionAllowed(row InstanceRow) bool {
return row.Status == "已退出" || row.Status == "启动失败"
}
func (s *Shell) deleteCancelButton(gtx layout.Context) layout.Dimensions {
style := material.Button(s.theme, &s.deleteCancel, "取消")
style.Background = color.NRGBA{R: 230, G: 232, B: 235, A: 255}
@@ -1703,7 +1776,11 @@ func (s *Shell) deleteCancelButton(gtx layout.Context) layout.Dimensions {
}
func (s *Shell) deleteConfirmButton(gtx layout.Context) layout.Dimensions {
style := material.Button(s.theme, &s.deleteConfirm, "删除")
label := "删除实例"
if s.deleteData.Value {
label = "删除实例及数据"
}
style := material.Button(s.theme, &s.deleteConfirm, label)
style.Background = color.NRGBA{R: 188, G: 51, B: 51, A: 255}
style.Color = color.NRGBA{R: 255, G: 255, B: 255, A: 255}
return style.Layout(gtx)
@@ -3145,11 +3222,18 @@ func (s *Shell) requestDelete(id string) {
return
}
s.pendingDeleteID = id
s.deleteData.Value = false
s.deleteDataRunning = false
s.deleteDataFeedback = ""
s.deleteDataFocus = true
}
func (s *Shell) consumeDeleteConfirmation(gtx layout.Context) {
for s.deleteBlocker.Clicked(gtx) {
}
if s.deleteDataRunning {
return
}
for s.deleteCancel.Clicked(gtx) {
s.cancelDelete()
}
@@ -3198,16 +3282,50 @@ func (s *Shell) dismissUnexpectedExitNotice() {
}
func (s *Shell) cancelDelete() {
if s.deleteDataRunning {
return
}
if row, ok := s.instanceRow(s.pendingDeleteID); ok {
s.instanceFeedback = fmt.Sprintf("已取消删除实例“%s”。", row.Name)
}
s.pendingDeleteID = ""
s.deleteData.Value = false
s.deleteDataFeedback = ""
s.deleteDataFocus = false
}
func (s *Shell) confirmDelete() {
id := s.pendingDeleteID
s.pendingDeleteID = ""
s.deleteInstance(id)
row, ok := s.instanceRow(s.pendingDeleteID)
if !ok {
s.pendingDeleteID = ""
return
}
if !s.deleteData.Value {
s.pendingDeleteID = ""
s.deleteDataFocus = false
s.deleteInstance(row.ID)
return
}
if !s.instanceDataDeletionAllowed(row) {
s.deleteDataFeedback = "当前状态不能删除 User Data Dir;请先停止浏览器并刷新状态。实例配置尚未删除。"
return
}
if s.instanceDataDeleter == nil {
s.deleteDataFeedback = "删除实例数据服务尚未准备好。实例配置和 User Data Dir 均未删除。"
return
}
s.deleteDataRequest++
request := s.deleteDataRequest
fingerprint := instanceRefreshFingerprint(row)
s.deleteDataRunning = true
s.deleteDataFeedback = ""
go func() {
err := s.instanceDataDeleter(context.Background(), row)
s.deleteDataResults <- instanceDataDeleteResult{id: row.ID, request: request, fingerprint: fingerprint, err: err}
if s.invalidate != nil {
s.invalidate()
}
}()
}
func (s *Shell) deleteInstance(id string) {
@@ -3229,6 +3347,54 @@ func (s *Shell) deleteInstance(id string) {
s.instanceFeedback = "找不到要删除的实例。"
}
func (s *Shell) consumeInstanceDataDeleteResults() {
for {
select {
case result := <-s.deleteDataResults:
if !s.deleteDataRunning || result.request != s.deleteDataRequest || result.id != s.pendingDeleteID {
continue
}
row, ok := s.instanceRow(result.id)
if !ok || instanceRefreshFingerprint(row) != result.fingerprint {
s.deleteDataRunning = false
s.deleteDataFeedback = "实例配置已变化,未继续删除数据。请重新打开删除确认。"
continue
}
s.deleteDataRunning = false
if result.err != nil {
s.deleteDataFeedback = "未删除实例配置或 User Data Dir。请关闭浏览器并检查目录权限后重试。"
continue
}
s.pendingDeleteID = ""
s.deleteData.Value = false
s.deleteDataFeedback = ""
s.deleteDataFocus = false
s.deleteInstanceAndData(row.ID)
default:
return
}
}
}
func (s *Shell) deleteInstanceAndData(id string) {
for i, row := range s.rows {
if row.ID != id {
continue
}
s.rows = append(s.rows[:i], s.rows[i+1:]...)
delete(s.startClicks, id)
delete(s.rowClicks, id)
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
}
s.instanceFeedback = "找不到要删除的实例。"
}
func (s *Shell) instanceRow(id string) (InstanceRow, bool) {
for _, row := range s.rows {
if row.ID == id {