package ui import ( "context" "errors" "fmt" "image" "image/color" "net/url" "path/filepath" "strconv" "strings" "sync" "time" "chub/internal/domain" "gioui.org/io/key" "gioui.org/layout" "gioui.org/op/clip" "gioui.org/op/paint" "gioui.org/unit" "gioui.org/widget" "gioui.org/widget/material" "golang.org/x/exp/shiny/materialdesign/icons" ) type page uint8 const ( pageInstances page = iota pageSettings pageCreate ) const ( instanceNameColumnWeight = 17 instanceBrowserColumnWeight = 9 instanceDirectoryColumnWeight = 28 instanceProxyColumnWeight = 9 instancePortColumnWeight = 9 instanceStatusColumnWeight = 10 instanceActionColumnWeight = 18 instanceActionIconSize = unit.Dp(16) instanceActionIconInset = unit.Dp(7) instanceStandardRowHeight = instanceActionIconSize + 2*instanceActionIconInset instanceListRowMinHeight = 2 * instanceStandardRowHeight ) var ( 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} ) type PathField string const ( PathChromeExecutable PathField = "chrome_executable" PathEdgeExecutable PathField = "edge_executable" PathDefaultUserData PathField = "default_user_data_dir" PathLogDirectory PathField = "log_directory" ) type PathSearcher func(context.Context, PathField, string) (string, error) type DirectoryChooser func(context.Context) (string, error) type ExecutableChooser func(context.Context) (string, error) // ProxyOption is a non-secret proxy item exposed to the UI. Server has already // been validated as an unauthenticated scheme://host:port endpoint. type ProxyOption struct { ID string Name string Server string } type InstanceStartOutcome struct { PID int RemoteDebugPort int External bool Source string Warning string } // ManagedInstanceExit is delivered by the application adapter after the root // process of a browser started by this Chub session exits. It contains only // DTO data; UI code never receives a platform process handle. type ManagedInstanceExit struct { InstanceID string LaunchGeneration uint64 PID int ExitCode int ExpectedStop bool } type InstanceStarter func(context.Context, InstanceRow, SettingsState, uint64) (InstanceStartOutcome, error) // InstanceStopper requests graceful shutdown only for the supplied configured // instance. Implementations must reject external or unverified processes. type InstanceStopper func(context.Context, InstanceRow, uint64) error // 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 // 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) // 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) type instanceStartState struct { request uint64 running bool } type instanceStartResult struct { id string request uint64 outcome InstanceStartOutcome err error } type instanceStopState struct { request uint64 running bool previousStatus string } type instanceStopResult struct { id string request uint64 err error } type instanceDataDeleteResult struct { id string request uint64 fingerprint string err error } type instanceRefreshState struct { request uint64 running bool } type instanceRefreshResult struct { request uint64 snapshots map[string]string results []InstanceRefreshResult err error } type instanceTabsResult struct { request uint64 instanceID string fingerprint string targets []CDPTarget err error } type managedExitKey struct { instanceID string launchGeneration uint64 pid int } type unexpectedExitNotice struct { instanceID string name string browser string } type pathSearchState struct { request uint64 cancel context.CancelFunc running bool } type pathSearchResult struct { field PathField request uint64 path string err error } type directoryPickState struct { request uint64 cancel context.CancelFunc running bool target directoryPickTarget } type directoryPickResult struct { request uint64 path string err error } type executablePickState struct { request uint64 cancel context.CancelFunc running bool field PathField } type executablePickResult struct { field PathField request uint64 path string err error } type directoryPickTarget uint8 const ( directoryPickCreate directoryPickTarget = iota directoryPickEdit directoryPickDefaultUserData directoryPickLogDirectory ) type proxyPickerTarget uint8 const ( proxyPickerCreate proxyPickerTarget = iota proxyPickerEdit proxyPickerSettings ) type proxyPickerState struct { open bool target proxyPickerTarget } // InstanceRow is the Gio view model for one configured browser instance. // PreferredRemoteDebugPort is saved configuration; runtime fields are refreshed // independently and are never saved by the edit form. type InstanceRow struct { ID string Name string Browser string UserDataDir string TargetURL string ProxyID string PreferredRemoteDebugPort int PID int RemoteDebugPort int OccupancySource string Status string } type SettingsState struct { ChromePath string EdgePath string DefaultDir string DefaultDirMode string LogDir string LogDirMode string RemoteDebugStartPort int ReservedRemoteDebugPorts []int CloseOnExit bool } // Shell owns every interactive Gio widget. Keeping this state outside Layout // prevents focus, editor content, and list scroll position from resetting. type Shell struct { theme *material.Theme page page instancesClick widget.Clickable settingsClick widget.Clickable newClick widget.Clickable refreshClick widget.Clickable startClick widget.Clickable createClick widget.Clickable backClick widget.Clickable instanceDirPick widget.Clickable chromePick widget.Clickable chromeSearch widget.Clickable edgePick widget.Clickable edgeSearch widget.Clickable dataPick widget.Clickable dataSearch widget.Clickable dataRestore widget.Clickable logPick widget.Clickable logSearch widget.Clickable logRestore widget.Clickable saveClick widget.Clickable cancelClick widget.Clickable chromePath widget.Editor edgePath widget.Editor dataDir widget.Editor logDir widget.Editor remoteDebugPort widget.Editor closeOnExit widget.Bool instanceName widget.Editor instanceDir widget.Editor instanceURL widget.Editor instancePort widget.Editor createProxyPick widget.Clickable editProxyPick widget.Clickable settingsProxyPick widget.Clickable browserKind widget.Enum editName widget.Editor editDir widget.Editor editURL widget.Editor editPreferredPort widget.Editor editPort widget.Editor editBrowserKind widget.Enum editDirPick widget.Clickable editTabs widget.Clickable editSave widget.Clickable editCancel widget.Clickable editBlocker widget.Clickable editDiscardBlocker widget.Clickable editDiscardSave widget.Clickable editDiscardDrop widget.Clickable editDiscardStay widget.Clickable pathFeedback string pathFieldFeedback map[PathField]string formFeedback string editFeedback string proxyServer widget.Editor proxyName widget.Editor proxySave widget.Clickable proxyDelete widget.Clickable proxyFeedback string proxies []ProxyOption createProxyID string editProxyID string settingsProxyID string nextProxy uint64 proxyPicker proxyPickerState proxyPickerBlocker widget.Clickable proxyPickerNone widget.Clickable proxyPickerChoices map[string]*widget.Clickable proxyDeleteConfirm widget.Clickable proxyDeleteCancel widget.Clickable proxyDeleteBlocker widget.Clickable pendingProxyDelete string proxyDeleteFocus bool list widget.List 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 deleteData widget.Bool deleteDataRunning bool deleteDataRequest uint64 deleteDataFeedback string deleteDataFocus bool deleteDataResults chan instanceDataDeleteResult 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 portableDefaultDir string portableLogDir string defaultDirMode string logDirMode string lastSavedSettings SettingsState defaultCreateDirActive bool lastSuggestedCreateDir string 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 instanceDataDeleter InstanceDataDeleter instanceRefresher InstanceRefresher instanceTabInspector InstanceTabInspector pathSearcher PathSearcher invalidate func() searches map[PathField]*pathSearchState searchResults chan pathSearchResult executableChooser ExecutableChooser executablePick executablePickState executableResults chan executablePickResult directoryChooser DirectoryChooser directoryPick directoryPickState directoryResults chan directoryPickResult } func NewShell(theme *material.Theme) *Shell { s := &Shell{theme: theme, rows: []InstanceRow{ {ID: "demo-operations", Name: "运营主账号", Browser: "Chrome", UserDataDir: `C:\Users\Public\chub\profiles\operations`, 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: "已退出"}, }, searches: map[PathField]*pathSearchState{ PathChromeExecutable: {}, PathEdgeExecutable: {}, PathDefaultUserData: {}, PathLogDirectory: {}, }, 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)} s.chromePath.SetText(`C:\Program Files\Google\Chrome\Application\chrome.exe`) s.edgePath.SetText(`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`) s.dataDir.SetText(`C:\Users\Public\chub\profiles`) s.logDir.SetText(`C:\Users\Public\chub\logs`) s.portableDefaultDir = s.dataDir.Text() s.portableLogDir = s.logDir.Text() s.defaultDirMode = "portable" s.logDirMode = "portable" s.remoteDebugPort.SetText(strconv.Itoa(domain.DefaultRemoteDebugPort)) s.instancePort.SetText(strconv.Itoa(s.recommendRemoteDebugPort(""))) s.closeOnExit.Value = true s.browserKind.Value = "chrome" s.editBrowserKind.Value = "chrome" s.editPort.ReadOnly = true s.list.Axis = layout.Vertical s.settingsList.Axis = layout.Vertical s.tabsList.Axis = layout.Vertical 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} return s } func (s *Shell) SetInstances(rows []InstanceRow) { s.rows = append([]InstanceRow(nil), rows...) for i := range s.rows { if s.rows[i].ID == "" { s.nextInstance++ s.rows[i].ID = fmt.Sprintf("restored-%d", s.nextInstance) } } s.assignPreferredRemoteDebugPorts() if s.selectedInstanceID == "" && len(s.rows) > 0 { s.selectedInstanceID = s.rows[0].ID } } func (s *Shell) SetProxies(options []ProxyOption) { s.proxies = append([]ProxyOption(nil), options...) for _, option := range s.proxies { if option.ID == "" { continue } if strings.HasPrefix(option.ID, "proxy-") { if value, err := strconv.ParseUint(strings.TrimPrefix(option.ID, "proxy-"), 10, 64); err == nil && value > s.nextProxy { s.nextProxy = value } } } if !s.proxyExists(s.createProxyID) { s.createProxyID = "" } if !s.proxyExists(s.editProxyID) { s.editProxyID = "" } if !s.proxyExists(s.settingsProxyID) { s.settingsProxyID = "" s.proxyName.SetText("") s.proxyServer.SetText("") } } // 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) } // 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) } } } func (s *Shell) SetSettings(value SettingsState) { s.chromePath.SetText(value.ChromePath) s.edgePath.SetText(value.EdgePath) s.dataDir.SetText(value.DefaultDir) s.logDir.SetText(value.LogDir) s.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) } port := value.RemoteDebugStartPort if port == 0 { port = domain.DefaultRemoteDebugPort } s.remoteDebugPort.SetText(strconv.Itoa(port)) s.closeOnExit.Value = value.CloseOnExit 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} } // 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) } func (s *Shell) OnSave(fn func(SettingsState)) { s.onSave = fn } func (s *Shell) OnInstancesChanged(fn func([]InstanceRow)) { s.onInstancesChanged = fn } func (s *Shell) OnProxiesChanged(fn func([]ProxyOption)) { s.onProxiesChanged = fn } func (s *Shell) OnStartInstance(starter InstanceStarter, invalidate func()) { s.instanceStarter = starter s.invalidate = invalidate } func (s *Shell) OnStopInstance(stopper InstanceStopper, invalidate func()) { s.instanceStopper = stopper s.invalidate = invalidate } func (s *Shell) OnDeleteInstanceData(deleter InstanceDataDeleter, invalidate func()) { s.instanceDataDeleter = deleter s.invalidate = invalidate } // ReportManagedExit is safe for the application's single per-instance monitor // goroutine. The event is consumed during the next UI frame and therefore does // not perform layout or mutate widgets from a background thread. The monitor // that reports this result is responsible for invalidating its window. func (s *Shell) ReportManagedExit(result ManagedInstanceExit) { if result.InstanceID == "" || result.LaunchGeneration == 0 || result.PID <= 0 { return } s.managedExitMu.Lock() s.managedExitResults = append(s.managedExitResults, result) s.managedExitMu.Unlock() } func (s *Shell) OnRefreshInstances(refresher InstanceRefresher, invalidate func()) { s.instanceRefresher = refresher s.invalidate = invalidate } func (s *Shell) OnInspectTabs(inspector InstanceTabInspector, invalidate func()) { s.instanceTabInspector = inspector s.invalidate = invalidate } func (s *Shell) OnPathSearch(searcher PathSearcher, invalidate func()) { s.pathSearcher = searcher s.invalidate = invalidate } func (s *Shell) OnChooseExecutable(chooser ExecutableChooser, invalidate func()) { s.executableChooser = chooser s.invalidate = invalidate } func (s *Shell) OnChooseDirectory(chooser DirectoryChooser, invalidate func()) { s.directoryChooser = chooser s.invalidate = invalidate } func (s *Shell) Layout(gtx layout.Context) layout.Dimensions { s.consumeSearchResults() s.consumeExecutableResults() s.consumeDirectoryResults() s.consumeStartResults() s.consumeStopResults() s.consumeInstanceDataDeleteResults() s.consumeRefreshResults() s.consumeTabsResults() s.consumeManagedExitResults() s.presentUnexpectedExitIfReady() s.consumeKeyboard(gtx) if s.unexpectedExitOpen { s.consumeUnexpectedExitDialog(gtx) } else if s.pendingDeleteID != "" { s.consumeDeleteConfirmation(gtx) } else if s.pendingProxyDelete != "" { s.consumeProxyDeleteConfirmation(gtx) } else if s.editingID != "" { s.consumeEditControls(gtx) } else { s.consumeControls(gtx) } s.presentUnexpectedExitIfReady() s.syncDefaultCreateDirectory() mainLayout := func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, layout.Rigid(s.sidebar), layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { return layout.Inset{Top: unit.Dp(24), Bottom: unit.Dp(24), Left: unit.Dp(28), Right: unit.Dp(28)}.Layout(gtx, s.content) }), ) } if !s.unexpectedExitOpen && s.pendingDeleteID == "" && s.pendingProxyDelete == "" && s.editingID == "" && !s.proxyPicker.open && !s.tabsOpen { return mainLayout(gtx) } if s.unexpectedExitOpen { return layout.Stack{Alignment: layout.Center}.Layout(gtx, layout.Expanded(mainLayout), layout.Stacked(s.unexpectedExitDialog), ) } if s.pendingDeleteID != "" { return layout.Stack{Alignment: layout.Center}.Layout(gtx, layout.Expanded(mainLayout), layout.Stacked(s.deleteConfirmation), ) } if s.pendingProxyDelete != "" { return layout.Stack{Alignment: layout.Center}.Layout(gtx, layout.Expanded(mainLayout), layout.Stacked(s.proxyDeleteConfirmation), ) } if s.pendingEditDiscard { return layout.Stack{Alignment: layout.Center}.Layout(gtx, layout.Expanded(mainLayout), layout.Stacked(s.editDialog), layout.Stacked(s.editDiscardConfirmation), ) } if s.editingID != "" { if s.tabsOpen { return layout.Stack{Alignment: layout.Center}.Layout(gtx, layout.Expanded(mainLayout), layout.Stacked(s.editDialog), layout.Stacked(s.tabsDialog), ) } if !s.proxyPicker.open { return layout.Stack{Alignment: layout.Center}.Layout(gtx, layout.Expanded(mainLayout), layout.Stacked(s.editDialog), ) } return layout.Stack{Alignment: layout.Center}.Layout(gtx, layout.Expanded(mainLayout), layout.Stacked(s.editDialog), layout.Stacked(s.proxyPickerDialog), ) } return layout.Stack{Alignment: layout.Center}.Layout(gtx, layout.Expanded(mainLayout), layout.Stacked(s.proxyPickerDialog), ) } func (s *Shell) consumeControls(gtx layout.Context) { for s.instancesClick.Clicked(gtx) { s.page = pageInstances } for s.settingsClick.Clicked(gtx) { s.page = pageSettings } for s.newClick.Clicked(gtx) { s.beginCreate() } for s.refreshClick.Clicked(gtx) { s.requestRefresh() } for s.createClick.Clicked(gtx) { s.createInstanceFromForm() } for s.instanceDirPick.Clicked(gtx) { s.chooseInstanceDirectory() } for s.backClick.Clicked(gtx) { s.page = pageInstances } for _, row := range s.rows { s.consumeRowKeyboard(gtx, row.ID) for click, ok := s.rowClickFor(row.ID).Update(gtx); ok; click, ok = s.rowClickFor(row.ID).Update(gtx) { s.selectedInstanceID = row.ID if click.NumClicks >= 2 { s.beginEdit(row.ID) } } for s.startClickFor(row.ID).Clicked(gtx) { s.requestInstanceAction(row.ID) } for s.editClickFor(row.ID).Clicked(gtx) { s.beginEdit(row.ID) } } for _, row := range append([]InstanceRow(nil), s.rows...) { for s.deleteClickFor(row.ID).Clicked(gtx) { s.requestDelete(row.ID) } } } func (s *Shell) beginCreate() { s.page = pageCreate s.formFeedback = "" s.pendingCreateID = s.allocateInstanceID() s.defaultCreateDirActive = true s.lastSuggestedCreateDir = "" s.instanceName.SetText("") s.instanceURL.SetText("") s.createProxyID = "" s.browserKind.Value = "chrome" if defaultDir := s.defaultCreateInstanceDir("新实例", s.pendingCreateID); defaultDir != "" { s.instanceDir.SetText(defaultDir) s.lastSuggestedCreateDir = defaultDir } else { s.instanceDir.SetText("") s.formFeedback = "无法确定程序目录中的默认 User Data Dir;请选择或输入一个绝对路径。" } s.instancePort.SetText(strconv.Itoa(s.recommendRemoteDebugPort(""))) } func (s *Shell) consumeKeyboard(gtx layout.Context) { for { event, ok := gtx.Event( key.Filter{Name: key.NameEscape}, key.Filter{Name: key.NameF5}, ) if !ok { return } keyEvent, ok := event.(key.Event) if !ok || keyEvent.State != key.Press { continue } switch keyEvent.Name { case key.NameEscape: if s.unexpectedExitOpen { s.dismissUnexpectedExitNotice() } else if s.pendingDeleteID != "" && !s.deleteDataRunning { s.cancelDelete() } else if s.pendingProxyDelete != "" { s.cancelProxyDelete() } else if s.proxyPicker.open { s.proxyPicker.open = false } else if s.tabsOpen { s.closeTabs(true) } else if s.pendingEditDiscard { s.pendingEditDiscard = false } else if s.editingID != "" { s.cancelEdit() } case key.NameF5: if s.pendingDeleteID == "" && s.pendingProxyDelete == "" && s.editingID == "" && s.page == pageInstances { s.requestRefresh() } } } } func (s *Shell) consumeRowKeyboard(gtx layout.Context, id string) { click := s.rowClickFor(id) for { event, ok := gtx.Event( key.Filter{Focus: click, Name: key.NameReturn}, key.Filter{Focus: click, Name: key.NameEnter}, ) if !ok { return } keyEvent, ok := event.(key.Event) if !ok || keyEvent.State != key.Release { continue } s.selectedInstanceID = id s.beginEdit(id) } } func (s *Shell) consumeEditControls(gtx layout.Context) { for s.editBlocker.Clicked(gtx) { } if s.tabsOpen { for s.tabsBlocker.Clicked(gtx) { } for s.tabsClose.Clicked(gtx) { s.closeTabs(true) } return } if s.pendingEditDiscard { for s.editDiscardBlocker.Clicked(gtx) { } for s.editDiscardStay.Clicked(gtx) { s.pendingEditDiscard = false } for s.editDiscardDrop.Clicked(gtx) { s.closeEdit("已放弃未保存的实例修改。") } for s.editDiscardSave.Clicked(gtx) { s.saveEdit() } return } for s.editDirPick.Clicked(gtx) { s.chooseDirectory(directoryPickEdit) } for s.editCancel.Clicked(gtx) { s.cancelEdit() } for s.editTabs.Clicked(gtx) { s.requestTabs() } for s.editSave.Clicked(gtx) { s.saveEdit() } } func (s *Shell) beginEdit(id string) { s.closeTabs(false) row, ok := s.instanceRow(id) if !ok { s.instanceFeedback = "找不到要编辑的实例。" return } s.selectedInstanceID = id s.editingID = id s.editOriginal = row s.editName.SetText(row.Name) s.editDir.SetText(row.UserDataDir) s.editURL.SetText(row.TargetURL) s.editPreferredPort.SetText(preferredRemoteDebugPortText(row.PreferredRemoteDebugPort)) s.editPort.SetText(actualRemoteDebugPortText(row.RemoteDebugPort)) s.editProxyID = row.ProxyID if strings.EqualFold(row.Browser, "Edge") { s.editBrowserKind.Value = "edge" } else { s.editBrowserKind.Value = "chrome" } s.editFeedback = "" s.pendingEditDiscard = false s.editFocusPending = true } func (s *Shell) editLocked(row InstanceRow) bool { switch row.Status { case "启动中", "停止中", "运行中", "运行中(调试不可用)", "外部已关联": return true default: return false } } func (s *Shell) editIsDirty() bool { if s.editingID == "" { return false } if strings.TrimSpace(s.editName.Text()) != s.editOriginal.Name || strings.TrimSpace(s.editURL.Text()) != s.editOriginal.TargetURL || s.editProxyID != s.editOriginal.ProxyID { return true } if s.editLocked(s.editOriginal) { return false } return strings.TrimSpace(s.editDir.Text()) != s.editOriginal.UserDataDir || browserDisplay(s.editBrowserKind.Value) != s.editOriginal.Browser || strings.TrimSpace(s.editPreferredPort.Text()) != preferredRemoteDebugPortText(s.editOriginal.PreferredRemoteDebugPort) } func (s *Shell) cancelEdit() { if s.editingID == "" { return } if s.editIsDirty() { s.pendingEditDiscard = true return } s.closeEdit("已取消实例编辑。") } func (s *Shell) saveEdit() { row, ok := s.instanceRow(s.editingID) if !ok { s.closeEdit("要编辑的实例已不存在。") return } name, err := s.validateInstanceName(s.editName.Text(), row.ID) if err != nil { s.editFeedback = err.Error() 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 } 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 } row.UserDataDir = filepath.Clean(userDataDir) row.Browser = browserDisplay(s.editBrowserKind.Value) row.PreferredRemoteDebugPort = preferredPort } row.Name = name row.TargetURL = targetURL if !s.proxyExists(s.editProxyID) && s.editProxyID != "" { s.editFeedback = "所选代理已不可用,请重新选择。" return } row.ProxyID = s.editProxyID if !s.replaceInstanceRow(row) { s.closeEdit("要编辑的实例已不存在。") return } s.notifyInstancesChanged() s.closeEdit(fmt.Sprintf("已保存实例“%s”。", row.Name)) } func normalizeTargetURL(value string) (string, error) { value = strings.TrimSpace(value) if value == "" { return "", nil } parsed, err := url.ParseRequestURI(value) if err != nil || parsed == nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || parsed.User != nil { return "", errors.New("启动 URL 必须是无认证信息的 http/https 地址。") } return value, nil } func (s *Shell) closeEdit(feedback string) { s.closeTabs(false) id := s.editingID s.editingID = "" s.pendingEditDiscard = false s.editFeedback = "" s.editFocusPending = false s.proxyPicker.open = false s.focusRestoreID = id s.instanceFeedback = feedback } func (s *Shell) sidebar(gtx layout.Context) layout.Dimensions { return layout.Inset{Top: unit.Dp(24), Left: unit.Dp(16), Right: unit.Dp(16)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(s.navButton(&s.instancesClick, "实例", s.page == pageInstances || s.page == pageCreate)), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(s.navButton(&s.settingsClick, "设置", s.page == pageSettings)), ) }) } func (s *Shell) navButton(click *widget.Clickable, label string, selected bool) layout.Widget { return func(gtx layout.Context) layout.Dimensions { style := material.Button(s.theme, click, label) if selected { style.Background = color.NRGBA{R: 214, G: 228, B: 255, A: 255} style.Color = color.NRGBA{R: 0, G: 76, B: 153, A: 255} } return style.Layout(gtx) } } func (s *Shell) content(gtx layout.Context) layout.Dimensions { if s.page == pageSettings { return s.settings(gtx) } if s.page == pageCreate { return s.createInstance(gtx) } return s.instances(gtx) } func (s *Shell) instances(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(material.H4(s.theme, "实例").Layout), layout.Rigid(material.Body1(s.theme, "统一查看、启动和管理 Chrome / Edge 浏览器进程").Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(18)}.Layout), layout.Rigid(s.instanceCommands), layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout), layout.Flexed(1, s.instanceTable), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(material.Caption(s.theme, s.instanceFeedback).Layout), ) } func (s *Shell) instanceCommands(gtx layout.Context) layout.Dimensions { newButton := func(gtx layout.Context) layout.Dimensions { return material.Button(s.theme, &s.newClick, "新建实例").Layout(gtx) } refreshButton := func(gtx layout.Context) layout.Dimensions { label := "刷新实例状态" buttonGtx := gtx if s.refreshClickState.running { label = "刷新中…" buttonGtx = gtx.Disabled() } style := material.Button(s.theme, &s.refreshClick, label) style.Background = color.NRGBA{R: 230, G: 232, B: 235, A: 255} style.Color = s.theme.Palette.Fg return style.Layout(buttonGtx) } if gtx.Constraints.Max.X <= gtx.Dp(640) { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(newButton), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(refreshButton), ) } return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, layout.Flexed(1, newButton), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Flexed(1, refreshButton), ) } func (s *Shell) instanceTable(gtx layout.Context) layout.Dimensions { return layout.Background{}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { rect := image.Rectangle{Max: gtx.Constraints.Min} if rect.Empty() { return layout.Dimensions{Size: rect.Max} } paint.FillShape(gtx.Ops, color.NRGBA{R: 199, G: 207, B: 218, A: 255}, clip.UniformRRect(rect, gtx.Dp(8)).Op(gtx.Ops)) inner := rect.Inset(gtx.Dp(1)) if !inner.Empty() { paint.FillShape(gtx.Ops, s.theme.Palette.Bg, clip.UniformRRect(inner, gtx.Dp(7)).Op(gtx.Ops)) } return layout.Dimensions{Size: rect.Max} }, func(gtx layout.Context) layout.Dimensions { return layout.Inset{Top: unit.Dp(8), Right: unit.Dp(10), Bottom: unit.Dp(8), Left: unit.Dp(10)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(s.instanceHeader), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { return material.List(s.theme, &s.list).Layout(gtx, len(s.rows), func(gtx layout.Context, i int) layout.Dimensions { row := s.rows[i] return layout.Inset{Bottom: unit.Dp(8)}.Layout(gtx, s.instanceListRow(row)) }) }), ) }) }, ) } func (s *Shell) instanceListRow(row InstanceRow) layout.Widget { return func(gtx layout.Context) layout.Dimensions { minimumHeight := gtx.Dp(instanceListRowMinHeight) if gtx.Constraints.Min.Y < minimumHeight { gtx.Constraints.Min.Y = minimumHeight } return layout.Background{}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { if s.selectedInstanceID == row.ID { rect := image.Rectangle{Max: gtx.Constraints.Min} paint.FillShape(gtx.Ops, color.NRGBA{R: 235, G: 242, B: 255, A: 255}, clip.UniformRRect(rect, gtx.Dp(6)).Op(gtx.Ops)) } return layout.Dimensions{Size: gtx.Constraints.Min} }, func(gtx layout.Context) layout.Dimensions { dims := layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Flexed(100-instanceActionColumnWeight, func(gtx layout.Context) layout.Dimensions { return s.rowClickFor(row.ID).Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Flexed(instanceNameColumnWeight, material.Body1(s.theme, row.Name).Layout), layout.Flexed(instanceBrowserColumnWeight, material.Body2(s.theme, row.Browser).Layout), layout.Flexed(instanceDirectoryColumnWeight, pathCell(s.theme, row.UserDataDir)), layout.Flexed(instanceProxyColumnWeight, proxyNameCell(s.theme, s.proxyDisplayName(row.ProxyID))), layout.Flexed(instancePortColumnWeight, remoteDebugPortCell(s.theme, row)), layout.Flexed(instanceStatusColumnWeight, statusLabel(s.theme, row.Status)), ) }) }), layout.Flexed(instanceActionColumnWeight, s.instanceActionButtons(row)), ) if s.focusRestoreID == row.ID { gtx.Execute(key.FocusCmd{Tag: s.rowClickFor(row.ID)}) s.focusRestoreID = "" } return dims }, ) } } func (s *Shell) instanceHeader(gtx layout.Context) layout.Dimensions { return layout.Background{}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { rect := image.Rectangle{Max: gtx.Constraints.Min} if rect.Dy() > 0 { line := image.Rect(rect.Min.X, rect.Max.Y-gtx.Dp(1), rect.Max.X, rect.Max.Y) paint.FillShape(gtx.Ops, color.NRGBA{R: 220, G: 226, B: 235, A: 255}, clip.Rect{Min: line.Min, Max: line.Max}.Op()) } return layout.Dimensions{Size: rect.Max} }, func(gtx layout.Context) layout.Dimensions { return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Flexed(instanceNameColumnWeight, material.Caption(s.theme, "实例名称").Layout), layout.Flexed(instanceBrowserColumnWeight, material.Caption(s.theme, "浏览器类型").Layout), layout.Flexed(instanceDirectoryColumnWeight, material.Caption(s.theme, "用户数据目录").Layout), layout.Flexed(instanceProxyColumnWeight, material.Caption(s.theme, "代理").Layout), layout.Flexed(instancePortColumnWeight, material.Caption(s.theme, "调试端口").Layout), layout.Flexed(instanceStatusColumnWeight, material.Caption(s.theme, "状态").Layout), layout.Flexed(instanceActionColumnWeight, func(gtx layout.Context) layout.Dimensions { return layout.E.Layout(gtx, material.Caption(s.theme, "操作").Layout) }), ) }) }, ) } func pathCell(theme *material.Theme, path string) layout.Widget { style := material.Body2(theme, path) style.MaxLines = 1 return style.Layout } func remoteDebugPortCell(theme *material.Theme, row InstanceRow) layout.Widget { style := material.Body2(theme, instancePortLabel(row)) style.MaxLines = 1 return style.Layout } func proxyNameCell(theme *material.Theme, name string) layout.Widget { style := material.Body2(theme, name) style.MaxLines = 1 return style.Layout } func actualRemoteDebugPortText(port int) string { if port > 0 { return strconv.Itoa(port) } return "—" } 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 "—" } func (s *Shell) instanceActionButtons(row InstanceRow) layout.Widget { return func(gtx layout.Context) layout.Dimensions { return layout.E.Layout(gtx, func(gtx layout.Context) layout.Dimensions { mode := instanceActionFor(row) startClick := s.startClickFor(row.ID) actionLabel := mode.label + " " + row.Name actionIcon := instanceStartIcon if mode.stop { actionIcon = instanceStopIcon } dims := layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Rigid(s.instanceIconButton(startClick, actionIcon, actionLabel, s.theme.Palette.ContrastBg, mode.enabled)), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(s.instanceIconButton(s.editClickFor(row.ID), instanceEditIcon, "编辑 "+row.Name, s.theme.Palette.ContrastBg)), layout.Rigid(layout.Spacer{Width: unit.Dp(10)}.Layout), layout.Rigid(s.instanceIconButton(s.deleteClickFor(row.ID), instanceDeleteIcon, "删除 "+row.Name, color.NRGBA{R: 188, G: 51, B: 51, A: 255})), ) if s.focusStartID == row.ID { gtx.Execute(key.FocusCmd{Tag: startClick}) s.focusStartID = "" } return dims }) } } type instanceActionMode struct { label string stop bool enabled bool } func instanceActionFor(row InstanceRow) instanceActionMode { switch row.Status { case "运行中", "运行中(调试不可用)": return instanceActionMode{label: "停止", stop: true, enabled: true} case "启动中": return instanceActionMode{label: "启动中", enabled: false} case "停止中": return instanceActionMode{label: "停止中", stop: true, enabled: false} case "外部已关联", "外部占用", "未知占用": return instanceActionMode{label: "重新检测", enabled: true} default: return instanceActionMode{label: "启动", enabled: true} } } func (s *Shell) instanceIconButton(click *widget.Clickable, icon *widget.Icon, description string, iconColor color.NRGBA, enabled ...bool) layout.Widget { return func(gtx layout.Context) layout.Dimensions { if len(enabled) > 0 && !enabled[0] { gtx = gtx.Disabled() } style := material.IconButton(s.theme, click, icon, description) style.Size = instanceActionIconSize style.Inset = layout.UniformInset(instanceActionIconInset) style.Background = color.NRGBA{} style.Color = iconColor return style.Layout(gtx) } } func (s *Shell) editDialog(gtx layout.Context) layout.Dimensions { row, ok := s.instanceRow(s.editingID) if !ok { s.closeEdit("要编辑的实例已不存在。") return layout.Dimensions{} } gtx.Constraints.Min = gtx.Constraints.Max return s.editBlocker.Layout(gtx, func(gtx layout.Context) layout.Dimensions { defer clip.Rect{Max: gtx.Constraints.Min}.Push(gtx.Ops).Pop() paint.Fill(gtx.Ops, color.NRGBA{R: 0, G: 0, B: 0, A: 92}) return layout.Center.Layout(gtx, s.editDialogCard(row)) }) } func (s *Shell) editDialogCard(row InstanceRow) layout.Widget { return func(gtx layout.Context) layout.Dimensions { if maxWidth := gtx.Dp(560); gtx.Constraints.Max.X > maxWidth { gtx.Constraints.Max.X = maxWidth } return layout.Background{}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { rect := image.Rectangle{Max: gtx.Constraints.Min} 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: gtx.Constraints.Min} }, func(gtx layout.Context) layout.Dimensions { return layout.Inset{Top: unit.Dp(20), Right: unit.Dp(24), Bottom: unit.Dp(20), Left: unit.Dp(24)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { locked := s.editLocked(row) dims := layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(material.H6(s.theme, "编辑实例").Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), layout.Rigid(material.Caption(s.theme, "修改下次启动时使用的保存配置。").Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout), layout.Rigid(s.instanceFormField(s.formField("实例名称", "用于在实例列表中识别此浏览器环境", &s.editName))), layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), layout.Rigid(s.instanceFormField(func(gtx layout.Context) layout.Dimensions { return s.editBrowserField(gtx, locked) })), layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), layout.Rigid(s.instanceFormField(func(gtx layout.Context) layout.Dimensions { return s.editDirectoryField(gtx, locked) })), layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), layout.Rigid(s.instanceFormField(s.formField("启动 URL(可选)", "仅支持 http/https;留空时启动浏览器默认页", &s.editURL))), layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), layout.Rigid(s.instanceFormField(func(gtx layout.Context) layout.Dimensions { return s.editPreferredPortField(gtx, locked) })), layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), layout.Rigid(s.instanceFormField(func(gtx layout.Context) layout.Dimensions { return s.proxyPickerField(gtx, proxyPickerEdit) })), layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), 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)), ) })), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(s.instanceFormFeedback(s.editFeedbackMessage(locked))), layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout), layout.Rigid(s.instanceFormActionBar(s.editActions)), ) if s.editFocusPending { gtx.Execute(key.FocusCmd{Tag: &s.editName}) s.editFocusPending = false } return dims }) }, ) } } func (s *Shell) editBrowserField(gtx layout.Context, locked bool) layout.Dimensions { buttonGtx := gtx if locked { buttonGtx = gtx.Disabled() } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(material.Body2(s.theme, "浏览器类型").Layout), layout.Rigid(material.Caption(s.theme, "Chrome 和 Edge 使用各自的启动定义").Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Alignment: layout.Middle}.Layout(buttonGtx, layout.Rigid(material.RadioButton(s.theme, &s.editBrowserKind, "chrome", "Chrome").Layout), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(material.RadioButton(s.theme, &s.editBrowserKind, "edge", "Edge").Layout), ) }), ) } func (s *Shell) editDirectoryField(gtx layout.Context, locked bool) layout.Dimensions { s.editDir.ReadOnly = locked buttonGtx := gtx if locked { buttonGtx = gtx.Disabled() } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(material.Body2(s.theme, "User Data Dir").Layout), layout.Rigid(material.Caption(s.theme, "浏览器独立数据目录;修改实例名称不会移动已有目录").Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Flexed(1, material.Editor(s.theme, &s.editDir, "请输入或粘贴绝对路径").Layout), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(layout.Context) layout.Dimensions { return material.Button(s.theme, &s.editDirPick, "选择路径").Layout(buttonGtx) }), ) }), ) } func (s *Shell) 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), ) } func (s *Shell) editFeedbackMessage(locked bool) string { label := s.editFeedback row, exists := s.instanceRow(s.editingID) if label == "" && (!exists || !s.canInspectTabs(row)) { label = "查看标签页需要处于已验证的运行或外部关联状态,并具有实际调试端口。" } if label == "" && locked { label = "运行中或外部关联实例的浏览器类型和 User Data Dir 已锁定。" } return label } func (s *Shell) editActions(gtx layout.Context) layout.Dimensions { row, canInspect := s.instanceRow(s.editingID) canInspect = canInspect && s.canInspectTabs(row) && !s.tabsLoading tabsGtx := gtx if !canInspect { tabsGtx = gtx.Disabled() } return layout.Flex{Alignment: layout.Middle}.Layout(gtx, 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), layout.Rigid(func(gtx layout.Context) layout.Dimensions { style := material.Button(s.theme, &s.editCancel, "取消") style.Background = color.NRGBA{R: 230, G: 232, B: 235, A: 255} style.Color = s.theme.Palette.Fg return style.Layout(gtx) }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(material.Button(s.theme, &s.editSave, "保存更改").Layout), ) } func (s *Shell) 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), ) }) } func (s *Shell) editDiscardConfirmation(gtx layout.Context) layout.Dimensions { gtx.Constraints.Min = gtx.Constraints.Max return s.editDiscardBlocker.Layout(gtx, func(gtx layout.Context) layout.Dimensions { defer clip.Rect{Max: gtx.Constraints.Min}.Push(gtx.Ops).Pop() paint.Fill(gtx.Ops, color.NRGBA{R: 0, G: 0, B: 0, A: 110}) return layout.Center.Layout(gtx, s.editDiscardCard) }) } func (s *Shell) editDiscardCard(gtx layout.Context) layout.Dimensions { if maxWidth := gtx.Dp(460); gtx.Constraints.Max.X > maxWidth { gtx.Constraints.Max.X = maxWidth } return layout.Background{}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { rect := image.Rectangle{Max: gtx.Constraints.Min} defer clip.UniformRRect(rect, gtx.Dp(8)).Push(gtx.Ops).Pop() paint.Fill(gtx.Ops, s.theme.Palette.Bg) return layout.Dimensions{Size: gtx.Constraints.Min} }, func(gtx layout.Context) layout.Dimensions { return layout.Inset{Top: unit.Dp(20), Right: unit.Dp(24), Bottom: unit.Dp(20), Left: unit.Dp(24)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(material.H6(s.theme, "放弃未保存的更改?").Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(material.Body1(s.theme, "可以先保存配置,或不保存并关闭编辑对话框。").Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(18)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Rigid(material.Button(s.theme, &s.editDiscardStay, "继续编辑").Layout), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(material.Button(s.theme, &s.editDiscardDrop, "不保存").Layout), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(material.Button(s.theme, &s.editDiscardSave, "保存").Layout), ) }), ) }) }, ) } func (s *Shell) deleteConfirmation(gtx layout.Context) layout.Dimensions { row, ok := s.instanceRow(s.pendingDeleteID) if !ok { s.pendingDeleteID = "" return layout.Dimensions{} } gtx.Constraints.Min = gtx.Constraints.Max return s.deleteBlocker.Layout(gtx, func(gtx layout.Context) layout.Dimensions { defer clip.Rect{Max: gtx.Constraints.Min}.Push(gtx.Ops).Pop() paint.Fill(gtx.Ops, color.NRGBA{R: 0, G: 0, B: 0, A: 92}) return layout.Center.Layout(gtx, s.deleteConfirmationCard(row)) }) } func (s *Shell) deleteConfirmationCard(row InstanceRow) layout.Widget { return func(gtx layout.Context) layout.Dimensions { if maxWidth := gtx.Dp(420); gtx.Constraints.Max.X > maxWidth { gtx.Constraints.Max.X = maxWidth } return layout.Background{}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { rect := image.Rectangle{Max: gtx.Constraints.Min} defer clip.UniformRRect(rect, gtx.Dp(8)).Push(gtx.Ops).Pop() paint.Fill(gtx.Ops, s.theme.Palette.Bg) return layout.Dimensions{Size: gtx.Constraints.Min} }, func(gtx layout.Context) layout.Dimensions { return layout.Inset{Top: unit.Dp(20), Right: unit.Dp(24), Bottom: unit.Dp(20), Left: unit.Dp(24)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { children := []layout.FlexChild{ layout.Rigid(material.H6(s.theme, "删除实例?").Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(material.Body1(s.theme, fmt.Sprintf("确定删除实例“%s”?", row.Name)).Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(material.Caption(s.theme, "默认只删除 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 }) }, ) } } 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 == "启动失败" } 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 { label := "删除实例" if s.deleteData.Value { label = "删除实例及数据" } style := material.Button(s.theme, &s.deleteConfirm, label) style.Background = color.NRGBA{R: 188, G: 51, B: 51, A: 255} style.Color = color.NRGBA{R: 255, G: 255, B: 255, A: 255} return style.Layout(gtx) } func (s *Shell) proxyDeleteConfirmation(gtx layout.Context) layout.Dimensions { proxy, ok := s.proxyOption(s.pendingProxyDelete) if !ok { s.pendingProxyDelete = "" return layout.Dimensions{} } gtx.Constraints.Min = gtx.Constraints.Max return s.proxyDeleteBlocker.Layout(gtx, func(gtx layout.Context) layout.Dimensions { defer clip.Rect{Max: gtx.Constraints.Min}.Push(gtx.Ops).Pop() paint.Fill(gtx.Ops, color.NRGBA{R: 0, G: 0, B: 0, A: 92}) return layout.Center.Layout(gtx, s.proxyDeleteConfirmationCard(proxy)) }) } func (s *Shell) proxyDeleteConfirmationCard(proxy ProxyOption) layout.Widget { return func(gtx layout.Context) layout.Dimensions { if maxWidth := gtx.Dp(460); gtx.Constraints.Max.X > maxWidth { gtx.Constraints.Max.X = maxWidth } return layout.Background{}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { rect := image.Rectangle{Max: gtx.Constraints.Min} defer clip.UniformRRect(rect, gtx.Dp(8)).Push(gtx.Ops).Pop() paint.Fill(gtx.Ops, s.theme.Palette.Bg) return layout.Dimensions{Size: gtx.Constraints.Min} }, func(gtx layout.Context) layout.Dimensions { return layout.Inset{Top: unit.Dp(20), Right: unit.Dp(24), Bottom: unit.Dp(20), Left: unit.Dp(24)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { dims := layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(material.H6(s.theme, "删除代理?").Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(material.Body1(s.theme, "确定删除已保存代理?").Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(material.Caption(s.theme, proxy.Server).Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(material.Caption(s.theme, "此操作不会修改任何实例。若代理仍被引用,Chub 会拒绝删除。").Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(18)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Rigid(s.proxyDeleteCancelButton), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(s.proxyDeleteConfirmButton), ) }), ) if s.proxyDeleteFocus { gtx.Execute(key.FocusCmd{Tag: &s.proxyDeleteCancel}) s.proxyDeleteFocus = false } return dims }) }, ) } } func (s *Shell) proxyDeleteCancelButton(gtx layout.Context) layout.Dimensions { style := material.Button(s.theme, &s.proxyDeleteCancel, "取消") style.Background = color.NRGBA{R: 230, G: 232, B: 235, A: 255} style.Color = s.theme.Palette.Fg return style.Layout(gtx) } func (s *Shell) proxyDeleteConfirmButton(gtx layout.Context) layout.Dimensions { style := material.Button(s.theme, &s.proxyDeleteConfirm, "删除代理") style.Background = color.NRGBA{R: 188, G: 51, B: 51, A: 255} style.Color = color.NRGBA{R: 255, G: 255, B: 255, A: 255} return style.Layout(gtx) } func (s *Shell) unexpectedExitDialog(gtx layout.Context) layout.Dimensions { if !s.unexpectedExitOpen || len(s.unexpectedExits) == 0 { return layout.Dimensions{} } gtx.Constraints.Min = gtx.Constraints.Max return s.unexpectedExitBlocker.Layout(gtx, func(gtx layout.Context) layout.Dimensions { defer clip.Rect{Max: gtx.Constraints.Min}.Push(gtx.Ops).Pop() paint.Fill(gtx.Ops, color.NRGBA{R: 0, G: 0, B: 0, A: 92}) return layout.Center.Layout(gtx, s.unexpectedExitCard) }) } func (s *Shell) unexpectedExitCard(gtx layout.Context) layout.Dimensions { if maxWidth := gtx.Dp(480); gtx.Constraints.Max.X > maxWidth { gtx.Constraints.Max.X = maxWidth } title := "浏览器已退出" message := "检测到以下由 Chub 启动的浏览器已退出。" if len(s.unexpectedExits) == 1 { notice := s.unexpectedExits[0] message = fmt.Sprintf("检测到“%s”的 %s 已退出。", notice.name, notice.browser) } else { title = fmt.Sprintf("%d 个浏览器已退出", len(s.unexpectedExits)) } return layout.Background{}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { rect := image.Rectangle{Max: gtx.Constraints.Min} defer clip.UniformRRect(rect, gtx.Dp(8)).Push(gtx.Ops).Pop() paint.Fill(gtx.Ops, s.theme.Palette.Bg) return layout.Dimensions{Size: gtx.Constraints.Min} }, func(gtx layout.Context) layout.Dimensions { return layout.Inset{Top: unit.Dp(20), Right: unit.Dp(24), Bottom: unit.Dp(20), Left: unit.Dp(24)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { children := []layout.FlexChild{ layout.Rigid(material.H6(s.theme, title).Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(material.Body1(s.theme, message).Layout), } if len(s.unexpectedExits) > 1 { for index, notice := range s.unexpectedExits { if index == 5 { children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(material.Caption(s.theme, fmt.Sprintf("另有 %d 个实例已退出。", len(s.unexpectedExits)-index)).Layout), ) break } notice := notice children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(material.Body2(s.theme, fmt.Sprintf("• %s · %s", notice.name, notice.browser)).Layout), ) } } children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), layout.Rigid(material.Caption(s.theme, "实例配置和 User Data Dir 未被删除。关闭提示后可重新启动。").Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(18)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.E.Layout(gtx, material.Button(s.theme, &s.unexpectedExitAck, "知道了").Layout) }), ) dims := layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) if s.unexpectedExitFocus { gtx.Execute(key.FocusCmd{Tag: &s.unexpectedExitAck}) s.unexpectedExitFocus = false } return dims }) }, ) } func statusLabel(theme *material.Theme, status string) layout.Widget { style := material.Label(theme, unit.Sp(13), status) style.Color = theme.Palette.Fg if color, ok := map[string]color.NRGBA{ "运行中": {R: 24, G: 125, B: 78, A: 255}, "启动中": {R: 175, G: 105, B: 0, A: 255}, "停止中": {R: 175, G: 105, B: 0, A: 255}, "启动失败": {R: 188, G: 51, B: 51, A: 255}, "运行中(调试不可用)": {R: 175, G: 105, B: 0, A: 255}, "外部已关联": {R: 146, G: 93, B: 0, A: 255}, "外部占用": {R: 190, G: 75, B: 35, A: 255}, "未知占用": {R: 190, G: 75, B: 35, A: 255}, "已退出": {R: 100, G: 105, B: 115, A: 255}, }[status]; ok { style.Color = color } return style.Layout } func (s *Shell) settings(gtx layout.Context) layout.Dimensions { for s.chromePick.Clicked(gtx) { s.chooseExecutable(PathChromeExecutable) } for s.chromeSearch.Clicked(gtx) { s.togglePathSearch(PathChromeExecutable) } for s.edgePick.Clicked(gtx) { s.chooseExecutable(PathEdgeExecutable) } for s.edgeSearch.Clicked(gtx) { s.togglePathSearch(PathEdgeExecutable) } for s.dataPick.Clicked(gtx) { s.chooseDirectory(directoryPickDefaultUserData) } for s.dataSearch.Clicked(gtx) { s.togglePathSearch(PathDefaultUserData) } for s.logPick.Clicked(gtx) { s.chooseDirectory(directoryPickLogDirectory) } for s.logSearch.Clicked(gtx) { s.togglePathSearch(PathLogDirectory) } for s.dataRestore.Clicked(gtx) { s.restorePortableDirectory(PathDefaultUserData) } for s.logRestore.Clicked(gtx) { s.restorePortableDirectory(PathLogDirectory) } for s.proxySave.Clicked(gtx) { s.saveProxy() } for s.proxyDelete.Clicked(gtx) { s.requestProxyDelete() } for s.saveClick.Clicked(gtx) { settings, err := s.settingsState() if err != nil { s.pathFeedback = err.Error() continue } s.remoteDebugPort.SetText(strconv.Itoa(settings.RemoteDebugStartPort)) s.defaultDirMode = settings.DefaultDirMode s.logDirMode = settings.LogDirMode s.defaultInstanceDirRoot = settings.DefaultDir s.lastSavedSettings = settings if s.onSave != nil { s.onSave(settings) } } for s.cancelClick.Clicked(gtx) { s.resetSettings() } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(material.H4(s.theme, "设置").Layout), layout.Rigid(material.Body1(s.theme, "配置浏览器可执行文件、默认目录和本地运行行为").Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(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) }) }), ) } 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, layout.Rigid(s.portableDirectoryField(PathDefaultUserData, "默认 User Data Dir", &s.dataDir, &s.dataPick, &s.dataSearch, &s.dataRestore)), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(s.portableDirectoryField(PathLogDirectory, "日志目录", &s.logDir, &s.logPick, &s.logSearch, &s.logRestore)), ) })), 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) }, ) } } func (s *Shell) proxySettings(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(s.settingsField(func(gtx layout.Context) layout.Dimensions { return s.proxyPickerField(gtx, proxyPickerSettings) })), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(s.settingsField(s.formField("代理名称", "用于实例列表和选择器识别;名称必须唯一", &s.proxyName))), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(s.settingsField(s.formField("代理地址", "支持 http、https、socks4、socks5;不允许用户名、密码或路径", &s.proxyServer))), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.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.proxySave, "保存代理").Layout), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(s.proxyDeleteButton), ) })), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(s.settingsFeedback(s.proxyFeedback)), ) } func (s *Shell) proxyDeleteButton(gtx layout.Context) layout.Dimensions { buttonGtx := gtx if s.settingsProxyID == "" { buttonGtx = gtx.Disabled() } style := material.Button(s.theme, &s.proxyDelete, "删除代理") style.Background = color.NRGBA{R: 188, G: 51, B: 51, A: 255} style.Color = color.NRGBA{R: 255, G: 255, B: 255, A: 255} return style.Layout(buttonGtx) } func (s *Shell) remoteDebugPortField(gtx layout.Context) layout.Dimensions { return 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) } func (s *Shell) pathField(field PathField, label string, editor *widget.Editor, pick, search *widget.Clickable) layout.Widget { return func(gtx layout.Context) layout.Dimensions { return s.settingsField(func(gtx layout.Context) layout.Dimensions { pathActions := func(gtx layout.Context) layout.Dimensions { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Rigid(material.Button(s.theme, pick, s.pathPickButtonLabel(field)).Layout), 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), ) })) } if feedback := s.pathFieldFeedback[field]; feedback != "" { children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(s.settingsFeedback(feedback)), ) } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) })(gtx) } } 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), ) }), ) } } func (s *Shell) createInstance(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(material.H4(s.theme, "新建实例").Layout), layout.Rigid(material.Body1(s.theme, "为一个独立的 Chrome 或 Edge User Data Dir 创建托管实例").Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(18)}.Layout), layout.Rigid(s.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), ) })), ) })), ) } func (s *Shell) formField(label, help string, editor *widget.Editor) layout.Widget { return func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(material.Body2(s.theme, label).Layout), layout.Rigid(material.Caption(s.theme, help).Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), layout.Rigid(material.Editor(s.theme, editor, "请输入").Layout), ) } } func (s *Shell) 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) } func (s *Shell) proxyPickerField(gtx layout.Context, target proxyPickerTarget) layout.Dimensions { click := &s.createProxyPick selected := s.createProxyID label := "代理" help := "选择设置中保存的无认证代理;留空表示不使用代理" if target == proxyPickerEdit { click = &s.editProxyPick selected = s.editProxyID } else if target == proxyPickerSettings { click = &s.settingsProxyPick selected = s.settingsProxyID label = "已保存代理" help = "选择项会回填代理名称和地址;选择新建会清空输入框" } for click.Clicked(gtx) { s.proxyPicker = proxyPickerState{open: true, target: target} } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(material.Body2(s.theme, label).Layout), layout.Rigid(material.Caption(s.theme, help).Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), layout.Rigid(material.Button(s.theme, click, s.proxyLabel(selected)).Layout), ) } func (s *Shell) proxyLabel(id string) string { if option, exists := s.proxyOption(id); exists { return proxyPickerLabel(option) } return s.proxyDisplayName(id) } func (s *Shell) proxyDisplayName(id string) string { if id == "" { return "无代理" } if option, exists := s.proxyOption(id); exists { return option.Name } return "代理不可用" } func proxyPickerLabel(option ProxyOption) string { return option.Name + " · " + option.Server } func (s *Shell) proxyExists(id string) bool { if _, exists := s.proxyOption(id); exists { return true } return id == "" } func (s *Shell) proxyOption(id string) (ProxyOption, bool) { if id == "" { return ProxyOption{}, false } for _, option := range s.proxies { if option.ID == id { return option, true } } return ProxyOption{}, false } func (s *Shell) proxyPickerChoiceFor(id string) *widget.Clickable { if click := s.proxyPickerChoices[id]; click != nil { return click } click := new(widget.Clickable) s.proxyPickerChoices[id] = click return click } func (s *Shell) selectPickerProxy(id string) { if !s.proxyExists(id) { return } if s.proxyPicker.target == proxyPickerEdit { s.editProxyID = id } else if s.proxyPicker.target == proxyPickerSettings { s.settingsProxyID = id if id == "" { s.proxyName.SetText("") s.proxyServer.SetText("") } else { for _, option := range s.proxies { if option.ID == id { s.proxyName.SetText(option.Name) s.proxyServer.SetText(option.Server) break } } } s.proxyFeedback = "" } else { s.createProxyID = id } s.proxyPicker.open = false } func (s *Shell) proxyPickerDialog(gtx layout.Context) layout.Dimensions { if !s.proxyPicker.open { return layout.Dimensions{} } gtx.Constraints.Min = gtx.Constraints.Max return s.proxyPickerBlocker.Layout(gtx, func(gtx layout.Context) layout.Dimensions { defer clip.Rect{Max: gtx.Constraints.Min}.Push(gtx.Ops).Pop() paint.Fill(gtx.Ops, color.NRGBA{R: 0, G: 0, B: 0, A: 92}) for s.proxyPickerBlocker.Clicked(gtx) { s.proxyPicker.open = false } for s.proxyPickerNone.Clicked(gtx) { s.selectPickerProxy("") } for _, option := range append([]ProxyOption(nil), s.proxies...) { for s.proxyPickerChoiceFor(option.ID).Clicked(gtx) { s.selectPickerProxy(option.ID) } } return layout.Center.Layout(gtx, s.proxyPickerCard) }) } func (s *Shell) proxyPickerCard(gtx layout.Context) layout.Dimensions { if maxWidth := gtx.Dp(440); gtx.Constraints.Max.X > maxWidth { gtx.Constraints.Max.X = maxWidth } return layout.Background{}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { rect := image.Rectangle{Max: gtx.Constraints.Min} defer clip.UniformRRect(rect, gtx.Dp(8)).Push(gtx.Ops).Pop() paint.Fill(gtx.Ops, s.theme.Palette.Bg) return layout.Dimensions{Size: gtx.Constraints.Min} }, func(gtx layout.Context) layout.Dimensions { return layout.Inset{Top: unit.Dp(20), Right: unit.Dp(24), Bottom: unit.Dp(20), Left: unit.Dp(24)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { children := []layout.FlexChild{ layout.Rigid(material.H6(s.theme, s.proxyPickerTitle()).Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(material.Caption(s.theme, "仅显示和使用无认证代理端点。按 Escape 或点击背景取消。").Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), layout.Rigid(material.Button(s.theme, &s.proxyPickerNone, s.proxyPickerNoneLabel()).Layout), } for _, option := range s.proxies { option := option children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return s.proxyPickerChoiceFor(option.ID).Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Inset{Top: unit.Dp(7), Bottom: unit.Dp(7), Left: unit.Dp(10), Right: unit.Dp(10)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { 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), ) }) }) }), ) } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) }) }, ) } func (s *Shell) proxyPickerTitle() string { if s.proxyPicker.target == proxyPickerSettings { return "选择已保存代理" } return "选择代理" } func (s *Shell) proxyPickerNoneLabel() string { if s.proxyPicker.target == proxyPickerSettings { return "新建代理(不选择)" } return "无代理" } func (s *Shell) instanceDirField(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(material.Body2(s.theme, "User Data Dir").Layout), layout.Rigid(material.Caption(s.theme, "默认目录会使用 instance-实例名-短 ID;必须是绝对路径").Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Flexed(1, material.Editor(s.theme, &s.instanceDir, "请输入或粘贴绝对路径").Layout), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(material.Button(s.theme, &s.instanceDirPick, s.instanceDirButtonLabel()).Layout), ) }), ) } func (s *Shell) createInstanceFromForm() { name, err := s.validateInstanceName(s.instanceName.Text(), "") if err != nil { s.formFeedback = err.Error() return } if s.defaultCreateDirActive { defaultDir := s.defaultCreateInstanceDir(name, s.pendingCreateID) if defaultDir == "" { s.formFeedback = "无法根据实例名称生成默认 User Data Dir;请选择或输入一个绝对路径。" return } s.instanceDir.SetText(defaultDir) s.lastSuggestedCreateDir = defaultDir } userDataDir := strings.TrimSpace(s.instanceDir.Text()) if userDataDir == "" || !filepath.IsAbs(userDataDir) { s.formFeedback = "请选择或输入绝对路径的 User Data Dir 后再创建。" return } 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 } instanceID := s.pendingCreateID if instanceID == "" || s.instanceIDExists(instanceID) { instanceID = s.allocateInstanceID() } s.rows = append(s.rows, InstanceRow{ ID: instanceID, Name: name, Browser: browserDisplay(s.browserKind.Value), UserDataDir: filepath.Clean(userDataDir), TargetURL: strings.TrimSpace(s.instanceURL.Text()), ProxyID: s.createProxyID, PreferredRemoteDebugPort: preferredPort, Status: "已退出", }) s.page = pageInstances s.formFeedback = "" s.pendingCreateID = "" s.defaultCreateDirActive = false s.lastSuggestedCreateDir = "" s.notifyInstancesChanged() } func (s *Shell) defaultCreateInstanceDir(name, instanceID string) string { if s.defaultInstanceDirRoot == "" || instanceID == "" { return "" } 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)) } 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 } func (s *Shell) togglePathSearch(field PathField) { state := s.searches[field] if state == nil { return } if state.running { state.cancel() state.cancel = nil state.running = false state.request++ s.setPathFieldFeedback(field, fmt.Sprintf("已取消%s搜索。", pathFieldLabel(field))) return } if s.pathSearcher == nil { s.setPathFieldFeedback(field, fmt.Sprintf("%s搜索服务尚未准备好。", pathFieldLabel(field))) return } state.request++ request := state.request ctx, cancel := context.WithCancel(context.Background()) state.cancel = cancel state.running = true current := s.pathEditor(field).Text() s.setPathFieldFeedback(field, fmt.Sprintf("正在搜索%s…", pathFieldLabel(field))) go func() { path, err := s.pathSearcher(ctx, field, current) s.searchResults <- pathSearchResult{field: field, request: request, path: path, err: err} if s.invalidate != nil { s.invalidate() } }() } func (s *Shell) consumeSearchResults() { for { select { case result := <-s.searchResults: state := s.searches[result.field] if state == nil || !state.running || result.request != state.request { continue } state.running = false state.cancel = nil if result.err != nil { if errors.Is(result.err, context.Canceled) { s.setPathFieldFeedback(result.field, fmt.Sprintf("已取消%s搜索。", pathFieldLabel(result.field))) } else { s.setPathFieldFeedback(result.field, fmt.Sprintf("搜索%s失败,请重试或手动输入路径。", pathFieldLabel(result.field))) } continue } if result.path == "" { 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))) continue } s.pathEditor(result.field).SetText(result.path) s.setPathFieldFeedback(result.field, fmt.Sprintf("已选择%s。", pathFieldLabel(result.field))) default: return } } } func (s *Shell) searchButtonLabel(field PathField) string { if state := s.searches[field]; state != nil && state.running { return "取消" } return "搜索" } func (s *Shell) pathPickButtonLabel(field PathField) string { if s.executablePick.running && s.executablePick.field == field { return "选择中…" } if s.directoryPick.running && ((field == PathDefaultUserData && s.directoryPick.target == directoryPickDefaultUserData) || (field == PathLogDirectory && s.directoryPick.target == directoryPickLogDirectory)) { return "选择中…" } return "选择" } func (s *Shell) setPathFieldFeedback(field PathField, message string) { if s.pathFieldFeedback == nil { s.pathFieldFeedback = make(map[PathField]string) } s.pathFieldFeedback[field] = message } func (s *Shell) pathEditor(field PathField) *widget.Editor { switch field { case PathChromeExecutable: return &s.chromePath case PathEdgeExecutable: return &s.edgePath case PathDefaultUserData: return &s.dataDir default: return &s.logDir } } func pathFieldLabel(field PathField) string { switch field { case PathChromeExecutable: return "Chrome 可执行文件" case PathEdgeExecutable: return "Edge 可执行文件" case PathDefaultUserData: return "默认 User Data Dir" default: return "日志目录" } } func browserDisplay(value string) string { if value == "edge" { return "Edge" } return "Chrome" } func (s *Shell) startClickFor(id string) *widget.Clickable { if click := s.startClicks[id]; click != nil { return click } click := new(widget.Clickable) s.startClicks[id] = click return click } func (s *Shell) rowClickFor(id string) *widget.Clickable { if click := s.rowClicks[id]; click != nil { return click } click := new(widget.Clickable) s.rowClicks[id] = click return click } func (s *Shell) editClickFor(id string) *widget.Clickable { if click := s.editClicks[id]; click != nil { return click } click := new(widget.Clickable) s.editClicks[id] = click return click } func (s *Shell) deleteClickFor(id string) *widget.Clickable { if click := s.deleteClicks[id]; click != nil { return click } click := new(widget.Clickable) s.deleteClicks[id] = click return click } func (s *Shell) saveProxy() { name, err := domain.NormalizeProxyName(s.proxyName.Text()) if err != nil { s.proxyFeedback = "请输入代理名称。" return } server, err := domain.NormalizeProxyServer(s.proxyServer.Text()) if err != nil || server == "" { s.proxyFeedback = "代理地址必须是无认证的 scheme://host:port。" return } for _, option := range s.proxies { if option.ID != s.settingsProxyID && strings.EqualFold(option.Name, name) { s.proxyFeedback = "该代理名称已存在,请使用其他名称。" return } if option.ID != s.settingsProxyID && option.Server == server { s.proxyFeedback = "该代理地址已存在,请从下拉框选择它。" return } } if s.settingsProxyID == "" { s.nextProxy++ s.settingsProxyID = fmt.Sprintf("proxy-%d", s.nextProxy) s.proxies = append(s.proxies, ProxyOption{ID: s.settingsProxyID, Name: name, Server: server}) s.proxyFeedback = fmt.Sprintf("已添加代理“%s”;后续启动将使用该地址。", name) } else { updated := false for index := range s.proxies { if s.proxies[index].ID != s.settingsProxyID { continue } s.proxies[index].Name = name s.proxies[index].Server = server updated = true break } if !updated { s.proxyFeedback = "所选代理已不可用,请重新选择或新建。" return } s.proxyFeedback = fmt.Sprintf("已保存代理“%s”;后续启动将使用最新端点。", name) } s.proxyName.SetText(name) s.proxyServer.SetText(server) s.notifyProxiesChanged() } func (s *Shell) requestProxyDelete() { id := s.settingsProxyID if id == "" { s.proxyFeedback = "请先选择要删除的代理。" return } if count := s.proxyUseCount(id); count > 0 { s.proxyFeedback = fmt.Sprintf("该代理仍被 %d 个实例使用,不能删除。", count) return } if _, exists := s.proxyOption(id); !exists { s.proxyFeedback = "所选代理已不可用。" return } s.pendingProxyDelete = id s.proxyDeleteFocus = true } func (s *Shell) cancelProxyDelete() { s.pendingProxyDelete = "" s.proxyDeleteFocus = false s.proxyFeedback = "已取消删除代理。" } func (s *Shell) confirmProxyDelete() { id := s.pendingProxyDelete s.pendingProxyDelete = "" s.proxyDeleteFocus = false if count := s.proxyUseCount(id); count > 0 { s.proxyFeedback = fmt.Sprintf("该代理仍被 %d 个实例使用,不能删除。", count) return } for index, option := range s.proxies { if option.ID != id { continue } s.proxies = append(s.proxies[:index], s.proxies[index+1:]...) delete(s.proxyPickerChoices, id) if s.createProxyID == id { s.createProxyID = "" } if s.editProxyID == id { s.editProxyID = "" } if s.settingsProxyID == id { s.settingsProxyID = "" s.proxyName.SetText("") s.proxyServer.SetText("") } s.proxyFeedback = fmt.Sprintf("已删除代理“%s”。", option.Name) s.notifyProxiesChanged() return } s.proxyFeedback = "找不到要删除的代理。" } func (s *Shell) proxyUseCount(id string) int { count := 0 for _, row := range s.rows { if row.ProxyID == id { count++ } } return count } func (s *Shell) notifyProxiesChanged() { if s.onProxiesChanged != nil { s.onProxiesChanged(append([]ProxyOption(nil), s.proxies...)) } } func (s *Shell) requestInstanceAction(id string) { row, ok := s.instanceRow(id) if !ok { s.instanceFeedback = "找不到要操作的实例。" return } mode := instanceActionFor(row) if !mode.enabled { s.instanceFeedback = fmt.Sprintf("%s%s,请稍候。", row.Name, mode.label) return } if mode.stop { s.requestStop(id) return } s.requestStart(id) } func (s *Shell) requestStop(id string) { row, ok := s.instanceRow(id) if !ok { s.instanceFeedback = "找不到要停止的实例。" return } if row.Status != "运行中" && row.Status != "运行中(调试不可用)" { s.instanceFeedback = fmt.Sprintf("%s 当前不能由 Chub 停止。", row.Name) return } if row.OccupancySource != "" && row.OccupancySource != "chub_registry" { s.instanceFeedback = fmt.Sprintf("%s 不是 Chub 托管实例,不能停止。", row.Name) return } state := s.stopStates[id] if state != nil && state.running { s.instanceFeedback = fmt.Sprintf("%s 正在停止,请稍候。", row.Name) return } stopper := s.instanceStopper if stopper == nil { s.instanceFeedback = "浏览器停止服务尚未准备好。" return } if state == nil { state = &instanceStopState{} s.stopStates[id] = state } state.request++ request := state.request state.running = true launchGeneration := uint64(0) if started := s.startStates[id]; started != nil { launchGeneration = started.request } state.previousStatus = row.Status s.setInstanceStatus(id, "停止中") s.instanceFeedback = fmt.Sprintf("正在请求 %s 正常退出…", row.Name) invalidate := s.invalidate go func() { err := stopper(context.Background(), row, launchGeneration) s.stopResults <- instanceStopResult{id: id, request: request, err: err} if invalidate != nil { invalidate() } }() } func (s *Shell) requestStart(id string) { row, ok := s.instanceRow(id) if !ok { s.instanceFeedback = "找不到要启动的实例。" return } state := s.startStates[id] if state != nil && state.running { s.instanceFeedback = fmt.Sprintf("%s 正在启动,请稍候。", row.Name) return } if stop := s.stopStates[id]; stop != nil && stop.running { s.instanceFeedback = fmt.Sprintf("%s 正在停止,请等待退出后再启动。", row.Name) return } starter := s.instanceStarter if starter == nil { s.instanceFeedback = "浏览器启动服务尚未准备好。" return } settings, err := s.settingsState() if err != nil { s.instanceFeedback = err.Error() return } settings.ReservedRemoteDebugPorts = s.reservedRemoteDebugPorts(id) if state == nil { state = &instanceStartState{} s.startStates[id] = state } state.request++ request := state.request state.running = true s.setInstanceStatus(id, "启动中") s.instanceFeedback = fmt.Sprintf("正在启动 %s…", row.Name) invalidate := s.invalidate go func() { outcome, err := starter(context.Background(), row, settings, request) s.startResults <- instanceStartResult{id: id, request: request, outcome: outcome, err: err} if invalidate != nil { invalidate() } }() } func (s *Shell) consumeStopResults() { for { select { case result := <-s.stopResults: state := s.stopStates[result.id] if state == nil || !state.running || result.request != state.request { continue } state.running = false row, exists := s.instanceRow(result.id) if !exists { continue } if result.err != nil { if row.Status == "停止中" { s.setInstanceRuntime(result.id, state.previousStatus, row.PID, row.RemoteDebugPort, row.OccupancySource) } s.instanceFeedback = fmt.Sprintf("停止 %s 失败:%v", row.Name, result.err) continue } s.setInstanceRuntime(result.id, "已退出", 0, 0, "") s.focusRestoreID = result.id s.instanceFeedback = fmt.Sprintf("%s 已正常退出。", row.Name) default: return } } } func (s *Shell) consumeStartResults() { for { select { case result := <-s.startResults: state := s.startStates[result.id] if state == nil || !state.running || result.request != state.request { continue } state.running = false row, exists := s.instanceRow(result.id) if !exists { continue } if result.err != nil { if errors.Is(result.err, domain.ErrProfileOccupied) { if result.outcome.Source == "chub_registry" { s.setInstanceRuntime(result.id, "运行中", result.outcome.PID, result.outcome.RemoteDebugPort, result.outcome.Source) s.instanceFeedback = fmt.Sprintf("%s 已由 Chub 启动,未重复创建浏览器。", row.Name) } else { s.setInstanceRuntime(result.id, "外部占用", result.outcome.PID, result.outcome.RemoteDebugPort, result.outcome.Source) s.instanceFeedback = fmt.Sprintf("%s 的 User Data Dir 已被外部浏览器占用,未再次启动。", row.Name) } } else { s.setInstanceStatus(result.id, "启动失败") s.instanceFeedback = fmt.Sprintf("启动 %s 失败:%v", row.Name, result.err) } continue } if result.outcome.External { s.setInstanceRuntime(result.id, "外部已关联", result.outcome.PID, result.outcome.RemoteDebugPort, result.outcome.Source) s.instanceFeedback = fmt.Sprintf("已关联外部 %s(PID %d,端口 %d);不会接管其生命周期。", row.Name, result.outcome.PID, result.outcome.RemoteDebugPort) continue } status := "运行中" if result.outcome.Warning != "" { status = "运行中(调试不可用)" } s.setInstanceRuntime(result.id, status, result.outcome.PID, result.outcome.RemoteDebugPort, "chub_registry") 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() } if s.applyPendingManagedExit(result.id, result.request, result.outcome.PID) { continue } if result.outcome.Warning != "" { s.instanceFeedback = fmt.Sprintf("%s 已启动(PID %d),但%s。", row.Name, result.outcome.PID, result.outcome.Warning) } else if fallbackPort { s.instanceFeedback = fmt.Sprintf("%s 的首选端口已被占用,已改用并保存端口 %d。", row.Name, result.outcome.RemoteDebugPort) } else { s.instanceFeedback = fmt.Sprintf("已启动 %s(PID %d,端口 %d)。", row.Name, result.outcome.PID, result.outcome.RemoteDebugPort) } default: return } } } func (s *Shell) requestRefresh() { if s.refreshClickState.running { s.instanceFeedback = "实例状态正在刷新,请稍候。" return } refresher := s.instanceRefresher if refresher == nil { s.instanceFeedback = "实例状态刷新服务尚未准备好。" return } rows := append([]InstanceRow(nil), s.rows...) snapshots := make(map[string]string, len(rows)) for _, row := range rows { snapshots[row.ID] = instanceRefreshFingerprint(row) } s.refreshClickState.request++ request := s.refreshClickState.request s.refreshClickState.running = true s.instanceFeedback = "正在刷新已保存实例的状态…" invalidate := s.invalidate go func() { results, err := refresher(context.Background(), rows) s.refreshResults <- instanceRefreshResult{request: request, snapshots: snapshots, results: results, err: err} if invalidate != nil { invalidate() } }() } func (s *Shell) consumeRefreshResults() { for { select { case result := <-s.refreshResults: if !s.refreshClickState.running || result.request != s.refreshClickState.request { continue } s.refreshClickState.running = false if result.err != nil { s.instanceFeedback = fmt.Sprintf("刷新实例状态失败:%v", result.err) continue } applied, skipped, failed := 0, 0, 0 for _, refreshed := range result.results { row, exists := s.instanceRow(refreshed.ID) if !exists || result.snapshots[refreshed.ID] != instanceRefreshFingerprint(row) { skipped++ continue } if refreshed.Status == "" { failed++ continue } s.setInstanceRuntime(refreshed.ID, refreshed.Status, refreshed.PID, refreshed.RemoteDebugPort, refreshed.OccupancySource) if refreshed.Message != "" { failed++ } applied++ } switch { case applied == 0 && failed > 0: s.instanceFeedback = "实例状态刷新完成,但部分实例无法确认,请查看状态后重试。" case skipped > 0: s.instanceFeedback = fmt.Sprintf("已刷新 %d 个实例;忽略 %d 个已编辑或删除记录的过期结果。", applied, skipped) case failed > 0: s.instanceFeedback = fmt.Sprintf("已刷新 %d 个实例;%d 个实例状态无法完全确认。", applied, failed) default: s.instanceFeedback = fmt.Sprintf("已刷新 %d 个已保存实例的状态。", applied) } default: return } } } func (s *Shell) consumeManagedExitResults() { s.managedExitMu.Lock() results := s.managedExitResults s.managedExitResults = nil s.managedExitMu.Unlock() for _, result := range results { key := managedExitKey{instanceID: result.InstanceID, launchGeneration: result.LaunchGeneration, pid: result.PID} row, exists := s.instanceRow(result.InstanceID) if !exists { continue } state := s.startStates[result.InstanceID] if state != nil && state.running && state.request == result.LaunchGeneration && row.Status == "启动中" { s.pendingManagedExit[key] = result continue } s.applyManagedExit(result) } } func (s *Shell) applyPendingManagedExit(id string, launchGeneration uint64, pid int) bool { key := managedExitKey{instanceID: id, launchGeneration: launchGeneration, pid: pid} result, exists := s.pendingManagedExit[key] if !exists { return false } delete(s.pendingManagedExit, key) return s.applyManagedExit(result) } func (s *Shell) applyManagedExit(result ManagedInstanceExit) bool { row, exists := s.instanceRow(result.InstanceID) if !exists || row.PID != result.PID { return false } state := s.startStates[result.InstanceID] if state == nil || state.request != result.LaunchGeneration { return false } if row.Status != "运行中" && row.Status != "运行中(调试不可用)" && row.Status != "停止中" { return false } s.setInstanceRuntime(result.InstanceID, "已退出", 0, 0, "") if result.ExpectedStop || row.Status == "停止中" { s.instanceFeedback = fmt.Sprintf("%s 已正常退出。", row.Name) return true } for _, notice := range s.unexpectedExits { if notice.instanceID == row.ID { return true } } s.unexpectedExits = append(s.unexpectedExits, unexpectedExitNotice{instanceID: row.ID, name: row.Name, browser: row.Browser}) if s.unexpectedExitFocusID == "" { s.unexpectedExitFocusID = row.ID } s.instanceFeedback = fmt.Sprintf("检测到“%s”的 %s 已退出,可重新启动。", row.Name, row.Browser) return true } func instanceRefreshFingerprint(row InstanceRow) string { return strings.Join([]string{row.ID, row.Name, row.Browser, row.UserDataDir, row.TargetURL, row.ProxyID, strconv.Itoa(row.PreferredRemoteDebugPort)}, "\x00") } func (s *Shell) requestDelete(id string) { row, ok := s.instanceRow(id) if !ok { s.instanceFeedback = "找不到要删除的实例。" return } if state := s.startStates[id]; state != nil && state.running { s.instanceFeedback = fmt.Sprintf("%s 正在启动,暂时不能删除。", row.Name) return } if state := s.stopStates[id]; state != nil && state.running { s.instanceFeedback = fmt.Sprintf("%s 正在停止,暂时不能删除。", row.Name) return } s.pendingDeleteID = id s.deleteData.Value = false s.deleteDataRunning = false s.deleteDataFeedback = "" s.deleteDataFocus = true } func (s *Shell) consumeDeleteConfirmation(gtx layout.Context) { for s.deleteBlocker.Clicked(gtx) { } if s.deleteDataRunning { return } for s.deleteCancel.Clicked(gtx) { s.cancelDelete() } for s.deleteConfirm.Clicked(gtx) { s.confirmDelete() } } func (s *Shell) consumeProxyDeleteConfirmation(gtx layout.Context) { for s.proxyDeleteBlocker.Clicked(gtx) { } for s.proxyDeleteCancel.Clicked(gtx) { s.cancelProxyDelete() } for s.proxyDeleteConfirm.Clicked(gtx) { s.confirmProxyDelete() } } func (s *Shell) consumeUnexpectedExitDialog(gtx layout.Context) { for s.unexpectedExitBlocker.Clicked(gtx) { } for s.unexpectedExitAck.Clicked(gtx) { s.dismissUnexpectedExitNotice() } } func (s *Shell) hasOtherModal() bool { return s.pendingDeleteID != "" || s.pendingProxyDelete != "" || s.editingID != "" || s.pendingEditDiscard || s.proxyPicker.open } func (s *Shell) presentUnexpectedExitIfReady() { if s.unexpectedExitOpen || len(s.unexpectedExits) == 0 || s.hasOtherModal() { return } s.unexpectedExitOpen = true s.unexpectedExitFocus = true } func (s *Shell) dismissUnexpectedExitNotice() { s.unexpectedExitOpen = false s.unexpectedExitFocus = false s.unexpectedExits = nil s.focusStartID = s.unexpectedExitFocusID s.unexpectedExitFocusID = "" } func (s *Shell) cancelDelete() { if s.deleteDataRunning { return } if row, ok := s.instanceRow(s.pendingDeleteID); ok { s.instanceFeedback = fmt.Sprintf("已取消删除实例“%s”。", row.Name) } s.pendingDeleteID = "" s.deleteData.Value = false s.deleteDataFeedback = "" s.deleteDataFocus = false } func (s *Shell) confirmDelete() { 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() } }() } func (s *Shell) deleteInstance(id string) { for i, row := range s.rows { if row.ID != id { continue } s.rows = append(s.rows[:i], s.rows[i+1:]...) delete(s.startClicks, id) delete(s.rowClicks, id) delete(s.editClicks, id) delete(s.deleteClicks, id) delete(s.startStates, id) delete(s.stopStates, id) s.instanceFeedback = fmt.Sprintf("已删除实例“%s”;其 User Data Dir 未被删除。", row.Name) s.notifyInstancesChanged() return } s.instanceFeedback = "找不到要删除的实例。" } func (s *Shell) 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 = "找不到要删除的实例。" } func (s *Shell) instanceRow(id string) (InstanceRow, bool) { for _, row := range s.rows { if row.ID == id { return row, true } } return InstanceRow{}, false } func (s *Shell) setInstanceStatus(id, status string) bool { for i := range s.rows { if s.rows[i].ID == id { s.rows[i].Status = status return true } } return false } func (s *Shell) setInstanceRuntime(id, status string, pid, remoteDebugPort int, source string) bool { for i := range s.rows { if s.rows[i].ID == id { s.rows[i].Status = status s.rows[i].PID = pid s.rows[i].RemoteDebugPort = remoteDebugPort s.rows[i].OccupancySource = source return true } } return false } func (s *Shell) replaceInstanceRow(updated InstanceRow) bool { for i := range s.rows { if s.rows[i].ID == updated.ID { s.rows[i] = updated return true } } return false } func (s *Shell) settingsState() (SettingsState, error) { port, err := normalizeRemoteDebugStartPort(s.remoteDebugPort.Text()) if err != nil { return SettingsState{}, err } 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 } return SettingsState{ ChromePath: s.chromePath.Text(), EdgePath: s.edgePath.Text(), DefaultDir: defaultDir, DefaultDirMode: s.selectedDirectoryMode(defaultDir, s.portableDefaultDir, s.defaultDirMode), LogDir: logDir, LogDirMode: s.selectedDirectoryMode(logDir, s.portableLogDir, s.logDirMode), RemoteDebugStartPort: port, CloseOnExit: s.closeOnExit.Value, }, nil } 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, "已恢复为程序目录下的默认日志目录。") } func normalizeRemoteDebugStartPort(value string) (int, error) { value = strings.TrimSpace(value) if value == "" { return domain.DefaultRemoteDebugPort, nil } port, err := strconv.Atoi(value) if err != nil || !domain.ValidRemoteDebugPort(port) { return 0, fmt.Errorf("远程调试起始端口必须在 %d 到 %d 之间", domain.MinRemoteDebugPort, domain.MaxRemoteDebugPort) } return port, nil } func 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 } func (s *Shell) notifyInstancesChanged() { if s.onInstancesChanged != nil { s.onInstancesChanged(append([]InstanceRow(nil), s.rows...)) } } func mustIcon(data []byte) *widget.Icon { icon, err := widget.NewIcon(data) if err != nil { panic(err) } return icon } func (s *Shell) chooseInstanceDirectory() { s.chooseDirectory(directoryPickCreate) } func (s *Shell) chooseDirectory(target directoryPickTarget) { if s.directoryPick.running { s.setDirectoryPickFeedback(target, "目录选择器已打开,请在系统窗口中选择或取消。") return } if s.directoryChooser == nil { s.setDirectoryPickFeedback(target, "目录选择器尚未准备好。") return } s.directoryPick.request++ request := s.directoryPick.request ctx, cancel := context.WithCancel(context.Background()) s.directoryPick.cancel = cancel s.directoryPick.running = true s.directoryPick.target = target s.setDirectoryPickFeedback(target, "正在打开目录选择器…") go func() { path, err := s.directoryChooser(ctx) s.directoryResults <- directoryPickResult{request: request, path: path, err: err} if s.invalidate != nil { s.invalidate() } }() } func (s *Shell) consumeDirectoryResults() { for { select { case result := <-s.directoryResults: if !s.directoryPick.running || result.request != s.directoryPick.request { continue } target := s.directoryPick.target s.directoryPick.running = false s.directoryPick.cancel = nil if result.err != nil { if errors.Is(result.err, context.Canceled) { s.setDirectoryPickFeedback(target, "已取消选择目录。") } else { s.setDirectoryPickFeedback(target, fmt.Sprintf("选择目录失败:%v", result.err)) } continue } if result.path == "" { s.setDirectoryPickFeedback(target, "未选择目录。") continue } switch target { case directoryPickEdit: s.editDir.SetText(result.path) case directoryPickDefaultUserData: s.dataDir.SetText(result.path) s.defaultDirMode = "custom" case directoryPickLogDirectory: s.logDir.SetText(result.path) s.logDirMode = "custom" default: s.instanceDir.SetText(result.path) s.defaultCreateDirActive = false } s.setDirectoryPickFeedback(target, "已更新目录。") default: return } } } func (s *Shell) setDirectoryPickFeedback(target directoryPickTarget, message string) { switch target { case directoryPickEdit: s.editFeedback = message case directoryPickDefaultUserData: s.setPathFieldFeedback(PathDefaultUserData, message) case directoryPickLogDirectory: s.setPathFieldFeedback(PathLogDirectory, message) default: s.formFeedback = message } } func (s *Shell) instanceDirButtonLabel() string { if s.directoryPick.running && s.directoryPick.target == directoryPickCreate { return "选择中…" } return "选择路径" } func (s *Shell) resetSettings() { s.SetSettings(s.lastSavedSettings) s.pathFeedback = "已恢复未保存前的设置。" }