feat: add proxy labels and default profiles

This commit is contained in:
QiuSW
2026-07-27 12:09:54 +08:00
parent a8b7277dab
commit 0f25f39480
10 changed files with 397 additions and 115 deletions
+10
View File
@@ -104,6 +104,16 @@ func NormalizeProxyServer(value string) (string, error) {
return scheme + "://" + net.JoinHostPort(strings.ToLower(parsed.Hostname()), strconv.Itoa(port)), nil
}
// NormalizeProxyName returns a non-empty display name for a locally saved
// proxy profile. It deliberately carries no endpoint or credentials semantics.
func NormalizeProxyName(value string) (string, error) {
name := strings.TrimSpace(value)
if name == "" {
return "", fmt.Errorf("%w: proxy name is required", ErrInvalidLaunchSpec)
}
return name, nil
}
func ValidRemoteDebugPort(port int) bool {
return port >= MinRemoteDebugPort && port <= MaxRemoteDebugPort
}
+10
View File
@@ -80,3 +80,13 @@ func TestNormalizeProxyServerCanonicalizesAndRejectsCredentials(t *testing.T) {
}
}
}
func TestNormalizeProxyNameRequiresNonEmptyDisplayText(t *testing.T) {
got, err := NormalizeProxyName(" 新加坡出口 ")
if err != nil || got != "新加坡出口" {
t.Fatalf("NormalizeProxyName() = %q, %v", got, err)
}
if _, err := NormalizeProxyName(" \t "); !errors.Is(err, ErrInvalidLaunchSpec) {
t.Fatalf("empty proxy name error = %v", err)
}
}
+71 -8
View File
@@ -68,6 +68,27 @@ func DefaultPath() (string, error) {
return defaultPathForExecutable(executable)
}
// DefaultInstanceUserDataRoot returns the portable root for new browser
// profiles. It resolves the actual executable location and never uses cwd.
func DefaultInstanceUserDataRoot() (string, error) {
executable, err := os.Executable()
if err != nil {
return "", fmt.Errorf("%w: resolve executable", ErrDefaultConfigUnavailable)
}
return defaultInstanceUserDataRootForExecutable(executable)
}
// DefaultInstanceUserDataDir returns a unique-instance profile path below the
// executable-side user_data_dirs root. It only computes a path; it does not
// create directories or validate their write permissions.
func DefaultInstanceUserDataDir(instanceID string) (string, error) {
root, err := DefaultInstanceUserDataRoot()
if err != nil {
return "", err
}
return defaultInstanceUserDataDir(root, instanceID)
}
// LegacyPath is the pre-portable configuration location. It is read only when
// the executable-side config does not exist yet.
func LegacyPath() (string, error) {
@@ -107,6 +128,23 @@ func defaultPathForExecutable(executable string) (string, error) {
return filepath.Join(filepath.Dir(filepath.Clean(path)), "config.json"), nil
}
func defaultInstanceUserDataRootForExecutable(executable string) (string, error) {
path := strings.TrimSpace(executable)
if path == "" || !filepath.IsAbs(path) {
return "", fmt.Errorf("%w: executable path must be absolute", ErrDefaultConfigUnavailable)
}
return filepath.Join(filepath.Dir(filepath.Clean(path)), "user_data_dirs"), nil
}
func defaultInstanceUserDataDir(root, instanceID string) (string, error) {
root = strings.TrimSpace(root)
instanceID = strings.TrimSpace(instanceID)
if root == "" || !filepath.IsAbs(root) || instanceID == "" || filepath.Base(instanceID) != instanceID || instanceID == "." {
return "", fmt.Errorf("%w: invalid default instance directory", ErrDefaultConfigUnavailable)
}
return filepath.Join(filepath.Clean(root), instanceID), nil
}
func openDefaultAt(target, legacy string) (*Store, error) {
store, targetExists, err := defaultStore(target)
if err != nil || targetExists {
@@ -303,28 +341,35 @@ func normalizeProxyConfig(value *File) error {
return errors.New("config is required")
}
byID := make(map[string]ProxyProfile, len(value.Proxies))
names := make(map[string]struct{}, len(value.Proxies))
names := make([]string, 0, len(value.Proxies))
servers := make(map[string]struct{}, len(value.Proxies))
for i := range value.Proxies {
profile := &value.Proxies[i]
profile.ID = strings.TrimSpace(profile.ID)
profile.Name = strings.TrimSpace(profile.Name)
if profile.ID == "" || profile.Name == "" {
name, err := domain.NormalizeProxyName(profile.Name)
if profile.ID == "" || err != nil {
return errors.New("invalid proxy configuration")
}
profile.Name = name
if _, exists := byID[profile.ID]; exists {
return errors.New("duplicate proxy configuration")
}
nameKey := strings.ToLower(profile.Name)
if _, exists := names[nameKey]; exists {
return errors.New("duplicate proxy configuration")
for _, existing := range names {
if strings.EqualFold(existing, profile.Name) {
return errors.New("duplicate proxy configuration")
}
}
server, err := domain.NormalizeProxyServer(profile.Server)
if err != nil || server == "" {
return errors.New("invalid proxy configuration")
}
if _, exists := servers[server]; exists {
return errors.New("duplicate proxy configuration")
}
profile.Server = server
byID[profile.ID] = *profile
names[nameKey] = struct{}{}
names = append(names, profile.Name)
servers[server] = struct{}{}
}
legacyByServer := make(map[string]string, len(value.Proxies))
@@ -351,10 +396,12 @@ func normalizeProxyConfig(value *File) error {
proxyID := legacyByServer[server]
if proxyID == "" {
proxyID = nextLegacyProxyID(byID)
profile := ProxyProfile{ID: proxyID, Name: "导入代理 " + strconv.Itoa(len(value.Proxies)+1), Server: server}
profile := ProxyProfile{ID: proxyID, Name: nextLegacyProxyName(names), Server: server}
value.Proxies = append(value.Proxies, profile)
byID[proxyID] = profile
legacyByServer[server] = proxyID
names = append(names, profile.Name)
servers[server] = struct{}{}
}
instance.ProxyID = proxyID
instance.Launch.ProxyServer = ""
@@ -371,6 +418,22 @@ func nextLegacyProxyID(existing map[string]ProxyProfile) string {
}
}
func nextLegacyProxyName(existing []string) string {
for index := 1; ; index++ {
candidate := "导入代理 " + strconv.Itoa(index)
duplicate := false
for _, name := range existing {
if strings.EqualFold(name, candidate) {
duplicate = true
break
}
}
if !duplicate {
return candidate
}
}
}
func replace(target, temp string) error {
backup := target + ".bak"
_, statErr := os.Stat(target)
+39
View File
@@ -112,6 +112,28 @@ func TestDefaultPathForExecutableUsesExecutableDirectory(t *testing.T) {
}
}
func TestDefaultInstanceUserDataDirectoryUsesExecutableDirectoryAndStableID(t *testing.T) {
executable := filepath.Join(t.TempDir(), "published", "chub.exe")
root, err := defaultInstanceUserDataRootForExecutable(executable)
if err != nil || root != filepath.Join(filepath.Dir(executable), "user_data_dirs") {
t.Fatalf("defaultInstanceUserDataRootForExecutable() = %q, %v", root, err)
}
first, err := defaultInstanceUserDataDir(root, "instance-one")
if err != nil || first != filepath.Join(root, "instance-one") {
t.Fatalf("defaultInstanceUserDataDir() = %q, %v", first, err)
}
second, err := defaultInstanceUserDataDir(root, "instance-two")
if err != nil || second == first {
t.Fatalf("distinct instance directory = %q, %v", second, err)
}
if _, err := defaultInstanceUserDataRootForExecutable("chub.exe"); !errors.Is(err, ErrDefaultConfigUnavailable) {
t.Fatalf("relative executable root error = %v", err)
}
if _, err := defaultInstanceUserDataDir(root, `..\other`); !errors.Is(err, ErrDefaultConfigUnavailable) {
t.Fatalf("unsafe instance id error = %v", err)
}
}
func TestOpenDefaultAtMigratesValidLegacyWithoutRemovingIt(t *testing.T) {
directory := t.TempDir()
target := filepath.Join(directory, "portable", "config.json")
@@ -249,6 +271,23 @@ func TestStoreRoundTripsReferencedProxyWithoutCredentials(t *testing.T) {
}
}
func TestStoreRejectsBlankOrDuplicateProxyNamesAndServers(t *testing.T) {
store, err := New(filepath.Join(t.TempDir(), "config.json"))
if err != nil {
t.Fatal(err)
}
for _, proxies := range [][]ProxyProfile{
{{ID: "blank", Name: " ", Server: "http://127.0.0.1:8080"}},
{{ID: "one", Name: "新加坡", Server: "http://127.0.0.1:8080"}, {ID: "two", Name: "新加坡", Server: "http://127.0.0.1:8081"}},
{{ID: "one", Name: "Singapore", Server: "http://127.0.0.1:8080"}, {ID: "two", Name: "sINGAPORE", Server: "http://127.0.0.1:8081"}},
{{ID: "one", Name: "新加坡", Server: "http://127.0.0.1:8080"}, {ID: "two", Name: "东京", Server: "HTTP://127.0.0.1:8080"}},
} {
if err := store.Save(File{Proxies: proxies}); err == nil {
t.Fatalf("unsafe proxy profiles persisted: %#v", proxies)
}
}
}
func TestStoreMigratesLegacyProxyAndRejectsUnsafeConfig(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"version":1,"settings":{},"instances":[{"id":"a","name":"运营","launch":{"Kind":"chrome","UserDataDir":"C:\\profiles\\a","ProxyServer":"http://127.0.0.1:8080"}}]}`), 0o600); err != nil {
+181 -84
View File
@@ -33,11 +33,12 @@ const (
)
const (
instanceNameColumnWeight = 20
instanceBrowserColumnWeight = 10
instanceDirectoryColumnWeight = 29
instancePortColumnWeight = 10
instanceStatusColumnWeight = 13
instanceNameColumnWeight = 17
instanceBrowserColumnWeight = 9
instanceDirectoryColumnWeight = 28
instanceProxyColumnWeight = 9
instancePortColumnWeight = 9
instanceStatusColumnWeight = 10
instanceActionColumnWeight = 18
)
@@ -314,6 +315,7 @@ type Shell struct {
formFeedback string
editFeedback string
proxyServer widget.Editor
proxyName widget.Editor
proxySave widget.Clickable
proxyDelete widget.Clickable
proxyFeedback string
@@ -332,69 +334,71 @@ type Shell struct {
pendingProxyDelete string
proxyDeleteFocus bool
list widget.List
settingsList widget.List
tabsList widget.List
rows []InstanceRow
selectedInstanceID string
rowClicks map[string]*widget.Clickable
startClicks map[string]*widget.Clickable
editClicks map[string]*widget.Clickable
deleteClicks map[string]*widget.Clickable
deleteConfirm widget.Clickable
deleteCancel widget.Clickable
deleteBlocker widget.Clickable
startStates map[string]*instanceStartState
startResults chan instanceStartResult
stopStates map[string]*instanceStopState
stopResults chan instanceStopResult
refreshClickState instanceRefreshState
refreshResults chan instanceRefreshResult
tabsResults chan instanceTabsResult
managedExitMu sync.Mutex
managedExitResults []ManagedInstanceExit
pendingManagedExit map[managedExitKey]ManagedInstanceExit
unexpectedExits []unexpectedExitNotice
unexpectedExitOpen bool
unexpectedExitAck widget.Clickable
unexpectedExitBlocker widget.Clickable
unexpectedExitFocus bool
unexpectedExitFocusID string
nextInstance uint64
instanceFeedback string
pendingDeleteID string
editingID string
editOriginal InstanceRow
pendingEditDiscard bool
editFocusPending bool
focusRestoreID string
focusStartID string
tabsOpen bool
tabsLoading bool
tabsRequest uint64
tabsInstanceID string
tabsFingerprint string
tabsTargets []CDPTarget
tabsFeedback string
tabsCancel context.CancelFunc
tabsClose widget.Clickable
tabsBlocker widget.Clickable
tabsFocusPending bool
tabsReturnFocus bool
onSave func(SettingsState)
onInstancesChanged func([]InstanceRow)
onProxiesChanged func([]ProxyOption)
instanceStarter InstanceStarter
instanceStopper InstanceStopper
instanceRefresher InstanceRefresher
instanceTabInspector InstanceTabInspector
pathSearcher PathSearcher
invalidate func()
searches map[PathField]*pathSearchState
searchResults chan pathSearchResult
directoryChooser DirectoryChooser
directoryPick directoryPickState
directoryResults chan directoryPickResult
list widget.List
settingsList widget.List
tabsList widget.List
rows []InstanceRow
selectedInstanceID string
rowClicks map[string]*widget.Clickable
startClicks map[string]*widget.Clickable
editClicks map[string]*widget.Clickable
deleteClicks map[string]*widget.Clickable
deleteConfirm widget.Clickable
deleteCancel widget.Clickable
deleteBlocker widget.Clickable
startStates map[string]*instanceStartState
startResults chan instanceStartResult
stopStates map[string]*instanceStopState
stopResults chan instanceStopResult
refreshClickState instanceRefreshState
refreshResults chan instanceRefreshResult
tabsResults chan instanceTabsResult
managedExitMu sync.Mutex
managedExitResults []ManagedInstanceExit
pendingManagedExit map[managedExitKey]ManagedInstanceExit
unexpectedExits []unexpectedExitNotice
unexpectedExitOpen bool
unexpectedExitAck widget.Clickable
unexpectedExitBlocker widget.Clickable
unexpectedExitFocus bool
unexpectedExitFocusID string
nextInstance uint64
pendingCreateID string
defaultInstanceDirRoot string
instanceFeedback string
pendingDeleteID string
editingID string
editOriginal InstanceRow
pendingEditDiscard bool
editFocusPending bool
focusRestoreID string
focusStartID string
tabsOpen bool
tabsLoading bool
tabsRequest uint64
tabsInstanceID string
tabsFingerprint string
tabsTargets []CDPTarget
tabsFeedback string
tabsCancel context.CancelFunc
tabsClose widget.Clickable
tabsBlocker widget.Clickable
tabsFocusPending bool
tabsReturnFocus bool
onSave func(SettingsState)
onInstancesChanged func([]InstanceRow)
onProxiesChanged func([]ProxyOption)
instanceStarter InstanceStarter
instanceStopper InstanceStopper
instanceRefresher InstanceRefresher
instanceTabInspector InstanceTabInspector
pathSearcher PathSearcher
invalidate func()
searches map[PathField]*pathSearchState
searchResults chan pathSearchResult
directoryChooser DirectoryChooser
directoryPick directoryPickState
directoryResults chan directoryPickResult
}
func NewShell(theme *material.Theme) *Shell {
@@ -459,10 +463,23 @@ func (s *Shell) SetProxies(options []ProxyOption) {
}
if !s.proxyExists(s.settingsProxyID) {
s.settingsProxyID = ""
s.proxyName.SetText("")
s.proxyServer.SetText("")
}
}
// SetDefaultInstanceUserDataRoot supplies the already resolved executable-side
// profile root. It is deliberately a string-only UI boundary: no filesystem
// lookup, creation, or permission check happens in the Gio shell.
func (s *Shell) SetDefaultInstanceUserDataRoot(root string) {
root = strings.TrimSpace(root)
if root == "" || !filepath.IsAbs(root) {
s.defaultInstanceDirRoot = ""
return
}
s.defaultInstanceDirRoot = filepath.Clean(root)
}
func (s *Shell) SetSettings(value SettingsState) {
s.chromePath.SetText(value.ChromePath)
s.edgePath.SetText(value.EdgePath)
@@ -662,6 +679,17 @@ func (s *Shell) consumeControls(gtx layout.Context) {
func (s *Shell) beginCreate() {
s.page = pageCreate
s.formFeedback = ""
s.pendingCreateID = s.allocateInstanceID()
s.instanceName.SetText("")
s.instanceURL.SetText("")
s.createProxyID = ""
s.browserKind.Value = "chrome"
if defaultDir := s.defaultCreateInstanceDir(s.pendingCreateID); defaultDir != "" {
s.instanceDir.SetText(defaultDir)
} else {
s.instanceDir.SetText("")
s.formFeedback = "无法确定程序目录中的默认 User Data Dir;请选择或输入一个绝对路径。"
}
s.instancePort.SetText(strconv.Itoa(s.recommendRemoteDebugPort("")))
}
@@ -1016,6 +1044,7 @@ func (s *Shell) instanceListRow(row InstanceRow) layout.Widget {
layout.Flexed(instanceNameColumnWeight, material.Body1(s.theme, row.Name).Layout),
layout.Flexed(instanceBrowserColumnWeight, material.Body2(s.theme, row.Browser).Layout),
layout.Flexed(instanceDirectoryColumnWeight, pathCell(s.theme, row.UserDataDir)),
layout.Flexed(instanceProxyColumnWeight, proxyNameCell(s.theme, s.proxyDisplayName(row.ProxyID))),
layout.Flexed(instancePortColumnWeight, remoteDebugPortCell(s.theme, row)),
layout.Flexed(instanceStatusColumnWeight, statusLabel(s.theme, row.Status)),
)
@@ -1049,6 +1078,7 @@ func (s *Shell) instanceHeader(gtx layout.Context) layout.Dimensions {
layout.Flexed(instanceNameColumnWeight, material.Caption(s.theme, "实例名称").Layout),
layout.Flexed(instanceBrowserColumnWeight, material.Caption(s.theme, "浏览器类型").Layout),
layout.Flexed(instanceDirectoryColumnWeight, material.Caption(s.theme, "用户数据目录").Layout),
layout.Flexed(instanceProxyColumnWeight, material.Caption(s.theme, "代理").Layout),
layout.Flexed(instancePortColumnWeight, material.Caption(s.theme, "调试端口").Layout),
layout.Flexed(instanceStatusColumnWeight, material.Caption(s.theme, "状态").Layout),
layout.Flexed(instanceActionColumnWeight, func(gtx layout.Context) layout.Dimensions {
@@ -1072,6 +1102,12 @@ func remoteDebugPortCell(theme *material.Theme, row InstanceRow) layout.Widget {
return style.Layout
}
func proxyNameCell(theme *material.Theme, name string) layout.Widget {
style := material.Body2(theme, name)
style.MaxLines = 1
return style.Layout
}
func actualRemoteDebugPortText(port int) string {
if port > 0 {
return strconv.Itoa(port)
@@ -1965,6 +2001,8 @@ func (s *Shell) proxySettings(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(s.settingsField(func(gtx layout.Context) layout.Dimensions { return s.proxyPickerField(gtx, proxyPickerSettings) })),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(s.settingsField(s.formField("代理名称", "用于实例列表和选择器识别;名称必须唯一", &s.proxyName))),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(s.settingsField(s.formField("代理地址", "支持 http、https、socks4、socks5;不允许用户名、密码或路径", &s.proxyServer))),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(s.settingsActionBar(func(gtx layout.Context) layout.Dimensions {
@@ -2124,7 +2162,7 @@ func (s *Shell) proxyPickerField(gtx layout.Context, target proxyPickerTarget) l
click = &s.settingsProxyPick
selected = s.settingsProxyID
label = "已保存代理"
help = "选择项会回填代理地址;选择新建会清空输入框"
help = "选择项会回填代理名称和地址;选择新建会清空输入框"
}
for click.Clicked(gtx) {
s.proxyPicker = proxyPickerState{open: true, target: target}
@@ -2138,17 +2176,26 @@ func (s *Shell) proxyPickerField(gtx layout.Context, target proxyPickerTarget) l
}
func (s *Shell) proxyLabel(id string) string {
if option, exists := s.proxyOption(id); exists {
return proxyPickerLabel(option)
}
return s.proxyDisplayName(id)
}
func (s *Shell) proxyDisplayName(id string) string {
if id == "" {
return "无代理"
}
for _, option := range s.proxies {
if option.ID == id {
return option.Server
}
if option, exists := s.proxyOption(id); exists {
return option.Name
}
return "代理不可用"
}
func proxyPickerLabel(option ProxyOption) string {
return option.Name + " · " + option.Server
}
func (s *Shell) proxyExists(id string) bool {
if _, exists := s.proxyOption(id); exists {
return true
@@ -2186,10 +2233,12 @@ func (s *Shell) selectPickerProxy(id string) {
} else if s.proxyPicker.target == proxyPickerSettings {
s.settingsProxyID = id
if id == "" {
s.proxyName.SetText("")
s.proxyServer.SetText("")
} else {
for _, option := range s.proxies {
if option.ID == id {
s.proxyName.SetText(option.Name)
s.proxyServer.SetText(option.Server)
break
}
@@ -2252,7 +2301,14 @@ func (s *Shell) proxyPickerCard(gtx layout.Context) layout.Dimensions {
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return s.proxyPickerChoiceFor(option.ID).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Inset{Top: unit.Dp(7), Bottom: unit.Dp(7), Left: unit.Dp(10), Right: unit.Dp(10)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return material.Body1(s.theme, option.Server).Layout(gtx)
name := material.Body1(s.theme, option.Name)
name.MaxLines = 1
server := material.Caption(s.theme, option.Server)
server.MaxLines = 1
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(name.Layout),
layout.Rigid(server.Layout),
)
})
})
}),
@@ -2300,8 +2356,8 @@ func (s *Shell) createInstanceFromForm() {
return
}
userDataDir := strings.TrimSpace(s.instanceDir.Text())
if userDataDir == "" {
s.formFeedback = "请选择或输入 User Data Dir 后再创建。"
if userDataDir == "" || !filepath.IsAbs(userDataDir) {
s.formFeedback = "请选择或输入绝对路径的 User Data Dir 后再创建。"
return
}
preferredPort, err := normalizePreferredRemoteDebugPort(s.instancePort.Text())
@@ -2313,12 +2369,15 @@ func (s *Shell) createInstanceFromForm() {
s.formFeedback = fmt.Sprintf("端口 %d 已被其他 Chub 实例预留或使用。", preferredPort)
return
}
s.nextInstance++
instanceID := s.pendingCreateID
if instanceID == "" || s.instanceIDExists(instanceID) {
instanceID = s.allocateInstanceID()
}
s.rows = append(s.rows, InstanceRow{
ID: fmt.Sprintf("instance-%d", s.nextInstance),
ID: instanceID,
Name: name,
Browser: browserDisplay(s.browserKind.Value),
UserDataDir: userDataDir,
UserDataDir: filepath.Clean(userDataDir),
TargetURL: strings.TrimSpace(s.instanceURL.Text()),
ProxyID: s.createProxyID,
PreferredRemoteDebugPort: preferredPort,
@@ -2326,9 +2385,36 @@ func (s *Shell) createInstanceFromForm() {
})
s.page = pageInstances
s.formFeedback = ""
s.pendingCreateID = ""
s.notifyInstancesChanged()
}
func (s *Shell) defaultCreateInstanceDir(instanceID string) string {
if s.defaultInstanceDirRoot == "" || instanceID == "" {
return ""
}
return filepath.Join(s.defaultInstanceDirRoot, instanceID)
}
func (s *Shell) allocateInstanceID() string {
for {
s.nextInstance++
candidate := fmt.Sprintf("instance-%d-%d", time.Now().UnixNano(), s.nextInstance)
if !s.instanceIDExists(candidate) && candidate != s.pendingCreateID {
return candidate
}
}
}
func (s *Shell) instanceIDExists(id string) bool {
for _, row := range s.rows {
if row.ID == id {
return true
}
}
return false
}
func (s *Shell) togglePathSearch(field PathField) {
state := s.searches[field]
if state == nil {
@@ -2469,12 +2555,21 @@ func (s *Shell) deleteClickFor(id string) *widget.Clickable {
}
func (s *Shell) saveProxy() {
name, err := domain.NormalizeProxyName(s.proxyName.Text())
if err != nil {
s.proxyFeedback = "请输入代理名称。"
return
}
server, err := domain.NormalizeProxyServer(s.proxyServer.Text())
if err != nil || server == "" {
s.proxyFeedback = "代理地址必须是无认证的 scheme://host:port。"
return
}
for _, option := range s.proxies {
if option.ID != s.settingsProxyID && strings.EqualFold(option.Name, name) {
s.proxyFeedback = "该代理名称已存在,请使用其他名称。"
return
}
if option.ID != s.settingsProxyID && option.Server == server {
s.proxyFeedback = "该代理地址已存在,请从下拉框选择它。"
return
@@ -2483,15 +2578,15 @@ func (s *Shell) saveProxy() {
if s.settingsProxyID == "" {
s.nextProxy++
s.settingsProxyID = fmt.Sprintf("proxy-%d", s.nextProxy)
s.proxies = append(s.proxies, ProxyOption{ID: s.settingsProxyID, Name: server, Server: server})
s.proxyFeedback = fmt.Sprintf("已添加代理“%s”;后续启动将使用该地址。", server)
s.proxies = append(s.proxies, ProxyOption{ID: s.settingsProxyID, Name: name, Server: server})
s.proxyFeedback = fmt.Sprintf("已添加代理“%s”;后续启动将使用该地址。", name)
} else {
updated := false
for index := range s.proxies {
if s.proxies[index].ID != s.settingsProxyID {
continue
}
s.proxies[index].Name = server
s.proxies[index].Name = name
s.proxies[index].Server = server
updated = true
break
@@ -2500,8 +2595,9 @@ func (s *Shell) saveProxy() {
s.proxyFeedback = "所选代理已不可用,请重新选择或新建。"
return
}
s.proxyFeedback = fmt.Sprintf("已保存代理“%s”;后续启动将使用最新端点。", server)
s.proxyFeedback = fmt.Sprintf("已保存代理“%s”;后续启动将使用最新端点。", name)
}
s.proxyName.SetText(name)
s.proxyServer.SetText(server)
s.notifyProxiesChanged()
}
@@ -2552,9 +2648,10 @@ func (s *Shell) confirmProxyDelete() {
}
if s.settingsProxyID == id {
s.settingsProxyID = ""
s.proxyName.SetText("")
s.proxyServer.SetText("")
}
s.proxyFeedback = fmt.Sprintf("已删除代理“%s”。", option.Server)
s.proxyFeedback = fmt.Sprintf("已删除代理“%s”。", option.Name)
s.notifyProxiesChanged()
return
}
+67 -13
View File
@@ -68,13 +68,16 @@ func TestShellInstanceRowsContainRequiredColumnsAndIndependentActionControls(t *
}
func TestInstanceListColumnWeightsPrioritizeDirectoryAndCompactAction(t *testing.T) {
got := instanceNameColumnWeight + instanceBrowserColumnWeight + instanceDirectoryColumnWeight + instancePortColumnWeight + instanceStatusColumnWeight + instanceActionColumnWeight
got := instanceNameColumnWeight + instanceBrowserColumnWeight + instanceDirectoryColumnWeight + instanceProxyColumnWeight + instancePortColumnWeight + instanceStatusColumnWeight + instanceActionColumnWeight
if got != 100 {
t.Fatalf("instance column weights = %d, want 100", got)
}
if instanceDirectoryColumnWeight <= instanceNameColumnWeight {
t.Fatal("user data directory must have the widest instance-list column")
}
if instanceProxyColumnWeight >= instanceDirectoryColumnWeight {
t.Fatal("proxy name must remain a compact display-only column")
}
if instanceActionColumnWeight <= instanceStatusColumnWeight {
t.Fatal("actions column must fit both compact icon commands")
}
@@ -716,8 +719,11 @@ func TestShellRequestsDiscardConfirmationForDirtyEdit(t *testing.T) {
func TestShellCreatesAndEditsInstanceProxySelection(t *testing.T) {
shell := NewShell(material.NewTheme())
shell.SetProxies([]ProxyOption{{ID: "proxy-sg", Name: "新加坡", Server: "http://127.0.0.1:8080"}})
if label := shell.proxyLabel("proxy-sg"); label != "http://127.0.0.1:8080" {
t.Fatalf("proxy label = %q, want complete proxy address", label)
if label := shell.proxyLabel("proxy-sg"); label != "新加坡 · http://127.0.0.1:8080" {
t.Fatalf("proxy label = %q, want name and complete proxy address", label)
}
if display := shell.proxyDisplayName("proxy-sg"); display != "新加坡" {
t.Fatalf("proxy display name = %q", display)
}
shell.instanceName.SetText("新实例")
shell.instanceDir.SetText(t.TempDir())
@@ -741,6 +747,7 @@ func TestShellPreventsDeletingReferencedProxy(t *testing.T) {
shell.SetProxies([]ProxyOption{{ID: "proxy-sg", Name: "新加坡", Server: "http://127.0.0.1:8080"}})
shell.rows[0].ProxyID = "proxy-sg"
shell.settingsProxyID = "proxy-sg"
shell.proxyName.SetText("新加坡")
shell.proxyServer.SetText("http://127.0.0.1:8080")
changes := 0
shell.OnProxiesChanged(func([]ProxyOption) { changes++ })
@@ -754,13 +761,13 @@ func TestShellPreventsDeletingReferencedProxy(t *testing.T) {
t.Fatalf("pending proxy deletion = %q", shell.pendingProxyDelete)
}
shell.cancelProxyDelete()
if shell.pendingProxyDelete != "" || len(shell.proxies) != 1 || changes != 0 || shell.settingsProxyID != "proxy-sg" || shell.proxyServer.Text() != "http://127.0.0.1:8080" {
t.Fatalf("cancelled delete state = proxies %#v, changes %d, selection %q, server %q", shell.proxies, changes, shell.settingsProxyID, shell.proxyServer.Text())
if shell.pendingProxyDelete != "" || len(shell.proxies) != 1 || changes != 0 || shell.settingsProxyID != "proxy-sg" || shell.proxyName.Text() != "新加坡" || shell.proxyServer.Text() != "http://127.0.0.1:8080" {
t.Fatalf("cancelled delete state = proxies %#v, changes %d, selection %q, name %q, server %q", shell.proxies, changes, shell.settingsProxyID, shell.proxyName.Text(), shell.proxyServer.Text())
}
shell.requestProxyDelete()
shell.confirmProxyDelete()
if len(shell.proxies) != 0 || changes != 1 || shell.settingsProxyID != "" || shell.proxyServer.Text() != "" {
t.Fatalf("unreferenced delete state = proxies %#v, changes %d, selection %q, server %q", shell.proxies, changes, shell.settingsProxyID, shell.proxyServer.Text())
if len(shell.proxies) != 0 || changes != 1 || shell.settingsProxyID != "" || shell.proxyName.Text() != "" || shell.proxyServer.Text() != "" {
t.Fatalf("unreferenced delete state = proxies %#v, changes %d, selection %q, name %q, server %q", shell.proxies, changes, shell.settingsProxyID, shell.proxyName.Text(), shell.proxyServer.Text())
}
}
@@ -772,29 +779,76 @@ func TestShellSavesProxyFromAddressPicker(t *testing.T) {
shell.proxyPicker.target = proxyPickerSettings
shell.selectPickerProxy("proxy-sg")
if shell.settingsProxyID != "proxy-sg" || shell.proxyServer.Text() != "http://127.0.0.1:8080" {
t.Fatalf("selected proxy = %q, server %q", shell.settingsProxyID, shell.proxyServer.Text())
if shell.settingsProxyID != "proxy-sg" || shell.proxyName.Text() != "旧名称" || shell.proxyServer.Text() != "http://127.0.0.1:8080" {
t.Fatalf("selected proxy = %q, name %q, server %q", shell.settingsProxyID, shell.proxyName.Text(), shell.proxyServer.Text())
}
shell.proxyName.SetText("新加坡出口")
shell.proxyServer.SetText("http://127.0.0.1:8081")
shell.saveProxy()
if len(shell.proxies) != 1 || shell.proxies[0].ID != "proxy-sg" || shell.proxies[0].Name != "http://127.0.0.1:8081" || shell.proxies[0].Server != "http://127.0.0.1:8081" || changes != 1 {
if len(shell.proxies) != 1 || shell.proxies[0].ID != "proxy-sg" || shell.proxies[0].Name != "新加坡出口" || shell.proxies[0].Server != "http://127.0.0.1:8081" || changes != 1 {
t.Fatalf("updated proxy = %#v, changes %d", shell.proxies, changes)
}
if display := shell.proxyDisplayName("proxy-sg"); display != "新加坡出口" {
t.Fatalf("updated proxy display name = %q", display)
}
shell.proxyPicker.target = proxyPickerSettings
shell.selectPickerProxy("")
if shell.settingsProxyID != "" || shell.proxyServer.Text() != "" {
t.Fatalf("new proxy state = id %q, server %q", shell.settingsProxyID, shell.proxyServer.Text())
if shell.settingsProxyID != "" || shell.proxyName.Text() != "" || shell.proxyServer.Text() != "" {
t.Fatalf("new proxy state = id %q, name %q, server %q", shell.settingsProxyID, shell.proxyName.Text(), shell.proxyServer.Text())
}
shell.proxyName.SetText("东京出口")
shell.proxyServer.SetText("http://127.0.0.1:8082")
shell.saveProxy()
if len(shell.proxies) != 2 || shell.settingsProxyID == "" || shell.proxies[1].Name != "http://127.0.0.1:8082" || shell.proxies[1].Server != "http://127.0.0.1:8082" || changes != 2 {
if len(shell.proxies) != 2 || shell.settingsProxyID == "" || shell.proxies[1].Name != "东京出口" || shell.proxies[1].Server != "http://127.0.0.1:8082" || changes != 2 {
t.Fatalf("created proxy = %#v, selected %q, changes %d", shell.proxies, shell.settingsProxyID, changes)
}
}
func TestShellRejectsDuplicateProxyNameWithoutReplacingSelectedProfile(t *testing.T) {
shell := NewShell(material.NewTheme())
shell.SetProxies([]ProxyOption{
{ID: "proxy-sg", Name: "新加坡出口", Server: "http://127.0.0.1:8080"},
{ID: "proxy-jp", Name: "东京出口", Server: "http://127.0.0.1:8081"},
})
shell.proxyPicker.target = proxyPickerSettings
shell.selectPickerProxy("proxy-jp")
shell.proxyName.SetText(" 新加坡出口 ")
shell.proxyServer.SetText("http://127.0.0.1:8082")
shell.saveProxy()
if !strings.Contains(shell.proxyFeedback, "名称已存在") || shell.proxies[1].Name != "东京出口" || shell.proxies[1].Server != "http://127.0.0.1:8081" {
t.Fatalf("duplicate name state = proxies %#v, feedback %q", shell.proxies, shell.proxyFeedback)
}
}
func TestShellUsesDistinctDefaultDirectoriesForNewInstances(t *testing.T) {
shell := NewShell(material.NewTheme())
root := filepath.Join(t.TempDir(), "user_data_dirs")
shell.SetDefaultInstanceUserDataRoot(root)
shell.beginCreate()
firstID := shell.pendingCreateID
firstDir := shell.instanceDir.Text()
if firstID == "" || firstDir != filepath.Join(root, firstID) {
t.Fatalf("first default directory = %q for id %q", firstDir, firstID)
}
shell.instanceName.SetText("第一个实例")
shell.createInstanceFromForm()
created := shell.rows[len(shell.rows)-1]
if created.ID != firstID || created.UserDataDir != firstDir {
t.Fatalf("created default instance = %#v", created)
}
shell.beginCreate()
secondID := shell.pendingCreateID
secondDir := shell.instanceDir.Text()
if secondID == "" || secondID == firstID || secondDir != filepath.Join(root, secondID) || secondDir == firstDir {
t.Fatalf("second default directory = %q for id %q, first %q", secondDir, secondID, firstDir)
}
}
func TestShellMarksUnexpectedManagedExitAndRestoresStartFocus(t *testing.T) {
shell := NewShell(material.NewTheme())
target := shell.rows[0]