From 3a3c92c8755418d2d719fb6087e8370cadcba02e Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 25 Jul 2026 17:04:26 +0800 Subject: [PATCH] feat: add reusable proxy configuration --- cmd/chub/main.go | 95 +++++- cmd/chub/main_test.go | 24 ++ docs/06-tasks.md | 4 +- docs/current-state.md | 6 +- docs/tasks/T-304.md | 8 +- docs/tasks/T-305.md | 4 +- internal/domain/browser.go | 31 +- internal/domain/browser_test.go | 17 + internal/platform/browser/launch.go | 27 +- internal/platform/config/store.go | 101 +++++- internal/platform/config/store_test.go | 37 +++ internal/ui/shell.go | 428 ++++++++++++++++++++++++- internal/ui/shell_test.go | 38 +++ 13 files changed, 772 insertions(+), 48 deletions(-) diff --git a/cmd/chub/main.go b/cmd/chub/main.go index adc8bd4..16697a4 100644 --- a/cmd/chub/main.go +++ b/cmd/chub/main.go @@ -10,6 +10,7 @@ import ( "os" "path/filepath" "strings" + "sync" "time" "chub/internal/domain" @@ -110,6 +111,7 @@ func runWindow(logger *slog.Logger) { launcher := browser.NewOSLauncher() cdp := browser.NewCDPInspector() externalProfiles := browser.NewWindowsProfileInspector() + proxies := newProxyDirectory() shell.OnStartInstance(instanceStarter{ launcher: launcher, resolver: discoverer, @@ -117,6 +119,7 @@ func runWindow(logger *slog.Logger) { externalProfiles: externalProfiles, remoteDebug: cdp, portAllocator: cdp, + proxyResolver: proxies, }.Start, window.Invalidate) shell.OnRefreshInstances(instanceStatusRefresher{ managedProfiles: launcher, @@ -153,9 +156,11 @@ func runWindow(logger *slog.Logger) { if store, err := config.New(path); err == nil { if saved, err := store.Load(); err == nil { shell.SetSettings(ui.SettingsState{ChromePath: saved.Settings.ChromePath, EdgePath: saved.Settings.EdgePath, DefaultDir: saved.Settings.DefaultDir, LogDir: saved.Settings.LogDir, RemoteDebugStartPort: saved.Settings.RemoteDebugStartPort, CloseOnExit: saved.Settings.CloseOnExit}) + proxies.Set(saved.Proxies) + shell.SetProxies(proxyOptions(saved.Proxies)) rows := make([]ui.InstanceRow, 0, len(saved.Instances)) for _, item := range saved.Instances { - rows = append(rows, ui.InstanceRow{ID: item.ID, Name: item.Name, Browser: browserLabel(item.Launch.Kind), UserDataDir: item.Launch.UserDataDir, TargetURL: item.Launch.TargetURL, Status: "已退出"}) + rows = append(rows, ui.InstanceRow{ID: item.ID, Name: item.Name, Browser: browserLabel(item.Launch.Kind), UserDataDir: item.Launch.UserDataDir, TargetURL: item.Launch.TargetURL, ProxyID: item.ProxyID, Status: "已退出"}) } shell.SetInstances(rows) shell.OnSave(func(value ui.SettingsState) { @@ -166,6 +171,11 @@ func runWindow(logger *slog.Logger) { saved.Instances = mergeInstanceConfig(saved.Instances, rows) go saveSettings(logger, store, configSnapshot(saved)) }) + shell.OnProxiesChanged(func(options []ui.ProxyOption) { + saved.Proxies = mergeProxyConfig(saved.Proxies, options) + proxies.Set(saved.Proxies) + go saveSettings(logger, store, configSnapshot(saved)) + }) } } } @@ -192,6 +202,55 @@ func saveSettings(logger *slog.Logger, store *config.Store, value config.File) { } } +type proxyServerResolver interface { + Resolve(string) (string, error) +} + +// proxyDirectory is the single in-memory authority used by UI launch requests. +// It stores only endpoints already validated by config and never exposes secrets. +type proxyDirectory struct { + mu sync.RWMutex + servers map[string]string +} + +func newProxyDirectory() *proxyDirectory { + return &proxyDirectory{servers: make(map[string]string)} +} + +func (d *proxyDirectory) Set(profiles []config.ProxyProfile) { + if d == nil { + return + } + servers := make(map[string]string, len(profiles)) + for _, profile := range profiles { + servers[profile.ID] = profile.Server + } + d.mu.Lock() + d.servers = servers + d.mu.Unlock() +} + +func (d *proxyDirectory) Resolve(id string) (string, error) { + if d == nil || strings.TrimSpace(id) == "" { + return "", errors.New("所选代理不可用") + } + d.mu.RLock() + server, exists := d.servers[id] + d.mu.RUnlock() + if !exists { + return "", errors.New("所选代理不可用") + } + return server, nil +} + +func proxyOptions(profiles []config.ProxyProfile) []ui.ProxyOption { + options := make([]ui.ProxyOption, 0, len(profiles)) + for _, profile := range profiles { + options = append(options, ui.ProxyOption{ID: profile.ID, Name: profile.Name, Server: profile.Server}) + } + return options +} + type executableResolver interface { Resolve(context.Context, domain.BrowserKind, string) (string, error) } @@ -211,6 +270,7 @@ type instanceStarter struct { externalProfiles browser.ExternalProfileInspector remoteDebug browser.RemoteDebugEndpointInspector portAllocator browser.RemoteDebugPortAllocator + proxyResolver proxyServerResolver } type instanceStatusRefresher struct { @@ -332,6 +392,16 @@ func (s instanceStarter) Start(ctx context.Context, row ui.InstanceRow, settings return ui.InstanceStartOutcome{PID: use.PID, Source: use.Source}, &browser.ProfileOccupiedError{UserDataDir: row.UserDataDir, PID: use.PID} } } + proxyServer := "" + if row.ProxyID != "" { + if s.proxyResolver == nil { + return ui.InstanceStartOutcome{}, errors.New("所选代理不可用") + } + proxyServer, err = s.proxyResolver.Resolve(row.ProxyID) + if err != nil { + return ui.InstanceStartOutcome{}, err + } + } configuredExecutable := settings.ChromePath if kind == domain.BrowserEdge { configuredExecutable = settings.EdgePath @@ -350,6 +420,7 @@ func (s instanceStarter) Start(ctx context.Context, row ui.InstanceRow, settings UserDataDir: row.UserDataDir, RemoteDebugPort: port, TargetURL: row.TargetURL, + ProxyServer: proxyServer, }) if err != nil { return ui.InstanceStartOutcome{}, err @@ -398,15 +469,37 @@ func mergeInstanceConfig(existing []config.Instance, rows []ui.InstanceRow) []co item.Launch.Kind = kind item.Launch.UserDataDir = row.UserDataDir item.Launch.TargetURL = row.TargetURL + item.ProxyID = row.ProxyID + if item.ProxyID != "" { + item.Launch.ProxyServer = "" + } item.UpdatedAt = time.Now() updated = append(updated, item) } return updated } +func mergeProxyConfig(existing []config.ProxyProfile, options []ui.ProxyOption) []config.ProxyProfile { + byID := make(map[string]config.ProxyProfile, len(existing)) + for _, profile := range existing { + byID[profile.ID] = profile + } + updated := make([]config.ProxyProfile, 0, len(options)) + for _, option := range options { + profile := byID[option.ID] + profile.ID = option.ID + profile.Name = option.Name + profile.Server = option.Server + profile.UpdatedAt = time.Now() + updated = append(updated, profile) + } + return updated +} + func configSnapshot(value config.File) config.File { clone := value clone.Instances = append([]config.Instance(nil), value.Instances...) + clone.Proxies = append([]config.ProxyProfile(nil), value.Proxies...) for i := range clone.Instances { clone.Instances[i].Launch.ExtraArgs = append([]string(nil), clone.Instances[i].Launch.ExtraArgs...) } diff --git a/cmd/chub/main_test.go b/cmd/chub/main_test.go index 2b30d5b..3762221 100644 --- a/cmd/chub/main_test.go +++ b/cmd/chub/main_test.go @@ -37,6 +37,23 @@ func TestInstanceStarterBuildsLaunchSpecFromInstanceAndSettings(t *testing.T) { } } +func TestInstanceStarterResolvesSelectedProxyAtLaunch(t *testing.T) { + launcher := &fakeProcessLauncher{handle: fakeProcessHandle{pid: 4242}} + resolver := &fakeExecutableResolver{path: `C:\Browser\chrome.exe`} + remote := &fakeRemoteDebugInspector{endpoint: browser.RemoteDebugEndpoint{Port: 9666}, inspectErr: browser.ErrRemoteDebugEndpointUnavailable} + proxies := newProxyDirectory() + proxies.Set([]config.ProxyProfile{{ID: "proxy-sg", Name: "SG", Server: "http://127.0.0.1:8080"}}) + starter := instanceStarter{launcher: launcher, resolver: resolver, managedProfiles: fakeManagedProfileInspector{}, externalProfiles: fakeExternalProfileInspector{}, remoteDebug: remote, portAllocator: remote, proxyResolver: proxies} + _, err := starter.Start(context.Background(), ui.InstanceRow{ID: "chrome-a", Browser: "Chrome", UserDataDir: `C:\profiles\chrome-a`, ProxyID: "proxy-sg"}, ui.SettingsState{RemoteDebugStartPort: 9666}) + if err != nil || launcher.spec.ProxyServer != "http://127.0.0.1:8080" { + t.Fatalf("proxy launch spec = %#v, error = %v", launcher.spec, err) + } + _, err = starter.Start(context.Background(), ui.InstanceRow{ID: "missing", Browser: "Chrome", UserDataDir: `C:\profiles\missing`, ProxyID: "gone"}, ui.SettingsState{RemoteDebugStartPort: 9666}) + if err == nil || launcher.calls != 1 { + t.Fatalf("missing proxy error = %v, launch calls = %d", err, launcher.calls) + } +} + func TestInstanceStarterAssociatesVerifiedExternalProfileWithoutLaunching(t *testing.T) { launcher := &fakeProcessLauncher{handle: fakeProcessHandle{pid: 4242}} remote := &fakeRemoteDebugInspector{endpoint: browser.RemoteDebugEndpoint{Port: 9668}} @@ -92,6 +109,13 @@ func TestMergeInstanceConfigPreservesExistingLaunchOptions(t *testing.T) { } } +func TestMergeInstanceConfigPersistsProxySelection(t *testing.T) { + updated := mergeInstanceConfig(nil, []ui.InstanceRow{{ID: "one", Name: "实例", Browser: "Chrome", UserDataDir: `C:\profiles\one`, ProxyID: "proxy-sg"}}) + if len(updated) != 1 || updated[0].ProxyID != "proxy-sg" || updated[0].Launch.ProxyServer != "" { + t.Fatalf("merged proxy = %#v", updated) + } +} + func TestInstanceStatusRefresherChecksOnlyConfiguredRows(t *testing.T) { remote := &fakeRemoteDebugInspector{endpoint: browser.RemoteDebugEndpoint{Port: 9777}} refresher := instanceStatusRefresher{ diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 606a7b2..5591a65 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -39,8 +39,8 @@ | T-301 | 接入 GUI 启动操作与实例删除确认 | T-204,T-208 | DONE | | T-302 | 本地 CDP 启动、外部关联与实例端口展示 | T-102,T-301 | DONE | | T-303 | 实例状态刷新与编辑对话框 | T-302 | DONE | -| T-304 | 代理配置库、实例选择与安全启动参数传递 | T-303 | DOING | -| T-305 | GUI 受管实例优雅停止与启停状态机 | T-304 | TODO | +| T-304 | 代理配置库、实例选择与安全启动参数传递 | T-303 | DONE | +| T-305 | GUI 受管实例优雅停止与启停状态机 | T-304 | DOING | ## Backlog diff --git a/docs/current-state.md b/docs/current-state.md index aaf5f2a..dc2e6cb 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -3,11 +3,11 @@ ## 快照 - 日期:2026-07-25 -- 阶段:Phase 3 真实实例操作(T-304 进行中;T-303、T-302、T-301 已完成;T-201 至 T-208 已完成) +- 阶段:Phase 3 真实实例操作(T-305 进行中;T-304、T-303、T-302、T-301 已完成;T-201 至 T-208 已完成) - 代码:已建立 Go module `chub`、`cmd/chub` 入口、logging 测试基座、浏览器 domain/application 合约、Chrome/Edge 参数/发现模块、启动 registry、Windows 身份/占用检查、loopback CDP 端口分配与端点校验、优雅关闭、Job Object、真实 Chrome/Edge smoke、JSON 配置存储、启动恢复、CLI JSON 合约、应用内事件总线、UI 状态测试和 Windows smoke 脚本,T-001 至 T-003、T-101 至 T-104、T-201 至 T-208、T-301 至 T-303 已完成 -- UI:Gio 双页 Shell 使用左侧“实例/设置”导航;实例页以等宽“新建实例 / 刷新实例状态”命令区开始,窄内容区自动堆叠。列表固定显示实例名称、浏览器类型、用户数据目录、调试端口、状态和紧凑编辑/启动/删除图标,并以外层边框和表头分隔组织。刷新通过后台回调只检查已保存实例,以配置快照丢弃编辑或删除后的过期结果;它结合 Chub registry、指定 profile 的外部占用证据和 loopback CDP 端点更新状态,但不扫描、接管或关闭其他 Chrome/Edge。双击、Enter 或编辑图标打开实例编辑弹层,支持名称、浏览器类型、User Data Dir、启动 URL 和只读实际端口;保存保留未公开启动选项,Escape 对脏表单先请求确认,活跃/外部关联实例锁定身份约束字段。启动通过异步回调接到 Windows 浏览器启动器:未占用目录从已保存的起始端口(空值默认 9666)选择 loopback CDP 端口;同目录外部浏览器只有在 `DevToolsActivePort` 与 CDP 端点可验证时才显示“外部已关联”,且不会接管其生命周期。删除使用确认弹层,只删除 Chub 实例配置而不删除 User Data Dir。新建、编辑和删除实例会保存到本地配置;四个设置路径有独立异步可取消搜索,切换页面后输入和任务状态保留;新建实例表单使用 Label、Windows 原生目录选择器和 Chrome/Edge RadioButton;CLI `list/events` 已可用,`start/stop/restart` 等待 BrowserManager adapter +- UI:Gio 双页 Shell 使用左侧“实例/设置”导航;实例页以等宽“新建实例 / 刷新实例状态”命令区开始,窄内容区自动堆叠。列表固定显示实例名称、浏览器类型、用户数据目录、调试端口、状态和紧凑编辑/启动/删除图标,并以外层边框和表头分隔组织。刷新通过后台回调只检查已保存实例,以配置快照丢弃编辑或删除后的过期结果;它结合 Chub registry、指定 profile 的外部占用证据和 loopback CDP 端点更新状态,但不扫描、接管或关闭其他 Chrome/Edge。双击、Enter 或编辑图标打开实例编辑弹层,支持名称、浏览器类型、User Data Dir、启动 URL、代理选择和只读实际端口;保存保留未公开启动选项,Escape 对脏表单先请求确认,活跃/外部关联实例锁定身份约束字段。设置页可维护名称加无认证端点的代理库,删除仍被实例引用的代理会被拒绝。启动通过异步回调接到 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 - 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目 -- blocker:无;当前优先完成代理配置库、实例代理选择与安全启动参数传递(T-304),再接入受管实例的 GUI 优雅停止与启停状态回传(T-305)。 +- blocker:无;下一步完成已注册实例的 GUI 优雅停止、停止结果等待和启停图标状态回传(T-305)。 ## 当前目录 diff --git a/docs/tasks/T-304.md b/docs/tasks/T-304.md index 15e610c..d04605f 100644 --- a/docs/tasks/T-304.md +++ b/docs/tasks/T-304.md @@ -3,7 +3,7 @@ id: T-304 title: 代理配置库、实例选择与安全启动参数传递 phase: 3 deps: [T-303] -status: DOING +status: DONE created: 2026-07-25 owner: codex --- @@ -32,7 +32,7 @@ owner: codex ## 执行记录 -- 状态:DOING -- 变更:待实现。 -- 验证:待执行。 +- 状态:DONE +- 变更:配置文件新增稳定 `proxyId` 实例关联和 `ProxyProfile` 无认证代理库;加载旧配置时把原有安全 `LaunchSpec.ProxyServer` 自动迁移为命名代理。domain 的统一端点校验同时保护配置和 Chromium 参数构造。Gio 设置页新增代理的就地新增、编辑、引用保护删除;新建和编辑实例通过持久代理选择弹层选择“无代理”或命名项。启动 adapter 在后台按 ID 解析最新端点并填入一次性 `LaunchSpec.ProxyServer`,找不到选择项时拒绝启动。 +- 验证:`gofmt -w cmd/chub/main.go internal/ui/shell.go internal/platform/config/store.go internal/domain/browser.go internal/platform/browser/launch.go internal/domain/browser_test.go internal/platform/config/store_test.go cmd/chub/main_test.go internal/ui/shell_test.go`、`go test ./...`、`go vet ./...`、`go build -o build/chub.exe ./cmd/chub`、`scripts/smoke-browser.ps1`(真实 Chrome/Edge)和 `scripts/smoke-windows.ps1` 均通过。 - 阻塞:无。 diff --git a/docs/tasks/T-305.md b/docs/tasks/T-305.md index 8828b92..04dc802 100644 --- a/docs/tasks/T-305.md +++ b/docs/tasks/T-305.md @@ -3,7 +3,7 @@ id: T-305 title: GUI 受管实例优雅停止与启停状态机 phase: 3 deps: [T-304] -status: TODO +status: DOING created: 2026-07-25 owner: codex --- @@ -29,7 +29,7 @@ owner: codex ## 执行记录 -- 状态:TODO +- 状态:DOING - 变更:待实现。 - 验证:待执行。 - 阻塞:无。 diff --git a/internal/domain/browser.go b/internal/domain/browser.go index c59fe90..f1d9694 100644 --- a/internal/domain/browser.go +++ b/internal/domain/browser.go @@ -3,8 +3,10 @@ package domain import ( "errors" "fmt" + "net" "net/url" "path/filepath" + "strconv" "strings" "time" ) @@ -70,11 +72,38 @@ func (s LaunchSpec) Normalize() (LaunchSpec, error) { normalized.UserDataDir = filepath.Clean(userDataDir) normalized.ProfileDirectory = strings.TrimSpace(s.ProfileDirectory) normalized.TargetURL = target - normalized.ProxyServer = strings.TrimSpace(s.ProxyServer) + proxy, err := NormalizeProxyServer(s.ProxyServer) + if err != nil { + return LaunchSpec{}, err + } + normalized.ProxyServer = proxy normalized.ExtraArgs = append([]string(nil), s.ExtraArgs...) return normalized, nil } +// NormalizeProxyServer accepts only non-authenticated Chromium proxy endpoints. +// Keeping this validation in domain makes configuration and process launch share +// the same security boundary: no proxy userinfo can reach disk or command args. +func NormalizeProxyServer(value string) (string, error) { + proxy := strings.TrimSpace(value) + if proxy == "" { + return "", nil + } + parsed, err := url.Parse(proxy) + if err != nil || parsed.User != nil || parsed.Hostname() == "" || parsed.Port() == "" || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" { + return "", fmt.Errorf("%w: proxy must be scheme://host:port without credentials", ErrInvalidLaunchSpec) + } + scheme := strings.ToLower(parsed.Scheme) + if scheme != "http" && scheme != "https" && scheme != "socks4" && scheme != "socks5" { + return "", fmt.Errorf("%w: unsupported proxy scheme", ErrInvalidLaunchSpec) + } + port, err := strconv.Atoi(parsed.Port()) + if err != nil || port < 1 || port > 65535 { + return "", fmt.Errorf("%w: proxy port out of range", ErrInvalidLaunchSpec) + } + return scheme + "://" + net.JoinHostPort(strings.ToLower(parsed.Hostname()), strconv.Itoa(port)), nil +} + func ValidRemoteDebugPort(port int) bool { return port >= MinRemoteDebugPort && port <= MaxRemoteDebugPort } diff --git a/internal/domain/browser_test.go b/internal/domain/browser_test.go index 942cedb..f70330c 100644 --- a/internal/domain/browser_test.go +++ b/internal/domain/browser_test.go @@ -63,3 +63,20 @@ func TestLaunchSpecNormalizeRejectsInvalidRemoteDebugPort(t *testing.T) { } } } + +func TestNormalizeProxyServerCanonicalizesAndRejectsCredentials(t *testing.T) { + got, err := NormalizeProxyServer(" HTTPS://Proxy.Local:8443 ") + if err != nil || got != "https://proxy.local:8443" { + t.Fatalf("NormalizeProxyServer() = %q, %v", got, err) + } + for _, value := range []string{ + "http://user:secret@127.0.0.1:8080", + "http://127.0.0.1:8080/path", + "ftp://127.0.0.1:21", + "socks5://127.0.0.1:70000", + } { + if _, err := NormalizeProxyServer(value); !errors.Is(err, ErrInvalidLaunchSpec) { + t.Errorf("NormalizeProxyServer(%q) error = %v", value, err) + } + } +} diff --git a/internal/platform/browser/launch.go b/internal/platform/browser/launch.go index 4f165e2..443e573 100644 --- a/internal/platform/browser/launch.go +++ b/internal/platform/browser/launch.go @@ -5,8 +5,6 @@ import ( "errors" "fmt" "io/fs" - "net" - "net/url" "os" "path/filepath" "strconv" @@ -92,10 +90,7 @@ func BuildArgs(spec domain.LaunchSpec) ([]string, error) { if err != nil { return nil, err } - proxy, err := normalizeProxy(normalized.ProxyServer) - if err != nil { - return nil, err - } + proxy := normalized.ProxyServer args := []string{ "--user-data-dir=" + normalized.UserDataDir, "--no-first-run", @@ -128,26 +123,6 @@ func BuildArgs(spec domain.LaunchSpec) ([]string, error) { return args, nil } -func normalizeProxy(value string) (string, error) { - proxy := strings.TrimSpace(value) - if proxy == "" { - return "", nil - } - parsed, err := url.Parse(proxy) - if err != nil || parsed.User != nil || parsed.Hostname() == "" || parsed.Port() == "" || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" { - return "", fmt.Errorf("%w: proxy must be scheme://host:port without credentials", domain.ErrInvalidLaunchSpec) - } - scheme := strings.ToLower(parsed.Scheme) - if scheme != "http" && scheme != "https" && scheme != "socks4" && scheme != "socks5" { - return "", fmt.Errorf("%w: unsupported proxy scheme", domain.ErrInvalidLaunchSpec) - } - port, err := strconv.Atoi(parsed.Port()) - if err != nil || port < 1 || port > 65535 { - return "", fmt.Errorf("%w: proxy port out of range", domain.ErrInvalidLaunchSpec) - } - return scheme + "://" + net.JoinHostPort(strings.ToLower(parsed.Hostname()), strconv.Itoa(port)), nil -} - func validateExtraArg(value string) error { arg := strings.TrimSpace(value) if arg == "" || !strings.HasPrefix(arg, "--") { diff --git a/internal/platform/config/store.go b/internal/platform/config/store.go index 04af41f..aba8fed 100644 --- a/internal/platform/config/store.go +++ b/internal/platform/config/store.go @@ -6,6 +6,8 @@ import ( "fmt" "os" "path/filepath" + "strconv" + "strings" "time" "chub/internal/domain" @@ -25,14 +27,25 @@ type Settings struct { type Instance struct { ID string `json:"id"` Name string `json:"name"` + ProxyID string `json:"proxyId,omitempty"` Launch domain.LaunchSpec `json:"launch"` UpdatedAt time.Time `json:"updatedAt"` } +// ProxyProfile is deliberately limited to a display name and a validated, +// non-authenticated endpoint. Credentials must never enter local config. +type ProxyProfile struct { + ID string `json:"id"` + Name string `json:"name"` + Server string `json:"server"` + UpdatedAt time.Time `json:"updatedAt"` +} + type File struct { - Version int `json:"version"` - Settings Settings `json:"settings"` - Instances []Instance `json:"instances"` + Version int `json:"version"` + Settings Settings `json:"settings"` + Instances []Instance `json:"instances"` + Proxies []ProxyProfile `json:"proxies"` } type Store struct{ path string } @@ -75,6 +88,9 @@ func (s *Store) Load() (File, error) { if result.Settings.RemoteDebugStartPort == 0 { result.Settings.RemoteDebugStartPort = domain.DefaultRemoteDebugPort } + if err := normalizeProxyConfig(&result); err != nil { + return File{}, err + } return result, nil } @@ -94,6 +110,12 @@ func (s *Store) Save(value File) error { if value.Instances == nil { value.Instances = []Instance{} } + if value.Proxies == nil { + value.Proxies = []ProxyProfile{} + } + if err := normalizeProxyConfig(&value); err != nil { + return err + } data, err := json.MarshalIndent(value, "", " ") if err != nil { return fmt.Errorf("encode config: %w", err) @@ -128,6 +150,79 @@ func (s *Store) Save(value File) error { return nil } +func normalizeProxyConfig(value *File) error { + if value == nil { + return errors.New("config is required") + } + byID := make(map[string]ProxyProfile, len(value.Proxies)) + names := 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 == "" { + return errors.New("invalid proxy configuration") + } + 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") + } + server, err := domain.NormalizeProxyServer(profile.Server) + if err != nil || server == "" { + return errors.New("invalid proxy configuration") + } + profile.Server = server + byID[profile.ID] = *profile + names[nameKey] = struct{}{} + } + + legacyByServer := make(map[string]string, len(value.Proxies)) + for _, profile := range value.Proxies { + legacyByServer[profile.Server] = profile.ID + } + for i := range value.Instances { + instance := &value.Instances[i] + instance.ProxyID = strings.TrimSpace(instance.ProxyID) + if instance.ProxyID != "" { + if _, exists := byID[instance.ProxyID]; !exists { + return errors.New("instance refers to an unavailable proxy") + } + instance.Launch.ProxyServer = "" + continue + } + if strings.TrimSpace(instance.Launch.ProxyServer) == "" { + continue + } + server, err := domain.NormalizeProxyServer(instance.Launch.ProxyServer) + if err != nil || server == "" { + return errors.New("invalid proxy configuration") + } + proxyID := legacyByServer[server] + if proxyID == "" { + proxyID = nextLegacyProxyID(byID) + profile := ProxyProfile{ID: proxyID, Name: "导入代理 " + strconv.Itoa(len(value.Proxies)+1), Server: server} + value.Proxies = append(value.Proxies, profile) + byID[proxyID] = profile + legacyByServer[server] = proxyID + } + instance.ProxyID = proxyID + instance.Launch.ProxyServer = "" + } + return nil +} + +func nextLegacyProxyID(existing map[string]ProxyProfile) string { + for index := 1; ; index++ { + candidate := "legacy-proxy-" + strconv.Itoa(index) + if _, exists := existing[candidate]; !exists { + return candidate + } + } +} + func replace(target, temp string) error { backup := target + ".bak" _, statErr := os.Stat(target) diff --git a/internal/platform/config/store_test.go b/internal/platform/config/store_test.go index 5ee48bc..b583e57 100644 --- a/internal/platform/config/store_test.go +++ b/internal/platform/config/store_test.go @@ -55,3 +55,40 @@ func TestStoreMissingFileReturnsDefaults(t *testing.T) { t.Fatalf("unexpected defaults: %#v", got) } } + +func TestStoreRoundTripsReferencedProxyWithoutCredentials(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + store, err := New(path) + if err != nil { + t.Fatal(err) + } + want := File{ + Proxies: []ProxyProfile{{ID: "proxy-sg", Name: "新加坡出口", Server: "HTTPS://Proxy.Local:8443"}}, + Instances: []Instance{{ID: "a", Name: "运营", ProxyID: "proxy-sg", Launch: domain.LaunchSpec{Kind: domain.BrowserChrome, UserDataDir: `C:\profiles\a`}}}, + } + if err := store.Save(want); err != nil { + t.Fatal(err) + } + got, err := store.Load() + if err != nil || len(got.Proxies) != 1 || got.Proxies[0].Server != "https://proxy.local:8443" || got.Instances[0].ProxyID != "proxy-sg" || got.Instances[0].Launch.ProxyServer != "" { + t.Fatalf("proxy round trip = %#v, error = %v", got, err) + } +} + +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 { + t.Fatal(err) + } + store, err := New(path) + if err != nil { + t.Fatal(err) + } + got, err := store.Load() + if err != nil || len(got.Proxies) != 1 || got.Instances[0].ProxyID == "" || got.Instances[0].Launch.ProxyServer != "" { + t.Fatalf("legacy proxy migration = %#v, error = %v", got, err) + } + if err := store.Save(File{Proxies: []ProxyProfile{{ID: "bad", Name: "bad", Server: "http://user:secret@127.0.0.1:8080"}}}); err == nil { + t.Fatal("credential proxy was persisted") + } +} diff --git a/internal/ui/shell.go b/internal/ui/shell.go index 9e0fdb4..c181cbb 100644 --- a/internal/ui/shell.go +++ b/internal/ui/shell.go @@ -58,6 +58,14 @@ type PathSearcher func(context.Context, PathField, string) (string, error) type DirectoryChooser func(context.Context) (string, error) +// ProxyOption is a non-secret proxy item exposed to the UI. Server has already +// been validated as an unauthenticated scheme://host:port endpoint. +type ProxyOption struct { + ID string + Name string + Server string +} + type InstanceStartOutcome struct { PID int RemoteDebugPort int @@ -142,6 +150,18 @@ const ( directoryPickEdit ) +type proxyPickerTarget uint8 + +const ( + proxyPickerCreate proxyPickerTarget = iota + proxyPickerEdit +) + +type proxyPickerState struct { + open bool + target proxyPickerTarget +} + // InstanceRow is the Gio view model for one configured browser instance. // Runtime fields are refreshed independently and are never saved by the edit form. type InstanceRow struct { @@ -150,6 +170,7 @@ type InstanceRow struct { Browser string UserDataDir string TargetURL string + ProxyID string PID int RemoteDebugPort int OccupancySource string @@ -199,6 +220,8 @@ type Shell struct { instanceName widget.Editor instanceDir widget.Editor instanceURL widget.Editor + createProxyPick widget.Clickable + editProxyPick widget.Clickable browserKind widget.Enum editName widget.Editor editDir widget.Editor @@ -216,6 +239,22 @@ type Shell struct { pathFeedback string formFeedback string editFeedback string + proxyName widget.Editor + proxyServer widget.Editor + proxySave widget.Clickable + proxyCancel widget.Clickable + proxyFeedback string + proxies []ProxyOption + createProxyID string + editProxyID string + editingProxyID string + nextProxy uint64 + proxyEditClicks map[string]*widget.Clickable + proxyDeleteClicks map[string]*widget.Clickable + proxyPicker proxyPickerState + proxyPickerBlocker widget.Clickable + proxyPickerNone widget.Clickable + proxyPickerChoices map[string]*widget.Clickable list widget.List rows []InstanceRow @@ -241,6 +280,7 @@ type Shell struct { focusRestoreID string onSave func(SettingsState) onInstancesChanged func([]InstanceRow) + onProxiesChanged func([]ProxyOption) instanceStarter InstanceStarter instanceRefresher InstanceRefresher pathSearcher PathSearcher @@ -263,7 +303,7 @@ func NewShell(theme *material.Theme) *Shell { PathEdgeExecutable: {}, PathDefaultUserData: {}, PathLogDirectory: {}, - }, rowClicks: make(map[string]*widget.Clickable), startClicks: make(map[string]*widget.Clickable), editClicks: make(map[string]*widget.Clickable), deleteClicks: make(map[string]*widget.Clickable), startStates: make(map[string]*instanceStartState), nextInstance: 4, startResults: make(chan instanceStartResult, 8), refreshResults: make(chan instanceRefreshResult, 1), searchResults: make(chan pathSearchResult, 8), directoryResults: make(chan directoryPickResult, 1)} + }, rowClicks: make(map[string]*widget.Clickable), startClicks: make(map[string]*widget.Clickable), editClicks: make(map[string]*widget.Clickable), deleteClicks: make(map[string]*widget.Clickable), proxyEditClicks: make(map[string]*widget.Clickable), proxyDeleteClicks: make(map[string]*widget.Clickable), proxyPickerChoices: make(map[string]*widget.Clickable), startStates: make(map[string]*instanceStartState), nextInstance: 4, startResults: make(chan instanceStartResult, 8), refreshResults: make(chan instanceRefreshResult, 1), searchResults: make(chan pathSearchResult, 8), directoryResults: make(chan directoryPickResult, 1)} s.chromePath.SetText(`C:\Program Files\Google\Chrome\Application\chrome.exe`) s.edgePath.SetText(`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`) s.dataDir.SetText(`C:\Users\Public\chub\profiles`) @@ -290,6 +330,26 @@ func (s *Shell) SetInstances(rows []InstanceRow) { } } +func (s *Shell) SetProxies(options []ProxyOption) { + s.proxies = append([]ProxyOption(nil), options...) + for _, option := range s.proxies { + if option.ID == "" { + continue + } + if strings.HasPrefix(option.ID, "proxy-") { + if value, err := strconv.ParseUint(strings.TrimPrefix(option.ID, "proxy-"), 10, 64); err == nil && value > s.nextProxy { + s.nextProxy = value + } + } + } + if !s.proxyExists(s.createProxyID) { + s.createProxyID = "" + } + if !s.proxyExists(s.editProxyID) { + s.editProxyID = "" + } +} + func (s *Shell) SetSettings(value SettingsState) { s.chromePath.SetText(value.ChromePath) s.edgePath.SetText(value.EdgePath) @@ -307,6 +367,8 @@ func (s *Shell) OnSave(fn func(SettingsState)) { s.onSave = fn } func (s *Shell) OnInstancesChanged(fn func([]InstanceRow)) { s.onInstancesChanged = fn } +func (s *Shell) OnProxiesChanged(fn func([]ProxyOption)) { s.onProxiesChanged = fn } + func (s *Shell) OnStartInstance(starter InstanceStarter, invalidate func()) { s.instanceStarter = starter s.invalidate = invalidate @@ -348,7 +410,7 @@ func (s *Shell) Layout(gtx layout.Context) layout.Dimensions { }), ) } - if s.pendingDeleteID == "" && s.editingID == "" { + if s.pendingDeleteID == "" && s.editingID == "" && !s.proxyPicker.open { return mainLayout(gtx) } if s.pendingDeleteID != "" { @@ -364,9 +426,22 @@ func (s *Shell) Layout(gtx layout.Context) layout.Dimensions { layout.Stacked(s.editDiscardConfirmation), ) } + if s.editingID != "" { + if !s.proxyPicker.open { + return layout.Stack{Alignment: layout.Center}.Layout(gtx, + layout.Expanded(mainLayout), + layout.Stacked(s.editDialog), + ) + } + return layout.Stack{Alignment: layout.Center}.Layout(gtx, + layout.Expanded(mainLayout), + layout.Stacked(s.editDialog), + layout.Stacked(s.proxyPickerDialog), + ) + } return layout.Stack{Alignment: layout.Center}.Layout(gtx, layout.Expanded(mainLayout), - layout.Stacked(s.editDialog), + layout.Stacked(s.proxyPickerDialog), ) } @@ -429,7 +504,9 @@ func (s *Shell) consumeKeyboard(gtx layout.Context) { } switch keyEvent.Name { case key.NameEscape: - if s.pendingEditDiscard { + if s.proxyPicker.open { + s.proxyPicker.open = false + } else if s.pendingEditDiscard { s.pendingEditDiscard = false } else if s.editingID != "" { s.cancelEdit() @@ -502,6 +579,7 @@ func (s *Shell) beginEdit(id string) { s.editDir.SetText(row.UserDataDir) s.editURL.SetText(row.TargetURL) s.editPort.SetText(remoteDebugPortText(row.RemoteDebugPort)) + s.editProxyID = row.ProxyID if strings.EqualFold(row.Browser, "Edge") { s.editBrowserKind.Value = "edge" } else { @@ -525,7 +603,7 @@ func (s *Shell) editIsDirty() bool { if s.editingID == "" { return false } - if strings.TrimSpace(s.editName.Text()) != s.editOriginal.Name || strings.TrimSpace(s.editURL.Text()) != s.editOriginal.TargetURL { + if strings.TrimSpace(s.editName.Text()) != s.editOriginal.Name || strings.TrimSpace(s.editURL.Text()) != s.editOriginal.TargetURL || s.editProxyID != s.editOriginal.ProxyID { return true } if s.editLocked(s.editOriginal) { @@ -573,6 +651,11 @@ func (s *Shell) saveEdit() { } row.Name = name row.TargetURL = targetURL + if !s.proxyExists(s.editProxyID) && s.editProxyID != "" { + s.editFeedback = "所选代理已不可用,请重新选择。" + return + } + row.ProxyID = s.editProxyID if !s.replaceInstanceRow(row) { s.closeEdit("要编辑的实例已不存在。") return @@ -599,6 +682,7 @@ func (s *Shell) closeEdit(feedback string) { s.pendingEditDiscard = false s.editFeedback = "" s.editFocusPending = false + s.proxyPicker.open = false s.focusRestoreID = id s.instanceFeedback = feedback } @@ -858,6 +942,8 @@ func (s *Shell) editDialogCard(row InstanceRow) layout.Widget { layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), layout.Rigid(s.formField("启动 URL(可选)", "仅支持 http/https;留空时启动浏览器默认页", &s.editURL)), layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return s.proxyPickerField(gtx, proxyPickerEdit) }), + layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), layout.Rigid(s.formField("当前调试端口", "运行时由全局起始端口分配,此处只读", &s.editPort)), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(statusLabel(s.theme, row.Status)), @@ -1087,6 +1173,20 @@ func (s *Shell) settings(gtx layout.Context) layout.Dimensions { for s.logSearch.Clicked(gtx) { s.togglePathSearch(PathLogDirectory) } + for s.proxySave.Clicked(gtx) { + s.saveProxy() + } + for s.proxyCancel.Clicked(gtx) { + s.cancelProxyEdit() + } + for _, option := range append([]ProxyOption(nil), s.proxies...) { + for s.proxyEditClickFor(option.ID).Clicked(gtx) { + s.beginProxyEdit(option.ID) + } + for s.proxyDeleteClickFor(option.ID).Clicked(gtx) { + s.deleteProxy(option.ID) + } + } for s.saveClick.Clicked(gtx) { settings, err := s.settingsState() if err != nil { @@ -1119,6 +1219,8 @@ func (s *Shell) settings(gtx layout.Context) layout.Dimensions { layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), layout.Rigid(material.CheckBox(s.theme, &s.closeOnExit, "退出时关闭托管浏览器").Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout), + layout.Rigid(s.proxySettings), + layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Rigid(material.Button(s.theme, &s.saveClick, "保存设置").Layout), @@ -1129,6 +1231,61 @@ func (s *Shell) settings(gtx layout.Context) layout.Dimensions { ) } +func (s *Shell) proxySettings(gtx layout.Context) layout.Dimensions { + saveLabel := "添加代理" + if s.editingProxyID != "" { + saveLabel = "保存代理" + } + children := []layout.FlexChild{ + layout.Rigid(material.H6(s.theme, "代理").Layout), + layout.Rigid(material.Caption(s.theme, "仅保存无认证 scheme://host:port 端点;实例可从选择器复用。").Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(s.formField("代理名称", "用于实例表单显示", &s.proxyName)), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(s.formField("代理地址", "支持 http、https、socks4、socks5;不允许用户名、密码或路径", &s.proxyServer)), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(material.Button(s.theme, &s.proxySave, saveLabel).Layout), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + cancelGtx := gtx + if s.editingProxyID == "" { + cancelGtx = gtx.Disabled() + } + return material.Button(s.theme, &s.proxyCancel, "取消编辑").Layout(cancelGtx) + }), + ) + }), + } + if s.proxyFeedback != "" { + children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(material.Caption(s.theme, s.proxyFeedback).Layout)) + } + if len(s.proxies) == 0 { + children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), layout.Rigid(material.Caption(s.theme, "尚无代理;实例将以无代理方式启动。").Layout)) + } + for _, option := range s.proxies { + option := option + children = append(children, + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(material.Body2(s.theme, option.Name).Layout), + layout.Rigid(material.Caption(s.theme, option.Server).Layout), + ) + }), + layout.Rigid(s.instanceIconButton(s.proxyEditClickFor(option.ID), instanceEditIcon, "编辑代理 "+option.Name, s.theme.Palette.ContrastBg)), + layout.Rigid(layout.Spacer{Width: unit.Dp(4)}.Layout), + layout.Rigid(s.instanceIconButton(s.proxyDeleteClickFor(option.ID), instanceDeleteIcon, "删除代理 "+option.Name, color.NRGBA{R: 188, G: 51, B: 51, A: 255})), + ) + }), + ) + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) +} + func (s *Shell) remoteDebugPortField(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(material.Body2(s.theme, "远程调试起始端口").Layout), @@ -1164,6 +1321,8 @@ func (s *Shell) createInstance(gtx layout.Context) layout.Dimensions { layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), layout.Rigid(s.formField("启动 URL(可选)", "仅支持 http/https;留空时启动浏览器默认页", &s.instanceURL)), layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return s.proxyPickerField(gtx, proxyPickerCreate) }), + layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout), layout.Rigid(material.Body2(s.theme, "浏览器类型").Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, @@ -1196,6 +1355,134 @@ func (s *Shell) formField(label, help string, editor *widget.Editor) layout.Widg } } +func (s *Shell) proxyPickerField(gtx layout.Context, target proxyPickerTarget) layout.Dimensions { + click := &s.createProxyPick + selected := s.createProxyID + if target == proxyPickerEdit { + click = &s.editProxyPick + selected = s.editProxyID + } + for click.Clicked(gtx) { + s.proxyPicker = proxyPickerState{open: true, target: target} + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(material.Body2(s.theme, "代理").Layout), + layout.Rigid(material.Caption(s.theme, "选择设置中保存的无认证代理;留空表示不使用代理").Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(material.Button(s.theme, click, s.proxyLabel(selected)).Layout), + ) +} + +func (s *Shell) proxyLabel(id string) string { + if id == "" { + return "无代理" + } + for _, option := range s.proxies { + if option.ID == id { + return option.Name + } + } + return "代理不可用" +} + +func (s *Shell) proxyExists(id string) bool { + if id == "" { + return true + } + for _, option := range s.proxies { + if option.ID == id { + return true + } + } + return false +} + +func (s *Shell) proxyPickerChoiceFor(id string) *widget.Clickable { + if click := s.proxyPickerChoices[id]; click != nil { + return click + } + click := new(widget.Clickable) + s.proxyPickerChoices[id] = click + return click +} + +func (s *Shell) selectPickerProxy(id string) { + if !s.proxyExists(id) { + return + } + if s.proxyPicker.target == proxyPickerEdit { + s.editProxyID = id + } else { + s.createProxyID = id + } + s.proxyPicker.open = false +} + +func (s *Shell) proxyPickerDialog(gtx layout.Context) layout.Dimensions { + if !s.proxyPicker.open { + return layout.Dimensions{} + } + gtx.Constraints.Min = gtx.Constraints.Max + return s.proxyPickerBlocker.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + defer clip.Rect{Max: gtx.Constraints.Min}.Push(gtx.Ops).Pop() + paint.Fill(gtx.Ops, color.NRGBA{R: 0, G: 0, B: 0, A: 92}) + for s.proxyPickerBlocker.Clicked(gtx) { + s.proxyPicker.open = false + } + for s.proxyPickerNone.Clicked(gtx) { + s.selectPickerProxy("") + } + for _, option := range append([]ProxyOption(nil), s.proxies...) { + for s.proxyPickerChoiceFor(option.ID).Clicked(gtx) { + s.selectPickerProxy(option.ID) + } + } + return layout.Center.Layout(gtx, s.proxyPickerCard) + }) +} + +func (s *Shell) proxyPickerCard(gtx layout.Context) layout.Dimensions { + if maxWidth := gtx.Dp(440); gtx.Constraints.Max.X > maxWidth { + gtx.Constraints.Max.X = maxWidth + } + return layout.Background{}.Layout(gtx, + func(gtx layout.Context) layout.Dimensions { + rect := image.Rectangle{Max: gtx.Constraints.Min} + defer clip.UniformRRect(rect, gtx.Dp(8)).Push(gtx.Ops).Pop() + paint.Fill(gtx.Ops, s.theme.Palette.Bg) + return layout.Dimensions{Size: gtx.Constraints.Min} + }, + func(gtx layout.Context) layout.Dimensions { + return layout.Inset{Top: unit.Dp(20), Right: unit.Dp(24), Bottom: unit.Dp(20), Left: unit.Dp(24)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + children := []layout.FlexChild{ + layout.Rigid(material.H6(s.theme, "选择代理").Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(material.Caption(s.theme, "仅显示和使用无认证代理端点。按 Escape 或点击背景取消。").Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + layout.Rigid(material.Button(s.theme, &s.proxyPickerNone, "无代理").Layout), + } + for _, option := range s.proxies { + option := option + children = append(children, + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + 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 layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(material.Body1(s.theme, option.Name).Layout), + layout.Rigid(material.Caption(s.theme, option.Server).Layout), + ) + }) + }) + }), + ) + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) + }) + }, + ) +} + func (s *Shell) instanceDirField(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(material.Body2(s.theme, "User Data Dir").Layout), @@ -1229,6 +1516,7 @@ func (s *Shell) createInstanceFromForm() { Browser: browserDisplay(s.browserKind.Value), UserDataDir: userDataDir, TargetURL: strings.TrimSpace(s.instanceURL.Text()), + ProxyID: s.createProxyID, Status: "已退出", }) s.page = pageInstances @@ -1375,6 +1663,134 @@ func (s *Shell) deleteClickFor(id string) *widget.Clickable { return click } +func (s *Shell) proxyEditClickFor(id string) *widget.Clickable { + if click := s.proxyEditClicks[id]; click != nil { + return click + } + click := new(widget.Clickable) + s.proxyEditClicks[id] = click + return click +} + +func (s *Shell) proxyDeleteClickFor(id string) *widget.Clickable { + if click := s.proxyDeleteClicks[id]; click != nil { + return click + } + click := new(widget.Clickable) + s.proxyDeleteClicks[id] = click + return click +} + +func (s *Shell) beginProxyEdit(id string) { + for _, option := range s.proxies { + if option.ID != id { + continue + } + s.editingProxyID = id + s.proxyName.SetText(option.Name) + s.proxyServer.SetText(option.Server) + s.proxyFeedback = "" + return + } + s.proxyFeedback = "找不到要编辑的代理。" +} + +func (s *Shell) cancelProxyEdit() { + s.editingProxyID = "" + s.proxyName.SetText("") + s.proxyServer.SetText("") + s.proxyFeedback = "已取消代理编辑。" +} + +func (s *Shell) saveProxy() { + name := strings.TrimSpace(s.proxyName.Text()) + if name == "" { + 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.editingProxyID && strings.EqualFold(option.Name, name) { + s.proxyFeedback = "代理名称已存在,请使用不同名称。" + return + } + } + if s.editingProxyID == "" { + s.nextProxy++ + s.proxies = append(s.proxies, ProxyOption{ID: fmt.Sprintf("proxy-%d", s.nextProxy), Name: name, Server: server}) + s.proxyFeedback = fmt.Sprintf("已添加代理“%s”。", name) + } else { + updated := false + for index := range s.proxies { + if s.proxies[index].ID != s.editingProxyID { + continue + } + s.proxies[index].Name = name + s.proxies[index].Server = server + updated = true + break + } + if !updated { + s.proxyFeedback = "找不到要编辑的代理。" + return + } + s.proxyFeedback = fmt.Sprintf("已保存代理“%s”;后续启动将使用最新端点。", name) + } + s.editingProxyID = "" + s.proxyName.SetText("") + s.proxyServer.SetText("") + s.notifyProxiesChanged() +} + +func (s *Shell) deleteProxy(id string) { + if count := s.proxyUseCount(id); count > 0 { + s.proxyFeedback = fmt.Sprintf("该代理仍被 %d 个实例使用,不能删除。", count) + return + } + for index, option := range s.proxies { + if option.ID != id { + continue + } + s.proxies = append(s.proxies[:index], s.proxies[index+1:]...) + delete(s.proxyEditClicks, id) + delete(s.proxyDeleteClicks, id) + delete(s.proxyPickerChoices, id) + if s.createProxyID == id { + s.createProxyID = "" + } + if s.editProxyID == id { + s.editProxyID = "" + } + if s.editingProxyID == id { + s.cancelProxyEdit() + } + s.proxyFeedback = fmt.Sprintf("已删除代理“%s”。", option.Name) + s.notifyProxiesChanged() + return + } + s.proxyFeedback = "找不到要删除的代理。" +} + +func (s *Shell) proxyUseCount(id string) int { + count := 0 + for _, row := range s.rows { + if row.ProxyID == id { + count++ + } + } + return count +} + +func (s *Shell) notifyProxiesChanged() { + if s.onProxiesChanged != nil { + s.onProxiesChanged(append([]ProxyOption(nil), s.proxies...)) + } +} + func (s *Shell) requestStart(id string) { row, ok := s.instanceRow(id) if !ok { @@ -1535,7 +1951,7 @@ func (s *Shell) consumeRefreshResults() { } func instanceRefreshFingerprint(row InstanceRow) string { - return strings.Join([]string{row.ID, row.Name, row.Browser, row.UserDataDir, row.TargetURL}, "\x00") + return strings.Join([]string{row.ID, row.Name, row.Browser, row.UserDataDir, row.TargetURL, row.ProxyID}, "\x00") } func (s *Shell) requestDelete(id string) { diff --git a/internal/ui/shell_test.go b/internal/ui/shell_test.go index 6333b10..6ed69ef 100644 --- a/internal/ui/shell_test.go +++ b/internal/ui/shell_test.go @@ -3,6 +3,7 @@ package ui import ( "context" "path/filepath" + "strings" "sync/atomic" "testing" "time" @@ -443,3 +444,40 @@ func TestShellRequestsDiscardConfirmationForDirtyEdit(t *testing.T) { t.Fatalf("dirty edit cancellation state = discard %v editing %q", shell.pendingEditDiscard, shell.editingID) } } + +func TestShellCreatesAndEditsInstanceProxySelection(t *testing.T) { + shell := NewShell(material.NewTheme()) + shell.SetProxies([]ProxyOption{{ID: "proxy-sg", Name: "新加坡", Server: "http://127.0.0.1:8080"}}) + shell.instanceName.SetText("新实例") + shell.instanceDir.SetText(t.TempDir()) + shell.createProxyID = "proxy-sg" + shell.createInstanceFromForm() + created := shell.rows[len(shell.rows)-1] + if created.ProxyID != "proxy-sg" { + t.Fatalf("created proxy id = %q", created.ProxyID) + } + shell.beginEdit(created.ID) + shell.editProxyID = "" + shell.saveEdit() + row, ok := shell.instanceRow(created.ID) + if !ok || row.ProxyID != "" { + t.Fatalf("edited proxy row = %#v", row) + } +} + +func TestShellPreventsDeletingReferencedProxy(t *testing.T) { + shell := NewShell(material.NewTheme()) + shell.SetProxies([]ProxyOption{{ID: "proxy-sg", Name: "新加坡", Server: "http://127.0.0.1:8080"}}) + shell.rows[0].ProxyID = "proxy-sg" + changes := 0 + shell.OnProxiesChanged(func([]ProxyOption) { changes++ }) + shell.deleteProxy("proxy-sg") + if len(shell.proxies) != 1 || changes != 0 || !strings.Contains(shell.proxyFeedback, "不能删除") { + t.Fatalf("referenced delete state = proxies %#v, changes %d, feedback %q", shell.proxies, changes, shell.proxyFeedback) + } + shell.rows[0].ProxyID = "" + shell.deleteProxy("proxy-sg") + if len(shell.proxies) != 0 || changes != 1 { + t.Fatalf("unreferenced delete state = proxies %#v, changes %d", shell.proxies, changes) + } +}