feat: add preferred debug port planning

This commit is contained in:
QiuSW
2026-07-27 09:29:10 +08:00
parent 3f382a431d
commit 79630e0d48
10 changed files with 518 additions and 74 deletions
+226 -37
View File
@@ -205,27 +205,30 @@ type proxyPickerState struct {
}
// InstanceRow is the Gio view model for one configured browser instance.
// Runtime fields are refreshed independently and are never saved by the edit form.
// PreferredRemoteDebugPort is saved configuration; runtime fields are refreshed
// independently and are never saved by the edit form.
type InstanceRow struct {
ID string
Name string
Browser string
UserDataDir string
TargetURL string
ProxyID string
PID int
RemoteDebugPort int
OccupancySource string
Status string
ID string
Name string
Browser string
UserDataDir string
TargetURL string
ProxyID string
PreferredRemoteDebugPort int
PID int
RemoteDebugPort int
OccupancySource string
Status string
}
type SettingsState struct {
ChromePath string
EdgePath string
DefaultDir string
LogDir string
RemoteDebugStartPort int
CloseOnExit bool
ChromePath string
EdgePath string
DefaultDir string
LogDir string
RemoteDebugStartPort int
ReservedRemoteDebugPorts []int
CloseOnExit bool
}
// Shell owns every interactive Gio widget. Keeping this state outside Layout
@@ -262,6 +265,7 @@ type Shell struct {
instanceName widget.Editor
instanceDir widget.Editor
instanceURL widget.Editor
instancePort widget.Editor
createProxyPick widget.Clickable
editProxyPick widget.Clickable
settingsProxyPick widget.Clickable
@@ -269,6 +273,7 @@ type Shell struct {
editName widget.Editor
editDir widget.Editor
editURL widget.Editor
editPreferredPort widget.Editor
editPort widget.Editor
editBrowserKind widget.Enum
editDirPick widget.Clickable
@@ -353,10 +358,10 @@ type Shell struct {
func NewShell(theme *material.Theme) *Shell {
s := &Shell{theme: theme, rows: []InstanceRow{
{ID: "demo-operations", Name: "运营主账号", Browser: "Chrome", UserDataDir: `C:\Users\Public\chub\profiles\operations`, PID: 18420, RemoteDebugPort: 9666, Status: "运行中"},
{ID: "demo-ads", Name: "广告投放", Browser: "Edge", UserDataDir: `C:\Users\Public\chub\profiles\ads`, Status: "启动中"},
{ID: "demo-assets", Name: "素材采集", Browser: "Chrome", UserDataDir: `C:\Users\Public\chub\profiles\assets`, PID: 16108, RemoteDebugPort: 9668, OccupancySource: "browser_message_window", Status: "外部已关联"},
{ID: "demo-backup", Name: "备用环境", Browser: "Edge", UserDataDir: `C:\Users\Public\chub\profiles\backup`, Status: "已退出"},
{ID: "demo-operations", Name: "运营主账号", Browser: "Chrome", UserDataDir: `C:\Users\Public\chub\profiles\operations`, PreferredRemoteDebugPort: 9666, PID: 18420, RemoteDebugPort: 9666, Status: "运行中"},
{ID: "demo-ads", Name: "广告投放", Browser: "Edge", UserDataDir: `C:\Users\Public\chub\profiles\ads`, PreferredRemoteDebugPort: 9667, Status: "启动中"},
{ID: "demo-assets", Name: "素材采集", Browser: "Chrome", UserDataDir: `C:\Users\Public\chub\profiles\assets`, PreferredRemoteDebugPort: 9668, PID: 16108, RemoteDebugPort: 9668, OccupancySource: "browser_message_window", Status: "外部已关联"},
{ID: "demo-backup", Name: "备用环境", Browser: "Edge", UserDataDir: `C:\Users\Public\chub\profiles\backup`, PreferredRemoteDebugPort: 9669, Status: "已退出"},
}, searches: map[PathField]*pathSearchState{
PathChromeExecutable: {},
PathEdgeExecutable: {},
@@ -368,6 +373,7 @@ func NewShell(theme *material.Theme) *Shell {
s.dataDir.SetText(`C:\Users\Public\chub\profiles`)
s.logDir.SetText(`C:\Users\Public\chub\logs`)
s.remoteDebugPort.SetText(strconv.Itoa(domain.DefaultRemoteDebugPort))
s.instancePort.SetText(strconv.Itoa(s.recommendRemoteDebugPort("")))
s.closeOnExit.Value = true
s.browserKind.Value = "chrome"
s.editBrowserKind.Value = "chrome"
@@ -385,6 +391,7 @@ func (s *Shell) SetInstances(rows []InstanceRow) {
s.rows[i].ID = fmt.Sprintf("restored-%d", s.nextInstance)
}
}
s.assignPreferredRemoteDebugPorts()
if s.selectedInstanceID == "" && len(s.rows) > 0 {
s.selectedInstanceID = s.rows[0].ID
}
@@ -555,7 +562,7 @@ func (s *Shell) consumeControls(gtx layout.Context) {
s.page = pageSettings
}
for s.newClick.Clicked(gtx) {
s.page = pageCreate
s.beginCreate()
}
for s.refreshClick.Clicked(gtx) {
s.requestRefresh()
@@ -591,6 +598,12 @@ func (s *Shell) consumeControls(gtx layout.Context) {
}
}
func (s *Shell) beginCreate() {
s.page = pageCreate
s.formFeedback = ""
s.instancePort.SetText(strconv.Itoa(s.recommendRemoteDebugPort("")))
}
func (s *Shell) consumeKeyboard(gtx layout.Context) {
for {
event, ok := gtx.Event(
@@ -684,7 +697,8 @@ func (s *Shell) beginEdit(id string) {
s.editName.SetText(row.Name)
s.editDir.SetText(row.UserDataDir)
s.editURL.SetText(row.TargetURL)
s.editPort.SetText(remoteDebugPortText(row.RemoteDebugPort))
s.editPreferredPort.SetText(preferredRemoteDebugPortText(row.PreferredRemoteDebugPort))
s.editPort.SetText(actualRemoteDebugPortText(row.RemoteDebugPort))
s.editProxyID = row.ProxyID
if strings.EqualFold(row.Browser, "Edge") {
s.editBrowserKind.Value = "edge"
@@ -715,7 +729,7 @@ func (s *Shell) editIsDirty() bool {
if s.editLocked(s.editOriginal) {
return false
}
return strings.TrimSpace(s.editDir.Text()) != s.editOriginal.UserDataDir || browserDisplay(s.editBrowserKind.Value) != s.editOriginal.Browser
return strings.TrimSpace(s.editDir.Text()) != s.editOriginal.UserDataDir || browserDisplay(s.editBrowserKind.Value) != s.editOriginal.Browser || strings.TrimSpace(s.editPreferredPort.Text()) != preferredRemoteDebugPortText(s.editOriginal.PreferredRemoteDebugPort)
}
func (s *Shell) cancelEdit() {
@@ -752,8 +766,18 @@ func (s *Shell) saveEdit() {
s.editFeedback = "User Data Dir 必须是绝对路径。"
return
}
preferredPort, err := normalizePreferredRemoteDebugPort(s.editPreferredPort.Text())
if err != nil {
s.editFeedback = err.Error()
return
}
if s.remoteDebugPortReservedByOther(preferredPort, row.ID) {
s.editFeedback = fmt.Sprintf("端口 %d 已被其他 Chub 实例预留或使用。", preferredPort)
return
}
row.UserDataDir = filepath.Clean(userDataDir)
row.Browser = browserDisplay(s.editBrowserKind.Value)
row.PreferredRemoteDebugPort = preferredPort
}
row.Name = name
row.TargetURL = targetURL
@@ -916,7 +940,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(instancePortColumnWeight, remoteDebugPortCell(s.theme, row.RemoteDebugPort)),
layout.Flexed(instancePortColumnWeight, remoteDebugPortCell(s.theme, row)),
layout.Flexed(instanceStatusColumnWeight, statusLabel(s.theme, row.Status)),
)
})
@@ -966,17 +990,47 @@ func pathCell(theme *material.Theme, path string) layout.Widget {
return style.Layout
}
func remoteDebugPortCell(theme *material.Theme, port int) layout.Widget {
return material.Body2(theme, remoteDebugPortText(port)).Layout
func remoteDebugPortCell(theme *material.Theme, row InstanceRow) layout.Widget {
style := material.Body2(theme, instancePortLabel(row))
style.MaxLines = 1
return style.Layout
}
func remoteDebugPortText(port int) string {
func actualRemoteDebugPortText(port int) string {
if port > 0 {
return strconv.Itoa(port)
}
return "—"
}
func preferredRemoteDebugPortText(port int) string {
if domain.ValidRemoteDebugPort(port) {
return strconv.Itoa(port)
}
return ""
}
func instancePortLabel(row InstanceRow) string {
if row.Status == "外部已关联" {
if domain.ValidRemoteDebugPort(row.RemoteDebugPort) {
return fmt.Sprintf("外部 %d", row.RemoteDebugPort)
}
return "—"
}
if row.Status == "启动中" && domain.ValidRemoteDebugPort(row.PreferredRemoteDebugPort) {
return fmt.Sprintf("准备 %d", row.PreferredRemoteDebugPort)
}
if domain.ValidRemoteDebugPort(row.RemoteDebugPort) {
return fmt.Sprintf("实际 %d", row.RemoteDebugPort)
}
if row.Status == "已退出" || row.Status == "启动失败" {
if domain.ValidRemoteDebugPort(row.PreferredRemoteDebugPort) {
return fmt.Sprintf("建议 %d", row.PreferredRemoteDebugPort)
}
}
return "—"
}
func (s *Shell) instanceActionButtons(row InstanceRow) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
return layout.E.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
@@ -1080,9 +1134,11 @@ 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.editPreferredPortField(gtx, locked) }),
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(s.formField("实际调试端口", "仅在已验证的运行或外部关联状态显示;此处只读", &s.editPort)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(statusLabel(s.theme, row.Status)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
@@ -1142,6 +1198,16 @@ func (s *Shell) editDirectoryField(gtx layout.Context, locked bool) layout.Dimen
)
}
func (s *Shell) editPreferredPortField(gtx layout.Context, locked bool) layout.Dimensions {
s.editPreferredPort.ReadOnly = locked
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.Body2(s.theme, "首选调试端口").Layout),
layout.Rigid(material.Caption(s.theme, "下次启动优先使用;启动时仍会在后台复检 loopback 可用性").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(material.Editor(s.theme, &s.editPreferredPort, "例如:9666").Layout),
)
}
func (s *Shell) editFeedbackLabel(locked bool) layout.Widget {
label := s.editFeedback
if label == "" && locked {
@@ -1667,6 +1733,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(s.formField("首选调试端口", "已按现有实例推荐;启动时会在后台复检 loopback 可用性", &s.instancePort)),
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),
@@ -1893,15 +1961,25 @@ func (s *Shell) createInstanceFromForm() {
s.formFeedback = "请选择或输入 User Data Dir 后再创建。"
return
}
preferredPort, err := normalizePreferredRemoteDebugPort(s.instancePort.Text())
if err != nil {
s.formFeedback = err.Error()
return
}
if s.remoteDebugPortReservedByOther(preferredPort, "") {
s.formFeedback = fmt.Sprintf("端口 %d 已被其他 Chub 实例预留或使用。", preferredPort)
return
}
s.nextInstance++
s.rows = append(s.rows, InstanceRow{
ID: fmt.Sprintf("instance-%d", s.nextInstance),
Name: name,
Browser: browserDisplay(s.browserKind.Value),
UserDataDir: userDataDir,
TargetURL: strings.TrimSpace(s.instanceURL.Text()),
ProxyID: s.createProxyID,
Status: "已退出",
ID: fmt.Sprintf("instance-%d", s.nextInstance),
Name: name,
Browser: browserDisplay(s.browserKind.Value),
UserDataDir: userDataDir,
TargetURL: strings.TrimSpace(s.instanceURL.Text()),
ProxyID: s.createProxyID,
PreferredRemoteDebugPort: preferredPort,
Status: "已退出",
})
s.page = pageInstances
s.formFeedback = ""
@@ -2247,6 +2325,7 @@ func (s *Shell) requestStart(id string) {
s.instanceFeedback = err.Error()
return
}
settings.ReservedRemoteDebugPorts = s.reservedRemoteDebugPorts(id)
if state == nil {
state = &instanceStartState{}
s.startStates[id] = state
@@ -2333,11 +2412,21 @@ func (s *Shell) consumeStartResults() {
status = "运行中(调试不可用)"
}
s.setInstanceRuntime(result.id, status, result.outcome.PID, result.outcome.RemoteDebugPort, "chub_registry")
fallbackPort := result.outcome.Warning == "" && domain.ValidRemoteDebugPort(result.outcome.RemoteDebugPort) && row.PreferredRemoteDebugPort != result.outcome.RemoteDebugPort
if fallbackPort {
if updated, exists := s.instanceRow(result.id); exists {
updated.PreferredRemoteDebugPort = result.outcome.RemoteDebugPort
s.replaceInstanceRow(updated)
}
s.notifyInstancesChanged()
}
if s.applyPendingManagedExit(result.id, result.request, result.outcome.PID) {
continue
}
if result.outcome.Warning != "" {
s.instanceFeedback = fmt.Sprintf("%s 已启动(PID %d),但%s。", row.Name, result.outcome.PID, result.outcome.Warning)
} else if fallbackPort {
s.instanceFeedback = fmt.Sprintf("%s 的首选端口已被占用,已改用并保存端口 %d。", row.Name, result.outcome.RemoteDebugPort)
} else {
s.instanceFeedback = fmt.Sprintf("已启动 %s(PID %d,端口 %d)。", row.Name, result.outcome.PID, result.outcome.RemoteDebugPort)
}
@@ -2482,7 +2571,7 @@ func (s *Shell) applyManagedExit(result ManagedInstanceExit) bool {
}
func instanceRefreshFingerprint(row InstanceRow) string {
return strings.Join([]string{row.ID, row.Name, row.Browser, row.UserDataDir, row.TargetURL, row.ProxyID}, "\x00")
return strings.Join([]string{row.ID, row.Name, row.Browser, row.UserDataDir, row.TargetURL, row.ProxyID, strconv.Itoa(row.PreferredRemoteDebugPort)}, "\x00")
}
func (s *Shell) requestDelete(id string) {
@@ -2653,6 +2742,106 @@ func normalizeRemoteDebugStartPort(value string) (int, error) {
return port, nil
}
func normalizePreferredRemoteDebugPort(value string) (int, error) {
value = strings.TrimSpace(value)
if value == "" {
return 0, fmt.Errorf("请输入首选调试端口(%d 到 %d)。", domain.MinRemoteDebugPort, domain.MaxRemoteDebugPort)
}
port, err := strconv.Atoi(value)
if err != nil || !domain.ValidRemoteDebugPort(port) {
return 0, fmt.Errorf("首选调试端口必须在 %d 到 %d 之间。", domain.MinRemoteDebugPort, domain.MaxRemoteDebugPort)
}
return port, nil
}
func (s *Shell) assignPreferredRemoteDebugPorts() {
used := make(map[int]struct{}, len(s.rows)*2)
for _, row := range s.rows {
if domain.ValidRemoteDebugPort(row.PreferredRemoteDebugPort) {
used[row.PreferredRemoteDebugPort] = struct{}{}
}
if domain.ValidRemoteDebugPort(row.RemoteDebugPort) {
used[row.RemoteDebugPort] = struct{}{}
}
}
for index := range s.rows {
if domain.ValidRemoteDebugPort(s.rows[index].PreferredRemoteDebugPort) {
continue
}
port := s.nextUnreservedRemoteDebugPort(used)
if port == 0 {
continue
}
s.rows[index].PreferredRemoteDebugPort = port
used[port] = struct{}{}
}
}
func (s *Shell) recommendRemoteDebugPort(excludeID string) int {
used := make(map[int]struct{}, len(s.rows)*2)
for _, row := range s.rows {
if row.ID == excludeID {
continue
}
if domain.ValidRemoteDebugPort(row.PreferredRemoteDebugPort) {
used[row.PreferredRemoteDebugPort] = struct{}{}
}
if domain.ValidRemoteDebugPort(row.RemoteDebugPort) {
used[row.RemoteDebugPort] = struct{}{}
}
}
return s.nextUnreservedRemoteDebugPort(used)
}
func (s *Shell) nextUnreservedRemoteDebugPort(used map[int]struct{}) int {
start, err := normalizeRemoteDebugStartPort(s.remoteDebugPort.Text())
if err != nil {
start = domain.DefaultRemoteDebugPort
}
for port := start; port <= domain.MaxRemoteDebugPort; port++ {
if _, exists := used[port]; !exists {
return port
}
}
return 0
}
func (s *Shell) remoteDebugPortReservedByOther(port int, excludeID string) bool {
if !domain.ValidRemoteDebugPort(port) {
return false
}
for _, row := range s.rows {
if row.ID == excludeID {
continue
}
if row.PreferredRemoteDebugPort == port || row.RemoteDebugPort == port {
return true
}
}
return false
}
func (s *Shell) reservedRemoteDebugPorts(excludeID string) []int {
reserved := make([]int, 0, len(s.rows)*2)
seen := make(map[int]struct{}, len(s.rows)*2)
for _, row := range s.rows {
if row.ID == excludeID {
continue
}
for _, port := range []int{row.PreferredRemoteDebugPort, row.RemoteDebugPort} {
if !domain.ValidRemoteDebugPort(port) {
continue
}
if _, exists := seen[port]; exists {
continue
}
seen[port] = struct{}{}
reserved = append(reserved, port)
}
}
return reserved
}
func (s *Shell) notifyInstancesChanged() {
if s.onInstancesChanged != nil {
s.onInstancesChanged(append([]InstanceRow(nil), s.rows...))