feat: add reusable proxy configuration
This commit is contained in:
+94
-1
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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)。
|
||||
|
||||
## 当前目录
|
||||
|
||||
|
||||
+4
-4
@@ -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` 均通过。
|
||||
- 阻塞:无。
|
||||
|
||||
+2
-2
@@ -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
|
||||
- 变更:待实现。
|
||||
- 验证:待执行。
|
||||
- 阻塞:无。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, "--") {
|
||||
|
||||
@@ -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"`
|
||||
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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+421
-5
@@ -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,11 +426,24 @@ 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.proxyPickerDialog),
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Shell) consumeControls(gtx layout.Context) {
|
||||
for s.instancesClick.Clicked(gtx) {
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user