fix: enable browser executable selection

This commit is contained in:
QiuSW
2026-07-27 22:46:31 +08:00
parent bc35bb0f3d
commit 03b23045a7
10 changed files with 342 additions and 21 deletions
@@ -7,3 +7,10 @@ import "context"
type DirectoryPicker interface {
ChooseDirectory(context.Context) (string, error)
}
// ExecutablePicker opens a platform file chooser for an existing browser
// executable. The implementation must block only in its own goroutine; UI
// callers receive the result asynchronously.
type ExecutablePicker interface {
ChooseExecutable(context.Context) (string, error)
}
@@ -0,0 +1,16 @@
//go:build !windows
package files
import (
"context"
"errors"
)
type unsupportedExecutablePicker struct{}
func NewExecutablePicker() ExecutablePicker { return unsupportedExecutablePicker{} }
func (unsupportedExecutablePicker) ChooseExecutable(context.Context) (string, error) {
return "", errors.New("executable picker is only supported on Windows")
}
@@ -0,0 +1,54 @@
//go:build windows
package files
import (
"context"
"fmt"
"path/filepath"
"strings"
)
type windowsExecutablePicker struct{ runner commandRunner }
func NewExecutablePicker() ExecutablePicker { return windowsExecutablePicker{runner: execRunner{}} }
func newExecutablePickerWithRunner(runner commandRunner) windowsExecutablePicker {
return windowsExecutablePicker{runner: runner}
}
func (p windowsExecutablePicker) ChooseExecutable(ctx context.Context) (string, error) {
if err := ctx.Err(); err != nil {
return "", err
}
output, err := p.runner.Run(ctx, "powershell.exe",
"-NoProfile", "-NonInteractive", "-STA", "-WindowStyle", "Hidden", "-Command", executableDialogScript)
if err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return "", ctxErr
}
return "", fmt.Errorf("open executable picker: %w", err)
}
selected := strings.TrimSpace(string(output))
if selected == "" {
return "", ErrSelectionCanceled
}
if !filepath.IsAbs(selected) {
return "", fmt.Errorf("executable picker returned a non-absolute path")
}
if !strings.EqualFold(filepath.Ext(selected), ".exe") {
return "", fmt.Errorf("executable picker returned a non-executable path")
}
return filepath.Clean(selected), nil
}
const executableDialogScript = `[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
Add-Type -AssemblyName System.Windows.Forms
$dialog = New-Object System.Windows.Forms.OpenFileDialog
$dialog.Title = '选择 Chrome 或 Edge 可执行文件'
$dialog.Filter = '浏览器可执行文件 (*.exe)|*.exe|所有文件 (*.*)|*.*'
$dialog.CheckFileExists = $true
$dialog.Multiselect = $false
if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
[Console]::Out.Write($dialog.FileName)
}`
@@ -0,0 +1,51 @@
//go:build windows
package files
import (
"context"
"errors"
"strings"
"testing"
)
func TestWindowsExecutablePickerReturnsCleanAbsoluteExecutable(t *testing.T) {
runner := &fakeRunner{out: []byte("C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe\r\n")}
picker := newExecutablePickerWithRunner(runner)
got, err := picker.ChooseExecutable(context.Background())
if err != nil {
t.Fatal(err)
}
if got != `C:\Program Files\Google\Chrome\Application\chrome.exe` {
t.Fatalf("path = %q", got)
}
command := strings.Join(runner.args, " ")
if runner.name != "powershell.exe" || !strings.Contains(command, "-STA") || !strings.Contains(command, "OpenFileDialog") {
t.Fatalf("unexpected picker command: %s %v", runner.name, runner.args)
}
}
func TestWindowsExecutablePickerRejectsInvalidSelection(t *testing.T) {
tests := []struct {
name string
out string
}{
{name: "relative", out: `chrome.exe`},
{name: "not executable", out: `C:\Browser\chrome.txt`},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
picker := newExecutablePickerWithRunner(&fakeRunner{out: []byte(test.out)})
if _, err := picker.ChooseExecutable(context.Background()); err == nil {
t.Fatal("invalid selection was accepted")
}
})
}
}
func TestWindowsExecutablePickerTreatsEmptySelectionAsCanceled(t *testing.T) {
picker := newExecutablePickerWithRunner(&fakeRunner{})
if _, err := picker.ChooseExecutable(context.Background()); !errors.Is(err, ErrSelectionCanceled) {
t.Fatalf("error = %v", err)
}
}
+127 -11
View File
@@ -65,6 +65,8 @@ 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 {
@@ -210,6 +212,20 @@ type directoryPickResult struct {
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 (
@@ -312,6 +328,7 @@ type Shell struct {
editDiscardDrop widget.Clickable
editDiscardStay widget.Clickable
pathFeedback string
pathFieldFeedback map[PathField]string
formFeedback string
editFeedback string
proxyServer widget.Editor
@@ -396,6 +413,9 @@ type Shell struct {
invalidate func()
searches map[PathField]*pathSearchState
searchResults chan pathSearchResult
executableChooser ExecutableChooser
executablePick executablePickState
executableResults chan executablePickResult
directoryChooser DirectoryChooser
directoryPick directoryPickState
directoryResults chan directoryPickResult
@@ -412,7 +432,7 @@ func NewShell(theme *material.Theme) *Shell {
PathEdgeExecutable: {},
PathDefaultUserData: {},
PathLogDirectory: {},
}, rowClicks: make(map[string]*widget.Clickable), startClicks: make(map[string]*widget.Clickable), editClicks: make(map[string]*widget.Clickable), deleteClicks: make(map[string]*widget.Clickable), proxyPickerChoices: make(map[string]*widget.Clickable), startStates: make(map[string]*instanceStartState), stopStates: make(map[string]*instanceStopState), pendingManagedExit: make(map[managedExitKey]ManagedInstanceExit), nextInstance: 4, startResults: make(chan instanceStartResult, 8), stopResults: make(chan instanceStopResult, 8), refreshResults: make(chan instanceRefreshResult, 1), tabsResults: make(chan instanceTabsResult, 1), searchResults: make(chan pathSearchResult, 8), directoryResults: make(chan directoryPickResult, 1)}
}, 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), 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`)
@@ -543,6 +563,11 @@ func (s *Shell) OnPathSearch(searcher PathSearcher, invalidate func()) {
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
@@ -550,6 +575,7 @@ func (s *Shell) OnChooseDirectory(chooser DirectoryChooser, invalidate func()) {
func (s *Shell) Layout(gtx layout.Context) layout.Dimensions {
s.consumeSearchResults()
s.consumeExecutableResults()
s.consumeDirectoryResults()
s.consumeStartResults()
s.consumeStopResults()
@@ -1847,13 +1873,13 @@ func statusLabel(theme *material.Theme, status string) layout.Widget {
func (s *Shell) settings(gtx layout.Context) layout.Dimensions {
for s.chromePick.Clicked(gtx) {
s.pathFeedback = "请选择 Chrome 可执行文件"
s.chooseExecutable(PathChromeExecutable)
}
for s.chromeSearch.Clicked(gtx) {
s.togglePathSearch(PathChromeExecutable)
}
for s.edgePick.Clicked(gtx) {
s.pathFeedback = "请选择 Edge 可执行文件"
s.chooseExecutable(PathEdgeExecutable)
}
for s.edgeSearch.Clicked(gtx) {
s.togglePathSearch(PathEdgeExecutable)
@@ -2044,7 +2070,7 @@ func (s *Shell) pathField(field PathField, label string, editor *widget.Editor,
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, "选择").Layout),
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),
)
@@ -2068,6 +2094,12 @@ func (s *Shell) pathField(field PathField, label string, editor *widget.Editor,
)
}))
}
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)
}
@@ -2425,11 +2457,11 @@ func (s *Shell) togglePathSearch(field PathField) {
state.cancel = nil
state.running = false
state.request++
s.pathFeedback = fmt.Sprintf("已取消%s搜索。", pathFieldLabel(field))
s.setPathFieldFeedback(field, fmt.Sprintf("已取消%s搜索。", pathFieldLabel(field)))
return
}
if s.pathSearcher == nil {
s.pathFeedback = fmt.Sprintf("%s搜索服务尚未准备好。", pathFieldLabel(field))
s.setPathFieldFeedback(field, fmt.Sprintf("%s搜索服务尚未准备好。", pathFieldLabel(field)))
return
}
state.request++
@@ -2438,7 +2470,7 @@ func (s *Shell) togglePathSearch(field PathField) {
state.cancel = cancel
state.running = true
current := s.pathEditor(field).Text()
s.pathFeedback = fmt.Sprintf("正在搜索%s…", pathFieldLabel(field))
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}
@@ -2460,18 +2492,88 @@ func (s *Shell) consumeSearchResults() {
state.cancel = nil
if result.err != nil {
if errors.Is(result.err, context.Canceled) {
s.pathFeedback = fmt.Sprintf("已取消%s搜索。", pathFieldLabel(result.field))
s.setPathFieldFeedback(result.field, fmt.Sprintf("已取消%s搜索。", pathFieldLabel(result.field)))
} else {
s.pathFeedback = fmt.Sprintf("搜索%s失败:%v", pathFieldLabel(result.field), result.err)
s.setPathFieldFeedback(result.field, fmt.Sprintf("搜索%s失败,请重试或手动输入路径。", pathFieldLabel(result.field)))
}
continue
}
if result.path == "" {
s.pathFeedback = fmt.Sprintf("未找到%s。", pathFieldLabel(result.field))
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.pathFeedback = fmt.Sprintf("已找到%s。", pathFieldLabel(result.field))
s.setPathFieldFeedback(result.field, fmt.Sprintf("已选择%s。", pathFieldLabel(result.field)))
default:
return
}
@@ -2485,6 +2587,20 @@ func (s *Shell) searchButtonLabel(field PathField) string {
return "搜索"
}
func (s *Shell) pathPickButtonLabel(field PathField) string {
if s.executablePick.running && s.executablePick.field == field {
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:
+75
View File
@@ -428,6 +428,81 @@ func TestShellAppliesLatestPathSearchResult(t *testing.T) {
if got := shell.chromePath.Text(); got != `C:\Browser\chrome.exe` {
t.Fatalf("chrome path = %q", got)
}
if got := shell.pathFieldFeedback[PathChromeExecutable]; !strings.Contains(got, "已找到") {
t.Fatalf("chrome path feedback = %q", got)
}
}
func TestShellConfirmsMatchingPathSearchResultNearItsField(t *testing.T) {
shell := NewShell(material.NewTheme())
shell.chromePath.SetText(`C:\Browser\chrome.exe`)
shell.OnPathSearch(func(context.Context, PathField, string) (string, error) {
return `C:\Browser\chrome.exe`, nil
}, nil)
shell.togglePathSearch(PathChromeExecutable)
var result pathSearchResult
select {
case result = <-shell.searchResults:
case <-time.After(time.Second):
t.Fatal("search did not produce a result")
}
shell.searchResults <- result
shell.consumeSearchResults()
if got := shell.pathFieldFeedback[PathChromeExecutable]; !strings.Contains(got, "已确认") || !strings.Contains(got, "当前路径可用") {
t.Fatalf("chrome path feedback = %q", got)
}
}
func TestShellAppliesSelectedExecutableToOnlyRequestedField(t *testing.T) {
shell := NewShell(material.NewTheme())
oldEdge := shell.edgePath.Text()
shell.OnChooseExecutable(func(context.Context) (string, error) {
return `C:\Browser\chrome.exe`, nil
}, nil)
shell.chooseExecutable(PathChromeExecutable)
if got := shell.pathPickButtonLabel(PathChromeExecutable); got != "选择中…" {
t.Fatalf("chrome picker label = %q", got)
}
var result executablePickResult
select {
case result = <-shell.executableResults:
case <-time.After(time.Second):
t.Fatal("executable picker did not produce a result")
}
shell.executableResults <- result
shell.consumeExecutableResults()
if got := shell.chromePath.Text(); got != `C:\Browser\chrome.exe` {
t.Fatalf("chrome path = %q", got)
}
if got := shell.edgePath.Text(); got != oldEdge {
t.Fatalf("edge path was unexpectedly changed to %q", got)
}
if got := shell.pathFieldFeedback[PathChromeExecutable]; !strings.Contains(got, "已选择") {
t.Fatalf("chrome picker feedback = %q", got)
}
}
func TestShellKeepsExecutableInputWhenSelectionIsCanceled(t *testing.T) {
shell := NewShell(material.NewTheme())
shell.chromePath.SetText(`C:\Browser\keep.exe`)
shell.OnChooseExecutable(func(context.Context) (string, error) {
return "", context.Canceled
}, nil)
shell.chooseExecutable(PathChromeExecutable)
var result executablePickResult
select {
case result = <-shell.executableResults:
case <-time.After(time.Second):
t.Fatal("executable picker did not produce a result")
}
shell.executableResults <- result
shell.consumeExecutableResults()
if got := shell.chromePath.Text(); got != `C:\Browser\keep.exe` {
t.Fatalf("canceled picker changed chrome path to %q", got)
}
if got := shell.pathFieldFeedback[PathChromeExecutable]; !strings.Contains(got, "已取消") {
t.Fatalf("chrome picker feedback = %q", got)
}
}
func TestShellSettingsCanBeRestored(t *testing.T) {