feat: add reusable proxy configuration

This commit is contained in:
QiuSW
2026-07-25 17:04:26 +08:00
parent f13e06500c
commit 3a3c92c875
13 changed files with 772 additions and 48 deletions
+94 -1
View File
@@ -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...)
}
+24
View File
@@ -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{