diff --git a/cmd/chub/main.go b/cmd/chub/main.go index 6cb2f12..7816033 100644 --- a/cmd/chub/main.go +++ b/cmd/chub/main.go @@ -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 { diff --git a/cmd/chub/main_test.go b/cmd/chub/main_test.go index d828630..f244fbd 100644 --- a/cmd/chub/main_test.go +++ b/cmd/chub/main_test.go @@ -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 { diff --git a/docs/06-tasks.md b/docs/06-tasks.md index c7562b9..999d5ca 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -56,7 +56,7 @@ | T-403 | 便携配置路径与安全 legacy 迁移 | T-202,T-309 | DONE | | T-404 | 代理名称、独立默认目录与实例列表代理列 | T-304,T-306,T-309,T-403 | DONE | | T-405 | 设置浏览器 executable 选择与可感知搜索反馈 | T-205,T-206,T-207 | DONE | -| T-406 | 实例删除确认中的 User Data Dir 显式删除 | T-301,T-303,T-305 | DOING | +| T-406 | 实例删除确认中的 User Data Dir 显式删除 | T-301,T-303,T-305 | DONE | ## Backlog diff --git a/docs/current-state.md b/docs/current-state.md index 0c9fdde..14455ac 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -3,9 +3,9 @@ ## 快照 - 日期:2026-07-27 -- 阶段:Phase 4 CDP 只读观察与后台入口(T-301 至 T-310、T-201 至 T-208、T-401、T-403 至 T-405 已完成;T-402 已规划;T-406 进行中) -- 代码:已建立 Go module `chub`、`cmd/chub` 入口、logging 测试基座、浏览器 domain/application 合约、Chrome/Edge 参数/发现模块、启动 registry、Windows 身份/占用检查、loopback CDP 端口分配与端点校验、受限 CDP 目标读取、exe 同级 JSON 配置与 legacy 安全迁移、优雅关闭、Job Object、真实 Chrome/Edge smoke、启动恢复、CLI JSON 合约、应用内事件总线、UI 状态测试和 Windows smoke 脚本,T-001 至 T-003、T-101 至 T-104、T-201 至 T-208、T-301 至 T-310、T-401、T-403 至 T-405 已完成 -- UI:Gio 双页 Shell 使用左侧“实例/设置”导航;实例页以等宽“新建实例 / 刷新实例状态”命令区开始,窄内容区自动堆叠。列表固定显示实例名称、浏览器类型、用户数据目录、代理名称、调试端口、状态和带边框的操作列;代理列只由稳定 `ProxyID` 解析当前名称(无代理时显示“无代理”),长名称省略且不挤压操作列。端口列在未启动/已退出时显示“建议 {端口}”、启动中显示“准备 {端口}”、托管运行显示“实际 {端口}”、外部关联显示“外部 {端口}”,避免将持久首选端口误作监听事实。新建表单从设置的起始端口推荐最低未冲突端口,并从实际 exe 同级 `user_data_dirs\\<实例 ID>` 预填一个独立、可编辑的 User Data Dir;ID 先分配,UI 不做目录 I/O,无法取得 exe 根目录时保留手动输入入口。编辑停止实例可修改首选端口;启动后台优先该端口并避开其他实例的首选/实际端口,成功回退后才保存新的首选值。操作顺序固定为启动/停止(或重新检测)、编辑、删除,删除与相邻操作保持更大间距以降低误点。主操作在已退出时启动,在 Chub 托管运行或调试不可用时显示停止,在启动/停止中禁用,在外部关联、外部占用或未知占用时仅重新检测;优雅停止通过后台 adapter 验证 registry 的 PID/profile 身份并等待其释放,绝不按进程名关闭或接管外部 Chrome/Edge。刷新通过后台回调只检查已保存实例,以配置快照丢弃编辑或删除后的过期结果;它结合 Chub registry、指定 profile 的外部占用证据和 loopback CDP 端点更新状态,但不扫描、接管或关闭其他 Chrome/Edge。双击、Enter 或编辑图标打开实例编辑弹层,支持名称、浏览器类型、User Data Dir、启动 URL、首选端口、以“名称 · 地址”显示的代理选择和只读实际端口;保存保留未公开启动选项,Escape 对脏表单先请求确认,活跃/外部关联实例锁定身份约束字段。设置页使用完整代理地址选择器、代理名称与地址输入及保存/删除操作;选择会回填名称和地址,保存按稳定 `proxyId` 新增或更新并校验名称/地址唯一性,删除未引用代理前必须确认,仍被实例引用的代理会被拒绝。启动通过异步回调接到 Windows 浏览器启动器:未占用目录从已保存的起始端口(空值默认 9666)选择 loopback CDP 端口,并在启动时按已选代理 ID 解析最新的 `--proxy-server` 参数;同目录外部浏览器只有在 `DevToolsActivePort` 与 CDP 端点可验证时才显示“外部已关联”,且不会接管其生命周期。删除使用确认弹层,只删除 Chub 实例配置而不删除 User Data Dir。新建、编辑和删除实例会保存到本地配置;四个设置路径有独立异步可取消搜索,切换页面后输入和任务状态保留;设置页 Chrome/Edge 路径已接入后台 Windows 原生 `.exe` 选择器,选择和搜索结果始终显示在对应字段旁;命中已填路径会明确确认而非静默完成。新建实例表单使用 Label、Windows 原生目录选择器和 Chrome/Edge RadioButton;CLI `list/events` 已可用,`start/stop/restart` 等待 BrowserManager adapter +- 阶段:Phase 4 CDP 只读观察与后台入口(T-301 至 T-310、T-201 至 T-208、T-401、T-403 至 T-406 已完成;T-402 已规划) +- 代码:已建立 Go module `chub`、`cmd/chub` 入口、logging 测试基座、浏览器 domain/application 合约、Chrome/Edge 参数/发现模块、启动 registry、Windows 身份/占用检查、loopback CDP 端口分配与端点校验、受限 CDP 目标读取、exe 同级 JSON 配置与 legacy 安全迁移、优雅关闭、Job Object、真实 Chrome/Edge smoke、启动恢复、CLI JSON 合约、应用内事件总线、UI 状态测试和 Windows smoke 脚本,T-001 至 T-003、T-101 至 T-104、T-201 至 T-208、T-301 至 T-310、T-401、T-403 至 T-406 已完成 +- UI:Gio 双页 Shell 使用左侧“实例/设置”导航;实例页以等宽“新建实例 / 刷新实例状态”命令区开始,窄内容区自动堆叠。列表固定显示实例名称、浏览器类型、用户数据目录、代理名称、调试端口、状态和带边框的操作列;代理列只由稳定 `ProxyID` 解析当前名称(无代理时显示“无代理”),长名称省略且不挤压操作列。端口列在未启动/已退出时显示“建议 {端口}”、启动中显示“准备 {端口}”、托管运行显示“实际 {端口}”、外部关联显示“外部 {端口}”,避免将持久首选端口误作监听事实。新建表单从设置的起始端口推荐最低未冲突端口,并从实际 exe 同级 `user_data_dirs\\<实例 ID>` 预填一个独立、可编辑的 User Data Dir;ID 先分配,UI 不做目录 I/O,无法取得 exe 根目录时保留手动输入入口。编辑停止实例可修改首选端口;启动后台优先该端口并避开其他实例的首选/实际端口,成功回退后才保存新的首选值。操作顺序固定为启动/停止(或重新检测)、编辑、删除,删除与相邻操作保持更大间距以降低误点。主操作在已退出时启动,在 Chub 托管运行或调试不可用时显示停止,在启动/停止中禁用,在外部关联、外部占用或未知占用时仅重新检测;优雅停止通过后台 adapter 验证 registry 的 PID/profile 身份并等待其释放,绝不按进程名关闭或接管外部 Chrome/Edge。刷新通过后台回调只检查已保存实例,以配置快照丢弃编辑或删除后的过期结果;它结合 Chub registry、指定 profile 的外部占用证据和 loopback CDP 端点更新状态,但不扫描、接管或关闭其他 Chrome/Edge。双击、Enter 或编辑图标打开实例编辑弹层,支持名称、浏览器类型、User Data Dir、启动 URL、首选端口、以“名称 · 地址”显示的代理选择和只读实际端口;保存保留未公开启动选项,Escape 对脏表单先请求确认,活跃/外部关联实例锁定身份约束字段。设置页使用完整代理地址选择器、代理名称与地址输入及保存/删除操作;选择会回填名称和地址,保存按稳定 `proxyId` 新增或更新并校验名称/地址唯一性,删除未引用代理前必须确认,仍被实例引用的代理会被拒绝。启动通过异步回调接到 Windows 浏览器启动器:未占用目录从已保存的起始端口(空值默认 9666)选择 loopback CDP 端口,并在启动时按已选代理 ID 解析最新的 `--proxy-server` 参数;同目录外部浏览器只有在 `DevToolsActivePort` 与 CDP 端点可验证时才显示“外部已关联”,且不会接管其生命周期。删除确认默认只删除 Chub 实例配置;仅已退出/启动失败实例可显式勾选删除 User Data Dir,后台会复核 Chub registry、外部占用与安全目录边界,成功删除数据后才移除配置;失败或过期结果保留配置和目录。新建、编辑和删除实例会保存到本地配置;四个设置路径有独立异步可取消搜索,切换页面后输入和任务状态保留;设置页 Chrome/Edge 路径已接入后台 Windows 原生 `.exe` 选择器,选择和搜索结果始终显示在对应字段旁;命中已填路径会明确确认而非静默完成。新建实例表单使用 Label、Windows 原生目录选择器和 Chrome/Edge RadioButton;CLI `list/events` 已可用,`start/stop/restart` 等待 BrowserManager adapter - T-307:本应用会话启动的受管 Chrome/Edge 根进程由单实例后台 `Wait` 监控;意外退出按 ID、启动代次和 PID 验证后立即变为“已退出”,清空运行时 PID/端口,并以可关闭、可合并的提示告知用户。提示关闭后焦点回到启动操作;Chub 请求停止、外部实例、过期事件和应用关闭取消监控均不提示。 - T-308:设置页以可滚动的外层工作区承载浏览器路径、默认目录、运行行为和代理分区;分区、字段、状态提示和保存操作区使用统一的浅色圆角描边。路径字段在紧凑内容区会将输入与“选择/搜索”操作纵向重排,切换页面后滚动、输入与异步搜索状态仍由持久 Gio widget 保留。 - 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目 @@ -16,8 +16,8 @@ - T-403:默认配置已改为实际 `chub.exe` 同级 `config.json`,不受当前工作目录影响。目标缺失时会校验并原子复制旧 AppData 配置,旧文件保持恢复副本;目标优先且不会自动合并、切回 AppData 或迁移浏览器 User Data Dir。CLI 与 GUI 共用 `OpenDefault()`;配置不可用时 CLI 返回无敏感细节的稳定错误,GUI 显示恢复提示。 - T-404:已完成代理名称/地址同次显式保存、名称与端点唯一性校验、选择回填、列表代理名称列以及独立默认 profile 目录。新建实例先分配稳定 ID,再由实际 exe 目录生成 `user_data_dirs\\<实例 ID>`;代理名称按 ProxyID 推导而不复制到实例配置,UI 不进行文件 I/O。已通过单元测试、竞态检测、vet、无控制台 GUI 打包和 Windows smoke。 - T-405:已完成 Windows `OpenFileDialog` executable picker、Chrome/Edge 后台结果通道与字段级状态反馈。搜索同路径时明确确认当前路径可用;选择取消或错误保留输入,选择成功只更新目标字段。已通过单元测试、竞态检测、vet、无控制台打包,以及真实原生对话框取消/预选 Chrome `.exe` 返回 smoke。 -- T-406:进行中。将实例删除确认框扩展为默认仅删除配置、可显式勾选删除 User Data Dir;文件删除将在后台复核 Chub/external profile 占用和目录边界,失败时保留配置与目录。 -- blocker:无;当前任务为 T-406,随后恢复 T-402。 +- T-406:已完成实例删除确认中的 User Data Dir 显式删除。默认仅删除配置;数据删除必须显式勾选且只对已退出/启动失败实例开放。后台在删除前复核 Chub/external profile 占用,目录删除拒绝不安全路径、卷根、文件、链接和 Windows 重解析点;失败与过期结果均保留配置和目录。 +- blocker:无;下一任务为 T-402。 ## 当前目录 diff --git a/docs/tasks/T-406.md b/docs/tasks/T-406.md index 7b26f36..57f09cf 100644 --- a/docs/tasks/T-406.md +++ b/docs/tasks/T-406.md @@ -3,7 +3,7 @@ id: T-406 title: 实例删除确认中的 User Data Dir 显式删除 phase: 4 deps: [T-301, T-303, T-305] -status: DOING +status: DONE created: 2026-07-27 owner: codex --- @@ -28,7 +28,7 @@ owner: codex ## 执行记录 -- 状态:DOING -- 变更:已完成需求、架构、API、用户故事、交互清单、原型验收场景与安全边界定义;待更新原型并提交文档后实现。 -- 验证:待执行。 +- 状态:DONE +- 变更:确认框默认只删除配置,新增默认未选的数据删除复选框、目录/不可恢复警告、动态确认文案、取消默认焦点和 Escape 取消。仅“已退出”与“启动失败”允许勾选;数据删除在后台运行,确认框在执行中保持并禁用重复/取消。成功后才删除配置,失败或过期结果保留配置、目录与确认框。`cmd/chub` 在删除前复核 Chub registry 与外部 profile 占用,`internal/platform/files` 拒绝相对路径、卷根、普通文件、链接及 Windows 重解析点。 +- 验证:`go test ./...`、`go test -race ./cmd/chub ./internal/ui ./internal/platform/files`、`go vet ./...`、`cmd /c build.bat` 均通过。目录删除测试在 Windows 临时真实目录上验证成功删除,并验证相对路径、卷根、文件、链接和已取消上下文均不会执行删除;Shell 与 adapter 测试覆盖默认配置删除、运行态禁用、成功/失败/过期结果、Chub/外部占用复核。 - 阻塞:无。 diff --git a/internal/platform/files/instance_data_remover.go b/internal/platform/files/instance_data_remover.go new file mode 100644 index 0000000..7d5de96 --- /dev/null +++ b/internal/platform/files/instance_data_remover.go @@ -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 +} diff --git a/internal/platform/files/instance_data_remover_test.go b/internal/platform/files/instance_data_remover_test.go new file mode 100644 index 0000000..d9ddf69 --- /dev/null +++ b/internal/platform/files/instance_data_remover_test.go @@ -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 } diff --git a/internal/platform/files/reparse_other.go b/internal/platform/files/reparse_other.go new file mode 100644 index 0000000..bc6e643 --- /dev/null +++ b/internal/platform/files/reparse_other.go @@ -0,0 +1,9 @@ +//go:build !windows + +package files + +import "io/fs" + +func isReparsePoint(fs.FileInfo) bool { + return false +} diff --git a/internal/platform/files/reparse_windows.go b/internal/platform/files/reparse_windows.go new file mode 100644 index 0000000..985aaa5 --- /dev/null +++ b/internal/platform/files/reparse_windows.go @@ -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 +} diff --git a/internal/platform/files/reparse_windows_test.go b/internal/platform/files/reparse_windows_test.go new file mode 100644 index 0000000..8760981 --- /dev/null +++ b/internal/platform/files/reparse_windows_test.go @@ -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") + } +} diff --git a/internal/ui/shell.go b/internal/ui/shell.go index 4a74b47..fc1dfa5 100644 --- a/internal/ui/shell.go +++ b/internal/ui/shell.go @@ -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 { diff --git a/internal/ui/shell_test.go b/internal/ui/shell_test.go index efe7fa0..8f5e5c7 100644 --- a/internal/ui/shell_test.go +++ b/internal/ui/shell_test.go @@ -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]