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)
}
}
+70 -7
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 {
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 {
+118 -21
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
@@ -361,6 +363,8 @@ type Shell struct {
unexpectedExitFocus bool
unexpectedExitFocusID string
nextInstance uint64
pendingCreateID string
defaultInstanceDirRoot string
instanceFeedback string
pendingDeleteID string
editingID string
@@ -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]