feat: add proxy labels and default profiles

This commit is contained in:
QiuSW
2026-07-27 12:09:54 +08:00
parent a8b7277dab
commit 0f25f39480
10 changed files with 397 additions and 115 deletions
+10 -1
View File
@@ -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()
+1 -1
View File
@@ -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
+4 -4
View File
@@ -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。
## 当前目录
+4 -4
View File
@@ -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` 保持未跟踪,未纳入任务提交。
- 阻塞:无。
+10
View File
@@ -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
}
+10
View File
@@ -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)
}
}
+71 -8
View File
@@ -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)
+39
View File
@@ -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 {
+181 -84
View File
@@ -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
}
+67 -13
View File
@@ -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]