diff --git a/cmd/chub/main.go b/cmd/chub/main.go index 5bddb33..f10bd32 100644 --- a/cmd/chub/main.go +++ b/cmd/chub/main.go @@ -28,7 +28,10 @@ import ( const version = "0.1.0-dev" -var openDefaultConfig = config.OpenDefault +var ( + openDefaultConfig = config.OpenDefault + defaultInstanceUserDataRoot = config.DefaultInstanceUserDataRoot +) func main() { if len(os.Args) > 1 { @@ -102,6 +105,12 @@ func runWindow(logger *slog.Logger) { window.Option(app.Title("Chub"), app.Size(unit.Dp(1100), unit.Dp(720))) theme := material.NewTheme() shell := ui.NewShell(theme) + if root, err := defaultInstanceUserDataRoot(); err != nil { + logger.Error("resolve default instance user data root", "error", err) + shell.ReportStartupIssue("无法确定程序目录中的默认 User Data Dir;新建实例时请选择其他绝对路径。") + } else { + shell.SetDefaultInstanceUserDataRoot(root) + } directoryPicker := files.NewDirectoryPicker() shell.OnChooseDirectory(directoryPicker.ChooseDirectory, window.Invalidate) discoverer := browser.NewDiscoverer() diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 8476530..15d663d 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -54,7 +54,7 @@ | T-401 | 已验证实例的 CDP 页面目标只读查看 | T-302,T-303,T-310 | DONE | | T-402 | Windows 通知区域入口与显式退出协调 | T-303,T-305,T-307 | TODO | | T-403 | 便携配置路径与安全 legacy 迁移 | T-202,T-309 | DONE | -| T-404 | 代理名称、独立默认目录与实例列表代理列 | T-304,T-306,T-309,T-403 | DOING | +| T-404 | 代理名称、独立默认目录与实例列表代理列 | T-304,T-306,T-309,T-403 | DONE | ## Backlog diff --git a/docs/current-state.md b/docs/current-state.md index 5fa63bc..4d5bfc8 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-402 已规划,T-404 进行中) +- 阶段:Phase 4 CDP 只读观察与后台入口(T-301 至 T-310、T-201 至 T-208、T-401、T-403、T-404 已完成;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 已完成 -- UI:Gio 双页 Shell 使用左侧“实例/设置”导航;实例页以等宽“新建实例 / 刷新实例状态”命令区开始,窄内容区自动堆叠。列表固定显示实例名称、浏览器类型、用户数据目录、调试端口、状态和带边框的操作列;端口列在未启动/已退出时显示“建议 {端口}”、启动中显示“准备 {端口}”、托管运行显示“实际 {端口}”、外部关联显示“外部 {端口}”,避免将持久首选端口误作监听事实。新建表单从设置的起始端口推荐最低未冲突端口,编辑停止实例可修改首选端口;启动后台优先该端口并避开其他实例的首选/实际端口,成功回退后才保存新的首选值。操作顺序固定为启动/停止(或重新检测)、编辑、删除,删除与相邻操作保持更大间距以降低误点。主操作在已退出时启动,在 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。新建、编辑和删除实例会保存到本地配置;四个设置路径有独立异步可取消搜索,切换页面后输入和任务状态保留;新建实例表单使用 Label、Windows 原生目录选择器和 Chrome/Edge RadioButton;CLI `list/events` 已可用,`start/stop/restart` 等待 BrowserManager adapter +- 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。新建、编辑和删除实例会保存到本地配置;四个设置路径有独立异步可取消搜索,切换页面后输入和任务状态保留;新建实例表单使用 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`,尚未复制或接入本项目 @@ -14,8 +14,8 @@ - T-401:仅在运行中或外部已关联实例已有有效实际端口时,编辑弹层可打开“标签页(CDP 页面目标)”只读模态层。后台先复核 loopback `/json/version` 的浏览器类型,再读取有限的 `/json/list`;只显示 ID、类型、标题和移除 userinfo/query/fragment 的 URL,不暴露 WebSocket 地址、不建立 WebSocket、也不执行浏览器控制。读取、错误、空列表和端口/状态变化都有反馈,Escape/关闭后焦点回到入口。 - T-402:已规划 Windows 通知区域图标、显示主窗口、刷新已保存实例状态和显式退出协调。首版不在托盘菜单中直接操作浏览器,不改变窗口关闭语义,也不实现关闭窗口后隐藏;Windows 原生 Shell adapter、Explorer 重启恢复、图标资源与退出清理将在实施阶段完成。 - T-403:默认配置已改为实际 `chub.exe` 同级 `config.json`,不受当前工作目录影响。目标缺失时会校验并原子复制旧 AppData 配置,旧文件保持恢复副本;目标优先且不会自动合并、切回 AppData 或迁移浏览器 User Data Dir。CLI 与 GUI 共用 `OpenDefault()`;配置不可用时 CLI 返回无敏感细节的稳定错误,GUI 显示恢复提示。 -- T-404:已定义代理名称与地址的同次显式保存、名称校验、列表显示和独立默认 profile 目录边界。新建实例将以实际 exe 目录生成 `user_data_dirs\\<稳定实例 ID>`,代理名称按 ProxyID 推导而不复制到实例配置;UI 不进行文件 I/O。局部表单/列表调整沿用已确认两页布局,无需新增 HTML 原型。 -- blocker:无;当前任务为 T-404,后续仍可实施 T-402。 +- T-404:已完成代理名称/地址同次显式保存、名称与端点唯一性校验、选择回填、列表代理名称列以及独立默认 profile 目录。新建实例先分配稳定 ID,再由实际 exe 目录生成 `user_data_dirs\\<实例 ID>`;代理名称按 ProxyID 推导而不复制到实例配置,UI 不进行文件 I/O。已通过单元测试、竞态检测、vet、无控制台 GUI 打包和 Windows smoke。 +- blocker:无;下一任务为 T-402。 ## 当前目录 diff --git a/docs/tasks/T-404.md b/docs/tasks/T-404.md index 6bd3f7c..57394c1 100644 --- a/docs/tasks/T-404.md +++ b/docs/tasks/T-404.md @@ -3,7 +3,7 @@ id: T-404 title: 代理名称、独立默认目录与实例列表代理列 phase: 4 deps: [T-304, T-306, T-309, T-403] -status: DOING +status: DONE created: 2026-07-27 owner: codex --- @@ -29,8 +29,8 @@ owner: codex ## 执行记录 -- 状态:DOING -- 变更:已完成需求、架构、API、用户故事、交互清单、任务路线与 UI 评审场景更新。 +- 状态:DONE +- 变更:已完成代理名称输入、名称/地址同次保存与校验、选择回填、稳定 ProxyID 保留、列表代理名称列以及实际 exe 同级 `user_data_dirs\\<实例 ID>` 默认目录。配置层拒绝空白/大小写重复名称和重复端点;默认目录只计算路径且不创建目录。 - UI/UX:依据 Gio 表单/列表适配约束,确认本次是已有两页工作区的局部字段和列调整,不改变导航、模态流程或已确认结构,故不新增 HTML 原型;实现保留现有持久 widget、稳定 ID、键盘与窄窗口重排契约。 -- 验证:文档链接、Git diff 检查和文档提交待完成。 +- 验证:`go test ./...`、`go test -race ./cmd/chub ./internal/domain ./internal/platform/config ./internal/ui`、`go vet ./...`、`build.bat`、`powershell -ExecutionPolicy Bypass -File scripts\\smoke-windows.ps1` 和 `git diff --check` 均通过;已确认 `build\\chub.exe` 为 Windows GUI 子系统。仅 `build.bat`、`run.bat` 保持未跟踪,未纳入任务提交。 - 阻塞:无。 diff --git a/internal/domain/browser.go b/internal/domain/browser.go index 029ace0..34f3e35 100644 --- a/internal/domain/browser.go +++ b/internal/domain/browser.go @@ -104,6 +104,16 @@ func NormalizeProxyServer(value string) (string, error) { return scheme + "://" + net.JoinHostPort(strings.ToLower(parsed.Hostname()), strconv.Itoa(port)), nil } +// NormalizeProxyName returns a non-empty display name for a locally saved +// proxy profile. It deliberately carries no endpoint or credentials semantics. +func NormalizeProxyName(value string) (string, error) { + name := strings.TrimSpace(value) + if name == "" { + return "", fmt.Errorf("%w: proxy name is required", ErrInvalidLaunchSpec) + } + return name, nil +} + func ValidRemoteDebugPort(port int) bool { return port >= MinRemoteDebugPort && port <= MaxRemoteDebugPort } diff --git a/internal/domain/browser_test.go b/internal/domain/browser_test.go index f70330c..d6d3a4c 100644 --- a/internal/domain/browser_test.go +++ b/internal/domain/browser_test.go @@ -80,3 +80,13 @@ func TestNormalizeProxyServerCanonicalizesAndRejectsCredentials(t *testing.T) { } } } + +func TestNormalizeProxyNameRequiresNonEmptyDisplayText(t *testing.T) { + got, err := NormalizeProxyName(" 新加坡出口 ") + if err != nil || got != "新加坡出口" { + t.Fatalf("NormalizeProxyName() = %q, %v", got, err) + } + if _, err := NormalizeProxyName(" \t "); !errors.Is(err, ErrInvalidLaunchSpec) { + t.Fatalf("empty proxy name error = %v", err) + } +} diff --git a/internal/platform/config/store.go b/internal/platform/config/store.go index 689cf87..2468215 100644 --- a/internal/platform/config/store.go +++ b/internal/platform/config/store.go @@ -68,6 +68,27 @@ func DefaultPath() (string, error) { return defaultPathForExecutable(executable) } +// DefaultInstanceUserDataRoot returns the portable root for new browser +// profiles. It resolves the actual executable location and never uses cwd. +func DefaultInstanceUserDataRoot() (string, error) { + executable, err := os.Executable() + if err != nil { + return "", fmt.Errorf("%w: resolve executable", ErrDefaultConfigUnavailable) + } + return defaultInstanceUserDataRootForExecutable(executable) +} + +// DefaultInstanceUserDataDir returns a unique-instance profile path below the +// executable-side user_data_dirs root. It only computes a path; it does not +// create directories or validate their write permissions. +func DefaultInstanceUserDataDir(instanceID string) (string, error) { + root, err := DefaultInstanceUserDataRoot() + if err != nil { + return "", err + } + return defaultInstanceUserDataDir(root, instanceID) +} + // LegacyPath is the pre-portable configuration location. It is read only when // the executable-side config does not exist yet. func LegacyPath() (string, error) { @@ -107,6 +128,23 @@ func defaultPathForExecutable(executable string) (string, error) { return filepath.Join(filepath.Dir(filepath.Clean(path)), "config.json"), nil } +func defaultInstanceUserDataRootForExecutable(executable string) (string, error) { + path := strings.TrimSpace(executable) + if path == "" || !filepath.IsAbs(path) { + return "", fmt.Errorf("%w: executable path must be absolute", ErrDefaultConfigUnavailable) + } + return filepath.Join(filepath.Dir(filepath.Clean(path)), "user_data_dirs"), nil +} + +func defaultInstanceUserDataDir(root, instanceID string) (string, error) { + root = strings.TrimSpace(root) + instanceID = strings.TrimSpace(instanceID) + if root == "" || !filepath.IsAbs(root) || instanceID == "" || filepath.Base(instanceID) != instanceID || instanceID == "." { + return "", fmt.Errorf("%w: invalid default instance directory", ErrDefaultConfigUnavailable) + } + return filepath.Join(filepath.Clean(root), instanceID), nil +} + func openDefaultAt(target, legacy string) (*Store, error) { store, targetExists, err := defaultStore(target) if err != nil || targetExists { @@ -303,28 +341,35 @@ func normalizeProxyConfig(value *File) error { return errors.New("config is required") } byID := make(map[string]ProxyProfile, len(value.Proxies)) - names := make(map[string]struct{}, len(value.Proxies)) + names := make([]string, 0, len(value.Proxies)) + servers := make(map[string]struct{}, len(value.Proxies)) for i := range value.Proxies { profile := &value.Proxies[i] profile.ID = strings.TrimSpace(profile.ID) - profile.Name = strings.TrimSpace(profile.Name) - if profile.ID == "" || profile.Name == "" { + name, err := domain.NormalizeProxyName(profile.Name) + if profile.ID == "" || err != nil { return errors.New("invalid proxy configuration") } + profile.Name = name if _, exists := byID[profile.ID]; exists { return errors.New("duplicate proxy configuration") } - nameKey := strings.ToLower(profile.Name) - if _, exists := names[nameKey]; exists { - return errors.New("duplicate proxy configuration") + for _, existing := range names { + if strings.EqualFold(existing, profile.Name) { + return errors.New("duplicate proxy configuration") + } } server, err := domain.NormalizeProxyServer(profile.Server) if err != nil || server == "" { return errors.New("invalid proxy configuration") } + if _, exists := servers[server]; exists { + return errors.New("duplicate proxy configuration") + } profile.Server = server byID[profile.ID] = *profile - names[nameKey] = struct{}{} + names = append(names, profile.Name) + servers[server] = struct{}{} } legacyByServer := make(map[string]string, len(value.Proxies)) @@ -351,10 +396,12 @@ func normalizeProxyConfig(value *File) error { proxyID := legacyByServer[server] if proxyID == "" { proxyID = nextLegacyProxyID(byID) - profile := ProxyProfile{ID: proxyID, Name: "导入代理 " + strconv.Itoa(len(value.Proxies)+1), Server: server} + profile := ProxyProfile{ID: proxyID, Name: nextLegacyProxyName(names), Server: server} value.Proxies = append(value.Proxies, profile) byID[proxyID] = profile legacyByServer[server] = proxyID + names = append(names, profile.Name) + servers[server] = struct{}{} } instance.ProxyID = proxyID instance.Launch.ProxyServer = "" @@ -371,6 +418,22 @@ func nextLegacyProxyID(existing map[string]ProxyProfile) string { } } +func nextLegacyProxyName(existing []string) string { + for index := 1; ; index++ { + candidate := "导入代理 " + strconv.Itoa(index) + duplicate := false + for _, name := range existing { + if strings.EqualFold(name, candidate) { + duplicate = true + break + } + } + if !duplicate { + return candidate + } + } +} + func replace(target, temp string) error { backup := target + ".bak" _, statErr := os.Stat(target) diff --git a/internal/platform/config/store_test.go b/internal/platform/config/store_test.go index e54571e..faac7bf 100644 --- a/internal/platform/config/store_test.go +++ b/internal/platform/config/store_test.go @@ -112,6 +112,28 @@ func TestDefaultPathForExecutableUsesExecutableDirectory(t *testing.T) { } } +func TestDefaultInstanceUserDataDirectoryUsesExecutableDirectoryAndStableID(t *testing.T) { + executable := filepath.Join(t.TempDir(), "published", "chub.exe") + root, err := defaultInstanceUserDataRootForExecutable(executable) + if err != nil || root != filepath.Join(filepath.Dir(executable), "user_data_dirs") { + t.Fatalf("defaultInstanceUserDataRootForExecutable() = %q, %v", root, err) + } + first, err := defaultInstanceUserDataDir(root, "instance-one") + if err != nil || first != filepath.Join(root, "instance-one") { + t.Fatalf("defaultInstanceUserDataDir() = %q, %v", first, err) + } + second, err := defaultInstanceUserDataDir(root, "instance-two") + if err != nil || second == first { + t.Fatalf("distinct instance directory = %q, %v", second, err) + } + if _, err := defaultInstanceUserDataRootForExecutable("chub.exe"); !errors.Is(err, ErrDefaultConfigUnavailable) { + t.Fatalf("relative executable root error = %v", err) + } + if _, err := defaultInstanceUserDataDir(root, `..\other`); !errors.Is(err, ErrDefaultConfigUnavailable) { + t.Fatalf("unsafe instance id error = %v", err) + } +} + func TestOpenDefaultAtMigratesValidLegacyWithoutRemovingIt(t *testing.T) { directory := t.TempDir() target := filepath.Join(directory, "portable", "config.json") @@ -249,6 +271,23 @@ func TestStoreRoundTripsReferencedProxyWithoutCredentials(t *testing.T) { } } +func TestStoreRejectsBlankOrDuplicateProxyNamesAndServers(t *testing.T) { + store, err := New(filepath.Join(t.TempDir(), "config.json")) + if err != nil { + t.Fatal(err) + } + for _, proxies := range [][]ProxyProfile{ + {{ID: "blank", Name: " ", Server: "http://127.0.0.1:8080"}}, + {{ID: "one", Name: "新加坡", Server: "http://127.0.0.1:8080"}, {ID: "two", Name: "新加坡", Server: "http://127.0.0.1:8081"}}, + {{ID: "one", Name: "Singapore", Server: "http://127.0.0.1:8080"}, {ID: "two", Name: "sINGAPORE", Server: "http://127.0.0.1:8081"}}, + {{ID: "one", Name: "新加坡", Server: "http://127.0.0.1:8080"}, {ID: "two", Name: "东京", Server: "HTTP://127.0.0.1:8080"}}, + } { + if err := store.Save(File{Proxies: proxies}); err == nil { + t.Fatalf("unsafe proxy profiles persisted: %#v", proxies) + } + } +} + func TestStoreMigratesLegacyProxyAndRejectsUnsafeConfig(t *testing.T) { path := filepath.Join(t.TempDir(), "config.json") if err := os.WriteFile(path, []byte(`{"version":1,"settings":{},"instances":[{"id":"a","name":"运营","launch":{"Kind":"chrome","UserDataDir":"C:\\profiles\\a","ProxyServer":"http://127.0.0.1:8080"}}]}`), 0o600); err != nil { diff --git a/internal/ui/shell.go b/internal/ui/shell.go index ef6985d..98afd14 100644 --- a/internal/ui/shell.go +++ b/internal/ui/shell.go @@ -33,11 +33,12 @@ const ( ) const ( - instanceNameColumnWeight = 20 - instanceBrowserColumnWeight = 10 - instanceDirectoryColumnWeight = 29 - instancePortColumnWeight = 10 - instanceStatusColumnWeight = 13 + instanceNameColumnWeight = 17 + instanceBrowserColumnWeight = 9 + instanceDirectoryColumnWeight = 28 + instanceProxyColumnWeight = 9 + instancePortColumnWeight = 9 + instanceStatusColumnWeight = 10 instanceActionColumnWeight = 18 ) @@ -314,6 +315,7 @@ type Shell struct { formFeedback string editFeedback string proxyServer widget.Editor + proxyName widget.Editor proxySave widget.Clickable proxyDelete widget.Clickable proxyFeedback string @@ -332,69 +334,71 @@ type Shell struct { pendingProxyDelete string proxyDeleteFocus bool - list widget.List - settingsList widget.List - tabsList 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 - tabsResults chan instanceTabsResult - 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 - tabsOpen bool - tabsLoading bool - tabsRequest uint64 - tabsInstanceID string - tabsFingerprint string - tabsTargets []CDPTarget - tabsFeedback string - tabsCancel context.CancelFunc - tabsClose widget.Clickable - tabsBlocker widget.Clickable - tabsFocusPending bool - tabsReturnFocus bool - onSave func(SettingsState) - onInstancesChanged func([]InstanceRow) - onProxiesChanged func([]ProxyOption) - instanceStarter InstanceStarter - instanceStopper InstanceStopper - instanceRefresher InstanceRefresher - instanceTabInspector InstanceTabInspector - pathSearcher PathSearcher - invalidate func() - searches map[PathField]*pathSearchState - searchResults chan pathSearchResult - directoryChooser DirectoryChooser - directoryPick directoryPickState - directoryResults chan directoryPickResult + list widget.List + settingsList widget.List + tabsList 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 + tabsResults chan instanceTabsResult + 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 + pendingCreateID string + defaultInstanceDirRoot string + instanceFeedback string + pendingDeleteID string + editingID string + editOriginal InstanceRow + pendingEditDiscard bool + editFocusPending bool + focusRestoreID string + focusStartID string + tabsOpen bool + tabsLoading bool + tabsRequest uint64 + tabsInstanceID string + tabsFingerprint string + tabsTargets []CDPTarget + tabsFeedback string + tabsCancel context.CancelFunc + tabsClose widget.Clickable + tabsBlocker widget.Clickable + tabsFocusPending bool + tabsReturnFocus bool + onSave func(SettingsState) + onInstancesChanged func([]InstanceRow) + onProxiesChanged func([]ProxyOption) + instanceStarter InstanceStarter + instanceStopper InstanceStopper + instanceRefresher InstanceRefresher + instanceTabInspector InstanceTabInspector + pathSearcher PathSearcher + invalidate func() + searches map[PathField]*pathSearchState + searchResults chan pathSearchResult + directoryChooser DirectoryChooser + directoryPick directoryPickState + directoryResults chan directoryPickResult } func NewShell(theme *material.Theme) *Shell { @@ -459,10 +463,23 @@ func (s *Shell) SetProxies(options []ProxyOption) { } if !s.proxyExists(s.settingsProxyID) { s.settingsProxyID = "" + s.proxyName.SetText("") s.proxyServer.SetText("") } } +// SetDefaultInstanceUserDataRoot supplies the already resolved executable-side +// profile root. It is deliberately a string-only UI boundary: no filesystem +// lookup, creation, or permission check happens in the Gio shell. +func (s *Shell) SetDefaultInstanceUserDataRoot(root string) { + root = strings.TrimSpace(root) + if root == "" || !filepath.IsAbs(root) { + s.defaultInstanceDirRoot = "" + return + } + s.defaultInstanceDirRoot = filepath.Clean(root) +} + func (s *Shell) SetSettings(value SettingsState) { s.chromePath.SetText(value.ChromePath) s.edgePath.SetText(value.EdgePath) @@ -662,6 +679,17 @@ func (s *Shell) consumeControls(gtx layout.Context) { func (s *Shell) beginCreate() { s.page = pageCreate s.formFeedback = "" + s.pendingCreateID = s.allocateInstanceID() + s.instanceName.SetText("") + s.instanceURL.SetText("") + s.createProxyID = "" + s.browserKind.Value = "chrome" + if defaultDir := s.defaultCreateInstanceDir(s.pendingCreateID); defaultDir != "" { + s.instanceDir.SetText(defaultDir) + } else { + s.instanceDir.SetText("") + s.formFeedback = "无法确定程序目录中的默认 User Data Dir;请选择或输入一个绝对路径。" + } s.instancePort.SetText(strconv.Itoa(s.recommendRemoteDebugPort(""))) } @@ -1016,6 +1044,7 @@ func (s *Shell) instanceListRow(row InstanceRow) layout.Widget { layout.Flexed(instanceNameColumnWeight, material.Body1(s.theme, row.Name).Layout), layout.Flexed(instanceBrowserColumnWeight, material.Body2(s.theme, row.Browser).Layout), layout.Flexed(instanceDirectoryColumnWeight, pathCell(s.theme, row.UserDataDir)), + layout.Flexed(instanceProxyColumnWeight, proxyNameCell(s.theme, s.proxyDisplayName(row.ProxyID))), layout.Flexed(instancePortColumnWeight, remoteDebugPortCell(s.theme, row)), layout.Flexed(instanceStatusColumnWeight, statusLabel(s.theme, row.Status)), ) @@ -1049,6 +1078,7 @@ func (s *Shell) instanceHeader(gtx layout.Context) layout.Dimensions { layout.Flexed(instanceNameColumnWeight, material.Caption(s.theme, "实例名称").Layout), layout.Flexed(instanceBrowserColumnWeight, material.Caption(s.theme, "浏览器类型").Layout), layout.Flexed(instanceDirectoryColumnWeight, material.Caption(s.theme, "用户数据目录").Layout), + layout.Flexed(instanceProxyColumnWeight, material.Caption(s.theme, "代理").Layout), layout.Flexed(instancePortColumnWeight, material.Caption(s.theme, "调试端口").Layout), layout.Flexed(instanceStatusColumnWeight, material.Caption(s.theme, "状态").Layout), layout.Flexed(instanceActionColumnWeight, func(gtx layout.Context) layout.Dimensions { @@ -1072,6 +1102,12 @@ func remoteDebugPortCell(theme *material.Theme, row InstanceRow) layout.Widget { return style.Layout } +func proxyNameCell(theme *material.Theme, name string) layout.Widget { + style := material.Body2(theme, name) + style.MaxLines = 1 + return style.Layout +} + func actualRemoteDebugPortText(port int) string { if port > 0 { return strconv.Itoa(port) @@ -1965,6 +2001,8 @@ func (s *Shell) proxySettings(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(s.settingsField(func(gtx layout.Context) layout.Dimensions { return s.proxyPickerField(gtx, proxyPickerSettings) })), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(s.settingsField(s.formField("代理名称", "用于实例列表和选择器识别;名称必须唯一", &s.proxyName))), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(s.settingsField(s.formField("代理地址", "支持 http、https、socks4、socks5;不允许用户名、密码或路径", &s.proxyServer))), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(s.settingsActionBar(func(gtx layout.Context) layout.Dimensions { @@ -2124,7 +2162,7 @@ func (s *Shell) proxyPickerField(gtx layout.Context, target proxyPickerTarget) l click = &s.settingsProxyPick selected = s.settingsProxyID label = "已保存代理" - help = "选择项会回填代理地址;选择新建会清空输入框" + help = "选择项会回填代理名称和地址;选择新建会清空输入框" } for click.Clicked(gtx) { s.proxyPicker = proxyPickerState{open: true, target: target} @@ -2138,17 +2176,26 @@ func (s *Shell) proxyPickerField(gtx layout.Context, target proxyPickerTarget) l } func (s *Shell) proxyLabel(id string) string { + if option, exists := s.proxyOption(id); exists { + return proxyPickerLabel(option) + } + return s.proxyDisplayName(id) +} + +func (s *Shell) proxyDisplayName(id string) string { if id == "" { return "无代理" } - for _, option := range s.proxies { - if option.ID == id { - return option.Server - } + if option, exists := s.proxyOption(id); exists { + return option.Name } return "代理不可用" } +func proxyPickerLabel(option ProxyOption) string { + return option.Name + " · " + option.Server +} + func (s *Shell) proxyExists(id string) bool { if _, exists := s.proxyOption(id); exists { return true @@ -2186,10 +2233,12 @@ func (s *Shell) selectPickerProxy(id string) { } else if s.proxyPicker.target == proxyPickerSettings { s.settingsProxyID = id if id == "" { + s.proxyName.SetText("") s.proxyServer.SetText("") } else { for _, option := range s.proxies { if option.ID == id { + s.proxyName.SetText(option.Name) s.proxyServer.SetText(option.Server) break } @@ -2252,7 +2301,14 @@ func (s *Shell) proxyPickerCard(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions { return s.proxyPickerChoiceFor(option.ID).Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Inset{Top: unit.Dp(7), Bottom: unit.Dp(7), Left: unit.Dp(10), Right: unit.Dp(10)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - return material.Body1(s.theme, option.Server).Layout(gtx) + name := material.Body1(s.theme, option.Name) + name.MaxLines = 1 + server := material.Caption(s.theme, option.Server) + server.MaxLines = 1 + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(name.Layout), + layout.Rigid(server.Layout), + ) }) }) }), @@ -2300,8 +2356,8 @@ func (s *Shell) createInstanceFromForm() { return } userDataDir := strings.TrimSpace(s.instanceDir.Text()) - if userDataDir == "" { - s.formFeedback = "请选择或输入 User Data Dir 后再创建。" + if userDataDir == "" || !filepath.IsAbs(userDataDir) { + s.formFeedback = "请选择或输入绝对路径的 User Data Dir 后再创建。" return } preferredPort, err := normalizePreferredRemoteDebugPort(s.instancePort.Text()) @@ -2313,12 +2369,15 @@ func (s *Shell) createInstanceFromForm() { s.formFeedback = fmt.Sprintf("端口 %d 已被其他 Chub 实例预留或使用。", preferredPort) return } - s.nextInstance++ + instanceID := s.pendingCreateID + if instanceID == "" || s.instanceIDExists(instanceID) { + instanceID = s.allocateInstanceID() + } s.rows = append(s.rows, InstanceRow{ - ID: fmt.Sprintf("instance-%d", s.nextInstance), + ID: instanceID, Name: name, Browser: browserDisplay(s.browserKind.Value), - UserDataDir: userDataDir, + UserDataDir: filepath.Clean(userDataDir), TargetURL: strings.TrimSpace(s.instanceURL.Text()), ProxyID: s.createProxyID, PreferredRemoteDebugPort: preferredPort, @@ -2326,9 +2385,36 @@ func (s *Shell) createInstanceFromForm() { }) s.page = pageInstances s.formFeedback = "" + s.pendingCreateID = "" s.notifyInstancesChanged() } +func (s *Shell) defaultCreateInstanceDir(instanceID string) string { + if s.defaultInstanceDirRoot == "" || instanceID == "" { + return "" + } + return filepath.Join(s.defaultInstanceDirRoot, instanceID) +} + +func (s *Shell) allocateInstanceID() string { + for { + s.nextInstance++ + candidate := fmt.Sprintf("instance-%d-%d", time.Now().UnixNano(), s.nextInstance) + if !s.instanceIDExists(candidate) && candidate != s.pendingCreateID { + return candidate + } + } +} + +func (s *Shell) instanceIDExists(id string) bool { + for _, row := range s.rows { + if row.ID == id { + return true + } + } + return false +} + func (s *Shell) togglePathSearch(field PathField) { state := s.searches[field] if state == nil { @@ -2469,12 +2555,21 @@ func (s *Shell) deleteClickFor(id string) *widget.Clickable { } func (s *Shell) saveProxy() { + name, err := domain.NormalizeProxyName(s.proxyName.Text()) + if err != nil { + s.proxyFeedback = "请输入代理名称。" + return + } server, err := domain.NormalizeProxyServer(s.proxyServer.Text()) if err != nil || server == "" { s.proxyFeedback = "代理地址必须是无认证的 scheme://host:port。" return } for _, option := range s.proxies { + if option.ID != s.settingsProxyID && strings.EqualFold(option.Name, name) { + s.proxyFeedback = "该代理名称已存在,请使用其他名称。" + return + } if option.ID != s.settingsProxyID && option.Server == server { s.proxyFeedback = "该代理地址已存在,请从下拉框选择它。" return @@ -2483,15 +2578,15 @@ func (s *Shell) saveProxy() { if s.settingsProxyID == "" { s.nextProxy++ s.settingsProxyID = fmt.Sprintf("proxy-%d", s.nextProxy) - s.proxies = append(s.proxies, ProxyOption{ID: s.settingsProxyID, Name: server, Server: server}) - s.proxyFeedback = fmt.Sprintf("已添加代理“%s”;后续启动将使用该地址。", server) + s.proxies = append(s.proxies, ProxyOption{ID: s.settingsProxyID, Name: name, Server: server}) + s.proxyFeedback = fmt.Sprintf("已添加代理“%s”;后续启动将使用该地址。", name) } else { updated := false for index := range s.proxies { if s.proxies[index].ID != s.settingsProxyID { continue } - s.proxies[index].Name = server + s.proxies[index].Name = name s.proxies[index].Server = server updated = true break @@ -2500,8 +2595,9 @@ func (s *Shell) saveProxy() { s.proxyFeedback = "所选代理已不可用,请重新选择或新建。" return } - s.proxyFeedback = fmt.Sprintf("已保存代理“%s”;后续启动将使用最新端点。", server) + s.proxyFeedback = fmt.Sprintf("已保存代理“%s”;后续启动将使用最新端点。", name) } + s.proxyName.SetText(name) s.proxyServer.SetText(server) s.notifyProxiesChanged() } @@ -2552,9 +2648,10 @@ func (s *Shell) confirmProxyDelete() { } if s.settingsProxyID == id { s.settingsProxyID = "" + s.proxyName.SetText("") s.proxyServer.SetText("") } - s.proxyFeedback = fmt.Sprintf("已删除代理“%s”。", option.Server) + s.proxyFeedback = fmt.Sprintf("已删除代理“%s”。", option.Name) s.notifyProxiesChanged() return } diff --git a/internal/ui/shell_test.go b/internal/ui/shell_test.go index 9c2b6ed..f4cfc06 100644 --- a/internal/ui/shell_test.go +++ b/internal/ui/shell_test.go @@ -68,13 +68,16 @@ func TestShellInstanceRowsContainRequiredColumnsAndIndependentActionControls(t * } func TestInstanceListColumnWeightsPrioritizeDirectoryAndCompactAction(t *testing.T) { - got := instanceNameColumnWeight + instanceBrowserColumnWeight + instanceDirectoryColumnWeight + instancePortColumnWeight + instanceStatusColumnWeight + instanceActionColumnWeight + got := instanceNameColumnWeight + instanceBrowserColumnWeight + instanceDirectoryColumnWeight + instanceProxyColumnWeight + instancePortColumnWeight + instanceStatusColumnWeight + instanceActionColumnWeight if got != 100 { t.Fatalf("instance column weights = %d, want 100", got) } if instanceDirectoryColumnWeight <= instanceNameColumnWeight { t.Fatal("user data directory must have the widest instance-list column") } + if instanceProxyColumnWeight >= instanceDirectoryColumnWeight { + t.Fatal("proxy name must remain a compact display-only column") + } if instanceActionColumnWeight <= instanceStatusColumnWeight { t.Fatal("actions column must fit both compact icon commands") } @@ -716,8 +719,11 @@ func TestShellRequestsDiscardConfirmationForDirtyEdit(t *testing.T) { func TestShellCreatesAndEditsInstanceProxySelection(t *testing.T) { shell := NewShell(material.NewTheme()) shell.SetProxies([]ProxyOption{{ID: "proxy-sg", Name: "新加坡", Server: "http://127.0.0.1:8080"}}) - if label := shell.proxyLabel("proxy-sg"); label != "http://127.0.0.1:8080" { - t.Fatalf("proxy label = %q, want complete proxy address", label) + if label := shell.proxyLabel("proxy-sg"); label != "新加坡 · http://127.0.0.1:8080" { + t.Fatalf("proxy label = %q, want name and complete proxy address", label) + } + if display := shell.proxyDisplayName("proxy-sg"); display != "新加坡" { + t.Fatalf("proxy display name = %q", display) } shell.instanceName.SetText("新实例") shell.instanceDir.SetText(t.TempDir()) @@ -741,6 +747,7 @@ func TestShellPreventsDeletingReferencedProxy(t *testing.T) { shell.SetProxies([]ProxyOption{{ID: "proxy-sg", Name: "新加坡", Server: "http://127.0.0.1:8080"}}) shell.rows[0].ProxyID = "proxy-sg" shell.settingsProxyID = "proxy-sg" + shell.proxyName.SetText("新加坡") shell.proxyServer.SetText("http://127.0.0.1:8080") changes := 0 shell.OnProxiesChanged(func([]ProxyOption) { changes++ }) @@ -754,13 +761,13 @@ func TestShellPreventsDeletingReferencedProxy(t *testing.T) { t.Fatalf("pending proxy deletion = %q", shell.pendingProxyDelete) } shell.cancelProxyDelete() - if shell.pendingProxyDelete != "" || len(shell.proxies) != 1 || changes != 0 || shell.settingsProxyID != "proxy-sg" || shell.proxyServer.Text() != "http://127.0.0.1:8080" { - t.Fatalf("cancelled delete state = proxies %#v, changes %d, selection %q, server %q", shell.proxies, changes, shell.settingsProxyID, shell.proxyServer.Text()) + if shell.pendingProxyDelete != "" || len(shell.proxies) != 1 || changes != 0 || shell.settingsProxyID != "proxy-sg" || shell.proxyName.Text() != "新加坡" || shell.proxyServer.Text() != "http://127.0.0.1:8080" { + t.Fatalf("cancelled delete state = proxies %#v, changes %d, selection %q, name %q, server %q", shell.proxies, changes, shell.settingsProxyID, shell.proxyName.Text(), shell.proxyServer.Text()) } shell.requestProxyDelete() shell.confirmProxyDelete() - if len(shell.proxies) != 0 || changes != 1 || shell.settingsProxyID != "" || shell.proxyServer.Text() != "" { - t.Fatalf("unreferenced delete state = proxies %#v, changes %d, selection %q, server %q", shell.proxies, changes, shell.settingsProxyID, shell.proxyServer.Text()) + if len(shell.proxies) != 0 || changes != 1 || shell.settingsProxyID != "" || shell.proxyName.Text() != "" || shell.proxyServer.Text() != "" { + t.Fatalf("unreferenced delete state = proxies %#v, changes %d, selection %q, name %q, server %q", shell.proxies, changes, shell.settingsProxyID, shell.proxyName.Text(), shell.proxyServer.Text()) } } @@ -772,29 +779,76 @@ func TestShellSavesProxyFromAddressPicker(t *testing.T) { shell.proxyPicker.target = proxyPickerSettings shell.selectPickerProxy("proxy-sg") - if shell.settingsProxyID != "proxy-sg" || shell.proxyServer.Text() != "http://127.0.0.1:8080" { - t.Fatalf("selected proxy = %q, server %q", shell.settingsProxyID, shell.proxyServer.Text()) + if shell.settingsProxyID != "proxy-sg" || shell.proxyName.Text() != "旧名称" || shell.proxyServer.Text() != "http://127.0.0.1:8080" { + t.Fatalf("selected proxy = %q, name %q, server %q", shell.settingsProxyID, shell.proxyName.Text(), shell.proxyServer.Text()) } + shell.proxyName.SetText("新加坡出口") shell.proxyServer.SetText("http://127.0.0.1:8081") shell.saveProxy() - if len(shell.proxies) != 1 || shell.proxies[0].ID != "proxy-sg" || shell.proxies[0].Name != "http://127.0.0.1:8081" || shell.proxies[0].Server != "http://127.0.0.1:8081" || changes != 1 { + if len(shell.proxies) != 1 || shell.proxies[0].ID != "proxy-sg" || shell.proxies[0].Name != "新加坡出口" || shell.proxies[0].Server != "http://127.0.0.1:8081" || changes != 1 { t.Fatalf("updated proxy = %#v, changes %d", shell.proxies, changes) } + if display := shell.proxyDisplayName("proxy-sg"); display != "新加坡出口" { + t.Fatalf("updated proxy display name = %q", display) + } shell.proxyPicker.target = proxyPickerSettings shell.selectPickerProxy("") - if shell.settingsProxyID != "" || shell.proxyServer.Text() != "" { - t.Fatalf("new proxy state = id %q, server %q", shell.settingsProxyID, shell.proxyServer.Text()) + if shell.settingsProxyID != "" || shell.proxyName.Text() != "" || shell.proxyServer.Text() != "" { + t.Fatalf("new proxy state = id %q, name %q, server %q", shell.settingsProxyID, shell.proxyName.Text(), shell.proxyServer.Text()) } + shell.proxyName.SetText("东京出口") shell.proxyServer.SetText("http://127.0.0.1:8082") shell.saveProxy() - if len(shell.proxies) != 2 || shell.settingsProxyID == "" || shell.proxies[1].Name != "http://127.0.0.1:8082" || shell.proxies[1].Server != "http://127.0.0.1:8082" || changes != 2 { + if len(shell.proxies) != 2 || shell.settingsProxyID == "" || shell.proxies[1].Name != "东京出口" || shell.proxies[1].Server != "http://127.0.0.1:8082" || changes != 2 { t.Fatalf("created proxy = %#v, selected %q, changes %d", shell.proxies, shell.settingsProxyID, changes) } } +func TestShellRejectsDuplicateProxyNameWithoutReplacingSelectedProfile(t *testing.T) { + shell := NewShell(material.NewTheme()) + shell.SetProxies([]ProxyOption{ + {ID: "proxy-sg", Name: "新加坡出口", Server: "http://127.0.0.1:8080"}, + {ID: "proxy-jp", Name: "东京出口", Server: "http://127.0.0.1:8081"}, + }) + shell.proxyPicker.target = proxyPickerSettings + shell.selectPickerProxy("proxy-jp") + shell.proxyName.SetText(" 新加坡出口 ") + shell.proxyServer.SetText("http://127.0.0.1:8082") + shell.saveProxy() + if !strings.Contains(shell.proxyFeedback, "名称已存在") || shell.proxies[1].Name != "东京出口" || shell.proxies[1].Server != "http://127.0.0.1:8081" { + t.Fatalf("duplicate name state = proxies %#v, feedback %q", shell.proxies, shell.proxyFeedback) + } +} + +func TestShellUsesDistinctDefaultDirectoriesForNewInstances(t *testing.T) { + shell := NewShell(material.NewTheme()) + root := filepath.Join(t.TempDir(), "user_data_dirs") + shell.SetDefaultInstanceUserDataRoot(root) + + shell.beginCreate() + firstID := shell.pendingCreateID + firstDir := shell.instanceDir.Text() + if firstID == "" || firstDir != filepath.Join(root, firstID) { + t.Fatalf("first default directory = %q for id %q", firstDir, firstID) + } + shell.instanceName.SetText("第一个实例") + shell.createInstanceFromForm() + created := shell.rows[len(shell.rows)-1] + if created.ID != firstID || created.UserDataDir != firstDir { + t.Fatalf("created default instance = %#v", created) + } + + shell.beginCreate() + secondID := shell.pendingCreateID + secondDir := shell.instanceDir.Text() + if secondID == "" || secondID == firstID || secondDir != filepath.Join(root, secondID) || secondDir == firstDir { + t.Fatalf("second default directory = %q for id %q, first %q", secondDir, secondID, firstDir) + } +} + func TestShellMarksUnexpectedManagedExitAndRestoresStartFocus(t *testing.T) { shell := NewShell(material.NewTheme()) target := shell.rows[0]