feat: support explicit instance data deletion
This commit is contained in:
@@ -133,6 +133,12 @@ func runWindow(logger *slog.Logger) {
|
||||
monitor: exitMonitor,
|
||||
}.Start, window.Invalidate)
|
||||
shell.OnStopInstance(instanceStopper{launcher: launcher, managedProfiles: launcher, monitor: exitMonitor}.Stop, window.Invalidate)
|
||||
dataRemover := files.NewInstanceDataRemover()
|
||||
shell.OnDeleteInstanceData(instanceDataDeleter{
|
||||
managedProfiles: launcher,
|
||||
externalProfiles: externalProfiles,
|
||||
dataRemover: dataRemover,
|
||||
}.Delete, window.Invalidate)
|
||||
shell.OnRefreshInstances(instanceStatusRefresher{
|
||||
managedProfiles: launcher,
|
||||
externalProfiles: externalProfiles,
|
||||
@@ -483,6 +489,43 @@ func (s instanceStatusRefresher) refreshOne(ctx context.Context, row ui.Instance
|
||||
return result
|
||||
}
|
||||
|
||||
type instanceDataDeleter struct {
|
||||
managedProfiles managedProfileInspector
|
||||
externalProfiles browser.ExternalProfileInspector
|
||||
dataRemover files.InstanceDataRemover
|
||||
}
|
||||
|
||||
func (d instanceDataDeleter) Delete(ctx context.Context, row ui.InstanceRow) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if d.managedProfiles == nil || d.externalProfiles == nil || d.dataRemover == nil {
|
||||
return errors.New("实例数据删除服务尚未准备好")
|
||||
}
|
||||
if strings.TrimSpace(row.UserDataDir) == "" || !filepath.IsAbs(row.UserDataDir) {
|
||||
return files.ErrUnsafeInstanceDataDirectory
|
||||
}
|
||||
kind, err := browserKind(row.Browser)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
managed, err := d.managedProfiles.InspectProfile(ctx, row.UserDataDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("检查 Chub 实例占用失败:%w", err)
|
||||
}
|
||||
if managed.Occupied {
|
||||
return domain.ErrProfileOccupied
|
||||
}
|
||||
external, err := d.externalProfiles.InspectProfile(ctx, kind, row.UserDataDir, row.PID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("检查外部浏览器占用失败:%w", err)
|
||||
}
|
||||
if external.Occupied {
|
||||
return domain.ErrProfileOccupied
|
||||
}
|
||||
return d.dataRemover.RemoveInstanceData(ctx, row.UserDataDir)
|
||||
}
|
||||
|
||||
func (s instanceStarter) Start(ctx context.Context, row ui.InstanceRow, settings ui.SettingsState, launchGeneration uint64) (ui.InstanceStartOutcome, error) {
|
||||
kind, err := browserKind(row.Browser)
|
||||
if err != nil {
|
||||
|
||||
+105
-2
@@ -12,6 +12,7 @@ import (
|
||||
"chub/internal/domain"
|
||||
"chub/internal/platform/browser"
|
||||
"chub/internal/platform/config"
|
||||
"chub/internal/platform/files"
|
||||
"chub/internal/ui"
|
||||
)
|
||||
|
||||
@@ -276,6 +277,90 @@ func TestInstanceStatusRefresherAssociatesExternalWithoutLifecycleControl(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceDataDeleterDeletesOnlyUnoccupiedConfiguredProfile(t *testing.T) {
|
||||
remover := &fakeInstanceDataRemover{}
|
||||
deleter := instanceDataDeleter{
|
||||
managedProfiles: fakeManagedProfileInspector{},
|
||||
externalProfiles: fakeExternalProfileInspector{},
|
||||
dataRemover: remover,
|
||||
}
|
||||
row := ui.InstanceRow{ID: "chrome-a", Browser: "Chrome", UserDataDir: `C:\profiles\chrome-a`}
|
||||
|
||||
if err := deleter.Delete(context.Background(), row); err != nil {
|
||||
t.Fatalf("Delete() error = %v", err)
|
||||
}
|
||||
if remover.calls != 1 || remover.userDataDir != row.UserDataDir {
|
||||
t.Fatalf("remover calls=%d userDataDir=%q, want 1 %q", remover.calls, remover.userDataDir, row.UserDataDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceDataDeleterRejectsOccupiedProfileBeforeRemoval(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
managed fakeManagedProfileInspector
|
||||
external fakeExternalProfileInspector
|
||||
}{
|
||||
{
|
||||
name: "chub managed instance",
|
||||
managed: fakeManagedProfileInspector{use: browser.ProfileUse{Occupied: true, PID: 4242}},
|
||||
},
|
||||
{
|
||||
name: "external browser instance",
|
||||
external: fakeExternalProfileInspector{use: browser.ProfileUse{Occupied: true, PID: 4243, Source: browser.ProfileSourceLock}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
remover := &fakeInstanceDataRemover{}
|
||||
deleter := instanceDataDeleter{
|
||||
managedProfiles: tt.managed,
|
||||
externalProfiles: tt.external,
|
||||
dataRemover: remover,
|
||||
}
|
||||
|
||||
err := deleter.Delete(context.Background(), ui.InstanceRow{ID: "chrome-a", Browser: "Chrome", UserDataDir: `C:\profiles\chrome-a`})
|
||||
if !errors.Is(err, domain.ErrProfileOccupied) {
|
||||
t.Fatalf("Delete() error = %v, want profile occupied", err)
|
||||
}
|
||||
if remover.calls != 0 {
|
||||
t.Fatalf("remover calls = %d, want 0", remover.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceDataDeleterPreservesDataWhenSafetyCheckFails(t *testing.T) {
|
||||
remover := &fakeInstanceDataRemover{}
|
||||
deleter := instanceDataDeleter{
|
||||
managedProfiles: fakeManagedProfileInspector{err: errors.New("registry unavailable")},
|
||||
externalProfiles: fakeExternalProfileInspector{},
|
||||
dataRemover: remover,
|
||||
}
|
||||
|
||||
err := deleter.Delete(context.Background(), ui.InstanceRow{ID: "chrome-a", Browser: "Chrome", UserDataDir: `C:\profiles\chrome-a`})
|
||||
if err == nil || remover.calls != 0 {
|
||||
t.Fatalf("Delete() error = %v, remover calls = %d; want safety failure before removal", err, remover.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceDataDeleterRejectsUnsafeDirectoryBeforeInspection(t *testing.T) {
|
||||
remover := &fakeInstanceDataRemover{}
|
||||
deleter := instanceDataDeleter{
|
||||
managedProfiles: fakeManagedProfileInspector{},
|
||||
externalProfiles: fakeExternalProfileInspector{},
|
||||
dataRemover: remover,
|
||||
}
|
||||
|
||||
err := deleter.Delete(context.Background(), ui.InstanceRow{ID: "chrome-a", Browser: "Chrome", UserDataDir: `profiles\chrome-a`})
|
||||
if !errors.Is(err, files.ErrUnsafeInstanceDataDirectory) {
|
||||
t.Fatalf("Delete() error = %v, want unsafe directory", err)
|
||||
}
|
||||
if remover.calls != 0 {
|
||||
t.Fatalf("remover calls = %d, want 0", remover.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceTabsInspectorReadsOnlyVerifiedRuntimeRows(t *testing.T) {
|
||||
remote := &fakeRemoteDebugInspector{targets: []browser.RemoteDebugTarget{{ID: "page-1", Type: "page", Title: "运营后台", URL: "https://example.com/orders"}}}
|
||||
inspector := instanceTabsInspector{remoteDebug: remote}
|
||||
@@ -373,10 +458,28 @@ func (m *fakeManagedExitObserver) ExpectStop(id string, generation uint64, pid i
|
||||
|
||||
func (m *fakeManagedExitObserver) ClearExpectedStop(string, uint64, int) { m.clearCalls++ }
|
||||
|
||||
type fakeManagedProfileInspector struct{ use browser.ProfileUse }
|
||||
type fakeManagedProfileInspector struct {
|
||||
use browser.ProfileUse
|
||||
err error
|
||||
}
|
||||
|
||||
func (i fakeManagedProfileInspector) InspectProfile(context.Context, string) (browser.ProfileUse, error) {
|
||||
return i.use, nil
|
||||
return i.use, i.err
|
||||
}
|
||||
|
||||
type fakeInstanceDataRemover struct {
|
||||
calls int
|
||||
userDataDir string
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *fakeInstanceDataRemover) RemoveInstanceData(ctx context.Context, userDataDir string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
r.calls++
|
||||
r.userDataDir = userDataDir
|
||||
return r.err
|
||||
}
|
||||
|
||||
type fakeManagedStopper struct {
|
||||
|
||||
Reference in New Issue
Block a user