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-25 16:36:26 +08:00
|
|
|
|
"net/url"
|
|
|
|
|
|
"path/filepath"
|
2026-07-25 15:53:31 +08:00
|
|
|
|
"strconv"
|
2026-07-22 17:02:59 +08:00
|
|
|
|
"strings"
|
2026-07-25 18:23:33 +08:00
|
|
|
|
"sync"
|
2026-07-27 10:01:07 +08:00
|
|
|
|
"time"
|
2026-07-22 15:13:55 +08:00
|
|
|
|
|
2026-07-25 15:53:31 +08:00
|
|
|
|
"chub/internal/domain"
|
2026-07-25 16:36:26 +08:00
|
|
|
|
"gioui.org/io/key"
|
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-27 12:09:54 +08:00
|
|
|
|
instanceNameColumnWeight = 17
|
|
|
|
|
|
instanceBrowserColumnWeight = 9
|
|
|
|
|
|
instanceDirectoryColumnWeight = 28
|
|
|
|
|
|
instanceProxyColumnWeight = 9
|
|
|
|
|
|
instancePortColumnWeight = 9
|
|
|
|
|
|
instanceStatusColumnWeight = 10
|
2026-07-25 17:57:04 +08:00
|
|
|
|
instanceActionColumnWeight = 18
|
2026-07-28 07:54:37 +08:00
|
|
|
|
instanceActionIconSize = unit.Dp(16)
|
|
|
|
|
|
instanceActionIconInset = unit.Dp(7)
|
|
|
|
|
|
instanceStandardRowHeight = instanceActionIconSize + 2*instanceActionIconInset
|
|
|
|
|
|
instanceListRowMinHeight = 2 * instanceStandardRowHeight
|
2026-07-22 16:37:34 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
var (
|
2026-07-27 09:40:37 +08:00
|
|
|
|
instanceStartIcon = mustIcon(icons.AVPlayArrow)
|
|
|
|
|
|
instanceStopIcon = mustIcon(icons.AVStop)
|
|
|
|
|
|
instanceEditIcon = mustIcon(icons.EditorModeEdit)
|
|
|
|
|
|
instanceDeleteIcon = mustIcon(icons.ActionDelete)
|
|
|
|
|
|
instanceFormWorkspaceBorder = color.NRGBA{R: 199, G: 207, B: 218, A: 255}
|
|
|
|
|
|
instanceFormComponentBorder = color.NRGBA{R: 225, G: 230, B: 238, A: 255}
|
|
|
|
|
|
instanceFormActionBorder = color.NRGBA{R: 215, G: 222, B: 232, A: 255}
|
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-27 22:46:31 +08:00
|
|
|
|
type ExecutableChooser func(context.Context) (string, error)
|
|
|
|
|
|
|
2026-07-25 17:04:26 +08:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 15:53:31 +08:00
|
|
|
|
type InstanceStartOutcome struct {
|
|
|
|
|
|
PID int
|
|
|
|
|
|
RemoteDebugPort int
|
|
|
|
|
|
External bool
|
|
|
|
|
|
Source string
|
|
|
|
|
|
Warning string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 18:23:33 +08:00
|
|
|
|
// 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)
|
2026-07-22 17:02:59 +08:00
|
|
|
|
|
2026-07-25 17:10:20 +08:00
|
|
|
|
// InstanceStopper requests graceful shutdown only for the supplied configured
|
|
|
|
|
|
// instance. Implementations must reject external or unverified processes.
|
2026-07-25 18:23:33 +08:00
|
|
|
|
type InstanceStopper func(context.Context, InstanceRow, uint64) error
|
2026-07-25 17:10:20 +08:00
|
|
|
|
|
2026-07-27 23:16:11 +08:00
|
|
|
|
// InstanceDataDeleter removes the explicitly selected User Data Dir only after
|
|
|
|
|
|
// its implementation has revalidated browser occupancy and path boundaries.
|
|
|
|
|
|
type InstanceDataDeleter func(context.Context, InstanceRow) error
|
|
|
|
|
|
|
2026-07-25 16:36:26 +08:00
|
|
|
|
// 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)
|
|
|
|
|
|
|
2026-07-27 10:01:07 +08:00
|
|
|
|
// CDPTarget contains the safe, read-only fields displayed for one Chromium
|
|
|
|
|
|
// target. URL must already be stripped of userinfo, query, and fragment.
|
|
|
|
|
|
type CDPTarget struct {
|
|
|
|
|
|
ID string
|
|
|
|
|
|
Type string
|
|
|
|
|
|
Title string
|
|
|
|
|
|
URL string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// InstanceTabInspector performs a bounded, read-only target lookup for one
|
|
|
|
|
|
// current instance snapshot. It must not send browser-control commands.
|
|
|
|
|
|
type InstanceTabInspector func(context.Context, InstanceRow) ([]CDPTarget, error)
|
|
|
|
|
|
|
2026-07-22 17:02:59 +08:00
|
|
|
|
type instanceStartState struct {
|
|
|
|
|
|
request uint64
|
|
|
|
|
|
running bool
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type instanceStartResult struct {
|
|
|
|
|
|
id string
|
|
|
|
|
|
request uint64
|
2026-07-25 15:53:31 +08:00
|
|
|
|
outcome InstanceStartOutcome
|
2026-07-22 17:02:59 +08:00
|
|
|
|
err error
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 17:10:20 +08:00
|
|
|
|
type instanceStopState struct {
|
|
|
|
|
|
request uint64
|
|
|
|
|
|
running bool
|
|
|
|
|
|
previousStatus string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type instanceStopResult struct {
|
|
|
|
|
|
id string
|
|
|
|
|
|
request uint64
|
|
|
|
|
|
err error
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-27 23:16:11 +08:00
|
|
|
|
type instanceDataDeleteResult struct {
|
|
|
|
|
|
id string
|
|
|
|
|
|
request uint64
|
|
|
|
|
|
fingerprint string
|
|
|
|
|
|
err error
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 16:36:26 +08:00
|
|
|
|
type instanceRefreshState struct {
|
|
|
|
|
|
request uint64
|
|
|
|
|
|
running bool
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type instanceRefreshResult struct {
|
|
|
|
|
|
request uint64
|
|
|
|
|
|
snapshots map[string]string
|
|
|
|
|
|
results []InstanceRefreshResult
|
|
|
|
|
|
err error
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-27 10:01:07 +08:00
|
|
|
|
type instanceTabsResult struct {
|
|
|
|
|
|
request uint64
|
|
|
|
|
|
instanceID string
|
|
|
|
|
|
fingerprint string
|
|
|
|
|
|
targets []CDPTarget
|
|
|
|
|
|
err error
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 18:23:33 +08:00
|
|
|
|
type managedExitKey struct {
|
|
|
|
|
|
instanceID string
|
|
|
|
|
|
launchGeneration uint64
|
|
|
|
|
|
pid int
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type unexpectedExitNotice struct {
|
|
|
|
|
|
instanceID string
|
|
|
|
|
|
name string
|
|
|
|
|
|
browser string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
2026-07-25 16:36:26 +08:00
|
|
|
|
target directoryPickTarget
|
2026-07-22 16:06:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type directoryPickResult struct {
|
|
|
|
|
|
request uint64
|
|
|
|
|
|
path string
|
|
|
|
|
|
err error
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-27 22:46:31 +08:00
|
|
|
|
type executablePickState struct {
|
|
|
|
|
|
request uint64
|
|
|
|
|
|
cancel context.CancelFunc
|
|
|
|
|
|
running bool
|
|
|
|
|
|
field PathField
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type executablePickResult struct {
|
|
|
|
|
|
field PathField
|
|
|
|
|
|
request uint64
|
|
|
|
|
|
path string
|
|
|
|
|
|
err error
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 16:36:26 +08:00
|
|
|
|
type directoryPickTarget uint8
|
|
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
|
directoryPickCreate directoryPickTarget = iota
|
|
|
|
|
|
directoryPickEdit
|
2026-07-28 00:12:29 +08:00
|
|
|
|
directoryPickDefaultUserData
|
|
|
|
|
|
directoryPickLogDirectory
|
2026-07-25 16:36:26 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-25 17:04:26 +08:00
|
|
|
|
type proxyPickerTarget uint8
|
|
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
|
proxyPickerCreate proxyPickerTarget = iota
|
|
|
|
|
|
proxyPickerEdit
|
2026-07-25 17:57:04 +08:00
|
|
|
|
proxyPickerSettings
|
2026-07-25 17:04:26 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
type proxyPickerState struct {
|
|
|
|
|
|
open bool
|
|
|
|
|
|
target proxyPickerTarget
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 16:36:26 +08:00
|
|
|
|
// InstanceRow is the Gio view model for one configured browser instance.
|
2026-07-27 09:29:10 +08:00
|
|
|
|
// PreferredRemoteDebugPort is saved configuration; runtime fields are refreshed
|
|
|
|
|
|
// independently and are never saved by the edit form.
|
2026-07-22 15:13:55 +08:00
|
|
|
|
type InstanceRow struct {
|
2026-07-27 09:29:10 +08:00
|
|
|
|
ID string
|
|
|
|
|
|
Name string
|
|
|
|
|
|
Browser string
|
|
|
|
|
|
UserDataDir string
|
|
|
|
|
|
TargetURL string
|
|
|
|
|
|
ProxyID string
|
|
|
|
|
|
PreferredRemoteDebugPort int
|
|
|
|
|
|
PID int
|
|
|
|
|
|
RemoteDebugPort int
|
|
|
|
|
|
OccupancySource string
|
|
|
|
|
|
Status string
|
2026-07-22 15:13:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 15:17:08 +08:00
|
|
|
|
type SettingsState struct {
|
2026-07-27 09:29:10 +08:00
|
|
|
|
ChromePath string
|
|
|
|
|
|
EdgePath string
|
|
|
|
|
|
DefaultDir string
|
2026-07-28 00:12:29 +08:00
|
|
|
|
DefaultDirMode string
|
2026-07-27 09:29:10 +08:00
|
|
|
|
LogDir string
|
2026-07-28 00:12:29 +08:00
|
|
|
|
LogDirMode string
|
2026-07-27 09:29:10 +08:00
|
|
|
|
RemoteDebugStartPort int
|
|
|
|
|
|
ReservedRemoteDebugPorts []int
|
|
|
|
|
|
CloseOnExit bool
|
2026-07-22 15:17:08 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
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
|
2026-07-25 16:36:26 +08:00
|
|
|
|
refreshClick widget.Clickable
|
2026-07-22 15:57:02 +08:00
|
|
|
|
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
|
2026-07-28 00:12:29 +08:00
|
|
|
|
dataRestore widget.Clickable
|
2026-07-22 15:57:02 +08:00
|
|
|
|
logPick widget.Clickable
|
|
|
|
|
|
logSearch widget.Clickable
|
2026-07-28 00:12:29 +08:00
|
|
|
|
logRestore widget.Clickable
|
2026-07-22 15:57:02 +08:00
|
|
|
|
saveClick widget.Clickable
|
|
|
|
|
|
cancelClick widget.Clickable
|
2026-07-22 15:13:55 +08:00
|
|
|
|
|
2026-07-25 16:36:26 +08:00
|
|
|
|
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
|
2026-07-27 09:29:10 +08:00
|
|
|
|
instancePort widget.Editor
|
2026-07-25 17:04:26 +08:00
|
|
|
|
createProxyPick widget.Clickable
|
|
|
|
|
|
editProxyPick widget.Clickable
|
2026-07-25 17:57:04 +08:00
|
|
|
|
settingsProxyPick widget.Clickable
|
2026-07-25 16:36:26 +08:00
|
|
|
|
browserKind widget.Enum
|
|
|
|
|
|
editName widget.Editor
|
|
|
|
|
|
editDir widget.Editor
|
|
|
|
|
|
editURL widget.Editor
|
2026-07-27 09:29:10 +08:00
|
|
|
|
editPreferredPort widget.Editor
|
2026-07-25 16:36:26 +08:00
|
|
|
|
editPort widget.Editor
|
|
|
|
|
|
editBrowserKind widget.Enum
|
|
|
|
|
|
editDirPick widget.Clickable
|
2026-07-27 10:01:07 +08:00
|
|
|
|
editTabs widget.Clickable
|
2026-07-25 16:36:26 +08:00
|
|
|
|
editSave widget.Clickable
|
|
|
|
|
|
editCancel widget.Clickable
|
|
|
|
|
|
editBlocker widget.Clickable
|
|
|
|
|
|
editDiscardBlocker widget.Clickable
|
|
|
|
|
|
editDiscardSave widget.Clickable
|
|
|
|
|
|
editDiscardDrop widget.Clickable
|
|
|
|
|
|
editDiscardStay widget.Clickable
|
|
|
|
|
|
pathFeedback string
|
2026-07-27 22:46:31 +08:00
|
|
|
|
pathFieldFeedback map[PathField]string
|
2026-07-25 16:36:26 +08:00
|
|
|
|
formFeedback string
|
|
|
|
|
|
editFeedback string
|
2026-07-25 17:04:26 +08:00
|
|
|
|
proxyServer widget.Editor
|
2026-07-27 12:09:54 +08:00
|
|
|
|
proxyName widget.Editor
|
2026-07-25 17:04:26 +08:00
|
|
|
|
proxySave widget.Clickable
|
2026-07-25 17:57:04 +08:00
|
|
|
|
proxyDelete widget.Clickable
|
2026-07-25 17:04:26 +08:00
|
|
|
|
proxyFeedback string
|
|
|
|
|
|
proxies []ProxyOption
|
|
|
|
|
|
createProxyID string
|
|
|
|
|
|
editProxyID string
|
2026-07-25 17:57:04 +08:00
|
|
|
|
settingsProxyID string
|
2026-07-25 17:04:26 +08:00
|
|
|
|
nextProxy uint64
|
|
|
|
|
|
proxyPicker proxyPickerState
|
|
|
|
|
|
proxyPickerBlocker widget.Clickable
|
|
|
|
|
|
proxyPickerNone widget.Clickable
|
|
|
|
|
|
proxyPickerChoices map[string]*widget.Clickable
|
2026-07-25 17:57:04 +08:00
|
|
|
|
proxyDeleteConfirm widget.Clickable
|
|
|
|
|
|
proxyDeleteCancel widget.Clickable
|
|
|
|
|
|
proxyDeleteBlocker widget.Clickable
|
|
|
|
|
|
pendingProxyDelete string
|
|
|
|
|
|
proxyDeleteFocus bool
|
2026-07-22 15:13:55 +08:00
|
|
|
|
|
2026-07-27 12:09:54 +08:00
|
|
|
|
list widget.List
|
|
|
|
|
|
settingsList widget.List
|
|
|
|
|
|
tabsList 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
|
2026-07-27 23:16:11 +08:00
|
|
|
|
deleteData widget.Bool
|
|
|
|
|
|
deleteDataRunning bool
|
|
|
|
|
|
deleteDataRequest uint64
|
|
|
|
|
|
deleteDataFeedback string
|
|
|
|
|
|
deleteDataFocus bool
|
|
|
|
|
|
deleteDataResults chan instanceDataDeleteResult
|
2026-07-27 12:09:54 +08:00
|
|
|
|
startStates map[string]*instanceStartState
|
|
|
|
|
|
startResults chan instanceStartResult
|
|
|
|
|
|
stopStates map[string]*instanceStopState
|
|
|
|
|
|
stopResults chan instanceStopResult
|
|
|
|
|
|
refreshClickState instanceRefreshState
|
|
|
|
|
|
refreshResults chan instanceRefreshResult
|
|
|
|
|
|
tabsResults chan instanceTabsResult
|
|
|
|
|
|
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
|
|
|
|
|
|
pendingCreateID string
|
|
|
|
|
|
defaultInstanceDirRoot string
|
2026-07-28 00:12:29 +08:00
|
|
|
|
portableDefaultDir string
|
|
|
|
|
|
portableLogDir string
|
|
|
|
|
|
defaultDirMode string
|
|
|
|
|
|
logDirMode string
|
|
|
|
|
|
lastSavedSettings SettingsState
|
|
|
|
|
|
defaultCreateDirActive bool
|
|
|
|
|
|
lastSuggestedCreateDir string
|
2026-07-27 12:09:54 +08:00
|
|
|
|
instanceFeedback string
|
|
|
|
|
|
pendingDeleteID string
|
|
|
|
|
|
editingID string
|
|
|
|
|
|
editOriginal InstanceRow
|
|
|
|
|
|
pendingEditDiscard bool
|
|
|
|
|
|
editFocusPending bool
|
|
|
|
|
|
focusRestoreID string
|
|
|
|
|
|
focusStartID string
|
|
|
|
|
|
tabsOpen bool
|
|
|
|
|
|
tabsLoading bool
|
|
|
|
|
|
tabsRequest uint64
|
|
|
|
|
|
tabsInstanceID string
|
|
|
|
|
|
tabsFingerprint string
|
|
|
|
|
|
tabsTargets []CDPTarget
|
|
|
|
|
|
tabsFeedback string
|
|
|
|
|
|
tabsCancel context.CancelFunc
|
|
|
|
|
|
tabsClose widget.Clickable
|
|
|
|
|
|
tabsBlocker widget.Clickable
|
|
|
|
|
|
tabsFocusPending bool
|
|
|
|
|
|
tabsReturnFocus bool
|
|
|
|
|
|
onSave func(SettingsState)
|
|
|
|
|
|
onInstancesChanged func([]InstanceRow)
|
|
|
|
|
|
onProxiesChanged func([]ProxyOption)
|
|
|
|
|
|
instanceStarter InstanceStarter
|
|
|
|
|
|
instanceStopper InstanceStopper
|
2026-07-27 23:16:11 +08:00
|
|
|
|
instanceDataDeleter InstanceDataDeleter
|
2026-07-27 12:09:54 +08:00
|
|
|
|
instanceRefresher InstanceRefresher
|
|
|
|
|
|
instanceTabInspector InstanceTabInspector
|
|
|
|
|
|
pathSearcher PathSearcher
|
|
|
|
|
|
invalidate func()
|
|
|
|
|
|
searches map[PathField]*pathSearchState
|
|
|
|
|
|
searchResults chan pathSearchResult
|
2026-07-27 22:46:31 +08:00
|
|
|
|
executableChooser ExecutableChooser
|
|
|
|
|
|
executablePick executablePickState
|
|
|
|
|
|
executableResults chan executablePickResult
|
2026-07-27 12:09:54 +08:00
|
|
|
|
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-27 09:29:10 +08:00
|
|
|
|
{ID: "demo-operations", Name: "运营主账号", Browser: "Chrome", UserDataDir: `C:\Users\Public\chub\profiles\operations`, PreferredRemoteDebugPort: 9666, PID: 18420, RemoteDebugPort: 9666, Status: "运行中"},
|
|
|
|
|
|
{ID: "demo-ads", Name: "广告投放", Browser: "Edge", UserDataDir: `C:\Users\Public\chub\profiles\ads`, PreferredRemoteDebugPort: 9667, Status: "启动中"},
|
|
|
|
|
|
{ID: "demo-assets", Name: "素材采集", Browser: "Chrome", UserDataDir: `C:\Users\Public\chub\profiles\assets`, PreferredRemoteDebugPort: 9668, PID: 16108, RemoteDebugPort: 9668, OccupancySource: "browser_message_window", Status: "外部已关联"},
|
|
|
|
|
|
{ID: "demo-backup", Name: "备用环境", Browser: "Edge", UserDataDir: `C:\Users\Public\chub\profiles\backup`, PreferredRemoteDebugPort: 9669, Status: "已退出"},
|
2026-07-22 15:57:02 +08:00
|
|
|
|
}, searches: map[PathField]*pathSearchState{
|
|
|
|
|
|
PathChromeExecutable: {},
|
|
|
|
|
|
PathEdgeExecutable: {},
|
|
|
|
|
|
PathDefaultUserData: {},
|
|
|
|
|
|
PathLogDirectory: {},
|
2026-07-27 23:16:11 +08:00
|
|
|
|
}, pathFieldFeedback: make(map[PathField]string), 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), deleteDataResults: make(chan instanceDataDeleteResult, 1), refreshResults: make(chan instanceRefreshResult, 1), tabsResults: make(chan instanceTabsResult, 1), searchResults: make(chan pathSearchResult, 8), executableResults: make(chan executablePickResult, 1), 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`)
|
2026-07-28 00:12:29 +08:00
|
|
|
|
s.portableDefaultDir = s.dataDir.Text()
|
|
|
|
|
|
s.portableLogDir = s.logDir.Text()
|
|
|
|
|
|
s.defaultDirMode = "portable"
|
|
|
|
|
|
s.logDirMode = "portable"
|
2026-07-25 15:53:31 +08:00
|
|
|
|
s.remoteDebugPort.SetText(strconv.Itoa(domain.DefaultRemoteDebugPort))
|
2026-07-27 09:29:10 +08:00
|
|
|
|
s.instancePort.SetText(strconv.Itoa(s.recommendRemoteDebugPort("")))
|
2026-07-22 15:13:55 +08:00
|
|
|
|
s.closeOnExit.Value = true
|
2026-07-22 15:57:02 +08:00
|
|
|
|
s.browserKind.Value = "chrome"
|
2026-07-25 16:36:26 +08:00
|
|
|
|
s.editBrowserKind.Value = "chrome"
|
|
|
|
|
|
s.editPort.ReadOnly = true
|
2026-07-22 16:42:45 +08:00
|
|
|
|
s.list.Axis = layout.Vertical
|
2026-07-26 17:42:40 +08:00
|
|
|
|
s.settingsList.Axis = layout.Vertical
|
2026-07-27 10:01:07 +08:00
|
|
|
|
s.tabsList.Axis = layout.Vertical
|
2026-07-28 00:12:29 +08:00
|
|
|
|
s.lastSavedSettings = SettingsState{ChromePath: s.chromePath.Text(), EdgePath: s.edgePath.Text(), DefaultDir: s.dataDir.Text(), DefaultDirMode: s.defaultDirMode, LogDir: s.logDir.Text(), LogDirMode: s.logDirMode, RemoteDebugStartPort: domain.DefaultRemoteDebugPort, CloseOnExit: true}
|
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-27 09:29:10 +08:00
|
|
|
|
s.assignPreferredRemoteDebugPorts()
|
2026-07-25 16:36:26 +08:00
|
|
|
|
if s.selectedInstanceID == "" && len(s.rows) > 0 {
|
|
|
|
|
|
s.selectedInstanceID = s.rows[0].ID
|
|
|
|
|
|
}
|
2026-07-22 15:17:08 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 17:04:26 +08:00
|
|
|
|
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 = ""
|
|
|
|
|
|
}
|
2026-07-25 17:57:04 +08:00
|
|
|
|
if !s.proxyExists(s.settingsProxyID) {
|
|
|
|
|
|
s.settingsProxyID = ""
|
2026-07-27 12:09:54 +08:00
|
|
|
|
s.proxyName.SetText("")
|
2026-07-25 17:57:04 +08:00
|
|
|
|
s.proxyServer.SetText("")
|
|
|
|
|
|
}
|
2026-07-25 17:04:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-27 12:09:54 +08:00
|
|
|
|
// SetDefaultInstanceUserDataRoot supplies the already resolved executable-side
|
|
|
|
|
|
// profile root. It is deliberately a string-only UI boundary: no filesystem
|
|
|
|
|
|
// lookup, creation, or permission check happens in the Gio shell.
|
|
|
|
|
|
func (s *Shell) SetDefaultInstanceUserDataRoot(root string) {
|
|
|
|
|
|
root = strings.TrimSpace(root)
|
|
|
|
|
|
if root == "" || !filepath.IsAbs(root) {
|
|
|
|
|
|
s.defaultInstanceDirRoot = ""
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
s.defaultInstanceDirRoot = filepath.Clean(root)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-28 00:12:29 +08:00
|
|
|
|
// SetPortableDirectoryDefaults supplies paths already resolved from chub.exe.
|
|
|
|
|
|
// It deliberately accepts only strings so the Gio shell never reads the file
|
|
|
|
|
|
// system while rendering or handling user input.
|
|
|
|
|
|
func (s *Shell) SetPortableDirectoryDefaults(defaultDir, logDir string) {
|
|
|
|
|
|
defaultDir = strings.TrimSpace(defaultDir)
|
|
|
|
|
|
logDir = strings.TrimSpace(logDir)
|
|
|
|
|
|
if defaultDir != "" && filepath.IsAbs(defaultDir) {
|
|
|
|
|
|
s.portableDefaultDir = filepath.Clean(defaultDir)
|
|
|
|
|
|
if s.defaultDirMode == "" || s.defaultDirMode == "portable" {
|
|
|
|
|
|
s.defaultDirMode = "portable"
|
|
|
|
|
|
s.dataDir.SetText(s.portableDefaultDir)
|
|
|
|
|
|
s.SetDefaultInstanceUserDataRoot(s.portableDefaultDir)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if logDir != "" && filepath.IsAbs(logDir) {
|
|
|
|
|
|
s.portableLogDir = filepath.Clean(logDir)
|
|
|
|
|
|
if s.logDirMode == "" || s.logDirMode == "portable" {
|
|
|
|
|
|
s.logDirMode = "portable"
|
|
|
|
|
|
s.logDir.SetText(s.portableLogDir)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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)
|
2026-07-28 00:12:29 +08:00
|
|
|
|
s.defaultDirMode = normalizeDirectoryMode(value.DefaultDirMode)
|
|
|
|
|
|
s.logDirMode = normalizeDirectoryMode(value.LogDirMode)
|
|
|
|
|
|
if s.defaultDirMode == "portable" && s.portableDefaultDir != "" {
|
|
|
|
|
|
s.dataDir.SetText(s.portableDefaultDir)
|
|
|
|
|
|
}
|
|
|
|
|
|
if s.logDirMode == "portable" && s.portableLogDir != "" {
|
|
|
|
|
|
s.logDir.SetText(s.portableLogDir)
|
|
|
|
|
|
}
|
|
|
|
|
|
if root := strings.TrimSpace(s.dataDir.Text()); root != "" && filepath.IsAbs(root) {
|
|
|
|
|
|
s.SetDefaultInstanceUserDataRoot(root)
|
|
|
|
|
|
}
|
2026-07-25 15:53:31 +08:00
|
|
|
|
port := value.RemoteDebugStartPort
|
|
|
|
|
|
if port == 0 {
|
|
|
|
|
|
port = domain.DefaultRemoteDebugPort
|
|
|
|
|
|
}
|
|
|
|
|
|
s.remoteDebugPort.SetText(strconv.Itoa(port))
|
2026-07-22 15:17:08 +08:00
|
|
|
|
s.closeOnExit.Value = value.CloseOnExit
|
2026-07-28 00:12:29 +08:00
|
|
|
|
s.lastSavedSettings = SettingsState{ChromePath: s.chromePath.Text(), EdgePath: s.edgePath.Text(), DefaultDir: s.dataDir.Text(), DefaultDirMode: s.defaultDirMode, LogDir: s.logDir.Text(), LogDirMode: s.logDirMode, RemoteDebugStartPort: port, CloseOnExit: s.closeOnExit.Value}
|
2026-07-22 15:17:08 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-27 11:33:22 +08:00
|
|
|
|
// ReportStartupIssue records a safe, user-recoverable configuration message.
|
|
|
|
|
|
// The application calls it before the frame loop starts; it performs no I/O.
|
|
|
|
|
|
func (s *Shell) ReportStartupIssue(message string) {
|
|
|
|
|
|
s.instanceFeedback = strings.TrimSpace(message)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 15:17:08 +08:00
|
|
|
|
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 }
|
|
|
|
|
|
|
2026-07-25 17:04:26 +08:00
|
|
|
|
func (s *Shell) OnProxiesChanged(fn func([]ProxyOption)) { s.onProxiesChanged = fn }
|
|
|
|
|
|
|
2026-07-22 17:02:59 +08:00
|
|
|
|
func (s *Shell) OnStartInstance(starter InstanceStarter, invalidate func()) {
|
|
|
|
|
|
s.instanceStarter = starter
|
|
|
|
|
|
s.invalidate = invalidate
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 17:10:20 +08:00
|
|
|
|
func (s *Shell) OnStopInstance(stopper InstanceStopper, invalidate func()) {
|
|
|
|
|
|
s.instanceStopper = stopper
|
|
|
|
|
|
s.invalidate = invalidate
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-27 23:16:11 +08:00
|
|
|
|
func (s *Shell) OnDeleteInstanceData(deleter InstanceDataDeleter, invalidate func()) {
|
|
|
|
|
|
s.instanceDataDeleter = deleter
|
|
|
|
|
|
s.invalidate = invalidate
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 18:23:33 +08:00
|
|
|
|
// 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()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 16:36:26 +08:00
|
|
|
|
func (s *Shell) OnRefreshInstances(refresher InstanceRefresher, invalidate func()) {
|
|
|
|
|
|
s.instanceRefresher = refresher
|
|
|
|
|
|
s.invalidate = invalidate
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-27 10:01:07 +08:00
|
|
|
|
func (s *Shell) OnInspectTabs(inspector InstanceTabInspector, invalidate func()) {
|
|
|
|
|
|
s.instanceTabInspector = inspector
|
|
|
|
|
|
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-27 22:46:31 +08:00
|
|
|
|
func (s *Shell) OnChooseExecutable(chooser ExecutableChooser, invalidate func()) {
|
|
|
|
|
|
s.executableChooser = chooser
|
|
|
|
|
|
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-27 22:46:31 +08:00
|
|
|
|
s.consumeExecutableResults()
|
2026-07-22 16:06:39 +08:00
|
|
|
|
s.consumeDirectoryResults()
|
2026-07-22 17:02:59 +08:00
|
|
|
|
s.consumeStartResults()
|
2026-07-25 17:10:20 +08:00
|
|
|
|
s.consumeStopResults()
|
2026-07-27 23:16:11 +08:00
|
|
|
|
s.consumeInstanceDataDeleteResults()
|
2026-07-25 16:36:26 +08:00
|
|
|
|
s.consumeRefreshResults()
|
2026-07-27 10:01:07 +08:00
|
|
|
|
s.consumeTabsResults()
|
2026-07-25 18:23:33 +08:00
|
|
|
|
s.consumeManagedExitResults()
|
|
|
|
|
|
s.presentUnexpectedExitIfReady()
|
2026-07-25 16:36:26 +08:00
|
|
|
|
s.consumeKeyboard(gtx)
|
2026-07-25 18:23:33 +08:00
|
|
|
|
if s.unexpectedExitOpen {
|
|
|
|
|
|
s.consumeUnexpectedExitDialog(gtx)
|
|
|
|
|
|
} else if s.pendingDeleteID != "" {
|
2026-07-22 17:02:59 +08:00
|
|
|
|
s.consumeDeleteConfirmation(gtx)
|
2026-07-25 17:57:04 +08:00
|
|
|
|
} else if s.pendingProxyDelete != "" {
|
|
|
|
|
|
s.consumeProxyDeleteConfirmation(gtx)
|
2026-07-25 16:36:26 +08:00
|
|
|
|
} else if s.editingID != "" {
|
|
|
|
|
|
s.consumeEditControls(gtx)
|
2026-07-22 17:02:59 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
s.consumeControls(gtx)
|
|
|
|
|
|
}
|
2026-07-25 18:23:33 +08:00
|
|
|
|
s.presentUnexpectedExitIfReady()
|
2026-07-28 00:12:29 +08:00
|
|
|
|
s.syncDefaultCreateDirectory()
|
2026-07-22 17:02:59 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-07-27 10:01:07 +08:00
|
|
|
|
if !s.unexpectedExitOpen && s.pendingDeleteID == "" && s.pendingProxyDelete == "" && s.editingID == "" && !s.proxyPicker.open && !s.tabsOpen {
|
2026-07-22 17:02:59 +08:00
|
|
|
|
return mainLayout(gtx)
|
|
|
|
|
|
}
|
2026-07-25 18:23:33 +08:00
|
|
|
|
if s.unexpectedExitOpen {
|
|
|
|
|
|
return layout.Stack{Alignment: layout.Center}.Layout(gtx,
|
|
|
|
|
|
layout.Expanded(mainLayout),
|
|
|
|
|
|
layout.Stacked(s.unexpectedExitDialog),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-07-25 16:36:26 +08:00
|
|
|
|
if s.pendingDeleteID != "" {
|
|
|
|
|
|
return layout.Stack{Alignment: layout.Center}.Layout(gtx,
|
|
|
|
|
|
layout.Expanded(mainLayout),
|
|
|
|
|
|
layout.Stacked(s.deleteConfirmation),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-07-25 17:57:04 +08:00
|
|
|
|
if s.pendingProxyDelete != "" {
|
|
|
|
|
|
return layout.Stack{Alignment: layout.Center}.Layout(gtx,
|
|
|
|
|
|
layout.Expanded(mainLayout),
|
|
|
|
|
|
layout.Stacked(s.proxyDeleteConfirmation),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-07-25 16:36:26 +08:00
|
|
|
|
if s.pendingEditDiscard {
|
|
|
|
|
|
return layout.Stack{Alignment: layout.Center}.Layout(gtx,
|
|
|
|
|
|
layout.Expanded(mainLayout),
|
|
|
|
|
|
layout.Stacked(s.editDialog),
|
|
|
|
|
|
layout.Stacked(s.editDiscardConfirmation),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-07-25 17:04:26 +08:00
|
|
|
|
if s.editingID != "" {
|
2026-07-27 10:01:07 +08:00
|
|
|
|
if s.tabsOpen {
|
|
|
|
|
|
return layout.Stack{Alignment: layout.Center}.Layout(gtx,
|
|
|
|
|
|
layout.Expanded(mainLayout),
|
|
|
|
|
|
layout.Stacked(s.editDialog),
|
|
|
|
|
|
layout.Stacked(s.tabsDialog),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-07-25 17:04:26 +08:00
|
|
|
|
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),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-07-22 17:02:59 +08:00
|
|
|
|
return layout.Stack{Alignment: layout.Center}.Layout(gtx,
|
|
|
|
|
|
layout.Expanded(mainLayout),
|
2026-07-25 17:04:26 +08:00
|
|
|
|
layout.Stacked(s.proxyPickerDialog),
|
2026-07-22 17:02:59 +08:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-07-27 09:29:10 +08:00
|
|
|
|
s.beginCreate()
|
2026-07-22 15:24:25 +08:00
|
|
|
|
}
|
2026-07-25 16:36:26 +08:00
|
|
|
|
for s.refreshClick.Clicked(gtx) {
|
|
|
|
|
|
s.requestRefresh()
|
|
|
|
|
|
}
|
2026-07-22 15:24:25 +08:00
|
|
|
|
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 {
|
2026-07-25 16:36:26 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-22 16:22:59 +08:00
|
|
|
|
for s.startClickFor(row.ID).Clicked(gtx) {
|
2026-07-25 17:10:20 +08:00
|
|
|
|
s.requestInstanceAction(row.ID)
|
2026-07-22 16:22:59 +08:00
|
|
|
|
}
|
2026-07-25 16:36:26 +08:00
|
|
|
|
for s.editClickFor(row.ID).Clicked(gtx) {
|
|
|
|
|
|
s.beginEdit(row.ID)
|
|
|
|
|
|
}
|
2026-07-22 16:22:59 +08:00
|
|
|
|
}
|
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-27 09:29:10 +08:00
|
|
|
|
func (s *Shell) beginCreate() {
|
|
|
|
|
|
s.page = pageCreate
|
|
|
|
|
|
s.formFeedback = ""
|
2026-07-27 12:09:54 +08:00
|
|
|
|
s.pendingCreateID = s.allocateInstanceID()
|
2026-07-28 00:12:29 +08:00
|
|
|
|
s.defaultCreateDirActive = true
|
|
|
|
|
|
s.lastSuggestedCreateDir = ""
|
2026-07-27 12:09:54 +08:00
|
|
|
|
s.instanceName.SetText("")
|
|
|
|
|
|
s.instanceURL.SetText("")
|
|
|
|
|
|
s.createProxyID = ""
|
|
|
|
|
|
s.browserKind.Value = "chrome"
|
2026-07-28 00:12:29 +08:00
|
|
|
|
if defaultDir := s.defaultCreateInstanceDir("新实例", s.pendingCreateID); defaultDir != "" {
|
2026-07-27 12:09:54 +08:00
|
|
|
|
s.instanceDir.SetText(defaultDir)
|
2026-07-28 00:12:29 +08:00
|
|
|
|
s.lastSuggestedCreateDir = defaultDir
|
2026-07-27 12:09:54 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
s.instanceDir.SetText("")
|
|
|
|
|
|
s.formFeedback = "无法确定程序目录中的默认 User Data Dir;请选择或输入一个绝对路径。"
|
|
|
|
|
|
}
|
2026-07-27 09:29:10 +08:00
|
|
|
|
s.instancePort.SetText(strconv.Itoa(s.recommendRemoteDebugPort("")))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 16:36:26 +08:00
|
|
|
|
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:
|
2026-07-25 18:23:33 +08:00
|
|
|
|
if s.unexpectedExitOpen {
|
|
|
|
|
|
s.dismissUnexpectedExitNotice()
|
2026-07-27 23:16:11 +08:00
|
|
|
|
} else if s.pendingDeleteID != "" && !s.deleteDataRunning {
|
|
|
|
|
|
s.cancelDelete()
|
2026-07-25 18:23:33 +08:00
|
|
|
|
} else if s.pendingProxyDelete != "" {
|
2026-07-25 17:57:04 +08:00
|
|
|
|
s.cancelProxyDelete()
|
|
|
|
|
|
} else if s.proxyPicker.open {
|
2026-07-25 17:04:26 +08:00
|
|
|
|
s.proxyPicker.open = false
|
2026-07-27 10:01:07 +08:00
|
|
|
|
} else if s.tabsOpen {
|
|
|
|
|
|
s.closeTabs(true)
|
2026-07-25 17:04:26 +08:00
|
|
|
|
} else if s.pendingEditDiscard {
|
2026-07-25 16:36:26 +08:00
|
|
|
|
s.pendingEditDiscard = false
|
|
|
|
|
|
} else if s.editingID != "" {
|
|
|
|
|
|
s.cancelEdit()
|
|
|
|
|
|
}
|
|
|
|
|
|
case key.NameF5:
|
2026-07-25 17:57:04 +08:00
|
|
|
|
if s.pendingDeleteID == "" && s.pendingProxyDelete == "" && s.editingID == "" && s.page == pageInstances {
|
2026-07-25 16:36:26 +08:00
|
|
|
|
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) {
|
|
|
|
|
|
}
|
2026-07-27 10:01:07 +08:00
|
|
|
|
if s.tabsOpen {
|
|
|
|
|
|
for s.tabsBlocker.Clicked(gtx) {
|
|
|
|
|
|
}
|
|
|
|
|
|
for s.tabsClose.Clicked(gtx) {
|
|
|
|
|
|
s.closeTabs(true)
|
|
|
|
|
|
}
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-07-25 16:36:26 +08:00
|
|
|
|
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()
|
|
|
|
|
|
}
|
2026-07-27 10:01:07 +08:00
|
|
|
|
for s.editTabs.Clicked(gtx) {
|
|
|
|
|
|
s.requestTabs()
|
|
|
|
|
|
}
|
2026-07-25 16:36:26 +08:00
|
|
|
|
for s.editSave.Clicked(gtx) {
|
|
|
|
|
|
s.saveEdit()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) beginEdit(id string) {
|
2026-07-27 10:01:07 +08:00
|
|
|
|
s.closeTabs(false)
|
2026-07-25 16:36:26 +08:00
|
|
|
|
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)
|
2026-07-27 09:29:10 +08:00
|
|
|
|
s.editPreferredPort.SetText(preferredRemoteDebugPortText(row.PreferredRemoteDebugPort))
|
|
|
|
|
|
s.editPort.SetText(actualRemoteDebugPortText(row.RemoteDebugPort))
|
2026-07-25 17:04:26 +08:00
|
|
|
|
s.editProxyID = row.ProxyID
|
2026-07-25 16:36:26 +08:00
|
|
|
|
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 {
|
2026-07-25 17:10:20 +08:00
|
|
|
|
case "启动中", "停止中", "运行中", "运行中(调试不可用)", "外部已关联":
|
2026-07-25 16:36:26 +08:00
|
|
|
|
return true
|
|
|
|
|
|
default:
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) editIsDirty() bool {
|
|
|
|
|
|
if s.editingID == "" {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
2026-07-25 17:04:26 +08:00
|
|
|
|
if strings.TrimSpace(s.editName.Text()) != s.editOriginal.Name || strings.TrimSpace(s.editURL.Text()) != s.editOriginal.TargetURL || s.editProxyID != s.editOriginal.ProxyID {
|
2026-07-25 16:36:26 +08:00
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
if s.editLocked(s.editOriginal) {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
2026-07-27 09:29:10 +08:00
|
|
|
|
return strings.TrimSpace(s.editDir.Text()) != s.editOriginal.UserDataDir || browserDisplay(s.editBrowserKind.Value) != s.editOriginal.Browser || strings.TrimSpace(s.editPreferredPort.Text()) != preferredRemoteDebugPortText(s.editOriginal.PreferredRemoteDebugPort)
|
2026-07-25 16:36:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-07-28 00:12:29 +08:00
|
|
|
|
name, err := s.validateInstanceName(s.editName.Text(), row.ID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
s.editFeedback = err.Error()
|
2026-07-25 16:36:26 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-07-27 09:29:10 +08:00
|
|
|
|
preferredPort, err := normalizePreferredRemoteDebugPort(s.editPreferredPort.Text())
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
s.editFeedback = err.Error()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if s.remoteDebugPortReservedByOther(preferredPort, row.ID) {
|
|
|
|
|
|
s.editFeedback = fmt.Sprintf("端口 %d 已被其他 Chub 实例预留或使用。", preferredPort)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-07-25 16:36:26 +08:00
|
|
|
|
row.UserDataDir = filepath.Clean(userDataDir)
|
|
|
|
|
|
row.Browser = browserDisplay(s.editBrowserKind.Value)
|
2026-07-27 09:29:10 +08:00
|
|
|
|
row.PreferredRemoteDebugPort = preferredPort
|
2026-07-25 16:36:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
row.Name = name
|
|
|
|
|
|
row.TargetURL = targetURL
|
2026-07-25 17:04:26 +08:00
|
|
|
|
if !s.proxyExists(s.editProxyID) && s.editProxyID != "" {
|
|
|
|
|
|
s.editFeedback = "所选代理已不可用,请重新选择。"
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
row.ProxyID = s.editProxyID
|
2026-07-25 16:36:26 +08:00
|
|
|
|
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) {
|
2026-07-27 10:01:07 +08:00
|
|
|
|
s.closeTabs(false)
|
2026-07-25 16:36:26 +08:00
|
|
|
|
id := s.editingID
|
|
|
|
|
|
s.editingID = ""
|
|
|
|
|
|
s.pendingEditDiscard = false
|
|
|
|
|
|
s.editFeedback = ""
|
|
|
|
|
|
s.editFocusPending = false
|
2026-07-25 17:04:26 +08:00
|
|
|
|
s.proxyPicker.open = false
|
2026-07-25 16:36:26 +08:00
|
|
|
|
s.focusRestoreID = id
|
|
|
|
|
|
s.instanceFeedback = feedback
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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),
|
2026-07-25 16:36:26 +08:00
|
|
|
|
layout.Rigid(s.instanceCommands),
|
2026-07-22 15:13:55 +08:00
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout),
|
2026-07-25 15:53:31 +08:00
|
|
|
|
layout.Flexed(1, s.instanceTable),
|
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-25 16:36:26 +08:00
|
|
|
|
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),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 15:53:31 +08:00
|
|
|
|
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]
|
2026-07-25 16:36:26 +08:00
|
|
|
|
return layout.Inset{Bottom: unit.Dp(8)}.Layout(gtx, s.instanceListRow(row))
|
2026-07-25 15:53:31 +08:00
|
|
|
|
})
|
|
|
|
|
|
}),
|
|
|
|
|
|
)
|
|
|
|
|
|
})
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 16:36:26 +08:00
|
|
|
|
func (s *Shell) instanceListRow(row InstanceRow) layout.Widget {
|
|
|
|
|
|
return func(gtx layout.Context) layout.Dimensions {
|
2026-07-28 07:54:37 +08:00
|
|
|
|
minimumHeight := gtx.Dp(instanceListRowMinHeight)
|
|
|
|
|
|
if gtx.Constraints.Min.Y < minimumHeight {
|
|
|
|
|
|
gtx.Constraints.Min.Y = minimumHeight
|
|
|
|
|
|
}
|
2026-07-25 16:36:26 +08:00
|
|
|
|
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)),
|
2026-07-27 12:09:54 +08:00
|
|
|
|
layout.Flexed(instanceProxyColumnWeight, proxyNameCell(s.theme, s.proxyDisplayName(row.ProxyID))),
|
2026-07-27 09:29:10 +08:00
|
|
|
|
layout.Flexed(instancePortColumnWeight, remoteDebugPortCell(s.theme, row)),
|
2026-07-25 16:36:26 +08:00
|
|
|
|
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
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 16:22:59 +08:00
|
|
|
|
func (s *Shell) instanceHeader(gtx layout.Context) layout.Dimensions {
|
2026-07-25 15:53:31 +08:00
|
|
|
|
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),
|
2026-07-27 12:09:54 +08:00
|
|
|
|
layout.Flexed(instanceProxyColumnWeight, material.Caption(s.theme, "代理").Layout),
|
2026-07-25 15:53:31 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}),
|
|
|
|
|
|
)
|
|
|
|
|
|
})
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
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-27 09:29:10 +08:00
|
|
|
|
func remoteDebugPortCell(theme *material.Theme, row InstanceRow) layout.Widget {
|
|
|
|
|
|
style := material.Body2(theme, instancePortLabel(row))
|
|
|
|
|
|
style.MaxLines = 1
|
|
|
|
|
|
return style.Layout
|
2026-07-25 16:36:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-27 12:09:54 +08:00
|
|
|
|
func proxyNameCell(theme *material.Theme, name string) layout.Widget {
|
|
|
|
|
|
style := material.Body2(theme, name)
|
|
|
|
|
|
style.MaxLines = 1
|
|
|
|
|
|
return style.Layout
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-27 09:29:10 +08:00
|
|
|
|
func actualRemoteDebugPortText(port int) string {
|
2026-07-25 15:53:31 +08:00
|
|
|
|
if port > 0 {
|
2026-07-25 16:36:26 +08:00
|
|
|
|
return strconv.Itoa(port)
|
2026-07-25 15:53:31 +08:00
|
|
|
|
}
|
2026-07-25 16:36:26 +08:00
|
|
|
|
return "—"
|
2026-07-25 15:53:31 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-27 09:29:10 +08:00
|
|
|
|
func preferredRemoteDebugPortText(port int) string {
|
|
|
|
|
|
if domain.ValidRemoteDebugPort(port) {
|
|
|
|
|
|
return strconv.Itoa(port)
|
|
|
|
|
|
}
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func instancePortLabel(row InstanceRow) string {
|
|
|
|
|
|
if row.Status == "外部已关联" {
|
|
|
|
|
|
if domain.ValidRemoteDebugPort(row.RemoteDebugPort) {
|
|
|
|
|
|
return fmt.Sprintf("外部 %d", row.RemoteDebugPort)
|
|
|
|
|
|
}
|
|
|
|
|
|
return "—"
|
|
|
|
|
|
}
|
|
|
|
|
|
if row.Status == "启动中" && domain.ValidRemoteDebugPort(row.PreferredRemoteDebugPort) {
|
|
|
|
|
|
return fmt.Sprintf("准备 %d", row.PreferredRemoteDebugPort)
|
|
|
|
|
|
}
|
|
|
|
|
|
if domain.ValidRemoteDebugPort(row.RemoteDebugPort) {
|
|
|
|
|
|
return fmt.Sprintf("实际 %d", row.RemoteDebugPort)
|
|
|
|
|
|
}
|
|
|
|
|
|
if row.Status == "已退出" || row.Status == "启动失败" {
|
|
|
|
|
|
if domain.ValidRemoteDebugPort(row.PreferredRemoteDebugPort) {
|
|
|
|
|
|
return fmt.Sprintf("建议 %d", row.PreferredRemoteDebugPort)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return "—"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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-25 17:10:20 +08:00
|
|
|
|
mode := instanceActionFor(row)
|
2026-07-25 18:23:33 +08:00
|
|
|
|
startClick := s.startClickFor(row.ID)
|
2026-07-25 17:10:20 +08:00
|
|
|
|
actionLabel := mode.label + " " + row.Name
|
|
|
|
|
|
actionIcon := instanceStartIcon
|
|
|
|
|
|
if mode.stop {
|
|
|
|
|
|
actionIcon = instanceStopIcon
|
2026-07-25 15:53:31 +08:00
|
|
|
|
}
|
2026-07-25 18:23:33 +08:00
|
|
|
|
dims := layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
|
|
|
|
|
layout.Rigid(s.instanceIconButton(startClick, actionIcon, actionLabel, s.theme.Palette.ContrastBg, mode.enabled)),
|
2026-07-25 17:57:04 +08:00
|
|
|
|
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),
|
2026-07-22 16:37:34 +08:00
|
|
|
|
layout.Rigid(s.instanceIconButton(s.deleteClickFor(row.ID), instanceDeleteIcon, "删除 "+row.Name, color.NRGBA{R: 188, G: 51, B: 51, A: 255})),
|
|
|
|
|
|
)
|
2026-07-25 18:23:33 +08:00
|
|
|
|
if s.focusStartID == row.ID {
|
|
|
|
|
|
gtx.Execute(key.FocusCmd{Tag: startClick})
|
|
|
|
|
|
s.focusStartID = ""
|
|
|
|
|
|
}
|
|
|
|
|
|
return dims
|
2026-07-22 16:30:20 +08:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 17:10:20 +08:00
|
|
|
|
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 {
|
2026-07-22 16:37:34 +08:00
|
|
|
|
return func(gtx layout.Context) layout.Dimensions {
|
2026-07-25 17:10:20 +08:00
|
|
|
|
if len(enabled) > 0 && !enabled[0] {
|
|
|
|
|
|
gtx = gtx.Disabled()
|
|
|
|
|
|
}
|
2026-07-22 16:37:34 +08:00
|
|
|
|
style := material.IconButton(s.theme, click, icon, description)
|
2026-07-28 07:54:37 +08:00
|
|
|
|
style.Size = instanceActionIconSize
|
|
|
|
|
|
style.Inset = layout.UniformInset(instanceActionIconInset)
|
2026-07-22 16:37:34 +08:00
|
|
|
|
style.Background = color.NRGBA{}
|
|
|
|
|
|
style.Color = iconColor
|
|
|
|
|
|
return style.Layout(gtx)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 16:36:26 +08:00
|
|
|
|
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}
|
2026-07-27 09:40:37 +08:00
|
|
|
|
if rect.Empty() {
|
|
|
|
|
|
return layout.Dimensions{Size: rect.Max}
|
|
|
|
|
|
}
|
|
|
|
|
|
paint.FillShape(gtx.Ops, instanceFormWorkspaceBorder, clip.UniformRRect(rect, gtx.Dp(10)).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(9)).Op(gtx.Ops))
|
|
|
|
|
|
}
|
2026-07-25 16:36:26 +08:00
|
|
|
|
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),
|
2026-07-27 09:40:37 +08:00
|
|
|
|
layout.Rigid(s.instanceFormField(s.formField("实例名称", "用于在实例列表中识别此浏览器环境", &s.editName))),
|
2026-07-25 16:36:26 +08:00
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
2026-07-27 09:40:37 +08:00
|
|
|
|
layout.Rigid(s.instanceFormField(func(gtx layout.Context) layout.Dimensions { return s.editBrowserField(gtx, locked) })),
|
2026-07-25 16:36:26 +08:00
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
2026-07-27 09:40:37 +08:00
|
|
|
|
layout.Rigid(s.instanceFormField(func(gtx layout.Context) layout.Dimensions { return s.editDirectoryField(gtx, locked) })),
|
2026-07-25 16:36:26 +08:00
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
2026-07-27 09:40:37 +08:00
|
|
|
|
layout.Rigid(s.instanceFormField(s.formField("启动 URL(可选)", "仅支持 http/https;留空时启动浏览器默认页", &s.editURL))),
|
2026-07-25 16:36:26 +08:00
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
2026-07-27 09:40:37 +08:00
|
|
|
|
layout.Rigid(s.instanceFormField(func(gtx layout.Context) layout.Dimensions { return s.editPreferredPortField(gtx, locked) })),
|
2026-07-27 09:29:10 +08:00
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
2026-07-27 09:40:37 +08:00
|
|
|
|
layout.Rigid(s.instanceFormField(func(gtx layout.Context) layout.Dimensions { return s.proxyPickerField(gtx, proxyPickerEdit) })),
|
2026-07-25 17:04:26 +08:00
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
2026-07-27 09:40:37 +08:00
|
|
|
|
layout.Rigid(s.instanceFormField(func(gtx layout.Context) layout.Dimensions {
|
|
|
|
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
|
|
|
|
layout.Rigid(s.formField("实际调试端口", "仅在已验证的运行或外部关联状态显示;此处只读", &s.editPort)),
|
|
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
|
|
|
|
layout.Rigid(statusLabel(s.theme, row.Status)),
|
|
|
|
|
|
)
|
|
|
|
|
|
})),
|
2026-07-25 16:36:26 +08:00
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
2026-07-27 09:40:37 +08:00
|
|
|
|
layout.Rigid(s.instanceFormFeedback(s.editFeedbackMessage(locked))),
|
2026-07-25 16:36:26 +08:00
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout),
|
2026-07-27 09:40:37 +08:00
|
|
|
|
layout.Rigid(s.instanceFormActionBar(s.editActions)),
|
2026-07-25 16:36:26 +08:00
|
|
|
|
)
|
|
|
|
|
|
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),
|
2026-07-28 00:12:29 +08:00
|
|
|
|
layout.Rigid(material.Caption(s.theme, "浏览器独立数据目录;修改实例名称不会移动已有目录").Layout),
|
2026-07-25 16:36:26 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}),
|
|
|
|
|
|
)
|
|
|
|
|
|
}),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-27 09:29:10 +08:00
|
|
|
|
func (s *Shell) editPreferredPortField(gtx layout.Context, locked bool) layout.Dimensions {
|
|
|
|
|
|
s.editPreferredPort.ReadOnly = locked
|
|
|
|
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
|
|
|
|
layout.Rigid(material.Body2(s.theme, "首选调试端口").Layout),
|
|
|
|
|
|
layout.Rigid(material.Caption(s.theme, "下次启动优先使用;启动时仍会在后台复检 loopback 可用性").Layout),
|
|
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
|
|
|
|
|
layout.Rigid(material.Editor(s.theme, &s.editPreferredPort, "例如:9666").Layout),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-27 09:40:37 +08:00
|
|
|
|
func (s *Shell) editFeedbackMessage(locked bool) string {
|
2026-07-25 16:36:26 +08:00
|
|
|
|
label := s.editFeedback
|
2026-07-27 10:01:07 +08:00
|
|
|
|
row, exists := s.instanceRow(s.editingID)
|
|
|
|
|
|
if label == "" && (!exists || !s.canInspectTabs(row)) {
|
|
|
|
|
|
label = "查看标签页需要处于已验证的运行或外部关联状态,并具有实际调试端口。"
|
|
|
|
|
|
}
|
2026-07-25 16:36:26 +08:00
|
|
|
|
if label == "" && locked {
|
|
|
|
|
|
label = "运行中或外部关联实例的浏览器类型和 User Data Dir 已锁定。"
|
|
|
|
|
|
}
|
2026-07-27 09:40:37 +08:00
|
|
|
|
return label
|
2026-07-25 16:36:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) editActions(gtx layout.Context) layout.Dimensions {
|
2026-07-27 10:01:07 +08:00
|
|
|
|
row, canInspect := s.instanceRow(s.editingID)
|
|
|
|
|
|
canInspect = canInspect && s.canInspectTabs(row) && !s.tabsLoading
|
|
|
|
|
|
tabsGtx := gtx
|
|
|
|
|
|
if !canInspect {
|
|
|
|
|
|
tabsGtx = gtx.Disabled()
|
|
|
|
|
|
}
|
2026-07-25 16:36:26 +08:00
|
|
|
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
2026-07-27 10:01:07 +08:00
|
|
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
|
|
|
|
style := material.Button(s.theme, &s.editTabs, "查看标签页")
|
|
|
|
|
|
style.Background = color.NRGBA{R: 230, G: 232, B: 235, A: 255}
|
|
|
|
|
|
style.Color = s.theme.Palette.Fg
|
|
|
|
|
|
dims := style.Layout(tabsGtx)
|
|
|
|
|
|
if s.tabsReturnFocus {
|
|
|
|
|
|
gtx.Execute(key.FocusCmd{Tag: &s.editTabs})
|
|
|
|
|
|
s.tabsReturnFocus = false
|
|
|
|
|
|
}
|
|
|
|
|
|
return dims
|
|
|
|
|
|
}),
|
|
|
|
|
|
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
2026-07-25 16:36:26 +08:00
|
|
|
|
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),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-27 10:01:07 +08:00
|
|
|
|
func (s *Shell) canInspectTabs(row InstanceRow) bool {
|
|
|
|
|
|
return domain.ValidRemoteDebugPort(row.RemoteDebugPort) && (row.Status == "运行中" || row.Status == "外部已关联")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func tabsSnapshotFingerprint(row InstanceRow) string {
|
|
|
|
|
|
return strings.Join([]string{row.ID, row.Browser, row.Status, strconv.Itoa(row.RemoteDebugPort)}, "\x00")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) requestTabs() {
|
|
|
|
|
|
row, exists := s.instanceRow(s.editingID)
|
|
|
|
|
|
if !exists || !s.canInspectTabs(row) {
|
|
|
|
|
|
s.editFeedback = "查看标签页需要处于已验证的运行或外部关联状态,并具有实际调试端口。"
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if s.tabsLoading {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
s.tabsRequest++
|
|
|
|
|
|
request := s.tabsRequest
|
|
|
|
|
|
fingerprint := tabsSnapshotFingerprint(row)
|
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
|
|
|
|
s.tabsCancel = cancel
|
|
|
|
|
|
s.tabsOpen = true
|
|
|
|
|
|
s.tabsLoading = true
|
|
|
|
|
|
s.tabsInstanceID = row.ID
|
|
|
|
|
|
s.tabsFingerprint = fingerprint
|
|
|
|
|
|
s.tabsTargets = nil
|
|
|
|
|
|
s.tabsFeedback = ""
|
|
|
|
|
|
s.tabsFocusPending = true
|
|
|
|
|
|
if s.instanceTabInspector == nil {
|
|
|
|
|
|
cancel()
|
|
|
|
|
|
s.tabsCancel = nil
|
|
|
|
|
|
s.tabsLoading = false
|
|
|
|
|
|
s.tabsFeedback = "CDP 页面目标服务尚未准备好。"
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
inspector := s.instanceTabInspector
|
|
|
|
|
|
invalidate := s.invalidate
|
|
|
|
|
|
go func() {
|
|
|
|
|
|
targets, err := inspector(ctx, row)
|
|
|
|
|
|
cancel()
|
|
|
|
|
|
s.tabsResults <- instanceTabsResult{request: request, instanceID: row.ID, fingerprint: fingerprint, targets: targets, err: err}
|
|
|
|
|
|
if invalidate != nil {
|
|
|
|
|
|
invalidate()
|
|
|
|
|
|
}
|
|
|
|
|
|
}()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) consumeTabsResults() {
|
|
|
|
|
|
for {
|
|
|
|
|
|
select {
|
|
|
|
|
|
case result := <-s.tabsResults:
|
|
|
|
|
|
if !s.tabsOpen || result.request != s.tabsRequest || result.instanceID != s.tabsInstanceID || result.fingerprint != s.tabsFingerprint {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
s.tabsLoading = false
|
|
|
|
|
|
s.tabsCancel = nil
|
|
|
|
|
|
row, exists := s.instanceRow(result.instanceID)
|
|
|
|
|
|
if !exists || !s.canInspectTabs(row) || tabsSnapshotFingerprint(row) != result.fingerprint {
|
|
|
|
|
|
s.tabsTargets = nil
|
|
|
|
|
|
s.tabsFeedback = "实例状态或调试端口已经变化;请关闭后重新打开标签页。"
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if result.err != nil {
|
|
|
|
|
|
s.tabsTargets = nil
|
|
|
|
|
|
s.tabsFeedback = fmt.Sprintf("无法读取标签页:%v", result.err)
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
s.tabsTargets = append([]CDPTarget(nil), result.targets...)
|
|
|
|
|
|
s.tabsFeedback = ""
|
|
|
|
|
|
default:
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) closeTabs(returnFocus bool) {
|
|
|
|
|
|
if s.tabsCancel != nil {
|
|
|
|
|
|
s.tabsCancel()
|
|
|
|
|
|
s.tabsCancel = nil
|
|
|
|
|
|
}
|
|
|
|
|
|
s.tabsRequest++
|
|
|
|
|
|
s.tabsOpen = false
|
|
|
|
|
|
s.tabsLoading = false
|
|
|
|
|
|
s.tabsInstanceID = ""
|
|
|
|
|
|
s.tabsFingerprint = ""
|
|
|
|
|
|
s.tabsTargets = nil
|
|
|
|
|
|
s.tabsFeedback = ""
|
|
|
|
|
|
s.tabsFocusPending = false
|
|
|
|
|
|
if returnFocus && s.editingID != "" {
|
|
|
|
|
|
s.tabsReturnFocus = true
|
|
|
|
|
|
} else if !returnFocus {
|
|
|
|
|
|
s.tabsReturnFocus = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) tabsDialog(gtx layout.Context) layout.Dimensions {
|
|
|
|
|
|
if !s.tabsOpen {
|
|
|
|
|
|
return layout.Dimensions{}
|
|
|
|
|
|
}
|
|
|
|
|
|
gtx.Constraints.Min = gtx.Constraints.Max
|
|
|
|
|
|
return s.tabsBlocker.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.tabsDialogCard)
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) tabsDialogCard(gtx layout.Context) layout.Dimensions {
|
|
|
|
|
|
if maxWidth := gtx.Dp(720); 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}
|
|
|
|
|
|
if rect.Empty() {
|
|
|
|
|
|
return layout.Dimensions{Size: rect.Max}
|
|
|
|
|
|
}
|
|
|
|
|
|
paint.FillShape(gtx.Ops, instanceFormWorkspaceBorder, clip.UniformRRect(rect, gtx.Dp(10)).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(9)).Op(gtx.Ops))
|
|
|
|
|
|
}
|
|
|
|
|
|
return layout.Dimensions{Size: rect.Max}
|
|
|
|
|
|
},
|
|
|
|
|
|
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 {
|
|
|
|
|
|
row, _ := s.instanceRow(s.tabsInstanceID)
|
|
|
|
|
|
caption := "只读查看经验证的 loopback CDP 页面目标。"
|
|
|
|
|
|
if row.Name != "" {
|
|
|
|
|
|
caption = fmt.Sprintf("%s · 实际端口 %d · 只读查看", row.Name, row.RemoteDebugPort)
|
|
|
|
|
|
}
|
|
|
|
|
|
children := []layout.FlexChild{
|
|
|
|
|
|
layout.Rigid(material.H6(s.theme, "标签页(CDP 页面目标)").Layout),
|
|
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
|
|
|
|
|
layout.Rigid(material.Caption(s.theme, caption).Layout),
|
|
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout),
|
|
|
|
|
|
}
|
|
|
|
|
|
if s.tabsLoading {
|
|
|
|
|
|
children = append(children, layout.Rigid(s.instanceFormFeedback("正在读取已验证的本地 CDP 页面目标…")))
|
|
|
|
|
|
} else if s.tabsFeedback != "" {
|
|
|
|
|
|
children = append(children, layout.Rigid(s.instanceFormFeedback(s.tabsFeedback)))
|
|
|
|
|
|
} else if len(s.tabsTargets) == 0 {
|
|
|
|
|
|
children = append(children, layout.Rigid(s.instanceFormFeedback("未发现可显示的 CDP 页面目标。")))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
children = append(children, layout.Rigid(s.tabsTargetList))
|
|
|
|
|
|
}
|
|
|
|
|
|
children = append(children,
|
|
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout),
|
|
|
|
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
|
|
|
|
return layout.E.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
|
|
|
|
style := material.Button(s.theme, &s.tabsClose, "关闭")
|
|
|
|
|
|
dims := style.Layout(gtx)
|
|
|
|
|
|
if s.tabsFocusPending {
|
|
|
|
|
|
gtx.Execute(key.FocusCmd{Tag: &s.tabsClose})
|
|
|
|
|
|
s.tabsFocusPending = false
|
|
|
|
|
|
}
|
|
|
|
|
|
return dims
|
|
|
|
|
|
})
|
|
|
|
|
|
}),
|
|
|
|
|
|
)
|
|
|
|
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
|
|
|
|
|
})
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) tabsTargetList(gtx layout.Context) layout.Dimensions {
|
|
|
|
|
|
height := gtx.Dp(280)
|
|
|
|
|
|
if gtx.Constraints.Max.Y < height {
|
|
|
|
|
|
height = gtx.Constraints.Max.Y
|
|
|
|
|
|
}
|
|
|
|
|
|
gtx.Constraints.Min.Y = height
|
|
|
|
|
|
gtx.Constraints.Max.Y = height
|
|
|
|
|
|
return material.List(s.theme, &s.tabsList).Layout(gtx, len(s.tabsTargets), func(gtx layout.Context, index int) layout.Dimensions {
|
|
|
|
|
|
return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, s.tabsTargetRow(s.tabsTargets[index]))
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) tabsTargetRow(target CDPTarget) layout.Widget {
|
|
|
|
|
|
return s.settingsSurface(instanceFormComponentBorder, unit.Dp(6), unit.Dp(8), func(gtx layout.Context) layout.Dimensions {
|
|
|
|
|
|
targetType := strings.TrimSpace(target.Type)
|
|
|
|
|
|
if targetType == "" {
|
|
|
|
|
|
targetType = "page"
|
|
|
|
|
|
}
|
|
|
|
|
|
title := strings.TrimSpace(target.Title)
|
|
|
|
|
|
if title == "" {
|
|
|
|
|
|
title = "(无标题)"
|
|
|
|
|
|
}
|
|
|
|
|
|
url := strings.TrimSpace(target.URL)
|
|
|
|
|
|
if url == "" {
|
|
|
|
|
|
url = "—"
|
|
|
|
|
|
}
|
|
|
|
|
|
titleStyle := material.Body1(s.theme, title)
|
|
|
|
|
|
titleStyle.MaxLines = 1
|
|
|
|
|
|
urlStyle := material.Body2(s.theme, url)
|
|
|
|
|
|
urlStyle.MaxLines = 2
|
|
|
|
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
|
|
|
|
layout.Rigid(material.Caption(s.theme, targetType).Layout),
|
|
|
|
|
|
layout.Rigid(titleStyle.Layout),
|
|
|
|
|
|
layout.Rigid(urlStyle.Layout),
|
|
|
|
|
|
)
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 16:36:26 +08:00
|
|
|
|
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),
|
|
|
|
|
|
)
|
|
|
|
|
|
}),
|
|
|
|
|
|
)
|
|
|
|
|
|
})
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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 {
|
2026-07-27 23:16:11 +08:00
|
|
|
|
children := []layout.FlexChild{
|
2026-07-22 17:02:59 +08:00
|
|
|
|
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),
|
2026-07-27 23:16:11 +08:00
|
|
|
|
layout.Rigid(material.Caption(s.theme, "默认只删除 Chub 实例配置;User Data Dir 会保留。").Layout),
|
|
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
|
|
|
|
|
}
|
|
|
|
|
|
if s.deleteDataRunning {
|
|
|
|
|
|
children = append(children,
|
|
|
|
|
|
layout.Rigid(s.instanceFormFeedback("正在删除 User Data Dir 数据,请勿关闭应用…")),
|
|
|
|
|
|
)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
children = append(children,
|
|
|
|
|
|
layout.Rigid(s.deleteDataOption(row)),
|
|
|
|
|
|
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),
|
|
|
|
|
|
)
|
|
|
|
|
|
}),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
dims := layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
|
|
|
|
|
if s.deleteDataFocus && !s.deleteDataRunning {
|
|
|
|
|
|
gtx.Execute(key.FocusCmd{Tag: &s.deleteCancel})
|
|
|
|
|
|
s.deleteDataFocus = false
|
|
|
|
|
|
}
|
|
|
|
|
|
return dims
|
2026-07-22 17:02:59 +08:00
|
|
|
|
})
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-27 23:16:11 +08:00
|
|
|
|
func (s *Shell) deleteDataOption(row InstanceRow) layout.Widget {
|
|
|
|
|
|
return func(gtx layout.Context) layout.Dimensions {
|
|
|
|
|
|
canDeleteData := s.instanceDataDeletionAllowed(row)
|
|
|
|
|
|
children := []layout.FlexChild{
|
|
|
|
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
|
|
|
|
if !canDeleteData {
|
|
|
|
|
|
gtx = gtx.Disabled()
|
|
|
|
|
|
}
|
|
|
|
|
|
return material.CheckBox(s.theme, &s.deleteData, "同时删除 User Data Dir 中的数据").Layout(gtx)
|
|
|
|
|
|
}),
|
|
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
|
|
|
|
|
}
|
|
|
|
|
|
if !canDeleteData {
|
|
|
|
|
|
children = append(children, layout.Rigid(material.Caption(s.theme, "浏览器正在运行、启动/停止或占用;请先停止并刷新状态后再删除数据。当前仍可只删除实例配置。").Layout))
|
|
|
|
|
|
} else if s.deleteData.Value {
|
|
|
|
|
|
children = append(children, layout.Rigid(s.instanceFormFeedback(fmt.Sprintf("将永久删除 User Data Dir:\n%s\n此操作无法恢复;执行前会再次检查浏览器占用。", row.UserDataDir))))
|
|
|
|
|
|
}
|
|
|
|
|
|
if s.deleteDataFeedback != "" {
|
|
|
|
|
|
children = append(children,
|
|
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
|
|
|
|
layout.Rigid(s.instanceFormFeedback(s.deleteDataFeedback)),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) instanceDataDeletionAllowed(row InstanceRow) bool {
|
|
|
|
|
|
return row.Status == "已退出" || row.Status == "启动失败"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 17:02:59 +08:00
|
|
|
|
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 {
|
2026-07-27 23:16:11 +08:00
|
|
|
|
label := "删除实例"
|
|
|
|
|
|
if s.deleteData.Value {
|
|
|
|
|
|
label = "删除实例及数据"
|
|
|
|
|
|
}
|
|
|
|
|
|
style := material.Button(s.theme, &s.deleteConfirm, label)
|
2026-07-22 17:02:59 +08:00
|
|
|
|
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-25 17:57:04 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 18:23:33 +08:00
|
|
|
|
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
|
|
|
|
|
|
})
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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-25 15:53:31 +08:00
|
|
|
|
"运行中": {R: 24, G: 125, B: 78, A: 255},
|
|
|
|
|
|
"启动中": {R: 175, G: 105, B: 0, A: 255},
|
2026-07-25 17:10:20 +08:00
|
|
|
|
"停止中": {R: 175, G: 105, B: 0, A: 255},
|
2026-07-25 15:53:31 +08:00
|
|
|
|
"启动失败": {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},
|
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) {
|
2026-07-27 22:46:31 +08:00
|
|
|
|
s.chooseExecutable(PathChromeExecutable)
|
2026-07-22 15:30:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
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) {
|
2026-07-27 22:46:31 +08:00
|
|
|
|
s.chooseExecutable(PathEdgeExecutable)
|
2026-07-22 15:30:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
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) {
|
2026-07-28 00:12:29 +08:00
|
|
|
|
s.chooseDirectory(directoryPickDefaultUserData)
|
2026-07-22 15:30:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
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) {
|
2026-07-28 00:12:29 +08:00
|
|
|
|
s.chooseDirectory(directoryPickLogDirectory)
|
2026-07-22 15:30:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
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-28 00:12:29 +08:00
|
|
|
|
for s.dataRestore.Clicked(gtx) {
|
|
|
|
|
|
s.restorePortableDirectory(PathDefaultUserData)
|
|
|
|
|
|
}
|
|
|
|
|
|
for s.logRestore.Clicked(gtx) {
|
|
|
|
|
|
s.restorePortableDirectory(PathLogDirectory)
|
|
|
|
|
|
}
|
2026-07-25 17:04:26 +08:00
|
|
|
|
for s.proxySave.Clicked(gtx) {
|
|
|
|
|
|
s.saveProxy()
|
|
|
|
|
|
}
|
2026-07-25 17:57:04 +08:00
|
|
|
|
for s.proxyDelete.Clicked(gtx) {
|
|
|
|
|
|
s.requestProxyDelete()
|
2026-07-25 17:04:26 +08:00
|
|
|
|
}
|
2026-07-22 15:17:08 +08:00
|
|
|
|
for s.saveClick.Clicked(gtx) {
|
2026-07-25 15:53:31 +08:00
|
|
|
|
settings, err := s.settingsState()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
s.pathFeedback = err.Error()
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
s.remoteDebugPort.SetText(strconv.Itoa(settings.RemoteDebugStartPort))
|
2026-07-28 00:12:29 +08:00
|
|
|
|
s.defaultDirMode = settings.DefaultDirMode
|
|
|
|
|
|
s.logDirMode = settings.LogDirMode
|
|
|
|
|
|
s.defaultInstanceDirRoot = settings.DefaultDir
|
|
|
|
|
|
s.lastSavedSettings = settings
|
2026-07-22 15:17:08 +08:00
|
|
|
|
if s.onSave != nil {
|
2026-07-25 15:53:31 +08:00
|
|
|
|
s.onSave(settings)
|
2026-07-22 15:17:08 +08:00
|
|
|
|
}
|
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),
|
2026-07-26 17:42:40 +08:00
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout),
|
|
|
|
|
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
|
|
|
|
|
return material.List(s.theme, &s.settingsList).Layout(gtx, 1, func(gtx layout.Context, _ int) layout.Dimensions {
|
|
|
|
|
|
return layout.Inset{Bottom: unit.Dp(8)}.Layout(gtx, s.settingsWorkspace)
|
|
|
|
|
|
})
|
2026-07-22 15:13:55 +08:00
|
|
|
|
}),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-26 17:42:40 +08:00
|
|
|
|
func (s *Shell) settingsWorkspace(gtx layout.Context) layout.Dimensions {
|
|
|
|
|
|
return s.settingsSurface(color.NRGBA{R: 199, G: 207, B: 218, A: 255}, unit.Dp(10), unit.Dp(12), func(gtx layout.Context) layout.Dimensions {
|
|
|
|
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
|
|
|
|
layout.Rigid(s.settingsSection("浏览器路径", "分别指定 Chrome 与 Edge 的可执行文件;留空时仍可使用自动发现。", func(gtx layout.Context) layout.Dimensions {
|
|
|
|
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
|
|
|
|
layout.Rigid(s.pathField(PathChromeExecutable, "Chrome 可执行文件", &s.chromePath, &s.chromePick, &s.chromeSearch)),
|
|
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.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.settingsSection("默认目录", "这些目录只作为新建实例和诊断日志的默认位置,不会移动或删除已有 profile。", func(gtx layout.Context) layout.Dimensions {
|
|
|
|
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
2026-07-28 00:12:29 +08:00
|
|
|
|
layout.Rigid(s.portableDirectoryField(PathDefaultUserData, "默认 User Data Dir", &s.dataDir, &s.dataPick, &s.dataSearch, &s.dataRestore)),
|
2026-07-26 17:42:40 +08:00
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
2026-07-28 00:12:29 +08:00
|
|
|
|
layout.Rigid(s.portableDirectoryField(PathLogDirectory, "日志目录", &s.logDir, &s.logPick, &s.logSearch, &s.logRestore)),
|
2026-07-26 17:42:40 +08:00
|
|
|
|
)
|
|
|
|
|
|
})),
|
|
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
|
|
|
|
|
layout.Rigid(s.settingsSection("运行行为", "影响后续启动时的端口分配,以及退出 Chub 时的默认处理。", func(gtx layout.Context) layout.Dimensions {
|
|
|
|
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
|
|
|
|
layout.Rigid(s.remoteDebugPortField),
|
|
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
|
|
|
|
layout.Rigid(s.settingsField(func(gtx layout.Context) layout.Dimensions {
|
|
|
|
|
|
return material.CheckBox(s.theme, &s.closeOnExit, "退出时关闭托管浏览器").Layout(gtx)
|
|
|
|
|
|
})),
|
|
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
|
|
|
|
layout.Rigid(s.settingsFeedback(s.pathFeedback)),
|
|
|
|
|
|
)
|
|
|
|
|
|
})),
|
|
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
|
|
|
|
|
layout.Rigid(s.settingsSection("代理", "仅保存无认证 scheme://host:port 端点;下拉框和实例表单显示完整地址。", s.proxySettings)),
|
|
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
|
|
|
|
|
layout.Rigid(s.settingsActionBar(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),
|
|
|
|
|
|
)
|
|
|
|
|
|
})),
|
|
|
|
|
|
)
|
|
|
|
|
|
})(gtx)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) settingsSection(title, description string, content layout.Widget) layout.Widget {
|
|
|
|
|
|
return s.settingsSurface(color.NRGBA{R: 215, G: 222, B: 232, A: 255}, unit.Dp(8), unit.Dp(12), func(gtx layout.Context) layout.Dimensions {
|
|
|
|
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
|
|
|
|
layout.Rigid(material.H6(s.theme, title).Layout),
|
|
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
|
|
|
|
|
layout.Rigid(material.Caption(s.theme, description).Layout),
|
|
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
|
|
|
|
|
layout.Rigid(content),
|
|
|
|
|
|
)
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) settingsField(content layout.Widget) layout.Widget {
|
|
|
|
|
|
return s.settingsSurface(color.NRGBA{R: 225, G: 230, B: 238, A: 255}, unit.Dp(6), unit.Dp(10), content)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) settingsActionBar(content layout.Widget) layout.Widget {
|
|
|
|
|
|
return s.settingsSurface(color.NRGBA{R: 215, G: 222, B: 232, A: 255}, unit.Dp(6), unit.Dp(8), content)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) settingsFeedback(message string) layout.Widget {
|
|
|
|
|
|
return func(gtx layout.Context) layout.Dimensions {
|
|
|
|
|
|
if strings.TrimSpace(message) == "" {
|
|
|
|
|
|
return layout.Dimensions{}
|
|
|
|
|
|
}
|
|
|
|
|
|
return s.settingsSurface(color.NRGBA{R: 215, G: 222, B: 232, A: 255}, unit.Dp(6), unit.Dp(8), material.Caption(s.theme, message).Layout)(gtx)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) settingsSurface(border color.NRGBA, radius, padding unit.Dp, content layout.Widget) layout.Widget {
|
|
|
|
|
|
return func(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, border, clip.UniformRRect(rect, gtx.Dp(radius)).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(radius-unit.Dp(1))).Op(gtx.Ops))
|
|
|
|
|
|
}
|
|
|
|
|
|
return layout.Dimensions{Size: rect.Max}
|
|
|
|
|
|
},
|
|
|
|
|
|
func(gtx layout.Context) layout.Dimensions {
|
|
|
|
|
|
return layout.Inset{Top: padding, Right: padding, Bottom: padding, Left: padding}.Layout(gtx, content)
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 17:04:26 +08:00
|
|
|
|
func (s *Shell) proxySettings(gtx layout.Context) layout.Dimensions {
|
2026-07-25 17:57:04 +08:00
|
|
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
2026-07-26 17:42:40 +08:00
|
|
|
|
layout.Rigid(s.settingsField(func(gtx layout.Context) layout.Dimensions { return s.proxyPickerField(gtx, proxyPickerSettings) })),
|
2026-07-25 17:04:26 +08:00
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
2026-07-27 12:09:54 +08:00
|
|
|
|
layout.Rigid(s.settingsField(s.formField("代理名称", "用于实例列表和选择器识别;名称必须唯一", &s.proxyName))),
|
|
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
2026-07-26 17:42:40 +08:00
|
|
|
|
layout.Rigid(s.settingsField(s.formField("代理地址", "支持 http、https、socks4、socks5;不允许用户名、密码或路径", &s.proxyServer))),
|
2026-07-25 17:04:26 +08:00
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
2026-07-26 17:42:40 +08:00
|
|
|
|
layout.Rigid(s.settingsActionBar(func(gtx layout.Context) layout.Dimensions {
|
2026-07-25 17:04:26 +08:00
|
|
|
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
2026-07-25 17:57:04 +08:00
|
|
|
|
layout.Rigid(material.Button(s.theme, &s.proxySave, "保存代理").Layout),
|
2026-07-25 17:04:26 +08:00
|
|
|
|
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
2026-07-25 17:57:04 +08:00
|
|
|
|
layout.Rigid(s.proxyDeleteButton),
|
2026-07-25 17:04:26 +08:00
|
|
|
|
)
|
2026-07-26 17:42:40 +08:00
|
|
|
|
})),
|
2026-07-25 17:57:04 +08:00
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
2026-07-26 17:42:40 +08:00
|
|
|
|
layout.Rigid(s.settingsFeedback(s.proxyFeedback)),
|
2026-07-25 17:57:04 +08:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) proxyDeleteButton(gtx layout.Context) layout.Dimensions {
|
|
|
|
|
|
buttonGtx := gtx
|
|
|
|
|
|
if s.settingsProxyID == "" {
|
|
|
|
|
|
buttonGtx = gtx.Disabled()
|
2026-07-25 17:04:26 +08:00
|
|
|
|
}
|
2026-07-25 17:57:04 +08:00
|
|
|
|
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)
|
2026-07-25 17:04:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 15:53:31 +08:00
|
|
|
|
func (s *Shell) remoteDebugPortField(gtx layout.Context) layout.Dimensions {
|
2026-07-26 17:42:40 +08:00
|
|
|
|
return s.settingsField(func(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),
|
|
|
|
|
|
)
|
|
|
|
|
|
})(gtx)
|
2026-07-25 15:53:31 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
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 {
|
2026-07-26 17:42:40 +08:00
|
|
|
|
return s.settingsField(func(gtx layout.Context) layout.Dimensions {
|
|
|
|
|
|
pathActions := func(gtx layout.Context) layout.Dimensions {
|
|
|
|
|
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
2026-07-27 22:46:31 +08:00
|
|
|
|
layout.Rigid(material.Button(s.theme, pick, s.pathPickButtonLabel(field)).Layout),
|
2026-07-26 17:42:40 +08:00
|
|
|
|
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
|
|
|
|
|
layout.Rigid(material.Button(s.theme, search, s.searchButtonLabel(field)).Layout),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
children := []layout.FlexChild{
|
|
|
|
|
|
layout.Rigid(material.Body2(s.theme, label).Layout),
|
|
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
|
|
|
|
|
}
|
|
|
|
|
|
if gtx.Constraints.Max.X <= gtx.Dp(640) {
|
|
|
|
|
|
children = append(children,
|
|
|
|
|
|
layout.Rigid(material.Editor(s.theme, editor, "请输入或粘贴路径").Layout),
|
|
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
|
|
|
|
layout.Rigid(pathActions),
|
|
|
|
|
|
)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
|
|
|
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
|
|
|
|
|
layout.Flexed(1, material.Editor(s.theme, editor, "请输入或粘贴路径").Layout),
|
|
|
|
|
|
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
|
|
|
|
|
layout.Rigid(pathActions),
|
|
|
|
|
|
)
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
2026-07-27 22:46:31 +08:00
|
|
|
|
if feedback := s.pathFieldFeedback[field]; feedback != "" {
|
|
|
|
|
|
children = append(children,
|
|
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
|
|
|
|
layout.Rigid(s.settingsFeedback(feedback)),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-07-26 17:42:40 +08:00
|
|
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
|
|
|
|
|
})(gtx)
|
2026-07-22 15:30:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-28 00:12:29 +08:00
|
|
|
|
func (s *Shell) portableDirectoryField(field PathField, label string, editor *widget.Editor, pick, search, restore *widget.Clickable) layout.Widget {
|
|
|
|
|
|
return func(gtx layout.Context) layout.Dimensions {
|
|
|
|
|
|
mode := s.directoryMode(field)
|
|
|
|
|
|
help := "自定义目录;仅影响后续新建实例或后续日志写入。"
|
|
|
|
|
|
if mode == "portable" {
|
|
|
|
|
|
help = "跟随程序目录;下次启动会按 chub.exe 所在目录重新解析。"
|
|
|
|
|
|
}
|
|
|
|
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
|
|
|
|
layout.Rigid(s.pathField(field, label, editor, pick, search)),
|
|
|
|
|
|
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.Caption(s.theme, help).Layout),
|
|
|
|
|
|
layout.Rigid(material.Button(s.theme, restore, "恢复程序目录默认").Layout),
|
|
|
|
|
|
)
|
|
|
|
|
|
}),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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-27 09:40:37 +08:00
|
|
|
|
layout.Rigid(s.instanceFormWorkspace(func(gtx layout.Context) layout.Dimensions {
|
|
|
|
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
|
|
|
|
layout.Rigid(s.instanceFormField(s.formField("实例名称", "用于在实例列表中识别此浏览器环境", &s.instanceName))),
|
|
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
|
|
|
|
layout.Rigid(s.instanceFormField(s.instanceDirField)),
|
|
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
|
|
|
|
layout.Rigid(s.instanceFormField(s.createBrowserField)),
|
|
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
|
|
|
|
layout.Rigid(s.instanceFormField(s.formField("启动 URL(可选)", "仅支持 http/https;留空时启动浏览器默认页", &s.instanceURL))),
|
|
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
|
|
|
|
layout.Rigid(s.instanceFormField(s.formField("首选调试端口", "已按现有实例推荐;启动时会在后台复检 loopback 可用性", &s.instancePort))),
|
|
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
|
|
|
|
layout.Rigid(s.instanceFormField(func(gtx layout.Context) layout.Dimensions { return s.proxyPickerField(gtx, proxyPickerCreate) })),
|
|
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
|
|
|
|
layout.Rigid(s.instanceFormFeedback(s.formFeedback)),
|
|
|
|
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
|
|
|
|
|
layout.Rigid(s.instanceFormActionBar(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:24:25 +08:00
|
|
|
|
)
|
2026-07-27 09:40:37 +08:00
|
|
|
|
})),
|
2026-07-22 15:24:25 +08:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-27 09:40:37 +08:00
|
|
|
|
func (s *Shell) createBrowserField(gtx layout.Context) layout.Dimensions {
|
|
|
|
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
|
|
|
|
layout.Rigid(material.Body2(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.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),
|
|
|
|
|
|
)
|
|
|
|
|
|
}),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) instanceFormWorkspace(content layout.Widget) layout.Widget {
|
|
|
|
|
|
return s.settingsSurface(instanceFormWorkspaceBorder, unit.Dp(10), unit.Dp(12), content)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) instanceFormField(content layout.Widget) layout.Widget {
|
|
|
|
|
|
return s.settingsSurface(instanceFormComponentBorder, unit.Dp(6), unit.Dp(8), content)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) instanceFormActionBar(content layout.Widget) layout.Widget {
|
|
|
|
|
|
return s.settingsSurface(instanceFormActionBorder, unit.Dp(6), unit.Dp(8), content)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) instanceFormFeedback(message string) layout.Widget {
|
|
|
|
|
|
if strings.TrimSpace(message) == "" {
|
|
|
|
|
|
return func(layout.Context) layout.Dimensions { return layout.Dimensions{} }
|
|
|
|
|
|
}
|
|
|
|
|
|
return s.settingsSurface(instanceFormActionBorder, unit.Dp(6), unit.Dp(8), material.Caption(s.theme, message).Layout)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 17:04:26 +08:00
|
|
|
|
func (s *Shell) proxyPickerField(gtx layout.Context, target proxyPickerTarget) layout.Dimensions {
|
|
|
|
|
|
click := &s.createProxyPick
|
|
|
|
|
|
selected := s.createProxyID
|
2026-07-25 17:57:04 +08:00
|
|
|
|
label := "代理"
|
|
|
|
|
|
help := "选择设置中保存的无认证代理;留空表示不使用代理"
|
2026-07-25 17:04:26 +08:00
|
|
|
|
if target == proxyPickerEdit {
|
|
|
|
|
|
click = &s.editProxyPick
|
|
|
|
|
|
selected = s.editProxyID
|
2026-07-25 17:57:04 +08:00
|
|
|
|
} else if target == proxyPickerSettings {
|
|
|
|
|
|
click = &s.settingsProxyPick
|
|
|
|
|
|
selected = s.settingsProxyID
|
|
|
|
|
|
label = "已保存代理"
|
2026-07-27 12:09:54 +08:00
|
|
|
|
help = "选择项会回填代理名称和地址;选择新建会清空输入框"
|
2026-07-25 17:04:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
for click.Clicked(gtx) {
|
|
|
|
|
|
s.proxyPicker = proxyPickerState{open: true, target: target}
|
|
|
|
|
|
}
|
|
|
|
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
2026-07-25 17:57:04 +08:00
|
|
|
|
layout.Rigid(material.Body2(s.theme, label).Layout),
|
|
|
|
|
|
layout.Rigid(material.Caption(s.theme, help).Layout),
|
2026-07-25 17:04:26 +08:00
|
|
|
|
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 {
|
2026-07-27 12:09:54 +08:00
|
|
|
|
if option, exists := s.proxyOption(id); exists {
|
|
|
|
|
|
return proxyPickerLabel(option)
|
|
|
|
|
|
}
|
|
|
|
|
|
return s.proxyDisplayName(id)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) proxyDisplayName(id string) string {
|
2026-07-25 17:04:26 +08:00
|
|
|
|
if id == "" {
|
|
|
|
|
|
return "无代理"
|
|
|
|
|
|
}
|
2026-07-27 12:09:54 +08:00
|
|
|
|
if option, exists := s.proxyOption(id); exists {
|
|
|
|
|
|
return option.Name
|
2026-07-25 17:04:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
return "代理不可用"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-27 12:09:54 +08:00
|
|
|
|
func proxyPickerLabel(option ProxyOption) string {
|
|
|
|
|
|
return option.Name + " · " + option.Server
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 17:04:26 +08:00
|
|
|
|
func (s *Shell) proxyExists(id string) bool {
|
2026-07-25 17:57:04 +08:00
|
|
|
|
if _, exists := s.proxyOption(id); exists {
|
2026-07-25 17:04:26 +08:00
|
|
|
|
return true
|
|
|
|
|
|
}
|
2026-07-25 17:57:04 +08:00
|
|
|
|
return id == ""
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) proxyOption(id string) (ProxyOption, bool) {
|
|
|
|
|
|
if id == "" {
|
|
|
|
|
|
return ProxyOption{}, false
|
|
|
|
|
|
}
|
2026-07-25 17:04:26 +08:00
|
|
|
|
for _, option := range s.proxies {
|
|
|
|
|
|
if option.ID == id {
|
2026-07-25 17:57:04 +08:00
|
|
|
|
return option, true
|
2026-07-25 17:04:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-25 17:57:04 +08:00
|
|
|
|
return ProxyOption{}, false
|
2026-07-25 17:04:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-07-25 17:57:04 +08:00
|
|
|
|
} else if s.proxyPicker.target == proxyPickerSettings {
|
|
|
|
|
|
s.settingsProxyID = id
|
|
|
|
|
|
if id == "" {
|
2026-07-27 12:09:54 +08:00
|
|
|
|
s.proxyName.SetText("")
|
2026-07-25 17:57:04 +08:00
|
|
|
|
s.proxyServer.SetText("")
|
|
|
|
|
|
} else {
|
|
|
|
|
|
for _, option := range s.proxies {
|
|
|
|
|
|
if option.ID == id {
|
2026-07-27 12:09:54 +08:00
|
|
|
|
s.proxyName.SetText(option.Name)
|
2026-07-25 17:57:04 +08:00
|
|
|
|
s.proxyServer.SetText(option.Server)
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
s.proxyFeedback = ""
|
2026-07-25 17:04:26 +08:00
|
|
|
|
} 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{
|
2026-07-25 17:57:04 +08:00
|
|
|
|
layout.Rigid(material.H6(s.theme, s.proxyPickerTitle()).Layout),
|
2026-07-25 17:04:26 +08:00
|
|
|
|
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),
|
2026-07-25 17:57:04 +08:00
|
|
|
|
layout.Rigid(material.Button(s.theme, &s.proxyPickerNone, s.proxyPickerNoneLabel()).Layout),
|
2026-07-25 17:04:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
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 {
|
2026-07-27 12:09:54 +08:00
|
|
|
|
name := material.Body1(s.theme, option.Name)
|
|
|
|
|
|
name.MaxLines = 1
|
|
|
|
|
|
server := material.Caption(s.theme, option.Server)
|
|
|
|
|
|
server.MaxLines = 1
|
|
|
|
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
|
|
|
|
layout.Rigid(name.Layout),
|
|
|
|
|
|
layout.Rigid(server.Layout),
|
|
|
|
|
|
)
|
2026-07-25 17:04:26 +08:00
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
}),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
|
|
|
|
|
})
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 17:57:04 +08:00
|
|
|
|
func (s *Shell) proxyPickerTitle() string {
|
|
|
|
|
|
if s.proxyPicker.target == proxyPickerSettings {
|
|
|
|
|
|
return "选择已保存代理"
|
|
|
|
|
|
}
|
|
|
|
|
|
return "选择代理"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) proxyPickerNoneLabel() string {
|
|
|
|
|
|
if s.proxyPicker.target == proxyPickerSettings {
|
|
|
|
|
|
return "新建代理(不选择)"
|
|
|
|
|
|
}
|
|
|
|
|
|
return "无代理"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 15:57:02 +08:00
|
|
|
|
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),
|
2026-07-28 00:12:29 +08:00
|
|
|
|
layout.Rigid(material.Caption(s.theme, "默认目录会使用 instance-实例名-短 ID;必须是绝对路径").Layout),
|
2026-07-22 15:57:02 +08:00
|
|
|
|
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() {
|
2026-07-28 00:12:29 +08:00
|
|
|
|
name, err := s.validateInstanceName(s.instanceName.Text(), "")
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
s.formFeedback = err.Error()
|
2026-07-22 17:02:59 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-07-28 00:12:29 +08:00
|
|
|
|
if s.defaultCreateDirActive {
|
|
|
|
|
|
defaultDir := s.defaultCreateInstanceDir(name, s.pendingCreateID)
|
|
|
|
|
|
if defaultDir == "" {
|
|
|
|
|
|
s.formFeedback = "无法根据实例名称生成默认 User Data Dir;请选择或输入一个绝对路径。"
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
s.instanceDir.SetText(defaultDir)
|
|
|
|
|
|
s.lastSuggestedCreateDir = defaultDir
|
|
|
|
|
|
}
|
2026-07-22 17:02:59 +08:00
|
|
|
|
userDataDir := strings.TrimSpace(s.instanceDir.Text())
|
2026-07-27 12:09:54 +08:00
|
|
|
|
if userDataDir == "" || !filepath.IsAbs(userDataDir) {
|
|
|
|
|
|
s.formFeedback = "请选择或输入绝对路径的 User Data Dir 后再创建。"
|
2026-07-22 17:02:59 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-07-27 09:29:10 +08:00
|
|
|
|
preferredPort, err := normalizePreferredRemoteDebugPort(s.instancePort.Text())
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
s.formFeedback = err.Error()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if s.remoteDebugPortReservedByOther(preferredPort, "") {
|
|
|
|
|
|
s.formFeedback = fmt.Sprintf("端口 %d 已被其他 Chub 实例预留或使用。", preferredPort)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-07-27 12:09:54 +08:00
|
|
|
|
instanceID := s.pendingCreateID
|
|
|
|
|
|
if instanceID == "" || s.instanceIDExists(instanceID) {
|
|
|
|
|
|
instanceID = s.allocateInstanceID()
|
|
|
|
|
|
}
|
2026-07-22 17:02:59 +08:00
|
|
|
|
s.rows = append(s.rows, InstanceRow{
|
2026-07-27 12:09:54 +08:00
|
|
|
|
ID: instanceID,
|
2026-07-27 09:29:10 +08:00
|
|
|
|
Name: name,
|
|
|
|
|
|
Browser: browserDisplay(s.browserKind.Value),
|
2026-07-27 12:09:54 +08:00
|
|
|
|
UserDataDir: filepath.Clean(userDataDir),
|
2026-07-27 09:29:10 +08:00
|
|
|
|
TargetURL: strings.TrimSpace(s.instanceURL.Text()),
|
|
|
|
|
|
ProxyID: s.createProxyID,
|
|
|
|
|
|
PreferredRemoteDebugPort: preferredPort,
|
|
|
|
|
|
Status: "已退出",
|
2026-07-22 17:02:59 +08:00
|
|
|
|
})
|
|
|
|
|
|
s.page = pageInstances
|
|
|
|
|
|
s.formFeedback = ""
|
2026-07-27 12:09:54 +08:00
|
|
|
|
s.pendingCreateID = ""
|
2026-07-28 00:12:29 +08:00
|
|
|
|
s.defaultCreateDirActive = false
|
|
|
|
|
|
s.lastSuggestedCreateDir = ""
|
2026-07-22 17:02:59 +08:00
|
|
|
|
s.notifyInstancesChanged()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-28 00:12:29 +08:00
|
|
|
|
func (s *Shell) defaultCreateInstanceDir(name, instanceID string) string {
|
2026-07-27 12:09:54 +08:00
|
|
|
|
if s.defaultInstanceDirRoot == "" || instanceID == "" {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
2026-07-28 00:12:29 +08:00
|
|
|
|
component, err := domain.InstanceProfileDirectoryName(name, instanceID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
return filepath.Join(s.defaultInstanceDirRoot, component)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) syncDefaultCreateDirectory() {
|
|
|
|
|
|
if !s.defaultCreateDirActive || s.pendingCreateID == "" {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
current := strings.TrimSpace(s.instanceDir.Text())
|
|
|
|
|
|
if s.lastSuggestedCreateDir != "" && !samePathText(current, s.lastSuggestedCreateDir) {
|
|
|
|
|
|
s.defaultCreateDirActive = false
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
name := strings.TrimSpace(s.instanceName.Text())
|
|
|
|
|
|
if name == "" {
|
|
|
|
|
|
name = "新实例"
|
|
|
|
|
|
}
|
|
|
|
|
|
suggested := s.defaultCreateInstanceDir(name, s.pendingCreateID)
|
|
|
|
|
|
if suggested == "" || samePathText(suggested, s.lastSuggestedCreateDir) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
s.instanceDir.SetText(suggested)
|
|
|
|
|
|
s.lastSuggestedCreateDir = suggested
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) validateInstanceName(value, excludeID string) (string, error) {
|
|
|
|
|
|
name, err := domain.NormalizeInstanceName(value)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", errors.New("实例名称不能为空,且不能包含 Windows 非法字符、保留设备名或末尾句点。")
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, row := range s.rows {
|
|
|
|
|
|
if row.ID == excludeID {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.EqualFold(strings.TrimSpace(row.Name), name) {
|
|
|
|
|
|
return "", errors.New("实例名称已存在,请更换。")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return name, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func samePathText(left, right string) bool {
|
|
|
|
|
|
left = strings.TrimSpace(left)
|
|
|
|
|
|
right = strings.TrimSpace(right)
|
|
|
|
|
|
if left == "" || right == "" {
|
|
|
|
|
|
return left == right
|
|
|
|
|
|
}
|
|
|
|
|
|
return strings.EqualFold(filepath.Clean(left), filepath.Clean(right))
|
2026-07-27 12:09:54 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) allocateInstanceID() string {
|
|
|
|
|
|
for {
|
|
|
|
|
|
s.nextInstance++
|
|
|
|
|
|
candidate := fmt.Sprintf("instance-%d-%d", time.Now().UnixNano(), s.nextInstance)
|
|
|
|
|
|
if !s.instanceIDExists(candidate) && candidate != s.pendingCreateID {
|
|
|
|
|
|
return candidate
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) instanceIDExists(id string) bool {
|
|
|
|
|
|
for _, row := range s.rows {
|
|
|
|
|
|
if row.ID == id {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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++
|
2026-07-27 22:46:31 +08:00
|
|
|
|
s.setPathFieldFeedback(field, fmt.Sprintf("已取消%s搜索。", pathFieldLabel(field)))
|
2026-07-22 15:57:02 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if s.pathSearcher == nil {
|
2026-07-27 22:46:31 +08:00
|
|
|
|
s.setPathFieldFeedback(field, fmt.Sprintf("%s搜索服务尚未准备好。", pathFieldLabel(field)))
|
2026-07-22 15:57:02 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
state.request++
|
|
|
|
|
|
request := state.request
|
|
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
|
|
state.cancel = cancel
|
|
|
|
|
|
state.running = true
|
|
|
|
|
|
current := s.pathEditor(field).Text()
|
2026-07-27 22:46:31 +08:00
|
|
|
|
s.setPathFieldFeedback(field, fmt.Sprintf("正在搜索%s…", pathFieldLabel(field)))
|
2026-07-22 15:57:02 +08:00
|
|
|
|
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) {
|
2026-07-27 22:46:31 +08:00
|
|
|
|
s.setPathFieldFeedback(result.field, fmt.Sprintf("已取消%s搜索。", pathFieldLabel(result.field)))
|
2026-07-22 15:57:02 +08:00
|
|
|
|
} else {
|
2026-07-27 22:46:31 +08:00
|
|
|
|
s.setPathFieldFeedback(result.field, fmt.Sprintf("搜索%s失败,请重试或手动输入路径。", pathFieldLabel(result.field)))
|
2026-07-22 15:57:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if result.path == "" {
|
2026-07-27 22:46:31 +08:00
|
|
|
|
s.setPathFieldFeedback(result.field, fmt.Sprintf("未找到%s。", pathFieldLabel(result.field)))
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
current := strings.TrimSpace(s.pathEditor(result.field).Text())
|
|
|
|
|
|
s.pathEditor(result.field).SetText(result.path)
|
|
|
|
|
|
if current != "" && strings.EqualFold(filepath.Clean(current), filepath.Clean(result.path)) {
|
|
|
|
|
|
s.setPathFieldFeedback(result.field, fmt.Sprintf("已确认%s:当前路径可用。", pathFieldLabel(result.field)))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
s.setPathFieldFeedback(result.field, fmt.Sprintf("已找到%s,已更新路径。", pathFieldLabel(result.field)))
|
|
|
|
|
|
}
|
|
|
|
|
|
default:
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) chooseExecutable(field PathField) {
|
|
|
|
|
|
if field != PathChromeExecutable && field != PathEdgeExecutable {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if s.executablePick.running {
|
|
|
|
|
|
if s.executablePick.field == field {
|
|
|
|
|
|
s.setPathFieldFeedback(field, fmt.Sprintf("正在打开%s选择器,请在系统窗口中选择或取消。", pathFieldLabel(field)))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
s.setPathFieldFeedback(field, "另一个浏览器可执行文件选择器已打开,请先完成或取消。")
|
|
|
|
|
|
}
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if s.executableChooser == nil {
|
|
|
|
|
|
s.setPathFieldFeedback(field, fmt.Sprintf("%s选择器尚未准备好。", pathFieldLabel(field)))
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
s.executablePick.request++
|
|
|
|
|
|
request := s.executablePick.request
|
|
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
|
|
s.executablePick.cancel = cancel
|
|
|
|
|
|
s.executablePick.running = true
|
|
|
|
|
|
s.executablePick.field = field
|
|
|
|
|
|
s.setPathFieldFeedback(field, fmt.Sprintf("正在打开%s选择器…", pathFieldLabel(field)))
|
|
|
|
|
|
go func() {
|
|
|
|
|
|
path, err := s.executableChooser(ctx)
|
|
|
|
|
|
s.executableResults <- executablePickResult{field: field, request: request, path: path, err: err}
|
|
|
|
|
|
if s.invalidate != nil {
|
|
|
|
|
|
s.invalidate()
|
|
|
|
|
|
}
|
|
|
|
|
|
}()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) consumeExecutableResults() {
|
|
|
|
|
|
for {
|
|
|
|
|
|
select {
|
|
|
|
|
|
case result := <-s.executableResults:
|
|
|
|
|
|
if !s.executablePick.running || result.request != s.executablePick.request || result.field != s.executablePick.field {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
cancel := s.executablePick.cancel
|
|
|
|
|
|
s.executablePick.running = false
|
|
|
|
|
|
s.executablePick.cancel = nil
|
|
|
|
|
|
if cancel != nil {
|
|
|
|
|
|
cancel()
|
|
|
|
|
|
}
|
|
|
|
|
|
if result.err != nil {
|
|
|
|
|
|
if errors.Is(result.err, context.Canceled) {
|
|
|
|
|
|
s.setPathFieldFeedback(result.field, fmt.Sprintf("已取消选择%s。", pathFieldLabel(result.field)))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
s.setPathFieldFeedback(result.field, fmt.Sprintf("选择%s失败,请重试或手动输入路径。", pathFieldLabel(result.field)))
|
|
|
|
|
|
}
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(result.path) == "" {
|
|
|
|
|
|
s.setPathFieldFeedback(result.field, fmt.Sprintf("未选择%s。", pathFieldLabel(result.field)))
|
2026-07-22 15:57:02 +08:00
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
s.pathEditor(result.field).SetText(result.path)
|
2026-07-27 22:46:31 +08:00
|
|
|
|
s.setPathFieldFeedback(result.field, fmt.Sprintf("已选择%s。", pathFieldLabel(result.field)))
|
2026-07-22 15:57:02 +08:00
|
|
|
|
default:
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) searchButtonLabel(field PathField) string {
|
|
|
|
|
|
if state := s.searches[field]; state != nil && state.running {
|
|
|
|
|
|
return "取消"
|
|
|
|
|
|
}
|
|
|
|
|
|
return "搜索"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-27 22:46:31 +08:00
|
|
|
|
func (s *Shell) pathPickButtonLabel(field PathField) string {
|
|
|
|
|
|
if s.executablePick.running && s.executablePick.field == field {
|
|
|
|
|
|
return "选择中…"
|
|
|
|
|
|
}
|
2026-07-28 00:12:29 +08:00
|
|
|
|
if s.directoryPick.running && ((field == PathDefaultUserData && s.directoryPick.target == directoryPickDefaultUserData) || (field == PathLogDirectory && s.directoryPick.target == directoryPickLogDirectory)) {
|
|
|
|
|
|
return "选择中…"
|
|
|
|
|
|
}
|
2026-07-27 22:46:31 +08:00
|
|
|
|
return "选择"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) setPathFieldFeedback(field PathField, message string) {
|
|
|
|
|
|
if s.pathFieldFeedback == nil {
|
|
|
|
|
|
s.pathFieldFeedback = make(map[PathField]string)
|
|
|
|
|
|
}
|
|
|
|
|
|
s.pathFieldFeedback[field] = message
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 15:57:02 +08:00
|
|
|
|
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-25 16:36:26 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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-25 17:04:26 +08:00
|
|
|
|
func (s *Shell) saveProxy() {
|
2026-07-27 12:09:54 +08:00
|
|
|
|
name, err := domain.NormalizeProxyName(s.proxyName.Text())
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
s.proxyFeedback = "请输入代理名称。"
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-07-25 17:04:26 +08:00
|
|
|
|
server, err := domain.NormalizeProxyServer(s.proxyServer.Text())
|
|
|
|
|
|
if err != nil || server == "" {
|
|
|
|
|
|
s.proxyFeedback = "代理地址必须是无认证的 scheme://host:port。"
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, option := range s.proxies {
|
2026-07-27 12:09:54 +08:00
|
|
|
|
if option.ID != s.settingsProxyID && strings.EqualFold(option.Name, name) {
|
|
|
|
|
|
s.proxyFeedback = "该代理名称已存在,请使用其他名称。"
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-07-25 17:57:04 +08:00
|
|
|
|
if option.ID != s.settingsProxyID && option.Server == server {
|
|
|
|
|
|
s.proxyFeedback = "该代理地址已存在,请从下拉框选择它。"
|
2026-07-25 17:04:26 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-25 17:57:04 +08:00
|
|
|
|
if s.settingsProxyID == "" {
|
2026-07-25 17:04:26 +08:00
|
|
|
|
s.nextProxy++
|
2026-07-25 17:57:04 +08:00
|
|
|
|
s.settingsProxyID = fmt.Sprintf("proxy-%d", s.nextProxy)
|
2026-07-27 12:09:54 +08:00
|
|
|
|
s.proxies = append(s.proxies, ProxyOption{ID: s.settingsProxyID, Name: name, Server: server})
|
|
|
|
|
|
s.proxyFeedback = fmt.Sprintf("已添加代理“%s”;后续启动将使用该地址。", name)
|
2026-07-25 17:04:26 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
updated := false
|
|
|
|
|
|
for index := range s.proxies {
|
2026-07-25 17:57:04 +08:00
|
|
|
|
if s.proxies[index].ID != s.settingsProxyID {
|
2026-07-25 17:04:26 +08:00
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-07-27 12:09:54 +08:00
|
|
|
|
s.proxies[index].Name = name
|
2026-07-25 17:04:26 +08:00
|
|
|
|
s.proxies[index].Server = server
|
|
|
|
|
|
updated = true
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
if !updated {
|
2026-07-25 17:57:04 +08:00
|
|
|
|
s.proxyFeedback = "所选代理已不可用,请重新选择或新建。"
|
2026-07-25 17:04:26 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-07-27 12:09:54 +08:00
|
|
|
|
s.proxyFeedback = fmt.Sprintf("已保存代理“%s”;后续启动将使用最新端点。", name)
|
2026-07-25 17:04:26 +08:00
|
|
|
|
}
|
2026-07-27 12:09:54 +08:00
|
|
|
|
s.proxyName.SetText(name)
|
2026-07-25 17:57:04 +08:00
|
|
|
|
s.proxyServer.SetText(server)
|
2026-07-25 17:04:26 +08:00
|
|
|
|
s.notifyProxiesChanged()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 17:57:04 +08:00
|
|
|
|
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
|
2026-07-25 17:04:26 +08:00
|
|
|
|
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 = ""
|
|
|
|
|
|
}
|
2026-07-25 17:57:04 +08:00
|
|
|
|
if s.settingsProxyID == id {
|
|
|
|
|
|
s.settingsProxyID = ""
|
2026-07-27 12:09:54 +08:00
|
|
|
|
s.proxyName.SetText("")
|
2026-07-25 17:57:04 +08:00
|
|
|
|
s.proxyServer.SetText("")
|
2026-07-25 17:04:26 +08:00
|
|
|
|
}
|
2026-07-27 12:09:54 +08:00
|
|
|
|
s.proxyFeedback = fmt.Sprintf("已删除代理“%s”。", option.Name)
|
2026-07-25 17:04:26 +08:00
|
|
|
|
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...))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 17:10:20 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-07-25 18:23:33 +08:00
|
|
|
|
stopper := s.instanceStopper
|
|
|
|
|
|
if stopper == nil {
|
2026-07-25 17:10:20 +08:00
|
|
|
|
s.instanceFeedback = "浏览器停止服务尚未准备好。"
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if state == nil {
|
|
|
|
|
|
state = &instanceStopState{}
|
|
|
|
|
|
s.stopStates[id] = state
|
|
|
|
|
|
}
|
|
|
|
|
|
state.request++
|
|
|
|
|
|
request := state.request
|
|
|
|
|
|
state.running = true
|
2026-07-25 18:23:33 +08:00
|
|
|
|
launchGeneration := uint64(0)
|
|
|
|
|
|
if started := s.startStates[id]; started != nil {
|
|
|
|
|
|
launchGeneration = started.request
|
|
|
|
|
|
}
|
2026-07-25 17:10:20 +08:00
|
|
|
|
state.previousStatus = row.Status
|
|
|
|
|
|
s.setInstanceStatus(id, "停止中")
|
|
|
|
|
|
s.instanceFeedback = fmt.Sprintf("正在请求 %s 正常退出…", row.Name)
|
2026-07-25 18:23:33 +08:00
|
|
|
|
invalidate := s.invalidate
|
2026-07-25 17:10:20 +08:00
|
|
|
|
go func() {
|
2026-07-25 18:23:33 +08:00
|
|
|
|
err := stopper(context.Background(), row, launchGeneration)
|
2026-07-25 17:10:20 +08:00
|
|
|
|
s.stopResults <- instanceStopResult{id: id, request: request, err: err}
|
2026-07-25 18:23:33 +08:00
|
|
|
|
if invalidate != nil {
|
|
|
|
|
|
invalidate()
|
2026-07-25 17:10:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
}()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-07-25 17:10:20 +08:00
|
|
|
|
if stop := s.stopStates[id]; stop != nil && stop.running {
|
|
|
|
|
|
s.instanceFeedback = fmt.Sprintf("%s 正在停止,请等待退出后再启动。", row.Name)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-07-25 18:23:33 +08:00
|
|
|
|
starter := s.instanceStarter
|
|
|
|
|
|
if starter == nil {
|
2026-07-22 17:02:59 +08:00
|
|
|
|
s.instanceFeedback = "浏览器启动服务尚未准备好。"
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-07-25 15:53:31 +08:00
|
|
|
|
settings, err := s.settingsState()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
s.instanceFeedback = err.Error()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-07-27 09:29:10 +08:00
|
|
|
|
settings.ReservedRemoteDebugPorts = s.reservedRemoteDebugPorts(id)
|
2026-07-22 17:02:59 +08:00
|
|
|
|
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)
|
2026-07-25 18:23:33 +08:00
|
|
|
|
invalidate := s.invalidate
|
2026-07-22 17:02:59 +08:00
|
|
|
|
go func() {
|
2026-07-25 18:23:33 +08:00
|
|
|
|
outcome, err := starter(context.Background(), row, settings, request)
|
2026-07-25 15:53:31 +08:00
|
|
|
|
s.startResults <- instanceStartResult{id: id, request: request, outcome: outcome, err: err}
|
2026-07-25 18:23:33 +08:00
|
|
|
|
if invalidate != nil {
|
|
|
|
|
|
invalidate()
|
2026-07-22 17:02:59 +08:00
|
|
|
|
}
|
|
|
|
|
|
}()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 17:10:20 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 17:02:59 +08:00
|
|
|
|
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 {
|
2026-07-25 15:53:31 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-07-22 17:02:59 +08:00
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-07-25 15:53:31 +08:00
|
|
|
|
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")
|
2026-07-27 09:29:10 +08:00
|
|
|
|
fallbackPort := result.outcome.Warning == "" && domain.ValidRemoteDebugPort(result.outcome.RemoteDebugPort) && row.PreferredRemoteDebugPort != result.outcome.RemoteDebugPort
|
|
|
|
|
|
if fallbackPort {
|
|
|
|
|
|
if updated, exists := s.instanceRow(result.id); exists {
|
|
|
|
|
|
updated.PreferredRemoteDebugPort = result.outcome.RemoteDebugPort
|
|
|
|
|
|
s.replaceInstanceRow(updated)
|
|
|
|
|
|
}
|
|
|
|
|
|
s.notifyInstancesChanged()
|
|
|
|
|
|
}
|
2026-07-25 18:23:33 +08:00
|
|
|
|
if s.applyPendingManagedExit(result.id, result.request, result.outcome.PID) {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-07-25 15:53:31 +08:00
|
|
|
|
if result.outcome.Warning != "" {
|
|
|
|
|
|
s.instanceFeedback = fmt.Sprintf("%s 已启动(PID %d),但%s。", row.Name, result.outcome.PID, result.outcome.Warning)
|
2026-07-27 09:29:10 +08:00
|
|
|
|
} else if fallbackPort {
|
|
|
|
|
|
s.instanceFeedback = fmt.Sprintf("%s 的首选端口已被占用,已改用并保存端口 %d。", row.Name, result.outcome.RemoteDebugPort)
|
2026-07-25 15:53:31 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
s.instanceFeedback = fmt.Sprintf("已启动 %s(PID %d,端口 %d)。", row.Name, result.outcome.PID, result.outcome.RemoteDebugPort)
|
|
|
|
|
|
}
|
2026-07-22 17:02:59 +08:00
|
|
|
|
default:
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 16:36:26 +08:00
|
|
|
|
func (s *Shell) requestRefresh() {
|
|
|
|
|
|
if s.refreshClickState.running {
|
|
|
|
|
|
s.instanceFeedback = "实例状态正在刷新,请稍候。"
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-07-25 18:23:33 +08:00
|
|
|
|
refresher := s.instanceRefresher
|
|
|
|
|
|
if refresher == nil {
|
2026-07-25 16:36:26 +08:00
|
|
|
|
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 = "正在刷新已保存实例的状态…"
|
2026-07-25 18:23:33 +08:00
|
|
|
|
invalidate := s.invalidate
|
2026-07-25 16:36:26 +08:00
|
|
|
|
go func() {
|
2026-07-25 18:23:33 +08:00
|
|
|
|
results, err := refresher(context.Background(), rows)
|
2026-07-25 16:36:26 +08:00
|
|
|
|
s.refreshResults <- instanceRefreshResult{request: request, snapshots: snapshots, results: results, err: err}
|
2026-07-25 18:23:33 +08:00
|
|
|
|
if invalidate != nil {
|
|
|
|
|
|
invalidate()
|
2026-07-25 16:36:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
}()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 18:23:33 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 16:36:26 +08:00
|
|
|
|
func instanceRefreshFingerprint(row InstanceRow) string {
|
2026-07-27 09:29:10 +08:00
|
|
|
|
return strings.Join([]string{row.ID, row.Name, row.Browser, row.UserDataDir, row.TargetURL, row.ProxyID, strconv.Itoa(row.PreferredRemoteDebugPort)}, "\x00")
|
2026-07-25 16:36:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 17:02:59 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-07-25 17:10:20 +08:00
|
|
|
|
if state := s.stopStates[id]; state != nil && state.running {
|
|
|
|
|
|
s.instanceFeedback = fmt.Sprintf("%s 正在停止,暂时不能删除。", row.Name)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-07-22 17:02:59 +08:00
|
|
|
|
s.pendingDeleteID = id
|
2026-07-27 23:16:11 +08:00
|
|
|
|
s.deleteData.Value = false
|
|
|
|
|
|
s.deleteDataRunning = false
|
|
|
|
|
|
s.deleteDataFeedback = ""
|
|
|
|
|
|
s.deleteDataFocus = true
|
2026-07-22 17:02:59 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) consumeDeleteConfirmation(gtx layout.Context) {
|
|
|
|
|
|
for s.deleteBlocker.Clicked(gtx) {
|
|
|
|
|
|
}
|
2026-07-27 23:16:11 +08:00
|
|
|
|
if s.deleteDataRunning {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-07-22 17:02:59 +08:00
|
|
|
|
for s.deleteCancel.Clicked(gtx) {
|
|
|
|
|
|
s.cancelDelete()
|
|
|
|
|
|
}
|
|
|
|
|
|
for s.deleteConfirm.Clicked(gtx) {
|
|
|
|
|
|
s.confirmDelete()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 17:57:04 +08:00
|
|
|
|
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()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 18:23:33 +08:00
|
|
|
|
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 = ""
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 17:02:59 +08:00
|
|
|
|
func (s *Shell) cancelDelete() {
|
2026-07-27 23:16:11 +08:00
|
|
|
|
if s.deleteDataRunning {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-07-22 17:02:59 +08:00
|
|
|
|
if row, ok := s.instanceRow(s.pendingDeleteID); ok {
|
|
|
|
|
|
s.instanceFeedback = fmt.Sprintf("已取消删除实例“%s”。", row.Name)
|
|
|
|
|
|
}
|
|
|
|
|
|
s.pendingDeleteID = ""
|
2026-07-27 23:16:11 +08:00
|
|
|
|
s.deleteData.Value = false
|
|
|
|
|
|
s.deleteDataFeedback = ""
|
|
|
|
|
|
s.deleteDataFocus = false
|
2026-07-22 17:02:59 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) confirmDelete() {
|
2026-07-27 23:16:11 +08:00
|
|
|
|
row, ok := s.instanceRow(s.pendingDeleteID)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
s.pendingDeleteID = ""
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if !s.deleteData.Value {
|
|
|
|
|
|
s.pendingDeleteID = ""
|
|
|
|
|
|
s.deleteDataFocus = false
|
|
|
|
|
|
s.deleteInstance(row.ID)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if !s.instanceDataDeletionAllowed(row) {
|
|
|
|
|
|
s.deleteDataFeedback = "当前状态不能删除 User Data Dir;请先停止浏览器并刷新状态。实例配置尚未删除。"
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if s.instanceDataDeleter == nil {
|
|
|
|
|
|
s.deleteDataFeedback = "删除实例数据服务尚未准备好。实例配置和 User Data Dir 均未删除。"
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
s.deleteDataRequest++
|
|
|
|
|
|
request := s.deleteDataRequest
|
|
|
|
|
|
fingerprint := instanceRefreshFingerprint(row)
|
|
|
|
|
|
s.deleteDataRunning = true
|
|
|
|
|
|
s.deleteDataFeedback = ""
|
|
|
|
|
|
go func() {
|
|
|
|
|
|
err := s.instanceDataDeleter(context.Background(), row)
|
|
|
|
|
|
s.deleteDataResults <- instanceDataDeleteResult{id: row.ID, request: request, fingerprint: fingerprint, err: err}
|
|
|
|
|
|
if s.invalidate != nil {
|
|
|
|
|
|
s.invalidate()
|
|
|
|
|
|
}
|
|
|
|
|
|
}()
|
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)
|
2026-07-25 16:36:26 +08:00
|
|
|
|
delete(s.rowClicks, id)
|
|
|
|
|
|
delete(s.editClicks, id)
|
2026-07-22 16:37:34 +08:00
|
|
|
|
delete(s.deleteClicks, id)
|
2026-07-22 17:02:59 +08:00
|
|
|
|
delete(s.startStates, id)
|
2026-07-25 17:10:20 +08:00
|
|
|
|
delete(s.stopStates, 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-27 23:16:11 +08:00
|
|
|
|
func (s *Shell) consumeInstanceDataDeleteResults() {
|
|
|
|
|
|
for {
|
|
|
|
|
|
select {
|
|
|
|
|
|
case result := <-s.deleteDataResults:
|
|
|
|
|
|
if !s.deleteDataRunning || result.request != s.deleteDataRequest || result.id != s.pendingDeleteID {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
row, ok := s.instanceRow(result.id)
|
|
|
|
|
|
if !ok || instanceRefreshFingerprint(row) != result.fingerprint {
|
|
|
|
|
|
s.deleteDataRunning = false
|
|
|
|
|
|
s.deleteDataFeedback = "实例配置已变化,未继续删除数据。请重新打开删除确认。"
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
s.deleteDataRunning = false
|
|
|
|
|
|
if result.err != nil {
|
|
|
|
|
|
s.deleteDataFeedback = "未删除实例配置或 User Data Dir。请关闭浏览器并检查目录权限后重试。"
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
s.pendingDeleteID = ""
|
|
|
|
|
|
s.deleteData.Value = false
|
|
|
|
|
|
s.deleteDataFeedback = ""
|
|
|
|
|
|
s.deleteDataFocus = false
|
|
|
|
|
|
s.deleteInstanceAndData(row.ID)
|
|
|
|
|
|
default:
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) deleteInstanceAndData(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 = "找不到要删除的实例。"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 15:53:31 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-07-22 17:02:59 +08:00
|
|
|
|
}
|
2026-07-25 15:53:31 +08:00
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 16:36:26 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 15:53:31 +08:00
|
|
|
|
func (s *Shell) settingsState() (SettingsState, error) {
|
|
|
|
|
|
port, err := normalizeRemoteDebugStartPort(s.remoteDebugPort.Text())
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return SettingsState{}, err
|
|
|
|
|
|
}
|
2026-07-28 00:12:29 +08:00
|
|
|
|
defaultDir, err := normalizeSettingsDirectory(s.dataDir.Text(), "默认 User Data Dir")
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return SettingsState{}, err
|
|
|
|
|
|
}
|
|
|
|
|
|
logDir, err := normalizeSettingsDirectory(s.logDir.Text(), "日志目录")
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return SettingsState{}, err
|
|
|
|
|
|
}
|
2026-07-25 15:53:31 +08:00
|
|
|
|
return SettingsState{
|
|
|
|
|
|
ChromePath: s.chromePath.Text(),
|
|
|
|
|
|
EdgePath: s.edgePath.Text(),
|
2026-07-28 00:12:29 +08:00
|
|
|
|
DefaultDir: defaultDir,
|
|
|
|
|
|
DefaultDirMode: s.selectedDirectoryMode(defaultDir, s.portableDefaultDir, s.defaultDirMode),
|
|
|
|
|
|
LogDir: logDir,
|
|
|
|
|
|
LogDirMode: s.selectedDirectoryMode(logDir, s.portableLogDir, s.logDirMode),
|
2026-07-25 15:53:31 +08:00
|
|
|
|
RemoteDebugStartPort: port,
|
|
|
|
|
|
CloseOnExit: s.closeOnExit.Value,
|
|
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-28 00:12:29 +08:00
|
|
|
|
func normalizeSettingsDirectory(value, label string) (string, error) {
|
|
|
|
|
|
value = strings.TrimSpace(value)
|
|
|
|
|
|
if value == "" || !filepath.IsAbs(value) {
|
|
|
|
|
|
return "", fmt.Errorf("%s必须是绝对路径。", label)
|
|
|
|
|
|
}
|
|
|
|
|
|
return filepath.Clean(value), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func normalizeDirectoryMode(value string) string {
|
|
|
|
|
|
if strings.EqualFold(strings.TrimSpace(value), "portable") {
|
|
|
|
|
|
return "portable"
|
|
|
|
|
|
}
|
|
|
|
|
|
return "custom"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) selectedDirectoryMode(value, portable, currentMode string) string {
|
|
|
|
|
|
if strings.EqualFold(currentMode, "portable") && samePathText(value, portable) {
|
|
|
|
|
|
return "portable"
|
|
|
|
|
|
}
|
|
|
|
|
|
return "custom"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) directoryMode(field PathField) string {
|
|
|
|
|
|
if field == PathDefaultUserData {
|
|
|
|
|
|
return s.selectedDirectoryMode(s.dataDir.Text(), s.portableDefaultDir, s.defaultDirMode)
|
|
|
|
|
|
}
|
|
|
|
|
|
return s.selectedDirectoryMode(s.logDir.Text(), s.portableLogDir, s.logDirMode)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) restorePortableDirectory(field PathField) {
|
|
|
|
|
|
if field == PathDefaultUserData {
|
|
|
|
|
|
if s.portableDefaultDir == "" {
|
|
|
|
|
|
s.setPathFieldFeedback(field, "无法确定程序目录中的默认 User Data Dir。")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
s.dataDir.SetText(s.portableDefaultDir)
|
|
|
|
|
|
s.defaultDirMode = "portable"
|
|
|
|
|
|
s.setPathFieldFeedback(field, "已恢复为程序目录下的默认 User Data Dir。")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if s.portableLogDir == "" {
|
|
|
|
|
|
s.setPathFieldFeedback(field, "无法确定程序目录中的默认日志目录。")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
s.logDir.SetText(s.portableLogDir)
|
|
|
|
|
|
s.logDirMode = "portable"
|
|
|
|
|
|
s.setPathFieldFeedback(field, "已恢复为程序目录下的默认日志目录。")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 15:53:31 +08:00
|
|
|
|
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
|
2026-07-22 17:02:59 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-27 09:29:10 +08:00
|
|
|
|
func normalizePreferredRemoteDebugPort(value string) (int, error) {
|
|
|
|
|
|
value = strings.TrimSpace(value)
|
|
|
|
|
|
if value == "" {
|
|
|
|
|
|
return 0, fmt.Errorf("请输入首选调试端口(%d 到 %d)。", domain.MinRemoteDebugPort, domain.MaxRemoteDebugPort)
|
|
|
|
|
|
}
|
|
|
|
|
|
port, err := strconv.Atoi(value)
|
|
|
|
|
|
if err != nil || !domain.ValidRemoteDebugPort(port) {
|
|
|
|
|
|
return 0, fmt.Errorf("首选调试端口必须在 %d 到 %d 之间。", domain.MinRemoteDebugPort, domain.MaxRemoteDebugPort)
|
|
|
|
|
|
}
|
|
|
|
|
|
return port, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) assignPreferredRemoteDebugPorts() {
|
|
|
|
|
|
used := make(map[int]struct{}, len(s.rows)*2)
|
|
|
|
|
|
for _, row := range s.rows {
|
|
|
|
|
|
if domain.ValidRemoteDebugPort(row.PreferredRemoteDebugPort) {
|
|
|
|
|
|
used[row.PreferredRemoteDebugPort] = struct{}{}
|
|
|
|
|
|
}
|
|
|
|
|
|
if domain.ValidRemoteDebugPort(row.RemoteDebugPort) {
|
|
|
|
|
|
used[row.RemoteDebugPort] = struct{}{}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
for index := range s.rows {
|
|
|
|
|
|
if domain.ValidRemoteDebugPort(s.rows[index].PreferredRemoteDebugPort) {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
port := s.nextUnreservedRemoteDebugPort(used)
|
|
|
|
|
|
if port == 0 {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
s.rows[index].PreferredRemoteDebugPort = port
|
|
|
|
|
|
used[port] = struct{}{}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) recommendRemoteDebugPort(excludeID string) int {
|
|
|
|
|
|
used := make(map[int]struct{}, len(s.rows)*2)
|
|
|
|
|
|
for _, row := range s.rows {
|
|
|
|
|
|
if row.ID == excludeID {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if domain.ValidRemoteDebugPort(row.PreferredRemoteDebugPort) {
|
|
|
|
|
|
used[row.PreferredRemoteDebugPort] = struct{}{}
|
|
|
|
|
|
}
|
|
|
|
|
|
if domain.ValidRemoteDebugPort(row.RemoteDebugPort) {
|
|
|
|
|
|
used[row.RemoteDebugPort] = struct{}{}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return s.nextUnreservedRemoteDebugPort(used)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) nextUnreservedRemoteDebugPort(used map[int]struct{}) int {
|
|
|
|
|
|
start, err := normalizeRemoteDebugStartPort(s.remoteDebugPort.Text())
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
start = domain.DefaultRemoteDebugPort
|
|
|
|
|
|
}
|
|
|
|
|
|
for port := start; port <= domain.MaxRemoteDebugPort; port++ {
|
|
|
|
|
|
if _, exists := used[port]; !exists {
|
|
|
|
|
|
return port
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) remoteDebugPortReservedByOther(port int, excludeID string) bool {
|
|
|
|
|
|
if !domain.ValidRemoteDebugPort(port) {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, row := range s.rows {
|
|
|
|
|
|
if row.ID == excludeID {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if row.PreferredRemoteDebugPort == port || row.RemoteDebugPort == port {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) reservedRemoteDebugPorts(excludeID string) []int {
|
|
|
|
|
|
reserved := make([]int, 0, len(s.rows)*2)
|
|
|
|
|
|
seen := make(map[int]struct{}, len(s.rows)*2)
|
|
|
|
|
|
for _, row := range s.rows {
|
|
|
|
|
|
if row.ID == excludeID {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, port := range []int{row.PreferredRemoteDebugPort, row.RemoteDebugPort} {
|
|
|
|
|
|
if !domain.ValidRemoteDebugPort(port) {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, exists := seen[port]; exists {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
seen[port] = struct{}{}
|
|
|
|
|
|
reserved = append(reserved, port)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return reserved
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 17:02:59 +08:00
|
|
|
|
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() {
|
2026-07-25 16:36:26 +08:00
|
|
|
|
s.chooseDirectory(directoryPickCreate)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Shell) chooseDirectory(target directoryPickTarget) {
|
2026-07-22 16:06:39 +08:00
|
|
|
|
if s.directoryPick.running {
|
2026-07-28 00:12:29 +08:00
|
|
|
|
s.setDirectoryPickFeedback(target, "目录选择器已打开,请在系统窗口中选择或取消。")
|
2026-07-22 16:06:39 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if s.directoryChooser == nil {
|
2026-07-28 00:12:29 +08:00
|
|
|
|
s.setDirectoryPickFeedback(target, "目录选择器尚未准备好。")
|
2026-07-22 16:06:39 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
s.directoryPick.request++
|
|
|
|
|
|
request := s.directoryPick.request
|
|
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
|
|
s.directoryPick.cancel = cancel
|
|
|
|
|
|
s.directoryPick.running = true
|
2026-07-25 16:36:26 +08:00
|
|
|
|
s.directoryPick.target = target
|
2026-07-28 00:12:29 +08:00
|
|
|
|
s.setDirectoryPickFeedback(target, "正在打开目录选择器…")
|
2026-07-22 16:06:39 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-07-25 16:36:26 +08:00
|
|
|
|
target := s.directoryPick.target
|
2026-07-22 16:06:39 +08:00
|
|
|
|
s.directoryPick.running = false
|
|
|
|
|
|
s.directoryPick.cancel = nil
|
|
|
|
|
|
if result.err != nil {
|
|
|
|
|
|
if errors.Is(result.err, context.Canceled) {
|
2026-07-28 00:12:29 +08:00
|
|
|
|
s.setDirectoryPickFeedback(target, "已取消选择目录。")
|
2026-07-22 16:06:39 +08:00
|
|
|
|
} else {
|
2026-07-28 00:12:29 +08:00
|
|
|
|
s.setDirectoryPickFeedback(target, fmt.Sprintf("选择目录失败:%v", result.err))
|
2026-07-22 16:06:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if result.path == "" {
|
2026-07-28 00:12:29 +08:00
|
|
|
|
s.setDirectoryPickFeedback(target, "未选择目录。")
|
2026-07-22 16:06:39 +08:00
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-07-28 00:12:29 +08:00
|
|
|
|
switch target {
|
|
|
|
|
|
case directoryPickEdit:
|
2026-07-25 16:36:26 +08:00
|
|
|
|
s.editDir.SetText(result.path)
|
2026-07-28 00:12:29 +08:00
|
|
|
|
case directoryPickDefaultUserData:
|
|
|
|
|
|
s.dataDir.SetText(result.path)
|
|
|
|
|
|
s.defaultDirMode = "custom"
|
|
|
|
|
|
case directoryPickLogDirectory:
|
|
|
|
|
|
s.logDir.SetText(result.path)
|
|
|
|
|
|
s.logDirMode = "custom"
|
|
|
|
|
|
default:
|
2026-07-25 16:36:26 +08:00
|
|
|
|
s.instanceDir.SetText(result.path)
|
2026-07-28 00:12:29 +08:00
|
|
|
|
s.defaultCreateDirActive = false
|
2026-07-25 16:36:26 +08:00
|
|
|
|
}
|
2026-07-28 00:12:29 +08:00
|
|
|
|
s.setDirectoryPickFeedback(target, "已更新目录。")
|
2026-07-22 16:06:39 +08:00
|
|
|
|
default:
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 16:36:26 +08:00
|
|
|
|
func (s *Shell) setDirectoryPickFeedback(target directoryPickTarget, message string) {
|
2026-07-28 00:12:29 +08:00
|
|
|
|
switch target {
|
|
|
|
|
|
case directoryPickEdit:
|
2026-07-25 16:36:26 +08:00
|
|
|
|
s.editFeedback = message
|
2026-07-28 00:12:29 +08:00
|
|
|
|
case directoryPickDefaultUserData:
|
|
|
|
|
|
s.setPathFieldFeedback(PathDefaultUserData, message)
|
|
|
|
|
|
case directoryPickLogDirectory:
|
|
|
|
|
|
s.setPathFieldFeedback(PathLogDirectory, message)
|
|
|
|
|
|
default:
|
|
|
|
|
|
s.formFeedback = message
|
2026-07-25 16:36:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 16:06:39 +08:00
|
|
|
|
func (s *Shell) instanceDirButtonLabel() string {
|
2026-07-28 00:12:29 +08:00
|
|
|
|
if s.directoryPick.running && s.directoryPick.target == directoryPickCreate {
|
2026-07-22 16:06:39 +08:00
|
|
|
|
return "选择中…"
|
|
|
|
|
|
}
|
|
|
|
|
|
return "选择路径"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 15:13:55 +08:00
|
|
|
|
func (s *Shell) resetSettings() {
|
2026-07-28 00:12:29 +08:00
|
|
|
|
s.SetSettings(s.lastSavedSettings)
|
|
|
|
|
|
s.pathFeedback = "已恢复未保存前的设置。"
|
2026-07-22 15:13:55 +08:00
|
|
|
|
}
|