2026-07-22 15:13:55 +08:00
|
|
|
|
package ui
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
2026-07-22 15:57:02 +08:00
|
|
|
|
"context"
|
|
|
|
|
|
"errors"
|
|
|
|
|
|
"fmt"
|
2026-07-22 17:02:59 +08:00
|
|
|
|
"image"
|
2026-07-22 15:13:55 +08:00
|
|
|
|
"image/color"
|
2026-07-22 17:02:59 +08:00
|
|
|
|
"strings"
|
2026-07-22 15:13:55 +08:00
|
|
|
|
|
|
|
|
|
|
"gioui.org/layout"
|
2026-07-22 17:02:59 +08:00
|
|
|
|
"gioui.org/op/clip"
|
|
|
|
|
|
"gioui.org/op/paint"
|
2026-07-22 15:13:55 +08:00
|
|
|
|
"gioui.org/unit"
|
|
|
|
|
|
"gioui.org/widget"
|
|
|
|
|
|
"gioui.org/widget/material"
|
2026-07-22 16:37:34 +08:00
|
|
|
|
"golang.org/x/exp/shiny/materialdesign/icons"
|
2026-07-22 15:13:55 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
type page uint8
|
|
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
|
pageInstances page = iota
|
|
|
|
|
|
pageSettings
|
2026-07-22 15:24:25 +08:00
|
|
|
|
pageCreate
|
2026-07-22 15:13:55 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-22 16:30:20 +08:00
|
|
|
|
const (
|
2026-07-22 16:37:34 +08:00
|
|
|
|
instanceNameColumnWeight = 24
|
2026-07-22 16:30:20 +08:00
|
|
|
|
instanceBrowserColumnWeight = 12
|
2026-07-22 16:37:34 +08:00
|
|
|
|
instanceDirectoryColumnWeight = 38
|
|
|
|
|
|
instanceStatusColumnWeight = 12
|
|
|
|
|
|
instanceActionColumnWeight = 14
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
var (
|
|
|
|
|
|
instanceStartIcon = mustIcon(icons.AVPlayArrow)
|
|
|
|
|
|
instanceDeleteIcon = mustIcon(icons.ActionDelete)
|
2026-07-22 16:30:20 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-22 15:57:02 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-07-22 16:06:39 +08:00
|
|
|
|
type DirectoryChooser func(context.Context) (string, error)
|
|
|
|
|
|
|
2026-07-22 17:02:59 +08:00
|
|
|
|
type InstanceStarter func(context.Context, InstanceRow, SettingsState) (int, error)
|
|
|
|
|
|
|
|
|
|
|
|
type instanceStartState struct {
|
|
|
|
|
|
request uint64
|
|
|
|
|
|
running bool
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type instanceStartResult struct {
|
|
|
|
|
|
id string
|
|
|
|
|
|
request uint64
|
|
|
|
|
|
pid int
|
|
|
|
|
|
err error
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 15:57:02 +08:00
|
|
|
|
type pathSearchState struct {
|
|
|
|
|
|
request uint64
|
|
|
|
|
|
cancel context.CancelFunc
|
|
|
|
|
|
running bool
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type pathSearchResult struct {
|
|
|
|
|
|
field PathField
|
|
|
|
|
|
request uint64
|
|
|
|
|
|
path string
|
|
|
|
|
|
err error
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 16:06:39 +08:00
|
|
|
|
type directoryPickState struct {
|
|
|
|
|
|
request uint64
|
|
|
|
|
|
cancel context.CancelFunc
|
|
|
|
|
|
running bool
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type directoryPickResult struct {
|
|
|
|
|
|
request uint64
|
|
|
|
|
|
path string
|
|
|
|
|
|
err error
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 15:13:55 +08:00
|
|
|
|
// 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 {
|
2026-07-22 16:22:59 +08:00
|
|
|
|
ID string
|
|
|
|
|
|
Name string
|
|
|
|
|
|
Browser string
|
|
|
|
|
|
UserDataDir string
|
2026-07-22 17:02:59 +08:00
|
|
|
|
TargetURL string
|
2026-07-22 16:22:59 +08:00
|
|
|
|
Status string
|
2026-07-22 15:13:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 15:17:08 +08:00
|
|
|
|
type SettingsState struct {
|
|
|
|
|
|
ChromePath string
|
|
|
|
|
|
EdgePath string
|
|
|
|
|
|
DefaultDir string
|
|
|
|
|
|
LogDir string
|
|
|
|
|
|
CloseOnExit bool
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 15:13:55 +08:00
|
|
|
|
// 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
|
|
|
|
|
|
|
2026-07-22 15:57:02 +08:00
|
|
|
|
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
|
2026-07-22 15:13:55 +08:00
|
|
|
|
|
2026-07-22 15:24:25 +08:00
|
|
|
|
chromePath widget.Editor
|
|
|
|
|
|
edgePath widget.Editor
|
|
|
|
|
|
dataDir widget.Editor
|
|
|
|
|
|
logDir widget.Editor
|
|
|
|
|
|
closeOnExit widget.Bool
|
|
|
|
|
|
instanceName widget.Editor
|
|
|
|
|
|
instanceDir widget.Editor
|
|
|
|
|
|
instanceURL widget.Editor
|
2026-07-22 15:57:02 +08:00
|
|
|
|
browserKind widget.Enum
|
2026-07-22 15:30:03 +08:00
|
|
|
|
pathFeedback string
|
2026-07-22 15:57:02 +08:00
|
|
|
|
formFeedback string
|
2026-07-22 15:13:55 +08:00
|
|
|
|
|
2026-07-22 17:02:59 +08:00
|
|
|
|
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
|
2026-07-22 15:13:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func NewShell(theme *material.Theme) *Shell {
|
|
|
|
|
|
s := &Shell{theme: theme, rows: []InstanceRow{
|
2026-07-22 16:22:59 +08:00
|
|
|
|
{ID: "demo-operations", Name: "运营主账号", Browser: "Chrome", UserDataDir: `C:\Users\Public\chub\profiles\operations`, Status: "运行中"},
|
|
|
|
|
|
{ID: "demo-ads", Name: "广告投放", Browser: "Edge", UserDataDir: `C:\Users\Public\chub\profiles\ads`, Status: "启动中"},
|
|
|
|
|
|
{ID: "demo-assets", Name: "素材采集", Browser: "Chrome", UserDataDir: `C:\Users\Public\chub\profiles\assets`, Status: "外部占用"},
|
|
|
|
|
|
{ID: "demo-backup", Name: "备用环境", Browser: "Edge", UserDataDir: `C:\Users\Public\chub\profiles\backup`, Status: "已退出"},
|
2026-07-22 15:57:02 +08:00
|
|
|
|
}, searches: map[PathField]*pathSearchState{
|
|
|
|
|
|
PathChromeExecutable: {},
|
|
|
|
|
|
PathEdgeExecutable: {},
|
|
|
|
|
|
PathDefaultUserData: {},
|
|
|
|
|
|
PathLogDirectory: {},
|
2026-07-22 17:02:59 +08:00
|
|
|
|
}, 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)}
|
2026-07-22 15:13:55 +08:00
|
|
|
|
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.closeOnExit.Value = true
|
2026-07-22 15:57:02 +08:00
|
|
|
|
s.browserKind.Value = "chrome"
|
2026-07-22 16:42:45 +08:00
|
|
|
|
s.list.Axis = layout.Vertical
|
2026-07-22 15:13:55 +08:00
|
|
|
|
return s
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 15:17:08 +08:00
|
|
|
|
func (s *Shell) SetInstances(rows []InstanceRow) {
|
|
|
|
|
|
s.rows = append([]InstanceRow(nil), rows...)
|
2026-07-22 16:22:59 +08:00
|
|
|
|
for i := range s.rows {
|
|
|
|
|
|
if s.rows[i].ID == "" {
|
|
|
|
|
|
s.nextInstance++
|
|
|
|
|
|
s.rows[i].ID = fmt.Sprintf("restored-%d", s.nextInstance)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-22 15:17:08 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
s.closeOnExit.Value = value.CloseOnExit
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) OnSave(fn func(SettingsState)) { s.onSave = fn }
|
|
|
|
|
|
|
2026-07-22 17:02:59 +08:00
|
|
|
|
func (s *Shell) OnInstancesChanged(fn func([]InstanceRow)) { s.onInstancesChanged = fn }
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) OnStartInstance(starter InstanceStarter, invalidate func()) {
|
|
|
|
|
|
s.instanceStarter = starter
|
|
|
|
|
|
s.invalidate = invalidate
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 15:57:02 +08:00
|
|
|
|
func (s *Shell) OnPathSearch(searcher PathSearcher, invalidate func()) {
|
|
|
|
|
|
s.pathSearcher = searcher
|
|
|
|
|
|
s.invalidate = invalidate
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 16:06:39 +08:00
|
|
|
|
func (s *Shell) OnChooseDirectory(chooser DirectoryChooser, invalidate func()) {
|
|
|
|
|
|
s.directoryChooser = chooser
|
|
|
|
|
|
s.invalidate = invalidate
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 15:13:55 +08:00
|
|
|
|
func (s *Shell) Layout(gtx layout.Context) layout.Dimensions {
|
2026-07-22 15:57:02 +08:00
|
|
|
|
s.consumeSearchResults()
|
2026-07-22 16:06:39 +08:00
|
|
|
|
s.consumeDirectoryResults()
|
2026-07-22 17:02:59 +08:00
|
|
|
|
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) {
|
2026-07-22 15:13:55 +08:00
|
|
|
|
for s.instancesClick.Clicked(gtx) {
|
|
|
|
|
|
s.page = pageInstances
|
|
|
|
|
|
}
|
|
|
|
|
|
for s.settingsClick.Clicked(gtx) {
|
|
|
|
|
|
s.page = pageSettings
|
|
|
|
|
|
}
|
2026-07-22 15:24:25 +08:00
|
|
|
|
for s.newClick.Clicked(gtx) {
|
|
|
|
|
|
s.page = pageCreate
|
|
|
|
|
|
}
|
|
|
|
|
|
for s.createClick.Clicked(gtx) {
|
2026-07-22 17:02:59 +08:00
|
|
|
|
s.createInstanceFromForm()
|
2026-07-22 15:24:25 +08:00
|
|
|
|
}
|
2026-07-22 15:57:02 +08:00
|
|
|
|
for s.instanceDirPick.Clicked(gtx) {
|
2026-07-22 16:06:39 +08:00
|
|
|
|
s.chooseInstanceDirectory()
|
2026-07-22 15:57:02 +08:00
|
|
|
|
}
|
2026-07-22 15:24:25 +08:00
|
|
|
|
for s.backClick.Clicked(gtx) {
|
|
|
|
|
|
s.page = pageInstances
|
|
|
|
|
|
}
|
2026-07-22 16:22:59 +08:00
|
|
|
|
for _, row := range s.rows {
|
|
|
|
|
|
for s.startClickFor(row.ID).Clicked(gtx) {
|
|
|
|
|
|
s.requestStart(row.ID)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-22 16:37:34 +08:00
|
|
|
|
for _, row := range append([]InstanceRow(nil), s.rows...) {
|
|
|
|
|
|
for s.deleteClickFor(row.ID).Clicked(gtx) {
|
2026-07-22 17:02:59 +08:00
|
|
|
|
s.requestDelete(row.ID)
|
2026-07-22 16:37:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-22 15:13:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 15:57:02 +08:00
|
|
|
|
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)),
|
2026-07-22 15:13:55 +08:00
|
|
|
|
)
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 15:57:02 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 15:13:55 +08:00
|
|
|
|
func (s *Shell) content(gtx layout.Context) layout.Dimensions {
|
|
|
|
|
|
if s.page == pageSettings {
|
|
|
|
|
|
return s.settings(gtx)
|
|
|
|
|
|
}
|
2026-07-22 15:24:25 +08:00
|
|
|
|
if s.page == pageCreate {
|
|
|
|
|
|
return s.createInstance(gtx)
|
|
|
|
|
|
}
|
2026-07-22 15:13:55 +08:00
|
|
|
|
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),
|
2026-07-22 16:22:59 +08:00
|
|
|
|
layout.Rigid(s.instanceHeader),
|
|
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
2026-07-22 15:13:55 +08:00
|
|
|
|
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]
|
2026-07-22 16:30:20 +08:00
|
|
|
|
return layout.Inset{Right: unit.Dp(4), Bottom: unit.Dp(10)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
2026-07-22 15:13:55 +08:00
|
|
|
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
2026-07-22 16:30:20 +08:00
|
|
|
|
layout.Flexed(instanceNameColumnWeight, material.Body1(s.theme, row.Name).Layout),
|
|
|
|
|
|
layout.Flexed(instanceBrowserColumnWeight, material.Body2(s.theme, row.Browser).Layout),
|
|
|
|
|
|
layout.Flexed(instanceDirectoryColumnWeight, pathCell(s.theme, row.UserDataDir)),
|
|
|
|
|
|
layout.Flexed(instanceStatusColumnWeight, statusLabel(s.theme, row.Status)),
|
2026-07-22 16:37:34 +08:00
|
|
|
|
layout.Flexed(instanceActionColumnWeight, s.instanceActionButtons(row)),
|
2026-07-22 15:13:55 +08:00
|
|
|
|
)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
}),
|
2026-07-22 16:22:59 +08:00
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
|
|
|
|
layout.Rigid(material.Caption(s.theme, s.instanceFeedback).Layout),
|
2026-07-22 15:13:55 +08:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 16:22:59 +08:00
|
|
|
|
func (s *Shell) instanceHeader(gtx layout.Context) layout.Dimensions {
|
2026-07-22 16:30:20 +08:00
|
|
|
|
return layout.Inset{Right: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
|
|
|
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
|
|
|
|
|
layout.Flexed(instanceNameColumnWeight, material.Caption(s.theme, "实例名称").Layout),
|
|
|
|
|
|
layout.Flexed(instanceBrowserColumnWeight, material.Caption(s.theme, "浏览器类型").Layout),
|
|
|
|
|
|
layout.Flexed(instanceDirectoryColumnWeight, material.Caption(s.theme, "用户数据目录").Layout),
|
|
|
|
|
|
layout.Flexed(instanceStatusColumnWeight, material.Caption(s.theme, "状态").Layout),
|
2026-07-22 16:37:34 +08:00
|
|
|
|
layout.Flexed(instanceActionColumnWeight, func(gtx layout.Context) layout.Dimensions {
|
|
|
|
|
|
return layout.E.Layout(gtx, material.Caption(s.theme, "操作").Layout)
|
2026-07-22 16:30:20 +08:00
|
|
|
|
}),
|
|
|
|
|
|
)
|
|
|
|
|
|
})
|
2026-07-22 16:22:59 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func pathCell(theme *material.Theme, path string) layout.Widget {
|
|
|
|
|
|
style := material.Body2(theme, path)
|
2026-07-22 16:30:20 +08:00
|
|
|
|
style.MaxLines = 1
|
2026-07-22 16:22:59 +08:00
|
|
|
|
return style.Layout
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 16:37:34 +08:00
|
|
|
|
func (s *Shell) instanceActionButtons(row InstanceRow) layout.Widget {
|
2026-07-22 16:30:20 +08:00
|
|
|
|
return func(gtx layout.Context) layout.Dimensions {
|
|
|
|
|
|
return layout.E.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
2026-07-22 16:37:34 +08:00
|
|
|
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
|
|
|
|
|
layout.Rigid(s.instanceIconButton(s.startClickFor(row.ID), instanceStartIcon, "启动 "+row.Name, s.theme.Palette.ContrastBg)),
|
|
|
|
|
|
layout.Rigid(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})),
|
|
|
|
|
|
)
|
2026-07-22 16:30:20 +08:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 16:37:34 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 17:02:59 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 15:13:55 +08:00
|
|
|
|
func statusLabel(theme *material.Theme, status string) layout.Widget {
|
|
|
|
|
|
style := material.Label(theme, unit.Sp(13), status)
|
2026-07-22 17:02:59 +08:00
|
|
|
|
style.Color = theme.Palette.Fg
|
|
|
|
|
|
if color, ok := map[string]color.NRGBA{
|
2026-07-22 15:13:55 +08:00
|
|
|
|
"运行中": {R: 24, G: 125, B: 78, A: 255},
|
|
|
|
|
|
"启动中": {R: 175, G: 105, B: 0, A: 255},
|
2026-07-22 17:02:59 +08:00
|
|
|
|
"启动失败": {R: 188, G: 51, B: 51, A: 255},
|
2026-07-22 15:13:55 +08:00
|
|
|
|
"外部占用": {R: 190, G: 75, B: 35, A: 255},
|
|
|
|
|
|
"已退出": {R: 100, G: 105, B: 115, A: 255},
|
2026-07-22 17:02:59 +08:00
|
|
|
|
}[status]; ok {
|
|
|
|
|
|
style.Color = color
|
|
|
|
|
|
}
|
2026-07-22 15:13:55 +08:00
|
|
|
|
return style.Layout
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) settings(gtx layout.Context) layout.Dimensions {
|
2026-07-22 15:30:03 +08:00
|
|
|
|
for s.chromePick.Clicked(gtx) {
|
|
|
|
|
|
s.pathFeedback = "请选择 Chrome 可执行文件"
|
|
|
|
|
|
}
|
|
|
|
|
|
for s.chromeSearch.Clicked(gtx) {
|
2026-07-22 15:57:02 +08:00
|
|
|
|
s.togglePathSearch(PathChromeExecutable)
|
2026-07-22 15:30:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
for s.edgePick.Clicked(gtx) {
|
|
|
|
|
|
s.pathFeedback = "请选择 Edge 可执行文件"
|
|
|
|
|
|
}
|
|
|
|
|
|
for s.edgeSearch.Clicked(gtx) {
|
2026-07-22 15:57:02 +08:00
|
|
|
|
s.togglePathSearch(PathEdgeExecutable)
|
2026-07-22 15:30:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
for s.dataPick.Clicked(gtx) {
|
|
|
|
|
|
s.pathFeedback = "请选择默认 User Data Dir"
|
|
|
|
|
|
}
|
|
|
|
|
|
for s.dataSearch.Clicked(gtx) {
|
2026-07-22 15:57:02 +08:00
|
|
|
|
s.togglePathSearch(PathDefaultUserData)
|
2026-07-22 15:30:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
for s.logPick.Clicked(gtx) {
|
|
|
|
|
|
s.pathFeedback = "请选择日志目录"
|
|
|
|
|
|
}
|
|
|
|
|
|
for s.logSearch.Clicked(gtx) {
|
2026-07-22 15:57:02 +08:00
|
|
|
|
s.togglePathSearch(PathLogDirectory)
|
2026-07-22 15:30:03 +08:00
|
|
|
|
}
|
2026-07-22 15:17:08 +08:00
|
|
|
|
for s.saveClick.Clicked(gtx) {
|
|
|
|
|
|
if s.onSave != nil {
|
|
|
|
|
|
s.onSave(SettingsState{ChromePath: s.chromePath.Text(), EdgePath: s.edgePath.Text(), DefaultDir: s.dataDir.Text(), LogDir: s.logDir.Text(), CloseOnExit: s.closeOnExit.Value})
|
|
|
|
|
|
}
|
2026-07-22 15:13:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
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),
|
2026-07-22 15:57:02 +08:00
|
|
|
|
layout.Rigid(s.pathField(PathChromeExecutable, "Chrome 可执行文件", &s.chromePath, &s.chromePick, &s.chromeSearch)),
|
2026-07-22 15:13:55 +08:00
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
2026-07-22 15:57:02 +08:00
|
|
|
|
layout.Rigid(s.pathField(PathEdgeExecutable, "Edge 可执行文件", &s.edgePath, &s.edgePick, &s.edgeSearch)),
|
2026-07-22 15:13:55 +08:00
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
2026-07-22 15:57:02 +08:00
|
|
|
|
layout.Rigid(s.pathField(PathDefaultUserData, "默认 User Data Dir", &s.dataDir, &s.dataPick, &s.dataSearch)),
|
2026-07-22 15:13:55 +08:00
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
2026-07-22 15:57:02 +08:00
|
|
|
|
layout.Rigid(s.pathField(PathLogDirectory, "日志目录", &s.logDir, &s.logPick, &s.logSearch)),
|
2026-07-22 15:30:03 +08:00
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
|
|
|
|
layout.Rigid(material.Caption(s.theme, s.pathFeedback).Layout),
|
2026-07-22 15:13:55 +08:00
|
|
|
|
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),
|
|
|
|
|
|
)
|
|
|
|
|
|
}),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 15:57:02 +08:00
|
|
|
|
func (s *Shell) pathField(field PathField, label string, editor *widget.Editor, pick, search *widget.Clickable) layout.Widget {
|
2026-07-22 15:30:03 +08:00
|
|
|
|
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),
|
2026-07-22 15:57:02 +08:00
|
|
|
|
layout.Rigid(material.Button(s.theme, search, s.searchButtonLabel(field)).Layout),
|
2026-07-22 15:30:03 +08:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 15:24:25 +08:00
|
|
|
|
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),
|
2026-07-22 15:57:02 +08:00
|
|
|
|
layout.Rigid(s.formField("实例名称", "用于在实例列表中识别此浏览器环境", &s.instanceName)),
|
2026-07-22 15:24:25 +08:00
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
2026-07-22 15:57:02 +08:00
|
|
|
|
layout.Rigid(s.instanceDirField),
|
2026-07-22 15:24:25 +08:00
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
2026-07-22 15:57:02 +08:00
|
|
|
|
layout.Rigid(s.formField("启动 URL(可选)", "仅支持 http/https;留空时启动浏览器默认页", &s.instanceURL)),
|
2026-07-22 15:24:25 +08:00
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout),
|
2026-07-22 15:57:02 +08:00
|
|
|
|
layout.Rigid(material.Body2(s.theme, "浏览器类型").Layout),
|
2026-07-22 15:24:25 +08:00
|
|
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
|
|
|
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
2026-07-22 15:57:02 +08:00
|
|
|
|
layout.Rigid(material.RadioButton(s.theme, &s.browserKind, "chrome", "Chrome").Layout),
|
2026-07-22 15:24:25 +08:00
|
|
|
|
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
2026-07-22 15:57:02 +08:00
|
|
|
|
layout.Rigid(material.RadioButton(s.theme, &s.browserKind, "edge", "Edge").Layout),
|
2026-07-22 15:24:25 +08:00
|
|
|
|
)
|
|
|
|
|
|
}),
|
2026-07-22 15:57:02 +08:00
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
|
|
|
|
layout.Rigid(material.Caption(s.theme, s.formFeedback).Layout),
|
2026-07-22 15:24:25 +08:00
|
|
|
|
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),
|
|
|
|
|
|
)
|
|
|
|
|
|
}),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 15:57:02 +08:00
|
|
|
|
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),
|
2026-07-22 16:06:39 +08:00
|
|
|
|
layout.Rigid(material.Button(s.theme, &s.instanceDirPick, s.instanceDirButtonLabel()).Layout),
|
2026-07-22 15:57:02 +08:00
|
|
|
|
)
|
|
|
|
|
|
}),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 17:02:59 +08:00
|
|
|
|
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()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 15:57:02 +08:00
|
|
|
|
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"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 16:22:59 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 16:37:34 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 16:22:59 +08:00
|
|
|
|
func (s *Shell) requestStart(id string) {
|
2026-07-22 17:02:59 +08:00
|
|
|
|
row, ok := s.instanceRow(id)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
s.instanceFeedback = "找不到要启动的实例。"
|
2026-07-22 16:22:59 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-07-22 17:02:59 +08:00
|
|
|
|
state := s.startStates[id]
|
|
|
|
|
|
if state != nil && state.running {
|
|
|
|
|
|
s.instanceFeedback = fmt.Sprintf("%s 正在启动,请稍候。", row.Name)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if s.instanceStarter == nil {
|
|
|
|
|
|
s.instanceFeedback = "浏览器启动服务尚未准备好。"
|
|
|
|
|
|
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)
|
|
|
|
|
|
settings := s.settingsState()
|
|
|
|
|
|
go func() {
|
|
|
|
|
|
pid, err := s.instanceStarter(context.Background(), row, settings)
|
|
|
|
|
|
s.startResults <- instanceStartResult{id: id, request: request, pid: pid, 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 {
|
|
|
|
|
|
s.setInstanceStatus(result.id, "启动失败")
|
|
|
|
|
|
s.instanceFeedback = fmt.Sprintf("启动 %s 失败:%v", row.Name, result.err)
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
s.setInstanceStatus(result.id, "运行中")
|
|
|
|
|
|
s.instanceFeedback = fmt.Sprintf("已启动 %s(PID %d)。", row.Name, result.pid)
|
|
|
|
|
|
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)
|
2026-07-22 16:22:59 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 16:37:34 +08:00
|
|
|
|
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)
|
2026-07-22 17:02:59 +08:00
|
|
|
|
delete(s.startStates, id)
|
2026-07-22 16:37:34 +08:00
|
|
|
|
s.instanceFeedback = fmt.Sprintf("已删除实例“%s”;其 User Data Dir 未被删除。", row.Name)
|
2026-07-22 17:02:59 +08:00
|
|
|
|
s.notifyInstancesChanged()
|
2026-07-22 16:37:34 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
s.instanceFeedback = "找不到要删除的实例。"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 17:02:59 +08:00
|
|
|
|
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) settingsState() SettingsState {
|
|
|
|
|
|
return SettingsState{
|
|
|
|
|
|
ChromePath: s.chromePath.Text(),
|
|
|
|
|
|
EdgePath: s.edgePath.Text(),
|
|
|
|
|
|
DefaultDir: s.dataDir.Text(),
|
|
|
|
|
|
LogDir: s.logDir.Text(),
|
|
|
|
|
|
CloseOnExit: s.closeOnExit.Value,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) notifyInstancesChanged() {
|
|
|
|
|
|
if s.onInstancesChanged != nil {
|
|
|
|
|
|
s.onInstancesChanged(append([]InstanceRow(nil), s.rows...))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 16:37:34 +08:00
|
|
|
|
func mustIcon(data []byte) *widget.Icon {
|
|
|
|
|
|
icon, err := widget.NewIcon(data)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
panic(err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return icon
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 16:06:39 +08:00
|
|
|
|
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 "选择路径"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 15:13:55 +08:00
|
|
|
|
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`)
|
|
|
|
|
|
}
|