feat: add proxy labels and default profiles
This commit is contained in:
+10
-1
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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` 保持未跟踪,未纳入任务提交。
|
||||
- 阻塞:无。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user