package ui import ( "context" "errors" "fmt" "image" "image/color" "strconv" "strings" "chub/internal/domain" "gioui.org/layout" "gioui.org/op/clip" "gioui.org/op/paint" "gioui.org/unit" "gioui.org/widget" "gioui.org/widget/material" "golang.org/x/exp/shiny/materialdesign/icons" ) type page uint8 const ( pageInstances page = iota pageSettings pageCreate ) const ( instanceNameColumnWeight = 20 instanceBrowserColumnWeight = 10 instanceDirectoryColumnWeight = 32 instancePortColumnWeight = 10 instanceStatusColumnWeight = 13 instanceActionColumnWeight = 15 ) var ( instanceStartIcon = mustIcon(icons.AVPlayArrow) instanceDeleteIcon = mustIcon(icons.ActionDelete) ) type PathField string const ( PathChromeExecutable PathField = "chrome_executable" PathEdgeExecutable PathField = "edge_executable" PathDefaultUserData PathField = "default_user_data_dir" PathLogDirectory PathField = "log_directory" ) type PathSearcher func(context.Context, PathField, string) (string, error) type DirectoryChooser func(context.Context) (string, 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 running bool } type instanceStartResult struct { id string request uint64 outcome InstanceStartOutcome err error } type pathSearchState struct { request uint64 cancel context.CancelFunc running bool } type pathSearchResult struct { field PathField request uint64 path string err error } type directoryPickState struct { request uint64 cancel context.CancelFunc running bool } type directoryPickResult struct { request uint64 path string err error } // 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 PID int RemoteDebugPort int OccupancySource string Status string } type SettingsState struct { ChromePath string EdgePath string DefaultDir string LogDir string RemoteDebugStartPort int CloseOnExit bool } // Shell owns every interactive Gio widget. Keeping this state outside Layout // prevents focus, editor content, and list scroll position from resetting. type Shell struct { theme *material.Theme page page instancesClick widget.Clickable settingsClick widget.Clickable newClick widget.Clickable startClick widget.Clickable createClick widget.Clickable backClick widget.Clickable instanceDirPick widget.Clickable chromePick widget.Clickable chromeSearch widget.Clickable edgePick widget.Clickable edgeSearch widget.Clickable dataPick widget.Clickable dataSearch widget.Clickable logPick widget.Clickable logSearch widget.Clickable saveClick widget.Clickable cancelClick widget.Clickable 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 startClicks 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 nextInstance uint64 instanceFeedback string pendingDeleteID string onSave func(SettingsState) onInstancesChanged func([]InstanceRow) instanceStarter InstanceStarter pathSearcher PathSearcher invalidate func() searches map[PathField]*pathSearchState searchResults chan pathSearchResult directoryChooser DirectoryChooser directoryPick directoryPickState directoryResults chan directoryPickResult } 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: "已退出"}, }, searches: map[PathField]*pathSearchState{ PathChromeExecutable: {}, PathEdgeExecutable: {}, PathDefaultUserData: {}, PathLogDirectory: {}, }, startClicks: make(map[string]*widget.Clickable), deleteClicks: make(map[string]*widget.Clickable), startStates: make(map[string]*instanceStartState), nextInstance: 4, startResults: make(chan instanceStartResult, 8), searchResults: make(chan pathSearchResult, 8), directoryResults: make(chan directoryPickResult, 1)} s.chromePath.SetText(`C:\Program Files\Google\Chrome\Application\chrome.exe`) 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 return s } func (s *Shell) SetInstances(rows []InstanceRow) { s.rows = append([]InstanceRow(nil), rows...) for i := range s.rows { if s.rows[i].ID == "" { s.nextInstance++ s.rows[i].ID = fmt.Sprintf("restored-%d", s.nextInstance) } } } func (s *Shell) SetSettings(value SettingsState) { s.chromePath.SetText(value.ChromePath) 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 } func (s *Shell) OnSave(fn func(SettingsState)) { s.onSave = fn } func (s *Shell) OnInstancesChanged(fn func([]InstanceRow)) { s.onInstancesChanged = fn } func (s *Shell) OnStartInstance(starter InstanceStarter, invalidate func()) { s.instanceStarter = starter s.invalidate = invalidate } func (s *Shell) OnPathSearch(searcher PathSearcher, invalidate func()) { s.pathSearcher = searcher s.invalidate = invalidate } func (s *Shell) OnChooseDirectory(chooser DirectoryChooser, invalidate func()) { s.directoryChooser = chooser s.invalidate = invalidate } func (s *Shell) Layout(gtx layout.Context) layout.Dimensions { s.consumeSearchResults() s.consumeDirectoryResults() s.consumeStartResults() if s.pendingDeleteID != "" { s.consumeDeleteConfirmation(gtx) } else { s.consumeControls(gtx) } mainLayout := func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, layout.Rigid(s.sidebar), layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { return layout.Inset{Top: unit.Dp(24), Bottom: unit.Dp(24), Left: unit.Dp(28), Right: unit.Dp(28)}.Layout(gtx, s.content) }), ) } if s.pendingDeleteID == "" { return mainLayout(gtx) } return layout.Stack{Alignment: layout.Center}.Layout(gtx, layout.Expanded(mainLayout), layout.Stacked(s.deleteConfirmation), ) } func (s *Shell) consumeControls(gtx layout.Context) { for s.instancesClick.Clicked(gtx) { s.page = pageInstances } for s.settingsClick.Clicked(gtx) { s.page = pageSettings } for s.newClick.Clicked(gtx) { s.page = pageCreate } for s.createClick.Clicked(gtx) { s.createInstanceFromForm() } for s.instanceDirPick.Clicked(gtx) { s.chooseInstanceDirectory() } for s.backClick.Clicked(gtx) { s.page = pageInstances } for _, row := range s.rows { for s.startClickFor(row.ID).Clicked(gtx) { s.requestStart(row.ID) } } for _, row := range append([]InstanceRow(nil), s.rows...) { for s.deleteClickFor(row.ID).Clicked(gtx) { s.requestDelete(row.ID) } } } func (s *Shell) sidebar(gtx layout.Context) layout.Dimensions { return layout.Inset{Top: unit.Dp(24), Left: unit.Dp(16), Right: unit.Dp(16)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(s.navButton(&s.instancesClick, "实例", s.page == pageInstances || s.page == pageCreate)), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(s.navButton(&s.settingsClick, "设置", s.page == pageSettings)), ) }) } func (s *Shell) navButton(click *widget.Clickable, label string, selected bool) layout.Widget { return func(gtx layout.Context) layout.Dimensions { style := material.Button(s.theme, click, label) if selected { style.Background = color.NRGBA{R: 214, G: 228, B: 255, A: 255} style.Color = color.NRGBA{R: 0, G: 76, B: 153, A: 255} } return style.Layout(gtx) } } func (s *Shell) content(gtx layout.Context) layout.Dimensions { if s.page == pageSettings { return s.settings(gtx) } if s.page == pageCreate { return s.createInstance(gtx) } return s.instances(gtx) } func (s *Shell) instances(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(material.H4(s.theme, "实例").Layout), layout.Rigid(material.Body1(s.theme, "统一查看、启动和管理 Chrome / Edge 浏览器进程").Layout), 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.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.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 { style := material.Body2(theme, path) style.MaxLines = 1 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, 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})), ) }) } } func (s *Shell) instanceIconButton(click *widget.Clickable, icon *widget.Icon, description string, iconColor color.NRGBA) layout.Widget { return func(gtx layout.Context) layout.Dimensions { style := material.IconButton(s.theme, click, icon, description) style.Size = unit.Dp(18) style.Inset = layout.UniformInset(unit.Dp(7)) style.Background = color.NRGBA{} style.Color = iconColor return style.Layout(gtx) } } func (s *Shell) deleteConfirmation(gtx layout.Context) layout.Dimensions { row, ok := s.instanceRow(s.pendingDeleteID) if !ok { s.pendingDeleteID = "" return layout.Dimensions{} } gtx.Constraints.Min = gtx.Constraints.Max return s.deleteBlocker.Layout(gtx, func(gtx layout.Context) layout.Dimensions { defer clip.Rect{Max: gtx.Constraints.Min}.Push(gtx.Ops).Pop() paint.Fill(gtx.Ops, color.NRGBA{R: 0, G: 0, B: 0, A: 92}) return layout.Center.Layout(gtx, s.deleteConfirmationCard(row)) }) } func (s *Shell) deleteConfirmationCard(row InstanceRow) layout.Widget { return func(gtx layout.Context) layout.Dimensions { if maxWidth := gtx.Dp(420); gtx.Constraints.Max.X > maxWidth { gtx.Constraints.Max.X = maxWidth } return layout.Background{}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { rect := image.Rectangle{Max: gtx.Constraints.Min} defer clip.UniformRRect(rect, gtx.Dp(8)).Push(gtx.Ops).Pop() paint.Fill(gtx.Ops, s.theme.Palette.Bg) return layout.Dimensions{Size: gtx.Constraints.Min} }, func(gtx layout.Context) layout.Dimensions { return layout.Inset{Top: unit.Dp(20), Right: unit.Dp(24), Bottom: unit.Dp(20), Left: unit.Dp(24)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(material.H6(s.theme, "删除实例?").Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(material.Body1(s.theme, fmt.Sprintf("确定删除实例“%s”?", row.Name)).Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(material.Caption(s.theme, "不会删除 User Data Dir;若浏览器仍在运行,它会继续运行。").Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(18)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Rigid(s.deleteCancelButton), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(s.deleteConfirmButton), ) }), ) }) }, ) } } func (s *Shell) deleteCancelButton(gtx layout.Context) layout.Dimensions { style := material.Button(s.theme, &s.deleteCancel, "取消") style.Background = color.NRGBA{R: 230, G: 232, B: 235, A: 255} style.Color = s.theme.Palette.Fg return style.Layout(gtx) } func (s *Shell) deleteConfirmButton(gtx layout.Context) layout.Dimensions { style := material.Button(s.theme, &s.deleteConfirm, "删除") style.Background = color.NRGBA{R: 188, G: 51, B: 51, A: 255} style.Color = color.NRGBA{R: 255, G: 255, B: 255, A: 255} return style.Layout(gtx) } 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: 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 } return style.Layout } func (s *Shell) settings(gtx layout.Context) layout.Dimensions { for s.chromePick.Clicked(gtx) { s.pathFeedback = "请选择 Chrome 可执行文件" } for s.chromeSearch.Clicked(gtx) { s.togglePathSearch(PathChromeExecutable) } for s.edgePick.Clicked(gtx) { s.pathFeedback = "请选择 Edge 可执行文件" } for s.edgeSearch.Clicked(gtx) { s.togglePathSearch(PathEdgeExecutable) } for s.dataPick.Clicked(gtx) { s.pathFeedback = "请选择默认 User Data Dir" } for s.dataSearch.Clicked(gtx) { s.togglePathSearch(PathDefaultUserData) } for s.logPick.Clicked(gtx) { s.pathFeedback = "请选择日志目录" } for s.logSearch.Clicked(gtx) { 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(settings) } } for s.cancelClick.Clicked(gtx) { s.resetSettings() } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(material.H4(s.theme, "设置").Layout), layout.Rigid(material.Body1(s.theme, "配置浏览器可执行文件、默认目录和本地运行行为").Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(18)}.Layout), layout.Rigid(s.pathField(PathChromeExecutable, "Chrome 可执行文件", &s.chromePath, &s.chromePick, &s.chromeSearch)), layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), layout.Rigid(s.pathField(PathEdgeExecutable, "Edge 可执行文件", &s.edgePath, &s.edgePick, &s.edgeSearch)), layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), layout.Rigid(s.pathField(PathDefaultUserData, "默认 User Data Dir", &s.dataDir, &s.dataPick, &s.dataSearch)), 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), layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Rigid(material.Button(s.theme, &s.saveClick, "保存设置").Layout), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(material.Button(s.theme, &s.cancelClick, "取消").Layout), ) }), ) } 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, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Inset{Right: unit.Dp(12)}.Layout(gtx, material.Body2(s.theme, label).Layout) }), layout.Flexed(1, material.Editor(s.theme, editor, "请输入或粘贴路径").Layout), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(material.Button(s.theme, pick, "选择").Layout), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(material.Button(s.theme, search, s.searchButtonLabel(field)).Layout), ) } } func (s *Shell) createInstance(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(material.H4(s.theme, "新建实例").Layout), layout.Rigid(material.Body1(s.theme, "为一个独立的 Chrome 或 Edge User Data Dir 创建托管实例").Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(18)}.Layout), layout.Rigid(s.formField("实例名称", "用于在实例列表中识别此浏览器环境", &s.instanceName)), layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), layout.Rigid(s.instanceDirField), 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(material.Body2(s.theme, "浏览器类型").Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Rigid(material.RadioButton(s.theme, &s.browserKind, "chrome", "Chrome").Layout), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(material.RadioButton(s.theme, &s.browserKind, "edge", "Edge").Layout), ) }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(material.Caption(s.theme, s.formFeedback).Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(18)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Rigid(material.Button(s.theme, &s.createClick, "创建实例").Layout), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(material.Button(s.theme, &s.backClick, "取消").Layout), ) }), ) } func (s *Shell) formField(label, help string, editor *widget.Editor) layout.Widget { return func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(material.Body2(s.theme, label).Layout), layout.Rigid(material.Caption(s.theme, help).Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), layout.Rigid(material.Editor(s.theme, editor, "请输入").Layout), ) } } func (s *Shell) instanceDirField(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(material.Body2(s.theme, "User Data Dir").Layout), layout.Rigid(material.Caption(s.theme, "浏览器独立数据目录;必须是绝对路径").Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Flexed(1, material.Editor(s.theme, &s.instanceDir, "请输入或粘贴绝对路径").Layout), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(material.Button(s.theme, &s.instanceDirPick, s.instanceDirButtonLabel()).Layout), ) }), ) } func (s *Shell) createInstanceFromForm() { name := strings.TrimSpace(s.instanceName.Text()) if name == "" { s.formFeedback = "请输入实例名称后再创建。" return } userDataDir := strings.TrimSpace(s.instanceDir.Text()) if userDataDir == "" { s.formFeedback = "请选择或输入 User Data Dir 后再创建。" 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()), Status: "已退出", }) s.page = pageInstances s.formFeedback = "" s.notifyInstancesChanged() } func (s *Shell) togglePathSearch(field PathField) { state := s.searches[field] if state == nil { return } if state.running { state.cancel() state.cancel = nil state.running = false state.request++ s.pathFeedback = fmt.Sprintf("已取消%s搜索。", pathFieldLabel(field)) return } if s.pathSearcher == nil { s.pathFeedback = fmt.Sprintf("%s搜索服务尚未准备好。", pathFieldLabel(field)) return } state.request++ request := state.request ctx, cancel := context.WithCancel(context.Background()) state.cancel = cancel state.running = true current := s.pathEditor(field).Text() s.pathFeedback = fmt.Sprintf("正在搜索%s…", pathFieldLabel(field)) go func() { path, err := s.pathSearcher(ctx, field, current) s.searchResults <- pathSearchResult{field: field, request: request, path: path, err: err} if s.invalidate != nil { s.invalidate() } }() } func (s *Shell) consumeSearchResults() { for { select { case result := <-s.searchResults: state := s.searches[result.field] if state == nil || !state.running || result.request != state.request { continue } state.running = false state.cancel = nil if result.err != nil { if errors.Is(result.err, context.Canceled) { s.pathFeedback = fmt.Sprintf("已取消%s搜索。", pathFieldLabel(result.field)) } else { s.pathFeedback = fmt.Sprintf("搜索%s失败:%v", pathFieldLabel(result.field), result.err) } continue } if result.path == "" { s.pathFeedback = fmt.Sprintf("未找到%s。", pathFieldLabel(result.field)) continue } s.pathEditor(result.field).SetText(result.path) s.pathFeedback = fmt.Sprintf("已找到%s。", pathFieldLabel(result.field)) default: return } } } func (s *Shell) searchButtonLabel(field PathField) string { if state := s.searches[field]; state != nil && state.running { return "取消" } return "搜索" } func (s *Shell) pathEditor(field PathField) *widget.Editor { switch field { case PathChromeExecutable: return &s.chromePath case PathEdgeExecutable: return &s.edgePath case PathDefaultUserData: return &s.dataDir default: return &s.logDir } } func pathFieldLabel(field PathField) string { switch field { case PathChromeExecutable: return "Chrome 可执行文件" case PathEdgeExecutable: return "Edge 可执行文件" case PathDefaultUserData: return "默认 User Data Dir" default: return "日志目录" } } func browserDisplay(value string) string { if value == "edge" { return "Edge" } return "Chrome" } func (s *Shell) startClickFor(id string) *widget.Clickable { if click := s.startClicks[id]; click != nil { return click } click := new(widget.Clickable) s.startClicks[id] = click return click } func (s *Shell) deleteClickFor(id string) *widget.Clickable { if click := s.deleteClicks[id]; click != nil { return click } click := new(widget.Clickable) s.deleteClicks[id] = click return click } func (s *Shell) requestStart(id string) { row, ok := s.instanceRow(id) if !ok { s.instanceFeedback = "找不到要启动的实例。" return } state := s.startStates[id] if state != nil && state.running { s.instanceFeedback = fmt.Sprintf("%s 正在启动,请稍候。", row.Name) return } if s.instanceStarter == nil { s.instanceFeedback = "浏览器启动服务尚未准备好。" return } settings, err := s.settingsState() if err != nil { s.instanceFeedback = err.Error() return } if state == nil { state = &instanceStartState{} s.startStates[id] = state } state.request++ request := state.request state.running = true s.setInstanceStatus(id, "启动中") s.instanceFeedback = fmt.Sprintf("正在启动 %s…", row.Name) go func() { 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() } }() } func (s *Shell) consumeStartResults() { for { select { case result := <-s.startResults: state := s.startStates[result.id] if state == nil || !state.running || result.request != state.request { continue } state.running = false row, exists := s.instanceRow(result.id) if !exists { continue } if result.err != nil { 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 } 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 } } } func (s *Shell) requestDelete(id string) { row, ok := s.instanceRow(id) if !ok { s.instanceFeedback = "找不到要删除的实例。" return } if state := s.startStates[id]; state != nil && state.running { s.instanceFeedback = fmt.Sprintf("%s 正在启动,暂时不能删除。", row.Name) return } s.pendingDeleteID = id } func (s *Shell) consumeDeleteConfirmation(gtx layout.Context) { for s.deleteBlocker.Clicked(gtx) { } for s.deleteCancel.Clicked(gtx) { s.cancelDelete() } for s.deleteConfirm.Clicked(gtx) { s.confirmDelete() } } func (s *Shell) cancelDelete() { if row, ok := s.instanceRow(s.pendingDeleteID); ok { s.instanceFeedback = fmt.Sprintf("已取消删除实例“%s”。", row.Name) } s.pendingDeleteID = "" } func (s *Shell) confirmDelete() { id := s.pendingDeleteID s.pendingDeleteID = "" s.deleteInstance(id) } func (s *Shell) deleteInstance(id string) { for i, row := range s.rows { if row.ID != id { continue } s.rows = append(s.rows[:i], s.rows[i+1:]...) delete(s.startClicks, id) delete(s.deleteClicks, id) delete(s.startStates, id) s.instanceFeedback = fmt.Sprintf("已删除实例“%s”;其 User Data Dir 未被删除。", row.Name) s.notifyInstancesChanged() return } s.instanceFeedback = "找不到要删除的实例。" } func (s *Shell) instanceRow(id string) (InstanceRow, bool) { for _, row := range s.rows { if row.ID == id { return row, true } } return InstanceRow{}, false } func (s *Shell) setInstanceStatus(id, status string) bool { for i := range s.rows { if s.rows[i].ID == id { s.rows[i].Status = status return true } } return false } 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() { if s.onInstancesChanged != nil { s.onInstancesChanged(append([]InstanceRow(nil), s.rows...)) } } func mustIcon(data []byte) *widget.Icon { icon, err := widget.NewIcon(data) if err != nil { panic(err) } return icon } func (s *Shell) chooseInstanceDirectory() { if s.directoryPick.running { s.formFeedback = "目录选择器已打开,请在系统窗口中选择或取消。" return } if s.directoryChooser == nil { s.formFeedback = "目录选择器尚未准备好。" return } s.directoryPick.request++ request := s.directoryPick.request ctx, cancel := context.WithCancel(context.Background()) s.directoryPick.cancel = cancel s.directoryPick.running = true s.formFeedback = "正在打开目录选择器…" go func() { path, err := s.directoryChooser(ctx) s.directoryResults <- directoryPickResult{request: request, path: path, err: err} if s.invalidate != nil { s.invalidate() } }() } func (s *Shell) consumeDirectoryResults() { for { select { case result := <-s.directoryResults: if !s.directoryPick.running || result.request != s.directoryPick.request { continue } s.directoryPick.running = false s.directoryPick.cancel = nil if result.err != nil { if errors.Is(result.err, context.Canceled) { s.formFeedback = "已取消选择 User Data Dir。" } else { s.formFeedback = fmt.Sprintf("选择 User Data Dir 失败:%v", result.err) } continue } if result.path == "" { s.formFeedback = "未选择 User Data Dir。" continue } s.instanceDir.SetText(result.path) s.formFeedback = "已更新 User Data Dir。" default: return } } } func (s *Shell) instanceDirButtonLabel() string { if s.directoryPick.running { return "选择中…" } return "选择路径" } func (s *Shell) resetSettings() { s.chromePath.SetText(`C:\Program Files\Google\Chrome\Application\chrome.exe`) 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)) }