feat: add local cdp startup and external association

This commit is contained in:
QiuSW
2026-07-25 15:53:31 +08:00
parent 972c637716
commit 4063b851eb
17 changed files with 845 additions and 158 deletions
+229 -79
View File
@@ -6,8 +6,10 @@ import (
"fmt"
"image"
"image/color"
"strconv"
"strings"
"chub/internal/domain"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/op/paint"
@@ -26,11 +28,12 @@ const (
)
const (
instanceNameColumnWeight = 24
instanceBrowserColumnWeight = 12
instanceDirectoryColumnWeight = 38
instanceStatusColumnWeight = 12
instanceActionColumnWeight = 14
instanceNameColumnWeight = 20
instanceBrowserColumnWeight = 10
instanceDirectoryColumnWeight = 32
instancePortColumnWeight = 10
instanceStatusColumnWeight = 13
instanceActionColumnWeight = 15
)
var (
@@ -51,7 +54,15 @@ type PathSearcher func(context.Context, PathField, string) (string, error)
type DirectoryChooser func(context.Context) (string, error)
type InstanceStarter func(context.Context, InstanceRow, SettingsState) (int, error)
type InstanceStartOutcome struct {
PID int
RemoteDebugPort int
External bool
Source string
Warning string
}
type InstanceStarter func(context.Context, InstanceRow, SettingsState) (InstanceStartOutcome, error)
type instanceStartState struct {
request uint64
@@ -61,7 +72,7 @@ type instanceStartState struct {
type instanceStartResult struct {
id string
request uint64
pid int
outcome InstanceStartOutcome
err error
}
@@ -93,20 +104,24 @@ type directoryPickResult struct {
// InstanceRow is the read-only view model used by the first Gio shell.
// Runtime data will replace these fixtures when the application service is wired.
type InstanceRow struct {
ID string
Name string
Browser string
UserDataDir string
TargetURL string
Status string
ID string
Name string
Browser string
UserDataDir string
TargetURL string
PID int
RemoteDebugPort int
OccupancySource string
Status string
}
type SettingsState struct {
ChromePath string
EdgePath string
DefaultDir string
LogDir string
CloseOnExit bool
ChromePath string
EdgePath string
DefaultDir string
LogDir string
RemoteDebugStartPort int
CloseOnExit bool
}
// Shell owns every interactive Gio widget. Keeping this state outside Layout
@@ -133,17 +148,18 @@ type Shell struct {
saveClick widget.Clickable
cancelClick widget.Clickable
chromePath widget.Editor
edgePath widget.Editor
dataDir widget.Editor
logDir widget.Editor
closeOnExit widget.Bool
instanceName widget.Editor
instanceDir widget.Editor
instanceURL widget.Editor
browserKind widget.Enum
pathFeedback string
formFeedback string
chromePath widget.Editor
edgePath widget.Editor
dataDir widget.Editor
logDir widget.Editor
remoteDebugPort widget.Editor
closeOnExit widget.Bool
instanceName widget.Editor
instanceDir widget.Editor
instanceURL widget.Editor
browserKind widget.Enum
pathFeedback string
formFeedback string
list widget.List
rows []InstanceRow
@@ -171,9 +187,9 @@ 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`, Status: "运行中"},
{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`, 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: "已退出"},
}, searches: map[PathField]*pathSearchState{
PathChromeExecutable: {},
@@ -185,6 +201,7 @@ func NewShell(theme *material.Theme) *Shell {
s.edgePath.SetText(`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`)
s.dataDir.SetText(`C:\Users\Public\chub\profiles`)
s.logDir.SetText(`C:\Users\Public\chub\logs`)
s.remoteDebugPort.SetText(strconv.Itoa(domain.DefaultRemoteDebugPort))
s.closeOnExit.Value = true
s.browserKind.Value = "chrome"
s.list.Axis = layout.Vertical
@@ -206,6 +223,11 @@ func (s *Shell) SetSettings(value SettingsState) {
s.edgePath.SetText(value.EdgePath)
s.dataDir.SetText(value.DefaultDir)
s.logDir.SetText(value.LogDir)
port := value.RemoteDebugStartPort
if port == 0 {
port = domain.DefaultRemoteDebugPort
}
s.remoteDebugPort.SetText(strconv.Itoa(port))
s.closeOnExit.Value = value.CloseOnExit
}
@@ -323,39 +345,77 @@ func (s *Shell) instances(gtx layout.Context) layout.Dimensions {
layout.Rigid(layout.Spacer{Height: unit.Dp(18)}.Layout),
layout.Rigid(material.Button(s.theme, &s.newClick, "新建实例").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout),
layout.Rigid(s.instanceHeader),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return material.List(s.theme, &s.list).Layout(gtx, len(s.rows), func(gtx layout.Context, i int) layout.Dimensions {
row := s.rows[i]
return layout.Inset{Right: unit.Dp(4), Bottom: unit.Dp(10)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
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(instanceStatusColumnWeight, statusLabel(s.theme, row.Status)),
layout.Flexed(instanceActionColumnWeight, s.instanceActionButtons(row)),
)
})
})
}),
layout.Flexed(1, s.instanceTable),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(material.Caption(s.theme, s.instanceFeedback).Layout),
)
}
func (s *Shell) instanceTable(gtx layout.Context) layout.Dimensions {
return layout.Background{}.Layout(gtx,
func(gtx layout.Context) layout.Dimensions {
rect := image.Rectangle{Max: gtx.Constraints.Min}
if rect.Empty() {
return layout.Dimensions{Size: rect.Max}
}
paint.FillShape(gtx.Ops, color.NRGBA{R: 199, G: 207, B: 218, A: 255}, clip.UniformRRect(rect, gtx.Dp(8)).Op(gtx.Ops))
inner := rect.Inset(gtx.Dp(1))
if !inner.Empty() {
paint.FillShape(gtx.Ops, s.theme.Palette.Bg, clip.UniformRRect(inner, gtx.Dp(7)).Op(gtx.Ops))
}
return layout.Dimensions{Size: rect.Max}
},
func(gtx layout.Context) layout.Dimensions {
return layout.Inset{Top: unit.Dp(8), Right: unit.Dp(10), Bottom: unit.Dp(8), Left: unit.Dp(10)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(s.instanceHeader),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return material.List(s.theme, &s.list).Layout(gtx, len(s.rows), func(gtx layout.Context, i int) layout.Dimensions {
row := s.rows[i]
return layout.Inset{Bottom: unit.Dp(8)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
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(instanceStatusColumnWeight, statusLabel(s.theme, row.Status)),
layout.Flexed(instanceActionColumnWeight, s.instanceActionButtons(row)),
)
})
})
}),
)
})
},
)
}
func (s *Shell) instanceHeader(gtx layout.Context) layout.Dimensions {
return layout.Inset{Right: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
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(instanceStatusColumnWeight, material.Caption(s.theme, "状态").Layout),
layout.Flexed(instanceActionColumnWeight, func(gtx layout.Context) layout.Dimensions {
return layout.E.Layout(gtx, material.Caption(s.theme, "操作").Layout)
}),
)
})
return layout.Background{}.Layout(gtx,
func(gtx layout.Context) layout.Dimensions {
rect := image.Rectangle{Max: gtx.Constraints.Min}
if rect.Dy() > 0 {
line := image.Rect(rect.Min.X, rect.Max.Y-gtx.Dp(1), rect.Max.X, rect.Max.Y)
paint.FillShape(gtx.Ops, color.NRGBA{R: 220, G: 226, B: 235, A: 255}, clip.Rect{Min: line.Min, Max: line.Max}.Op())
}
return layout.Dimensions{Size: rect.Max}
},
func(gtx layout.Context) layout.Dimensions {
return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
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(instancePortColumnWeight, material.Caption(s.theme, "调试端口").Layout),
layout.Flexed(instanceStatusColumnWeight, material.Caption(s.theme, "状态").Layout),
layout.Flexed(instanceActionColumnWeight, func(gtx layout.Context) layout.Dimensions {
return layout.E.Layout(gtx, material.Caption(s.theme, "操作").Layout)
}),
)
})
},
)
}
func pathCell(theme *material.Theme, path string) layout.Widget {
@@ -364,11 +424,23 @@ func pathCell(theme *material.Theme, path string) layout.Widget {
return style.Layout
}
func remoteDebugPortCell(theme *material.Theme, port int) layout.Widget {
label := "—"
if port > 0 {
label = strconv.Itoa(port)
}
return material.Body2(theme, label).Layout
}
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 {
actionLabel := "启动 " + row.Name
if row.Status == "外部已关联" {
actionLabel = "重新检测 " + row.Name
}
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(s.instanceIconButton(s.startClickFor(row.ID), instanceStartIcon, "启动 "+row.Name, s.theme.Palette.ContrastBg)),
layout.Rigid(s.instanceIconButton(s.startClickFor(row.ID), instanceStartIcon, actionLabel, s.theme.Palette.ContrastBg)),
layout.Rigid(layout.Spacer{Width: unit.Dp(4)}.Layout),
layout.Rigid(s.instanceIconButton(s.deleteClickFor(row.ID), instanceDeleteIcon, "删除 "+row.Name, color.NRGBA{R: 188, G: 51, B: 51, A: 255})),
)
@@ -454,11 +526,14 @@ func statusLabel(theme *material.Theme, status string) layout.Widget {
style := material.Label(theme, unit.Sp(13), status)
style.Color = theme.Palette.Fg
if color, ok := map[string]color.NRGBA{
"运行中": {R: 24, G: 125, B: 78, A: 255},
"启动中": {R: 175, G: 105, B: 0, A: 255},
"启动失败": {R: 188, G: 51, B: 51, A: 255},
"外部占用": {R: 190, G: 75, B: 35, A: 255},
"已退出": {R: 100, G: 105, B: 115, A: 255},
"运行中": {R: 24, G: 125, B: 78, A: 255},
"启动中": {R: 175, G: 105, B: 0, A: 255},
"启动失败": {R: 188, G: 51, B: 51, A: 255},
"运行中(调试不可用)": {R: 175, G: 105, B: 0, A: 255},
"外部已关联": {R: 146, G: 93, B: 0, A: 255},
"外部占用": {R: 190, G: 75, B: 35, A: 255},
"未知占用": {R: 190, G: 75, B: 35, A: 255},
"已退出": {R: 100, G: 105, B: 115, A: 255},
}[status]; ok {
style.Color = color
}
@@ -491,8 +566,14 @@ func (s *Shell) settings(gtx layout.Context) layout.Dimensions {
s.togglePathSearch(PathLogDirectory)
}
for s.saveClick.Clicked(gtx) {
settings, err := s.settingsState()
if err != nil {
s.pathFeedback = err.Error()
continue
}
s.remoteDebugPort.SetText(strconv.Itoa(settings.RemoteDebugStartPort))
if s.onSave != nil {
s.onSave(SettingsState{ChromePath: s.chromePath.Text(), EdgePath: s.edgePath.Text(), DefaultDir: s.dataDir.Text(), LogDir: s.logDir.Text(), CloseOnExit: s.closeOnExit.Value})
s.onSave(settings)
}
}
for s.cancelClick.Clicked(gtx) {
@@ -510,6 +591,8 @@ func (s *Shell) settings(gtx layout.Context) layout.Dimensions {
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(s.pathField(PathLogDirectory, "日志目录", &s.logDir, &s.logPick, &s.logSearch)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(s.remoteDebugPortField),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(material.Caption(s.theme, s.pathFeedback).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(material.CheckBox(s.theme, &s.closeOnExit, "退出时关闭托管浏览器").Layout),
@@ -524,6 +607,15 @@ func (s *Shell) settings(gtx layout.Context) layout.Dimensions {
)
}
func (s *Shell) remoteDebugPortField(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.Body2(s.theme, "远程调试起始端口").Layout),
layout.Rigid(material.Caption(s.theme, "留空时使用 9666;仅绑定 127.0.0.1,启动时顺序尝试下一个可用端口").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(material.Editor(s.theme, &s.remoteDebugPort, "例如:9666").Layout),
)
}
func (s *Shell) pathField(field PathField, label string, editor *widget.Editor, pick, search *widget.Clickable) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
@@ -758,6 +850,11 @@ func (s *Shell) requestStart(id string) {
s.instanceFeedback = "浏览器启动服务尚未准备好。"
return
}
settings, err := s.settingsState()
if err != nil {
s.instanceFeedback = err.Error()
return
}
if state == nil {
state = &instanceStartState{}
s.startStates[id] = state
@@ -767,10 +864,9 @@ func (s *Shell) requestStart(id string) {
state.running = true
s.setInstanceStatus(id, "启动中")
s.instanceFeedback = fmt.Sprintf("正在启动 %s…", row.Name)
settings := s.settingsState()
go func() {
pid, err := s.instanceStarter(context.Background(), row, settings)
s.startResults <- instanceStartResult{id: id, request: request, pid: pid, err: err}
outcome, err := s.instanceStarter(context.Background(), row, settings)
s.startResults <- instanceStartResult{id: id, request: request, outcome: outcome, err: err}
if s.invalidate != nil {
s.invalidate()
}
@@ -791,12 +887,35 @@ func (s *Shell) consumeStartResults() {
continue
}
if result.err != nil {
s.setInstanceStatus(result.id, "启动失败")
s.instanceFeedback = fmt.Sprintf("启动 %s 失败:%v", row.Name, result.err)
if errors.Is(result.err, domain.ErrProfileOccupied) {
if result.outcome.Source == "chub_registry" {
s.setInstanceRuntime(result.id, "运行中", result.outcome.PID, result.outcome.RemoteDebugPort, result.outcome.Source)
s.instanceFeedback = fmt.Sprintf("%s 已由 Chub 启动,未重复创建浏览器。", row.Name)
} else {
s.setInstanceRuntime(result.id, "外部占用", result.outcome.PID, result.outcome.RemoteDebugPort, result.outcome.Source)
s.instanceFeedback = fmt.Sprintf("%s 的 User Data Dir 已被外部浏览器占用,未再次启动。", row.Name)
}
} else {
s.setInstanceStatus(result.id, "启动失败")
s.instanceFeedback = fmt.Sprintf("启动 %s 失败:%v", row.Name, result.err)
}
continue
}
s.setInstanceStatus(result.id, "运行中")
s.instanceFeedback = fmt.Sprintf("已启动 %s(PID %d)。", row.Name, result.pid)
if result.outcome.External {
s.setInstanceRuntime(result.id, "外部已关联", result.outcome.PID, result.outcome.RemoteDebugPort, result.outcome.Source)
s.instanceFeedback = fmt.Sprintf("已关联外部 %s(PID %d,端口 %d);不会接管其生命周期。", row.Name, result.outcome.PID, result.outcome.RemoteDebugPort)
continue
}
status := "运行中"
if result.outcome.Warning != "" {
status = "运行中(调试不可用)"
}
s.setInstanceRuntime(result.id, status, result.outcome.PID, result.outcome.RemoteDebugPort, "chub_registry")
if result.outcome.Warning != "" {
s.instanceFeedback = fmt.Sprintf("%s 已启动(PID %d),但%s。", row.Name, result.outcome.PID, result.outcome.Warning)
} else {
s.instanceFeedback = fmt.Sprintf("已启动 %s(PID %d,端口 %d)。", row.Name, result.outcome.PID, result.outcome.RemoteDebugPort)
}
default:
return
}
@@ -875,14 +994,44 @@ func (s *Shell) setInstanceStatus(id, status string) bool {
return false
}
func (s *Shell) settingsState() SettingsState {
return SettingsState{
ChromePath: s.chromePath.Text(),
EdgePath: s.edgePath.Text(),
DefaultDir: s.dataDir.Text(),
LogDir: s.logDir.Text(),
CloseOnExit: s.closeOnExit.Value,
func (s *Shell) setInstanceRuntime(id, status string, pid, remoteDebugPort int, source string) bool {
for i := range s.rows {
if s.rows[i].ID == id {
s.rows[i].Status = status
s.rows[i].PID = pid
s.rows[i].RemoteDebugPort = remoteDebugPort
s.rows[i].OccupancySource = source
return true
}
}
return false
}
func (s *Shell) settingsState() (SettingsState, error) {
port, err := normalizeRemoteDebugStartPort(s.remoteDebugPort.Text())
if err != nil {
return SettingsState{}, err
}
return SettingsState{
ChromePath: s.chromePath.Text(),
EdgePath: s.edgePath.Text(),
DefaultDir: s.dataDir.Text(),
LogDir: s.logDir.Text(),
RemoteDebugStartPort: port,
CloseOnExit: s.closeOnExit.Value,
}, nil
}
func normalizeRemoteDebugStartPort(value string) (int, error) {
value = strings.TrimSpace(value)
if value == "" {
return domain.DefaultRemoteDebugPort, nil
}
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) notifyInstancesChanged() {
@@ -964,4 +1113,5 @@ func (s *Shell) resetSettings() {
s.edgePath.SetText(`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`)
s.dataDir.SetText(`C:\Users\Public\chub\profiles`)
s.logDir.SetText(`C:\Users\Public\chub\logs`)
s.remoteDebugPort.SetText(strconv.Itoa(domain.DefaultRemoteDebugPort))
}