feat: support explicit instance data deletion
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnsafeInstanceDataDirectory = errors.New("unsafe instance data directory")
|
||||
ErrInstanceDataNotDirectory = errors.New("instance data path is not a directory")
|
||||
)
|
||||
|
||||
// InstanceDataRemover deletes one explicitly authorized browser User Data Dir.
|
||||
// Callers must check browser occupancy before invoking it.
|
||||
type InstanceDataRemover interface {
|
||||
RemoveInstanceData(context.Context, string) error
|
||||
}
|
||||
|
||||
type instanceDataRemover struct {
|
||||
lstat func(string) (os.FileInfo, error)
|
||||
removeAll func(string) error
|
||||
}
|
||||
|
||||
func NewInstanceDataRemover() InstanceDataRemover {
|
||||
return newInstanceDataRemover(os.Lstat, os.RemoveAll)
|
||||
}
|
||||
|
||||
func newInstanceDataRemover(lstat func(string) (os.FileInfo, error), removeAll func(string) error) instanceDataRemover {
|
||||
return instanceDataRemover{lstat: lstat, removeAll: removeAll}
|
||||
}
|
||||
|
||||
func (r instanceDataRemover) RemoveInstanceData(ctx context.Context, userDataDir string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
path, err := normalizeRemovableInstanceDataDir(userDataDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := r.lstat(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect instance data directory: %w", err)
|
||||
}
|
||||
if info.Mode()&(os.ModeSymlink|os.ModeIrregular) != 0 || isReparsePoint(info) {
|
||||
return fmt.Errorf("%w: links and reparse points are not removable", ErrUnsafeInstanceDataDirectory)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return ErrInstanceDataNotDirectory
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.removeAll(path); err != nil {
|
||||
return fmt.Errorf("remove instance data directory: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeRemovableInstanceDataDir(value string) (string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || !filepath.IsAbs(value) {
|
||||
return "", fmt.Errorf("%w: path must be absolute", ErrUnsafeInstanceDataDirectory)
|
||||
}
|
||||
path := filepath.Clean(value)
|
||||
volumeRoot := filepath.Clean(filepath.VolumeName(path) + string(filepath.Separator))
|
||||
if strings.EqualFold(path, volumeRoot) {
|
||||
return "", fmt.Errorf("%w: volume root is not removable", ErrUnsafeInstanceDataDirectory)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestInstanceDataRemoverDeletesExistingDirectory(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "profile")
|
||||
if err := os.MkdirAll(filepath.Join(target, "Default"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(target, "Local State"), []byte("state"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
remover := NewInstanceDataRemover()
|
||||
if err := remover.RemoveInstanceData(context.Background(), target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Lstat(target); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("target still exists or could not be inspected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceDataRemoverRejectsUnsafePathsBeforeDeletion(t *testing.T) {
|
||||
called := false
|
||||
remover := newInstanceDataRemover(func(string) (os.FileInfo, error) {
|
||||
return nil, os.ErrNotExist
|
||||
}, func(string) error {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
for _, path := range []string{"", "relative-profile", filepath.VolumeName(t.TempDir()) + string(filepath.Separator)} {
|
||||
if err := remover.RemoveInstanceData(context.Background(), path); !errors.Is(err, ErrUnsafeInstanceDataDirectory) {
|
||||
t.Fatalf("path %q error = %v", path, err)
|
||||
}
|
||||
}
|
||||
if called {
|
||||
t.Fatal("unsafe path reached removal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceDataRemoverRejectsFilesAndLinks(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "not-a-directory")
|
||||
if err := os.WriteFile(file, []byte("data"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := NewInstanceDataRemover().RemoveInstanceData(context.Background(), file); !errors.Is(err, ErrInstanceDataNotDirectory) {
|
||||
t.Fatalf("file error = %v", err)
|
||||
}
|
||||
|
||||
linkInfo := fakeFileInfo{mode: os.ModeSymlink | 0o777}
|
||||
remover := newInstanceDataRemover(func(string) (os.FileInfo, error) {
|
||||
return linkInfo, nil
|
||||
}, func(string) error {
|
||||
t.Fatal("link reached removal")
|
||||
return nil
|
||||
})
|
||||
if err := remover.RemoveInstanceData(context.Background(), t.TempDir()); !errors.Is(err, ErrUnsafeInstanceDataDirectory) {
|
||||
t.Fatalf("link error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceDataRemoverHonorsCanceledContext(t *testing.T) {
|
||||
target := filepath.Join(t.TempDir(), "profile")
|
||||
if err := os.Mkdir(target, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if err := NewInstanceDataRemover().RemoveInstanceData(ctx, target); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if _, err := os.Lstat(target); err != nil {
|
||||
t.Fatalf("canceled deletion changed target: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeFileInfo struct {
|
||||
mode os.FileMode
|
||||
sys any
|
||||
}
|
||||
|
||||
func (f fakeFileInfo) Name() string { return "fake" }
|
||||
func (f fakeFileInfo) Size() int64 { return 0 }
|
||||
func (f fakeFileInfo) Mode() os.FileMode { return f.mode }
|
||||
func (f fakeFileInfo) ModTime() time.Time { return time.Time{} }
|
||||
func (f fakeFileInfo) IsDir() bool { return f.mode.IsDir() }
|
||||
func (f fakeFileInfo) Sys() any { return f.sys }
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !windows
|
||||
|
||||
package files
|
||||
|
||||
import "io/fs"
|
||||
|
||||
func isReparsePoint(fs.FileInfo) bool {
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build windows
|
||||
|
||||
package files
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func isReparsePoint(info fs.FileInfo) bool {
|
||||
attributes, ok := info.Sys().(*syscall.Win32FileAttributeData)
|
||||
return ok && attributes.FileAttributes&syscall.FILE_ATTRIBUTE_REPARSE_POINT != 0
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//go:build windows
|
||||
|
||||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInstanceDataRemoverRejectsWindowsReparsePoint(t *testing.T) {
|
||||
called := false
|
||||
remover := newInstanceDataRemover(func(string) (os.FileInfo, error) {
|
||||
return fakeFileInfo{
|
||||
mode: os.ModeDir,
|
||||
sys: &syscall.Win32FileAttributeData{FileAttributes: syscall.FILE_ATTRIBUTE_REPARSE_POINT},
|
||||
}, nil
|
||||
}, func(string) error {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
|
||||
err := remover.RemoveInstanceData(context.Background(), `C:\profiles\junction`)
|
||||
if !errors.Is(err, ErrUnsafeInstanceDataDirectory) {
|
||||
t.Fatalf("RemoveInstanceData() error = %v, want unsafe directory", err)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("reparse point reached removal")
|
||||
}
|
||||
}
|
||||
+182
-16
@@ -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 {
|
||||
|
||||
@@ -136,6 +136,111 @@ func TestShellDeletesOnlyAfterConfirmation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellDeletesInstanceDataBeforeRemovingConfiguration(t *testing.T) {
|
||||
shell := NewShell(material.NewTheme())
|
||||
target := shell.rows[3]
|
||||
deletions := 0
|
||||
shell.OnDeleteInstanceData(func(_ context.Context, row InstanceRow) error {
|
||||
deletions++
|
||||
if row.ID != target.ID || row.UserDataDir != target.UserDataDir {
|
||||
t.Fatalf("delete snapshot = %#v", row)
|
||||
}
|
||||
return nil
|
||||
}, nil)
|
||||
|
||||
shell.requestDelete(target.ID)
|
||||
shell.deleteData.Value = true
|
||||
shell.confirmDelete()
|
||||
if !shell.deleteDataRunning || len(shell.rows) != 4 {
|
||||
t.Fatalf("data deletion started=%v rows=%d", shell.deleteDataRunning, len(shell.rows))
|
||||
}
|
||||
var result instanceDataDeleteResult
|
||||
select {
|
||||
case result = <-shell.deleteDataResults:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("data deletion did not produce a result")
|
||||
}
|
||||
if deletions != 1 {
|
||||
t.Fatalf("deletions = %d", deletions)
|
||||
}
|
||||
shell.deleteDataResults <- result
|
||||
shell.consumeInstanceDataDeleteResults()
|
||||
if shell.pendingDeleteID != "" || shell.deleteDataRunning || len(shell.rows) != 3 {
|
||||
t.Fatalf("data deletion state = pending %q running=%v rows=%d", shell.pendingDeleteID, shell.deleteDataRunning, len(shell.rows))
|
||||
}
|
||||
if _, exists := shell.instanceRow(target.ID); exists || !strings.Contains(shell.instanceFeedback, "及其 User Data Dir 数据") {
|
||||
t.Fatalf("data deletion did not remove target: rows=%#v feedback=%q", shell.rows, shell.instanceFeedback)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellKeepsInstanceWhenDataDeletionFails(t *testing.T) {
|
||||
shell := NewShell(material.NewTheme())
|
||||
target := shell.rows[3]
|
||||
shell.OnDeleteInstanceData(func(context.Context, InstanceRow) error {
|
||||
return errors.New("permission denied")
|
||||
}, nil)
|
||||
|
||||
shell.requestDelete(target.ID)
|
||||
shell.deleteData.Value = true
|
||||
shell.confirmDelete()
|
||||
var result instanceDataDeleteResult
|
||||
select {
|
||||
case result = <-shell.deleteDataResults:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("data deletion did not produce a result")
|
||||
}
|
||||
shell.deleteDataResults <- result
|
||||
shell.consumeInstanceDataDeleteResults()
|
||||
if !shell.deleteData.Value || shell.pendingDeleteID != target.ID || shell.deleteDataRunning || len(shell.rows) != 4 {
|
||||
t.Fatalf("failed deletion state = data=%v pending=%q running=%v rows=%d", shell.deleteData.Value, shell.pendingDeleteID, shell.deleteDataRunning, len(shell.rows))
|
||||
}
|
||||
if !strings.Contains(shell.deleteDataFeedback, "未删除实例配置或 User Data Dir") {
|
||||
t.Fatalf("failure feedback = %q", shell.deleteDataFeedback)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellRejectsInstanceDataDeletionWhenStatusIsActive(t *testing.T) {
|
||||
shell := NewShell(material.NewTheme())
|
||||
target := shell.rows[0]
|
||||
called := false
|
||||
shell.OnDeleteInstanceData(func(context.Context, InstanceRow) error {
|
||||
called = true
|
||||
return nil
|
||||
}, nil)
|
||||
|
||||
shell.requestDelete(target.ID)
|
||||
shell.deleteData.Value = true
|
||||
shell.confirmDelete()
|
||||
if called || shell.deleteDataRunning || len(shell.rows) != 4 {
|
||||
t.Fatalf("active data deletion called=%v running=%v rows=%d", called, shell.deleteDataRunning, len(shell.rows))
|
||||
}
|
||||
if !strings.Contains(shell.deleteDataFeedback, "当前状态不能删除") {
|
||||
t.Fatalf("active deletion feedback = %q", shell.deleteDataFeedback)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellDiscardsStaleInstanceDataDeletionResult(t *testing.T) {
|
||||
shell := NewShell(material.NewTheme())
|
||||
target := shell.rows[3]
|
||||
shell.OnDeleteInstanceData(func(context.Context, InstanceRow) error { return nil }, nil)
|
||||
|
||||
shell.requestDelete(target.ID)
|
||||
shell.deleteData.Value = true
|
||||
shell.confirmDelete()
|
||||
var result instanceDataDeleteResult
|
||||
select {
|
||||
case result = <-shell.deleteDataResults:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("data deletion did not produce a result")
|
||||
}
|
||||
shell.rows[3].UserDataDir = "C:\\profiles\\changed"
|
||||
shell.deleteDataResults <- result
|
||||
shell.consumeInstanceDataDeleteResults()
|
||||
if shell.pendingDeleteID != target.ID || len(shell.rows) != 4 || !strings.Contains(shell.deleteDataFeedback, "配置已变化") {
|
||||
t.Fatalf("stale deletion state = pending=%q rows=%d feedback=%q", shell.pendingDeleteID, len(shell.rows), shell.deleteDataFeedback)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellStartsInstanceAsynchronouslyAndAppliesResult(t *testing.T) {
|
||||
shell := NewShell(material.NewTheme())
|
||||
target := shell.rows[0]
|
||||
|
||||
Reference in New Issue
Block a user