Files
cdp_hub/internal/ui/shell.go
T

2670 lines
87 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package ui
import (
"context"
"errors"
"fmt"
"image"
"image/color"
"net/url"
"path/filepath"
"strconv"
"strings"
"sync"
"chub/internal/domain"
"gioui.org/io/key"
"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 = 29
instancePortColumnWeight = 10
instanceStatusColumnWeight = 13
instanceActionColumnWeight = 18
)
var (
instanceStartIcon = mustIcon(icons.AVPlayArrow)
instanceStopIcon = mustIcon(icons.AVStop)
instanceEditIcon = mustIcon(icons.EditorModeEdit)
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)
// ProxyOption is a non-secret proxy item exposed to the UI. Server has already
// been validated as an unauthenticated scheme://host:port endpoint.
type ProxyOption struct {
ID string
Name string
Server string
}
type InstanceStartOutcome struct {
PID int
RemoteDebugPort int
External bool
Source string
Warning string
}
// ManagedInstanceExit is delivered by the application adapter after the root
// process of a browser started by this Chub session exits. It contains only
// DTO data; UI code never receives a platform process handle.
type ManagedInstanceExit struct {
InstanceID string
LaunchGeneration uint64
PID int
ExitCode int
ExpectedStop bool
}
type InstanceStarter func(context.Context, InstanceRow, SettingsState, uint64) (InstanceStartOutcome, error)
// InstanceStopper requests graceful shutdown only for the supplied configured
// instance. Implementations must reject external or unverified processes.
type InstanceStopper func(context.Context, InstanceRow, uint64) error
// InstanceRefreshResult carries a read-only status check for one configured
// instance. The UI applies it only while the row's editable fields still match
// the snapshot passed to the refresher.
type InstanceRefreshResult struct {
ID string
Status string
PID int
RemoteDebugPort int
OccupancySource string
Message string
}
// InstanceRefresher performs background status checks only for the supplied
// configured instances. It must not enumerate or take control of unrelated
// browser processes.
type InstanceRefresher func(context.Context, []InstanceRow) ([]InstanceRefreshResult, error)
type instanceStartState struct {
request uint64
running bool
}
type instanceStartResult struct {
id string
request uint64
outcome InstanceStartOutcome
err error
}
type instanceStopState struct {
request uint64
running bool
previousStatus string
}
type instanceStopResult struct {
id string
request uint64
err error
}
type instanceRefreshState struct {
request uint64
running bool
}
type instanceRefreshResult struct {
request uint64
snapshots map[string]string
results []InstanceRefreshResult
err error
}
type managedExitKey struct {
instanceID string
launchGeneration uint64
pid int
}
type unexpectedExitNotice struct {
instanceID string
name string
browser string
}
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
target directoryPickTarget
}
type directoryPickResult struct {
request uint64
path string
err error
}
type directoryPickTarget uint8
const (
directoryPickCreate directoryPickTarget = iota
directoryPickEdit
)
type proxyPickerTarget uint8
const (
proxyPickerCreate proxyPickerTarget = iota
proxyPickerEdit
proxyPickerSettings
)
type proxyPickerState struct {
open bool
target proxyPickerTarget
}
// InstanceRow is the Gio view model for one configured browser instance.
// 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
}
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
refreshClick 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
createProxyPick widget.Clickable
editProxyPick widget.Clickable
settingsProxyPick widget.Clickable
browserKind widget.Enum
editName widget.Editor
editDir widget.Editor
editURL widget.Editor
editPort widget.Editor
editBrowserKind widget.Enum
editDirPick widget.Clickable
editSave widget.Clickable
editCancel widget.Clickable
editBlocker widget.Clickable
editDiscardBlocker widget.Clickable
editDiscardSave widget.Clickable
editDiscardDrop widget.Clickable
editDiscardStay widget.Clickable
pathFeedback string
formFeedback string
editFeedback string
proxyServer widget.Editor
proxySave widget.Clickable
proxyDelete widget.Clickable
proxyFeedback string
proxies []ProxyOption
createProxyID string
editProxyID string
settingsProxyID string
nextProxy uint64
proxyPicker proxyPickerState
proxyPickerBlocker widget.Clickable
proxyPickerNone widget.Clickable
proxyPickerChoices map[string]*widget.Clickable
proxyDeleteConfirm widget.Clickable
proxyDeleteCancel widget.Clickable
proxyDeleteBlocker widget.Clickable
pendingProxyDelete string
proxyDeleteFocus bool
list 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
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
onSave func(SettingsState)
onInstancesChanged func([]InstanceRow)
onProxiesChanged func([]ProxyOption)
instanceStarter InstanceStarter
instanceStopper InstanceStopper
instanceRefresher InstanceRefresher
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: {},
}, rowClicks: make(map[string]*widget.Clickable), startClicks: make(map[string]*widget.Clickable), editClicks: make(map[string]*widget.Clickable), deleteClicks: make(map[string]*widget.Clickable), proxyPickerChoices: make(map[string]*widget.Clickable), startStates: make(map[string]*instanceStartState), stopStates: make(map[string]*instanceStopState), pendingManagedExit: make(map[managedExitKey]ManagedInstanceExit), nextInstance: 4, startResults: make(chan instanceStartResult, 8), stopResults: make(chan instanceStopResult, 8), refreshResults: make(chan instanceRefreshResult, 1), 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.editBrowserKind.Value = "chrome"
s.editPort.ReadOnly = true
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)
}
}
if s.selectedInstanceID == "" && len(s.rows) > 0 {
s.selectedInstanceID = s.rows[0].ID
}
}
func (s *Shell) SetProxies(options []ProxyOption) {
s.proxies = append([]ProxyOption(nil), options...)
for _, option := range s.proxies {
if option.ID == "" {
continue
}
if strings.HasPrefix(option.ID, "proxy-") {
if value, err := strconv.ParseUint(strings.TrimPrefix(option.ID, "proxy-"), 10, 64); err == nil && value > s.nextProxy {
s.nextProxy = value
}
}
}
if !s.proxyExists(s.createProxyID) {
s.createProxyID = ""
}
if !s.proxyExists(s.editProxyID) {
s.editProxyID = ""
}
if !s.proxyExists(s.settingsProxyID) {
s.settingsProxyID = ""
s.proxyServer.SetText("")
}
}
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) OnProxiesChanged(fn func([]ProxyOption)) { s.onProxiesChanged = fn }
func (s *Shell) OnStartInstance(starter InstanceStarter, invalidate func()) {
s.instanceStarter = starter
s.invalidate = invalidate
}
func (s *Shell) OnStopInstance(stopper InstanceStopper, invalidate func()) {
s.instanceStopper = stopper
s.invalidate = invalidate
}
// ReportManagedExit is safe for the application's single per-instance monitor
// goroutine. The event is consumed during the next UI frame and therefore does
// not perform layout or mutate widgets from a background thread. The monitor
// that reports this result is responsible for invalidating its window.
func (s *Shell) ReportManagedExit(result ManagedInstanceExit) {
if result.InstanceID == "" || result.LaunchGeneration == 0 || result.PID <= 0 {
return
}
s.managedExitMu.Lock()
s.managedExitResults = append(s.managedExitResults, result)
s.managedExitMu.Unlock()
}
func (s *Shell) OnRefreshInstances(refresher InstanceRefresher, invalidate func()) {
s.instanceRefresher = refresher
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()
s.consumeStopResults()
s.consumeRefreshResults()
s.consumeManagedExitResults()
s.presentUnexpectedExitIfReady()
s.consumeKeyboard(gtx)
if s.unexpectedExitOpen {
s.consumeUnexpectedExitDialog(gtx)
} else if s.pendingDeleteID != "" {
s.consumeDeleteConfirmation(gtx)
} else if s.pendingProxyDelete != "" {
s.consumeProxyDeleteConfirmation(gtx)
} else if s.editingID != "" {
s.consumeEditControls(gtx)
} else {
s.consumeControls(gtx)
}
s.presentUnexpectedExitIfReady()
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.unexpectedExitOpen && s.pendingDeleteID == "" && s.pendingProxyDelete == "" && s.editingID == "" && !s.proxyPicker.open {
return mainLayout(gtx)
}
if s.unexpectedExitOpen {
return layout.Stack{Alignment: layout.Center}.Layout(gtx,
layout.Expanded(mainLayout),
layout.Stacked(s.unexpectedExitDialog),
)
}
if s.pendingDeleteID != "" {
return layout.Stack{Alignment: layout.Center}.Layout(gtx,
layout.Expanded(mainLayout),
layout.Stacked(s.deleteConfirmation),
)
}
if s.pendingProxyDelete != "" {
return layout.Stack{Alignment: layout.Center}.Layout(gtx,
layout.Expanded(mainLayout),
layout.Stacked(s.proxyDeleteConfirmation),
)
}
if s.pendingEditDiscard {
return layout.Stack{Alignment: layout.Center}.Layout(gtx,
layout.Expanded(mainLayout),
layout.Stacked(s.editDialog),
layout.Stacked(s.editDiscardConfirmation),
)
}
if s.editingID != "" {
if !s.proxyPicker.open {
return layout.Stack{Alignment: layout.Center}.Layout(gtx,
layout.Expanded(mainLayout),
layout.Stacked(s.editDialog),
)
}
return layout.Stack{Alignment: layout.Center}.Layout(gtx,
layout.Expanded(mainLayout),
layout.Stacked(s.editDialog),
layout.Stacked(s.proxyPickerDialog),
)
}
return layout.Stack{Alignment: layout.Center}.Layout(gtx,
layout.Expanded(mainLayout),
layout.Stacked(s.proxyPickerDialog),
)
}
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.refreshClick.Clicked(gtx) {
s.requestRefresh()
}
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 {
s.consumeRowKeyboard(gtx, row.ID)
for click, ok := s.rowClickFor(row.ID).Update(gtx); ok; click, ok = s.rowClickFor(row.ID).Update(gtx) {
s.selectedInstanceID = row.ID
if click.NumClicks >= 2 {
s.beginEdit(row.ID)
}
}
for s.startClickFor(row.ID).Clicked(gtx) {
s.requestInstanceAction(row.ID)
}
for s.editClickFor(row.ID).Clicked(gtx) {
s.beginEdit(row.ID)
}
}
for _, row := range append([]InstanceRow(nil), s.rows...) {
for s.deleteClickFor(row.ID).Clicked(gtx) {
s.requestDelete(row.ID)
}
}
}
func (s *Shell) consumeKeyboard(gtx layout.Context) {
for {
event, ok := gtx.Event(
key.Filter{Name: key.NameEscape},
key.Filter{Name: key.NameF5},
)
if !ok {
return
}
keyEvent, ok := event.(key.Event)
if !ok || keyEvent.State != key.Press {
continue
}
switch keyEvent.Name {
case key.NameEscape:
if s.unexpectedExitOpen {
s.dismissUnexpectedExitNotice()
} else if s.pendingProxyDelete != "" {
s.cancelProxyDelete()
} else if s.proxyPicker.open {
s.proxyPicker.open = false
} else if s.pendingEditDiscard {
s.pendingEditDiscard = false
} else if s.editingID != "" {
s.cancelEdit()
}
case key.NameF5:
if s.pendingDeleteID == "" && s.pendingProxyDelete == "" && s.editingID == "" && s.page == pageInstances {
s.requestRefresh()
}
}
}
}
func (s *Shell) consumeRowKeyboard(gtx layout.Context, id string) {
click := s.rowClickFor(id)
for {
event, ok := gtx.Event(
key.Filter{Focus: click, Name: key.NameReturn},
key.Filter{Focus: click, Name: key.NameEnter},
)
if !ok {
return
}
keyEvent, ok := event.(key.Event)
if !ok || keyEvent.State != key.Release {
continue
}
s.selectedInstanceID = id
s.beginEdit(id)
}
}
func (s *Shell) consumeEditControls(gtx layout.Context) {
for s.editBlocker.Clicked(gtx) {
}
if s.pendingEditDiscard {
for s.editDiscardBlocker.Clicked(gtx) {
}
for s.editDiscardStay.Clicked(gtx) {
s.pendingEditDiscard = false
}
for s.editDiscardDrop.Clicked(gtx) {
s.closeEdit("已放弃未保存的实例修改。")
}
for s.editDiscardSave.Clicked(gtx) {
s.saveEdit()
}
return
}
for s.editDirPick.Clicked(gtx) {
s.chooseDirectory(directoryPickEdit)
}
for s.editCancel.Clicked(gtx) {
s.cancelEdit()
}
for s.editSave.Clicked(gtx) {
s.saveEdit()
}
}
func (s *Shell) beginEdit(id string) {
row, ok := s.instanceRow(id)
if !ok {
s.instanceFeedback = "找不到要编辑的实例。"
return
}
s.selectedInstanceID = id
s.editingID = id
s.editOriginal = row
s.editName.SetText(row.Name)
s.editDir.SetText(row.UserDataDir)
s.editURL.SetText(row.TargetURL)
s.editPort.SetText(remoteDebugPortText(row.RemoteDebugPort))
s.editProxyID = row.ProxyID
if strings.EqualFold(row.Browser, "Edge") {
s.editBrowserKind.Value = "edge"
} else {
s.editBrowserKind.Value = "chrome"
}
s.editFeedback = ""
s.pendingEditDiscard = false
s.editFocusPending = true
}
func (s *Shell) editLocked(row InstanceRow) bool {
switch row.Status {
case "启动中", "停止中", "运行中", "运行中(调试不可用)", "外部已关联":
return true
default:
return false
}
}
func (s *Shell) editIsDirty() bool {
if s.editingID == "" {
return false
}
if strings.TrimSpace(s.editName.Text()) != s.editOriginal.Name || strings.TrimSpace(s.editURL.Text()) != s.editOriginal.TargetURL || s.editProxyID != s.editOriginal.ProxyID {
return true
}
if s.editLocked(s.editOriginal) {
return false
}
return strings.TrimSpace(s.editDir.Text()) != s.editOriginal.UserDataDir || browserDisplay(s.editBrowserKind.Value) != s.editOriginal.Browser
}
func (s *Shell) cancelEdit() {
if s.editingID == "" {
return
}
if s.editIsDirty() {
s.pendingEditDiscard = true
return
}
s.closeEdit("已取消实例编辑。")
}
func (s *Shell) saveEdit() {
row, ok := s.instanceRow(s.editingID)
if !ok {
s.closeEdit("要编辑的实例已不存在。")
return
}
name := strings.TrimSpace(s.editName.Text())
if name == "" {
s.editFeedback = "请输入实例名称。"
return
}
targetURL, err := normalizeTargetURL(s.editURL.Text())
if err != nil {
s.editFeedback = err.Error()
return
}
locked := s.editLocked(row)
if !locked {
userDataDir := strings.TrimSpace(s.editDir.Text())
if userDataDir == "" || !filepath.IsAbs(userDataDir) {
s.editFeedback = "User Data Dir 必须是绝对路径。"
return
}
row.UserDataDir = filepath.Clean(userDataDir)
row.Browser = browserDisplay(s.editBrowserKind.Value)
}
row.Name = name
row.TargetURL = targetURL
if !s.proxyExists(s.editProxyID) && s.editProxyID != "" {
s.editFeedback = "所选代理已不可用,请重新选择。"
return
}
row.ProxyID = s.editProxyID
if !s.replaceInstanceRow(row) {
s.closeEdit("要编辑的实例已不存在。")
return
}
s.notifyInstancesChanged()
s.closeEdit(fmt.Sprintf("已保存实例“%s”。", row.Name))
}
func normalizeTargetURL(value string) (string, error) {
value = strings.TrimSpace(value)
if value == "" {
return "", nil
}
parsed, err := url.ParseRequestURI(value)
if err != nil || parsed == nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || parsed.User != nil {
return "", errors.New("启动 URL 必须是无认证信息的 http/https 地址。")
}
return value, nil
}
func (s *Shell) closeEdit(feedback string) {
id := s.editingID
s.editingID = ""
s.pendingEditDiscard = false
s.editFeedback = ""
s.editFocusPending = false
s.proxyPicker.open = false
s.focusRestoreID = id
s.instanceFeedback = feedback
}
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(s.instanceCommands),
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) instanceCommands(gtx layout.Context) layout.Dimensions {
newButton := func(gtx layout.Context) layout.Dimensions {
return material.Button(s.theme, &s.newClick, "新建实例").Layout(gtx)
}
refreshButton := func(gtx layout.Context) layout.Dimensions {
label := "刷新实例状态"
buttonGtx := gtx
if s.refreshClickState.running {
label = "刷新中…"
buttonGtx = gtx.Disabled()
}
style := material.Button(s.theme, &s.refreshClick, label)
style.Background = color.NRGBA{R: 230, G: 232, B: 235, A: 255}
style.Color = s.theme.Palette.Fg
return style.Layout(buttonGtx)
}
if gtx.Constraints.Max.X <= gtx.Dp(640) {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(newButton),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(refreshButton),
)
}
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Flexed(1, newButton),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Flexed(1, refreshButton),
)
}
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, s.instanceListRow(row))
})
}),
)
})
},
)
}
func (s *Shell) instanceListRow(row InstanceRow) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
return layout.Background{}.Layout(gtx,
func(gtx layout.Context) layout.Dimensions {
if s.selectedInstanceID == row.ID {
rect := image.Rectangle{Max: gtx.Constraints.Min}
paint.FillShape(gtx.Ops, color.NRGBA{R: 235, G: 242, B: 255, A: 255}, clip.UniformRRect(rect, gtx.Dp(6)).Op(gtx.Ops))
}
return layout.Dimensions{Size: gtx.Constraints.Min}
},
func(gtx layout.Context) layout.Dimensions {
dims := layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(100-instanceActionColumnWeight, func(gtx layout.Context) layout.Dimensions {
return s.rowClickFor(row.ID).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)),
)
if s.focusRestoreID == row.ID {
gtx.Execute(key.FocusCmd{Tag: s.rowClickFor(row.ID)})
s.focusRestoreID = ""
}
return dims
},
)
}
}
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 {
return material.Body2(theme, remoteDebugPortText(port)).Layout
}
func remoteDebugPortText(port int) string {
if port > 0 {
return strconv.Itoa(port)
}
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 {
mode := instanceActionFor(row)
startClick := s.startClickFor(row.ID)
actionLabel := mode.label + " " + row.Name
actionIcon := instanceStartIcon
if mode.stop {
actionIcon = instanceStopIcon
}
dims := layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(s.instanceIconButton(startClick, actionIcon, actionLabel, s.theme.Palette.ContrastBg, mode.enabled)),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(s.instanceIconButton(s.editClickFor(row.ID), instanceEditIcon, "编辑 "+row.Name, s.theme.Palette.ContrastBg)),
layout.Rigid(layout.Spacer{Width: unit.Dp(10)}.Layout),
layout.Rigid(s.instanceIconButton(s.deleteClickFor(row.ID), instanceDeleteIcon, "删除 "+row.Name, color.NRGBA{R: 188, G: 51, B: 51, A: 255})),
)
if s.focusStartID == row.ID {
gtx.Execute(key.FocusCmd{Tag: startClick})
s.focusStartID = ""
}
return dims
})
}
}
type instanceActionMode struct {
label string
stop bool
enabled bool
}
func instanceActionFor(row InstanceRow) instanceActionMode {
switch row.Status {
case "运行中", "运行中(调试不可用)":
return instanceActionMode{label: "停止", stop: true, enabled: true}
case "启动中":
return instanceActionMode{label: "启动中", enabled: false}
case "停止中":
return instanceActionMode{label: "停止中", stop: true, enabled: false}
case "外部已关联", "外部占用", "未知占用":
return instanceActionMode{label: "重新检测", enabled: true}
default:
return instanceActionMode{label: "启动", enabled: true}
}
}
func (s *Shell) instanceIconButton(click *widget.Clickable, icon *widget.Icon, description string, iconColor color.NRGBA, enabled ...bool) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
if len(enabled) > 0 && !enabled[0] {
gtx = gtx.Disabled()
}
style := material.IconButton(s.theme, click, icon, description)
style.Size = unit.Dp(16)
style.Inset = layout.UniformInset(unit.Dp(7))
style.Background = color.NRGBA{}
style.Color = iconColor
return style.Layout(gtx)
}
}
func (s *Shell) editDialog(gtx layout.Context) layout.Dimensions {
row, ok := s.instanceRow(s.editingID)
if !ok {
s.closeEdit("要编辑的实例已不存在。")
return layout.Dimensions{}
}
gtx.Constraints.Min = gtx.Constraints.Max
return s.editBlocker.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.editDialogCard(row))
})
}
func (s *Shell) editDialogCard(row InstanceRow) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
if maxWidth := gtx.Dp(560); 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 {
locked := s.editLocked(row)
dims := layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.H6(s.theme, "编辑实例").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(material.Caption(s.theme, "修改下次启动时使用的保存配置。").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout),
layout.Rigid(s.formField("实例名称", "用于在实例列表中识别此浏览器环境", &s.editName)),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return s.editBrowserField(gtx, locked) }),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return s.editDirectoryField(gtx, locked) }),
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.proxyPickerField(gtx, proxyPickerEdit) }),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
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),
layout.Rigid(s.editFeedbackLabel(locked)),
layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout),
layout.Rigid(s.editActions),
)
if s.editFocusPending {
gtx.Execute(key.FocusCmd{Tag: &s.editName})
s.editFocusPending = false
}
return dims
})
},
)
}
}
func (s *Shell) editBrowserField(gtx layout.Context, locked bool) layout.Dimensions {
buttonGtx := gtx
if locked {
buttonGtx = gtx.Disabled()
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.Body2(s.theme, "浏览器类型").Layout),
layout.Rigid(material.Caption(s.theme, "Chrome 和 Edge 使用各自的启动定义").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(buttonGtx,
layout.Rigid(material.RadioButton(s.theme, &s.editBrowserKind, "chrome", "Chrome").Layout),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(material.RadioButton(s.theme, &s.editBrowserKind, "edge", "Edge").Layout),
)
}),
)
}
func (s *Shell) editDirectoryField(gtx layout.Context, locked bool) layout.Dimensions {
s.editDir.ReadOnly = locked
buttonGtx := gtx
if locked {
buttonGtx = gtx.Disabled()
}
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.editDir, "请输入或粘贴绝对路径").Layout),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(layout.Context) layout.Dimensions {
return material.Button(s.theme, &s.editDirPick, "选择路径").Layout(buttonGtx)
}),
)
}),
)
}
func (s *Shell) editFeedbackLabel(locked bool) layout.Widget {
label := s.editFeedback
if label == "" && locked {
label = "运行中或外部关联实例的浏览器类型和 User Data Dir 已锁定。"
}
return material.Caption(s.theme, label).Layout
}
func (s *Shell) editActions(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
style := material.Button(s.theme, &s.editCancel, "取消")
style.Background = color.NRGBA{R: 230, G: 232, B: 235, A: 255}
style.Color = s.theme.Palette.Fg
return style.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(material.Button(s.theme, &s.editSave, "保存更改").Layout),
)
}
func (s *Shell) editDiscardConfirmation(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Min = gtx.Constraints.Max
return s.editDiscardBlocker.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: 110})
return layout.Center.Layout(gtx, s.editDiscardCard)
})
}
func (s *Shell) editDiscardCard(gtx layout.Context) layout.Dimensions {
if maxWidth := gtx.Dp(460); 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, "可以先保存配置,或不保存并关闭编辑对话框。").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.editDiscardStay, "继续编辑").Layout),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(material.Button(s.theme, &s.editDiscardDrop, "不保存").Layout),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(material.Button(s.theme, &s.editDiscardSave, "保存").Layout),
)
}),
)
})
},
)
}
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 (s *Shell) proxyDeleteConfirmation(gtx layout.Context) layout.Dimensions {
proxy, ok := s.proxyOption(s.pendingProxyDelete)
if !ok {
s.pendingProxyDelete = ""
return layout.Dimensions{}
}
gtx.Constraints.Min = gtx.Constraints.Max
return s.proxyDeleteBlocker.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.proxyDeleteConfirmationCard(proxy))
})
}
func (s *Shell) proxyDeleteConfirmationCard(proxy ProxyOption) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
if maxWidth := gtx.Dp(460); 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 {
dims := 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, "确定删除已保存代理?").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(material.Caption(s.theme, proxy.Server).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(material.Caption(s.theme, "此操作不会修改任何实例。若代理仍被引用,Chub 会拒绝删除。").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.proxyDeleteCancelButton),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(s.proxyDeleteConfirmButton),
)
}),
)
if s.proxyDeleteFocus {
gtx.Execute(key.FocusCmd{Tag: &s.proxyDeleteCancel})
s.proxyDeleteFocus = false
}
return dims
})
},
)
}
}
func (s *Shell) proxyDeleteCancelButton(gtx layout.Context) layout.Dimensions {
style := material.Button(s.theme, &s.proxyDeleteCancel, "取消")
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) proxyDeleteConfirmButton(gtx layout.Context) layout.Dimensions {
style := material.Button(s.theme, &s.proxyDeleteConfirm, "删除代理")
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 (s *Shell) unexpectedExitDialog(gtx layout.Context) layout.Dimensions {
if !s.unexpectedExitOpen || len(s.unexpectedExits) == 0 {
return layout.Dimensions{}
}
gtx.Constraints.Min = gtx.Constraints.Max
return s.unexpectedExitBlocker.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.unexpectedExitCard)
})
}
func (s *Shell) unexpectedExitCard(gtx layout.Context) layout.Dimensions {
if maxWidth := gtx.Dp(480); gtx.Constraints.Max.X > maxWidth {
gtx.Constraints.Max.X = maxWidth
}
title := "浏览器已退出"
message := "检测到以下由 Chub 启动的浏览器已退出。"
if len(s.unexpectedExits) == 1 {
notice := s.unexpectedExits[0]
message = fmt.Sprintf("检测到“%s”的 %s 已退出。", notice.name, notice.browser)
} else {
title = fmt.Sprintf("%d 个浏览器已退出", len(s.unexpectedExits))
}
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 {
children := []layout.FlexChild{
layout.Rigid(material.H6(s.theme, title).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(material.Body1(s.theme, message).Layout),
}
if len(s.unexpectedExits) > 1 {
for index, notice := range s.unexpectedExits {
if index == 5 {
children = append(children,
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(material.Caption(s.theme, fmt.Sprintf("另有 %d 个实例已退出。", len(s.unexpectedExits)-index)).Layout),
)
break
}
notice := notice
children = append(children,
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(material.Body2(s.theme, fmt.Sprintf("• %s · %s", notice.name, notice.browser)).Layout),
)
}
}
children = append(children,
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.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.E.Layout(gtx, material.Button(s.theme, &s.unexpectedExitAck, "知道了").Layout)
}),
)
dims := layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
if s.unexpectedExitFocus {
gtx.Execute(key.FocusCmd{Tag: &s.unexpectedExitAck})
s.unexpectedExitFocus = false
}
return dims
})
},
)
}
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: 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.proxySave.Clicked(gtx) {
s.saveProxy()
}
for s.proxyDelete.Clicked(gtx) {
s.requestProxyDelete()
}
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(s.proxySettings),
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) proxySettings(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.H6(s.theme, "代理").Layout),
layout.Rigid(material.Caption(s.theme, "仅保存无认证 scheme://host:port 端点;下拉框和实例表单显示完整地址。").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return s.proxyPickerField(gtx, proxyPickerSettings) }),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(s.formField("代理地址", "支持 http、https、socks4、socks5;不允许用户名、密码或路径", &s.proxyServer)),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(material.Button(s.theme, &s.proxySave, "保存代理").Layout),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(s.proxyDeleteButton),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(material.Caption(s.theme, s.proxyFeedback).Layout),
)
}
func (s *Shell) proxyDeleteButton(gtx layout.Context) layout.Dimensions {
buttonGtx := gtx
if s.settingsProxyID == "" {
buttonGtx = gtx.Disabled()
}
style := material.Button(s.theme, &s.proxyDelete, "删除代理")
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(buttonGtx)
}
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(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),
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) proxyPickerField(gtx layout.Context, target proxyPickerTarget) layout.Dimensions {
click := &s.createProxyPick
selected := s.createProxyID
label := "代理"
help := "选择设置中保存的无认证代理;留空表示不使用代理"
if target == proxyPickerEdit {
click = &s.editProxyPick
selected = s.editProxyID
} else if target == proxyPickerSettings {
click = &s.settingsProxyPick
selected = s.settingsProxyID
label = "已保存代理"
help = "选择项会回填代理地址;选择新建会清空输入框"
}
for click.Clicked(gtx) {
s.proxyPicker = proxyPickerState{open: true, target: target}
}
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.Button(s.theme, click, s.proxyLabel(selected)).Layout),
)
}
func (s *Shell) proxyLabel(id string) string {
if id == "" {
return "无代理"
}
for _, option := range s.proxies {
if option.ID == id {
return option.Server
}
}
return "代理不可用"
}
func (s *Shell) proxyExists(id string) bool {
if _, exists := s.proxyOption(id); exists {
return true
}
return id == ""
}
func (s *Shell) proxyOption(id string) (ProxyOption, bool) {
if id == "" {
return ProxyOption{}, false
}
for _, option := range s.proxies {
if option.ID == id {
return option, true
}
}
return ProxyOption{}, false
}
func (s *Shell) proxyPickerChoiceFor(id string) *widget.Clickable {
if click := s.proxyPickerChoices[id]; click != nil {
return click
}
click := new(widget.Clickable)
s.proxyPickerChoices[id] = click
return click
}
func (s *Shell) selectPickerProxy(id string) {
if !s.proxyExists(id) {
return
}
if s.proxyPicker.target == proxyPickerEdit {
s.editProxyID = id
} else if s.proxyPicker.target == proxyPickerSettings {
s.settingsProxyID = id
if id == "" {
s.proxyServer.SetText("")
} else {
for _, option := range s.proxies {
if option.ID == id {
s.proxyServer.SetText(option.Server)
break
}
}
}
s.proxyFeedback = ""
} else {
s.createProxyID = id
}
s.proxyPicker.open = false
}
func (s *Shell) proxyPickerDialog(gtx layout.Context) layout.Dimensions {
if !s.proxyPicker.open {
return layout.Dimensions{}
}
gtx.Constraints.Min = gtx.Constraints.Max
return s.proxyPickerBlocker.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})
for s.proxyPickerBlocker.Clicked(gtx) {
s.proxyPicker.open = false
}
for s.proxyPickerNone.Clicked(gtx) {
s.selectPickerProxy("")
}
for _, option := range append([]ProxyOption(nil), s.proxies...) {
for s.proxyPickerChoiceFor(option.ID).Clicked(gtx) {
s.selectPickerProxy(option.ID)
}
}
return layout.Center.Layout(gtx, s.proxyPickerCard)
})
}
func (s *Shell) proxyPickerCard(gtx layout.Context) layout.Dimensions {
if maxWidth := gtx.Dp(440); 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 {
children := []layout.FlexChild{
layout.Rigid(material.H6(s.theme, s.proxyPickerTitle()).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(material.Caption(s.theme, "仅显示和使用无认证代理端点。按 Escape 或点击背景取消。").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(material.Button(s.theme, &s.proxyPickerNone, s.proxyPickerNoneLabel()).Layout),
}
for _, option := range s.proxies {
option := option
children = append(children,
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
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)
})
})
}),
)
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
})
},
)
}
func (s *Shell) proxyPickerTitle() string {
if s.proxyPicker.target == proxyPickerSettings {
return "选择已保存代理"
}
return "选择代理"
}
func (s *Shell) proxyPickerNoneLabel() string {
if s.proxyPicker.target == proxyPickerSettings {
return "新建代理(不选择)"
}
return "无代理"
}
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()),
ProxyID: s.createProxyID,
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) rowClickFor(id string) *widget.Clickable {
if click := s.rowClicks[id]; click != nil {
return click
}
click := new(widget.Clickable)
s.rowClicks[id] = click
return click
}
func (s *Shell) editClickFor(id string) *widget.Clickable {
if click := s.editClicks[id]; click != nil {
return click
}
click := new(widget.Clickable)
s.editClicks[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) saveProxy() {
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 && option.Server == server {
s.proxyFeedback = "该代理地址已存在,请从下拉框选择它。"
return
}
}
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)
} else {
updated := false
for index := range s.proxies {
if s.proxies[index].ID != s.settingsProxyID {
continue
}
s.proxies[index].Name = server
s.proxies[index].Server = server
updated = true
break
}
if !updated {
s.proxyFeedback = "所选代理已不可用,请重新选择或新建。"
return
}
s.proxyFeedback = fmt.Sprintf("已保存代理“%s”;后续启动将使用最新端点。", server)
}
s.proxyServer.SetText(server)
s.notifyProxiesChanged()
}
func (s *Shell) requestProxyDelete() {
id := s.settingsProxyID
if id == "" {
s.proxyFeedback = "请先选择要删除的代理。"
return
}
if count := s.proxyUseCount(id); count > 0 {
s.proxyFeedback = fmt.Sprintf("该代理仍被 %d 个实例使用,不能删除。", count)
return
}
if _, exists := s.proxyOption(id); !exists {
s.proxyFeedback = "所选代理已不可用。"
return
}
s.pendingProxyDelete = id
s.proxyDeleteFocus = true
}
func (s *Shell) cancelProxyDelete() {
s.pendingProxyDelete = ""
s.proxyDeleteFocus = false
s.proxyFeedback = "已取消删除代理。"
}
func (s *Shell) confirmProxyDelete() {
id := s.pendingProxyDelete
s.pendingProxyDelete = ""
s.proxyDeleteFocus = false
if count := s.proxyUseCount(id); count > 0 {
s.proxyFeedback = fmt.Sprintf("该代理仍被 %d 个实例使用,不能删除。", count)
return
}
for index, option := range s.proxies {
if option.ID != id {
continue
}
s.proxies = append(s.proxies[:index], s.proxies[index+1:]...)
delete(s.proxyPickerChoices, id)
if s.createProxyID == id {
s.createProxyID = ""
}
if s.editProxyID == id {
s.editProxyID = ""
}
if s.settingsProxyID == id {
s.settingsProxyID = ""
s.proxyServer.SetText("")
}
s.proxyFeedback = fmt.Sprintf("已删除代理“%s”。", option.Server)
s.notifyProxiesChanged()
return
}
s.proxyFeedback = "找不到要删除的代理。"
}
func (s *Shell) proxyUseCount(id string) int {
count := 0
for _, row := range s.rows {
if row.ProxyID == id {
count++
}
}
return count
}
func (s *Shell) notifyProxiesChanged() {
if s.onProxiesChanged != nil {
s.onProxiesChanged(append([]ProxyOption(nil), s.proxies...))
}
}
func (s *Shell) requestInstanceAction(id string) {
row, ok := s.instanceRow(id)
if !ok {
s.instanceFeedback = "找不到要操作的实例。"
return
}
mode := instanceActionFor(row)
if !mode.enabled {
s.instanceFeedback = fmt.Sprintf("%s%s,请稍候。", row.Name, mode.label)
return
}
if mode.stop {
s.requestStop(id)
return
}
s.requestStart(id)
}
func (s *Shell) requestStop(id string) {
row, ok := s.instanceRow(id)
if !ok {
s.instanceFeedback = "找不到要停止的实例。"
return
}
if row.Status != "运行中" && row.Status != "运行中(调试不可用)" {
s.instanceFeedback = fmt.Sprintf("%s 当前不能由 Chub 停止。", row.Name)
return
}
if row.OccupancySource != "" && row.OccupancySource != "chub_registry" {
s.instanceFeedback = fmt.Sprintf("%s 不是 Chub 托管实例,不能停止。", row.Name)
return
}
state := s.stopStates[id]
if state != nil && state.running {
s.instanceFeedback = fmt.Sprintf("%s 正在停止,请稍候。", row.Name)
return
}
stopper := s.instanceStopper
if stopper == nil {
s.instanceFeedback = "浏览器停止服务尚未准备好。"
return
}
if state == nil {
state = &instanceStopState{}
s.stopStates[id] = state
}
state.request++
request := state.request
state.running = true
launchGeneration := uint64(0)
if started := s.startStates[id]; started != nil {
launchGeneration = started.request
}
state.previousStatus = row.Status
s.setInstanceStatus(id, "停止中")
s.instanceFeedback = fmt.Sprintf("正在请求 %s 正常退出…", row.Name)
invalidate := s.invalidate
go func() {
err := stopper(context.Background(), row, launchGeneration)
s.stopResults <- instanceStopResult{id: id, request: request, err: err}
if invalidate != nil {
invalidate()
}
}()
}
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 stop := s.stopStates[id]; stop != nil && stop.running {
s.instanceFeedback = fmt.Sprintf("%s 正在停止,请等待退出后再启动。", row.Name)
return
}
starter := s.instanceStarter
if starter == 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)
invalidate := s.invalidate
go func() {
outcome, err := starter(context.Background(), row, settings, request)
s.startResults <- instanceStartResult{id: id, request: request, outcome: outcome, err: err}
if invalidate != nil {
invalidate()
}
}()
}
func (s *Shell) consumeStopResults() {
for {
select {
case result := <-s.stopResults:
state := s.stopStates[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 row.Status == "停止中" {
s.setInstanceRuntime(result.id, state.previousStatus, row.PID, row.RemoteDebugPort, row.OccupancySource)
}
s.instanceFeedback = fmt.Sprintf("停止 %s 失败:%v", row.Name, result.err)
continue
}
s.setInstanceRuntime(result.id, "已退出", 0, 0, "")
s.focusRestoreID = result.id
s.instanceFeedback = fmt.Sprintf("%s 已正常退出。", row.Name)
default:
return
}
}
}
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 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 {
s.instanceFeedback = fmt.Sprintf("已启动 %s(PID %d,端口 %d)。", row.Name, result.outcome.PID, result.outcome.RemoteDebugPort)
}
default:
return
}
}
}
func (s *Shell) requestRefresh() {
if s.refreshClickState.running {
s.instanceFeedback = "实例状态正在刷新,请稍候。"
return
}
refresher := s.instanceRefresher
if refresher == nil {
s.instanceFeedback = "实例状态刷新服务尚未准备好。"
return
}
rows := append([]InstanceRow(nil), s.rows...)
snapshots := make(map[string]string, len(rows))
for _, row := range rows {
snapshots[row.ID] = instanceRefreshFingerprint(row)
}
s.refreshClickState.request++
request := s.refreshClickState.request
s.refreshClickState.running = true
s.instanceFeedback = "正在刷新已保存实例的状态…"
invalidate := s.invalidate
go func() {
results, err := refresher(context.Background(), rows)
s.refreshResults <- instanceRefreshResult{request: request, snapshots: snapshots, results: results, err: err}
if invalidate != nil {
invalidate()
}
}()
}
func (s *Shell) consumeRefreshResults() {
for {
select {
case result := <-s.refreshResults:
if !s.refreshClickState.running || result.request != s.refreshClickState.request {
continue
}
s.refreshClickState.running = false
if result.err != nil {
s.instanceFeedback = fmt.Sprintf("刷新实例状态失败:%v", result.err)
continue
}
applied, skipped, failed := 0, 0, 0
for _, refreshed := range result.results {
row, exists := s.instanceRow(refreshed.ID)
if !exists || result.snapshots[refreshed.ID] != instanceRefreshFingerprint(row) {
skipped++
continue
}
if refreshed.Status == "" {
failed++
continue
}
s.setInstanceRuntime(refreshed.ID, refreshed.Status, refreshed.PID, refreshed.RemoteDebugPort, refreshed.OccupancySource)
if refreshed.Message != "" {
failed++
}
applied++
}
switch {
case applied == 0 && failed > 0:
s.instanceFeedback = "实例状态刷新完成,但部分实例无法确认,请查看状态后重试。"
case skipped > 0:
s.instanceFeedback = fmt.Sprintf("已刷新 %d 个实例;忽略 %d 个已编辑或删除记录的过期结果。", applied, skipped)
case failed > 0:
s.instanceFeedback = fmt.Sprintf("已刷新 %d 个实例;%d 个实例状态无法完全确认。", applied, failed)
default:
s.instanceFeedback = fmt.Sprintf("已刷新 %d 个已保存实例的状态。", applied)
}
default:
return
}
}
}
func (s *Shell) consumeManagedExitResults() {
s.managedExitMu.Lock()
results := s.managedExitResults
s.managedExitResults = nil
s.managedExitMu.Unlock()
for _, result := range results {
key := managedExitKey{instanceID: result.InstanceID, launchGeneration: result.LaunchGeneration, pid: result.PID}
row, exists := s.instanceRow(result.InstanceID)
if !exists {
continue
}
state := s.startStates[result.InstanceID]
if state != nil && state.running && state.request == result.LaunchGeneration && row.Status == "启动中" {
s.pendingManagedExit[key] = result
continue
}
s.applyManagedExit(result)
}
}
func (s *Shell) applyPendingManagedExit(id string, launchGeneration uint64, pid int) bool {
key := managedExitKey{instanceID: id, launchGeneration: launchGeneration, pid: pid}
result, exists := s.pendingManagedExit[key]
if !exists {
return false
}
delete(s.pendingManagedExit, key)
return s.applyManagedExit(result)
}
func (s *Shell) applyManagedExit(result ManagedInstanceExit) bool {
row, exists := s.instanceRow(result.InstanceID)
if !exists || row.PID != result.PID {
return false
}
state := s.startStates[result.InstanceID]
if state == nil || state.request != result.LaunchGeneration {
return false
}
if row.Status != "运行中" && row.Status != "运行中(调试不可用)" && row.Status != "停止中" {
return false
}
s.setInstanceRuntime(result.InstanceID, "已退出", 0, 0, "")
if result.ExpectedStop || row.Status == "停止中" {
s.instanceFeedback = fmt.Sprintf("%s 已正常退出。", row.Name)
return true
}
for _, notice := range s.unexpectedExits {
if notice.instanceID == row.ID {
return true
}
}
s.unexpectedExits = append(s.unexpectedExits, unexpectedExitNotice{instanceID: row.ID, name: row.Name, browser: row.Browser})
if s.unexpectedExitFocusID == "" {
s.unexpectedExitFocusID = row.ID
}
s.instanceFeedback = fmt.Sprintf("检测到“%s”的 %s 已退出,可重新启动。", row.Name, row.Browser)
return true
}
func instanceRefreshFingerprint(row InstanceRow) string {
return strings.Join([]string{row.ID, row.Name, row.Browser, row.UserDataDir, row.TargetURL, row.ProxyID}, "\x00")
}
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
}
if state := s.stopStates[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) consumeProxyDeleteConfirmation(gtx layout.Context) {
for s.proxyDeleteBlocker.Clicked(gtx) {
}
for s.proxyDeleteCancel.Clicked(gtx) {
s.cancelProxyDelete()
}
for s.proxyDeleteConfirm.Clicked(gtx) {
s.confirmProxyDelete()
}
}
func (s *Shell) consumeUnexpectedExitDialog(gtx layout.Context) {
for s.unexpectedExitBlocker.Clicked(gtx) {
}
for s.unexpectedExitAck.Clicked(gtx) {
s.dismissUnexpectedExitNotice()
}
}
func (s *Shell) hasOtherModal() bool {
return s.pendingDeleteID != "" || s.pendingProxyDelete != "" || s.editingID != "" || s.pendingEditDiscard || s.proxyPicker.open
}
func (s *Shell) presentUnexpectedExitIfReady() {
if s.unexpectedExitOpen || len(s.unexpectedExits) == 0 || s.hasOtherModal() {
return
}
s.unexpectedExitOpen = true
s.unexpectedExitFocus = true
}
func (s *Shell) dismissUnexpectedExitNotice() {
s.unexpectedExitOpen = false
s.unexpectedExitFocus = false
s.unexpectedExits = nil
s.focusStartID = s.unexpectedExitFocusID
s.unexpectedExitFocusID = ""
}
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.rowClicks, id)
delete(s.editClicks, id)
delete(s.deleteClicks, id)
delete(s.startStates, id)
delete(s.stopStates, 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) replaceInstanceRow(updated InstanceRow) bool {
for i := range s.rows {
if s.rows[i].ID == updated.ID {
s.rows[i] = updated
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() {
s.chooseDirectory(directoryPickCreate)
}
func (s *Shell) chooseDirectory(target directoryPickTarget) {
if s.directoryPick.running {
if target == directoryPickEdit {
s.editFeedback = "目录选择器已打开,请在系统窗口中选择或取消。"
} else {
s.formFeedback = "目录选择器已打开,请在系统窗口中选择或取消。"
}
return
}
if s.directoryChooser == nil {
if target == directoryPickEdit {
s.editFeedback = "目录选择器尚未准备好。"
} else {
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.directoryPick.target = target
if target == directoryPickEdit {
s.editFeedback = "正在打开目录选择器…"
} else {
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
}
target := s.directoryPick.target
s.directoryPick.running = false
s.directoryPick.cancel = nil
if result.err != nil {
if errors.Is(result.err, context.Canceled) {
s.setDirectoryPickFeedback(target, "已取消选择 User Data Dir。")
} else {
s.setDirectoryPickFeedback(target, fmt.Sprintf("选择 User Data Dir 失败:%v", result.err))
}
continue
}
if result.path == "" {
s.setDirectoryPickFeedback(target, "未选择 User Data Dir。")
continue
}
if target == directoryPickEdit {
s.editDir.SetText(result.path)
} else {
s.instanceDir.SetText(result.path)
}
s.setDirectoryPickFeedback(target, "已更新 User Data Dir。")
default:
return
}
}
}
func (s *Shell) setDirectoryPickFeedback(target directoryPickTarget, message string) {
if target == directoryPickEdit {
s.editFeedback = message
return
}
s.formFeedback = message
}
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))
}