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
+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
}