fix: enable browser executable selection
This commit is contained in:
@@ -113,6 +113,8 @@ func runWindow(logger *slog.Logger) {
|
|||||||
}
|
}
|
||||||
directoryPicker := files.NewDirectoryPicker()
|
directoryPicker := files.NewDirectoryPicker()
|
||||||
shell.OnChooseDirectory(directoryPicker.ChooseDirectory, window.Invalidate)
|
shell.OnChooseDirectory(directoryPicker.ChooseDirectory, window.Invalidate)
|
||||||
|
executablePicker := files.NewExecutablePicker()
|
||||||
|
shell.OnChooseExecutable(executablePicker.ChooseExecutable, window.Invalidate)
|
||||||
discoverer := browser.NewDiscoverer()
|
discoverer := browser.NewDiscoverer()
|
||||||
launcher := browser.NewOSLauncher()
|
launcher := browser.NewOSLauncher()
|
||||||
cdp := browser.NewCDPInspector()
|
cdp := browser.NewCDPInspector()
|
||||||
|
|||||||
+1
-1
@@ -55,7 +55,7 @@
|
|||||||
| T-402 | Windows 通知区域入口与显式退出协调 | T-303,T-305,T-307 | TODO |
|
| T-402 | Windows 通知区域入口与显式退出协调 | T-303,T-305,T-307 | TODO |
|
||||||
| T-403 | 便携配置路径与安全 legacy 迁移 | T-202,T-309 | DONE |
|
| T-403 | 便携配置路径与安全 legacy 迁移 | T-202,T-309 | DONE |
|
||||||
| T-404 | 代理名称、独立默认目录与实例列表代理列 | T-304,T-306,T-309,T-403 | DONE |
|
| T-404 | 代理名称、独立默认目录与实例列表代理列 | T-304,T-306,T-309,T-403 | DONE |
|
||||||
| T-405 | 设置浏览器 executable 选择与可感知搜索反馈 | T-205,T-206,T-207 | DOING |
|
| T-405 | 设置浏览器 executable 选择与可感知搜索反馈 | T-205,T-206,T-207 | DONE |
|
||||||
|
|
||||||
## Backlog
|
## Backlog
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
## 快照
|
## 快照
|
||||||
|
|
||||||
- 日期:2026-07-27
|
- 日期:2026-07-27
|
||||||
- 阶段:Phase 4 CDP 只读观察与后台入口(T-301 至 T-310、T-201 至 T-208、T-401、T-403、T-404 已完成;T-402 已规划;T-405 进行中)
|
- 阶段:Phase 4 CDP 只读观察与后台入口(T-301 至 T-310、T-201 至 T-208、T-401、T-403 至 T-405 已完成;T-402 已规划)
|
||||||
- 代码:已建立 Go module `chub`、`cmd/chub` 入口、logging 测试基座、浏览器 domain/application 合约、Chrome/Edge 参数/发现模块、启动 registry、Windows 身份/占用检查、loopback CDP 端口分配与端点校验、受限 CDP 目标读取、exe 同级 JSON 配置与 legacy 安全迁移、优雅关闭、Job Object、真实 Chrome/Edge smoke、启动恢复、CLI JSON 合约、应用内事件总线、UI 状态测试和 Windows smoke 脚本,T-001 至 T-003、T-101 至 T-104、T-201 至 T-208、T-301 至 T-310、T-401、T-403 已完成
|
- 代码:已建立 Go module `chub`、`cmd/chub` 入口、logging 测试基座、浏览器 domain/application 合约、Chrome/Edge 参数/发现模块、启动 registry、Windows 身份/占用检查、loopback CDP 端口分配与端点校验、受限 CDP 目标读取、exe 同级 JSON 配置与 legacy 安全迁移、优雅关闭、Job Object、真实 Chrome/Edge smoke、启动恢复、CLI JSON 合约、应用内事件总线、UI 状态测试和 Windows smoke 脚本,T-001 至 T-003、T-101 至 T-104、T-201 至 T-208、T-301 至 T-310、T-401、T-403 至 T-405 已完成
|
||||||
- UI:Gio 双页 Shell 使用左侧“实例/设置”导航;实例页以等宽“新建实例 / 刷新实例状态”命令区开始,窄内容区自动堆叠。列表固定显示实例名称、浏览器类型、用户数据目录、代理名称、调试端口、状态和带边框的操作列;代理列只由稳定 `ProxyID` 解析当前名称(无代理时显示“无代理”),长名称省略且不挤压操作列。端口列在未启动/已退出时显示“建议 {端口}”、启动中显示“准备 {端口}”、托管运行显示“实际 {端口}”、外部关联显示“外部 {端口}”,避免将持久首选端口误作监听事实。新建表单从设置的起始端口推荐最低未冲突端口,并从实际 exe 同级 `user_data_dirs\\<实例 ID>` 预填一个独立、可编辑的 User Data Dir;ID 先分配,UI 不做目录 I/O,无法取得 exe 根目录时保留手动输入入口。编辑停止实例可修改首选端口;启动后台优先该端口并避开其他实例的首选/实际端口,成功回退后才保存新的首选值。操作顺序固定为启动/停止(或重新检测)、编辑、删除,删除与相邻操作保持更大间距以降低误点。主操作在已退出时启动,在 Chub 托管运行或调试不可用时显示停止,在启动/停止中禁用,在外部关联、外部占用或未知占用时仅重新检测;优雅停止通过后台 adapter 验证 registry 的 PID/profile 身份并等待其释放,绝不按进程名关闭或接管外部 Chrome/Edge。刷新通过后台回调只检查已保存实例,以配置快照丢弃编辑或删除后的过期结果;它结合 Chub registry、指定 profile 的外部占用证据和 loopback CDP 端点更新状态,但不扫描、接管或关闭其他 Chrome/Edge。双击、Enter 或编辑图标打开实例编辑弹层,支持名称、浏览器类型、User Data Dir、启动 URL、首选端口、以“名称 · 地址”显示的代理选择和只读实际端口;保存保留未公开启动选项,Escape 对脏表单先请求确认,活跃/外部关联实例锁定身份约束字段。设置页使用完整代理地址选择器、代理名称与地址输入及保存/删除操作;选择会回填名称和地址,保存按稳定 `proxyId` 新增或更新并校验名称/地址唯一性,删除未引用代理前必须确认,仍被实例引用的代理会被拒绝。启动通过异步回调接到 Windows 浏览器启动器:未占用目录从已保存的起始端口(空值默认 9666)选择 loopback CDP 端口,并在启动时按已选代理 ID 解析最新的 `--proxy-server` 参数;同目录外部浏览器只有在 `DevToolsActivePort` 与 CDP 端点可验证时才显示“外部已关联”,且不会接管其生命周期。删除使用确认弹层,只删除 Chub 实例配置而不删除 User Data Dir。新建、编辑和删除实例会保存到本地配置;四个设置路径有独立异步可取消搜索,切换页面后输入和任务状态保留;新建实例表单使用 Label、Windows 原生目录选择器和 Chrome/Edge RadioButton;CLI `list/events` 已可用,`start/stop/restart` 等待 BrowserManager adapter
|
- UI:Gio 双页 Shell 使用左侧“实例/设置”导航;实例页以等宽“新建实例 / 刷新实例状态”命令区开始,窄内容区自动堆叠。列表固定显示实例名称、浏览器类型、用户数据目录、代理名称、调试端口、状态和带边框的操作列;代理列只由稳定 `ProxyID` 解析当前名称(无代理时显示“无代理”),长名称省略且不挤压操作列。端口列在未启动/已退出时显示“建议 {端口}”、启动中显示“准备 {端口}”、托管运行显示“实际 {端口}”、外部关联显示“外部 {端口}”,避免将持久首选端口误作监听事实。新建表单从设置的起始端口推荐最低未冲突端口,并从实际 exe 同级 `user_data_dirs\\<实例 ID>` 预填一个独立、可编辑的 User Data Dir;ID 先分配,UI 不做目录 I/O,无法取得 exe 根目录时保留手动输入入口。编辑停止实例可修改首选端口;启动后台优先该端口并避开其他实例的首选/实际端口,成功回退后才保存新的首选值。操作顺序固定为启动/停止(或重新检测)、编辑、删除,删除与相邻操作保持更大间距以降低误点。主操作在已退出时启动,在 Chub 托管运行或调试不可用时显示停止,在启动/停止中禁用,在外部关联、外部占用或未知占用时仅重新检测;优雅停止通过后台 adapter 验证 registry 的 PID/profile 身份并等待其释放,绝不按进程名关闭或接管外部 Chrome/Edge。刷新通过后台回调只检查已保存实例,以配置快照丢弃编辑或删除后的过期结果;它结合 Chub registry、指定 profile 的外部占用证据和 loopback CDP 端点更新状态,但不扫描、接管或关闭其他 Chrome/Edge。双击、Enter 或编辑图标打开实例编辑弹层,支持名称、浏览器类型、User Data Dir、启动 URL、首选端口、以“名称 · 地址”显示的代理选择和只读实际端口;保存保留未公开启动选项,Escape 对脏表单先请求确认,活跃/外部关联实例锁定身份约束字段。设置页使用完整代理地址选择器、代理名称与地址输入及保存/删除操作;选择会回填名称和地址,保存按稳定 `proxyId` 新增或更新并校验名称/地址唯一性,删除未引用代理前必须确认,仍被实例引用的代理会被拒绝。启动通过异步回调接到 Windows 浏览器启动器:未占用目录从已保存的起始端口(空值默认 9666)选择 loopback CDP 端口,并在启动时按已选代理 ID 解析最新的 `--proxy-server` 参数;同目录外部浏览器只有在 `DevToolsActivePort` 与 CDP 端点可验证时才显示“外部已关联”,且不会接管其生命周期。删除使用确认弹层,只删除 Chub 实例配置而不删除 User Data Dir。新建、编辑和删除实例会保存到本地配置;四个设置路径有独立异步可取消搜索,切换页面后输入和任务状态保留;设置页 Chrome/Edge 路径已接入后台 Windows 原生 `.exe` 选择器,选择和搜索结果始终显示在对应字段旁;命中已填路径会明确确认而非静默完成。新建实例表单使用 Label、Windows 原生目录选择器和 Chrome/Edge RadioButton;CLI `list/events` 已可用,`start/stop/restart` 等待 BrowserManager adapter
|
||||||
- T-307:本应用会话启动的受管 Chrome/Edge 根进程由单实例后台 `Wait` 监控;意外退出按 ID、启动代次和 PID 验证后立即变为“已退出”,清空运行时 PID/端口,并以可关闭、可合并的提示告知用户。提示关闭后焦点回到启动操作;Chub 请求停止、外部实例、过期事件和应用关闭取消监控均不提示。
|
- T-307:本应用会话启动的受管 Chrome/Edge 根进程由单实例后台 `Wait` 监控;意外退出按 ID、启动代次和 PID 验证后立即变为“已退出”,清空运行时 PID/端口,并以可关闭、可合并的提示告知用户。提示关闭后焦点回到启动操作;Chub 请求停止、外部实例、过期事件和应用关闭取消监控均不提示。
|
||||||
- T-308:设置页以可滚动的外层工作区承载浏览器路径、默认目录、运行行为和代理分区;分区、字段、状态提示和保存操作区使用统一的浅色圆角描边。路径字段在紧凑内容区会将输入与“选择/搜索”操作纵向重排,切换页面后滚动、输入与异步搜索状态仍由持久 Gio widget 保留。
|
- T-308:设置页以可滚动的外层工作区承载浏览器路径、默认目录、运行行为和代理分区;分区、字段、状态提示和保存操作区使用统一的浅色圆角描边。路径字段在紧凑内容区会将输入与“选择/搜索”操作纵向重排,切换页面后滚动、输入与异步搜索状态仍由持久 Gio widget 保留。
|
||||||
- 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目
|
- 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目
|
||||||
@@ -15,8 +15,8 @@
|
|||||||
- T-402:已规划 Windows 通知区域图标、显示主窗口、刷新已保存实例状态和显式退出协调。首版不在托盘菜单中直接操作浏览器,不改变窗口关闭语义,也不实现关闭窗口后隐藏;Windows 原生 Shell adapter、Explorer 重启恢复、图标资源与退出清理将在实施阶段完成。
|
- T-402:已规划 Windows 通知区域图标、显示主窗口、刷新已保存实例状态和显式退出协调。首版不在托盘菜单中直接操作浏览器,不改变窗口关闭语义,也不实现关闭窗口后隐藏;Windows 原生 Shell adapter、Explorer 重启恢复、图标资源与退出清理将在实施阶段完成。
|
||||||
- T-403:默认配置已改为实际 `chub.exe` 同级 `config.json`,不受当前工作目录影响。目标缺失时会校验并原子复制旧 AppData 配置,旧文件保持恢复副本;目标优先且不会自动合并、切回 AppData 或迁移浏览器 User Data Dir。CLI 与 GUI 共用 `OpenDefault()`;配置不可用时 CLI 返回无敏感细节的稳定错误,GUI 显示恢复提示。
|
- T-403:默认配置已改为实际 `chub.exe` 同级 `config.json`,不受当前工作目录影响。目标缺失时会校验并原子复制旧 AppData 配置,旧文件保持恢复副本;目标优先且不会自动合并、切回 AppData 或迁移浏览器 User Data Dir。CLI 与 GUI 共用 `OpenDefault()`;配置不可用时 CLI 返回无敏感细节的稳定错误,GUI 显示恢复提示。
|
||||||
- T-404:已完成代理名称/地址同次显式保存、名称与端点唯一性校验、选择回填、列表代理名称列以及独立默认 profile 目录。新建实例先分配稳定 ID,再由实际 exe 目录生成 `user_data_dirs\\<实例 ID>`;代理名称按 ProxyID 推导而不复制到实例配置,UI 不进行文件 I/O。已通过单元测试、竞态检测、vet、无控制台 GUI 打包和 Windows smoke。
|
- T-404:已完成代理名称/地址同次显式保存、名称与端点唯一性校验、选择回填、列表代理名称列以及独立默认 profile 目录。新建实例先分配稳定 ID,再由实际 exe 目录生成 `user_data_dirs\\<实例 ID>`;代理名称按 ProxyID 推导而不复制到实例配置,UI 不进行文件 I/O。已通过单元测试、竞态检测、vet、无控制台 GUI 打包和 Windows smoke。
|
||||||
- T-405:进行中。已定位设置页 Chrome/Edge“选择”按钮只写提示、未调用文件选择器;标准路径搜索虽可返回默认已填路径,但成功反馈仅位于页面下方而不可感知。将接入后台 Windows `.exe` 文件选择器,并将选择/搜索的取消、成功、确认当前路径及失败反馈固定在相应字段旁。
|
- T-405:已完成 Windows `OpenFileDialog` executable picker、Chrome/Edge 后台结果通道与字段级状态反馈。搜索同路径时明确确认当前路径可用;选择取消或错误保留输入,选择成功只更新目标字段。已通过单元测试、竞态检测、vet、无控制台打包,以及真实原生对话框取消/预选 Chrome `.exe` 返回 smoke。
|
||||||
- blocker:无;当前任务为 T-405,随后恢复 T-402。
|
- blocker:无;下一任务为 T-402。
|
||||||
|
|
||||||
## 当前目录
|
## 当前目录
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -3,7 +3,7 @@ id: T-405
|
|||||||
title: 设置浏览器 executable 选择与可感知搜索反馈
|
title: 设置浏览器 executable 选择与可感知搜索反馈
|
||||||
phase: 4
|
phase: 4
|
||||||
deps: [T-205, T-206, T-207]
|
deps: [T-205, T-206, T-207]
|
||||||
status: DOING
|
status: DONE
|
||||||
created: 2026-07-27
|
created: 2026-07-27
|
||||||
owner: codex
|
owner: codex
|
||||||
---
|
---
|
||||||
@@ -27,7 +27,7 @@ owner: codex
|
|||||||
|
|
||||||
## 执行记录
|
## 执行记录
|
||||||
|
|
||||||
- 状态:DOING
|
- 状态:DONE
|
||||||
- 变更:已完成根因分析和需求、架构、API、用户故事、交互清单、原型验收说明的更新;待文档提交后实现。
|
- 变更:新增 Windows `OpenFileDialog` executable picker 与非 Windows 明确失败实现;`cmd/chub` 已注入 chooser。Gio shell 在后台选择 Chrome/Edge executable,以请求代次丢弃过期结果;选择成功只更新目标字段,取消/失败保留输入。四个路径搜索的反馈均移动至对应字段,Chrome/Edge 搜索命中当前输入时显示明确确认。
|
||||||
- 验证:待执行。
|
- 验证:`go test ./...`、`go test -race ./cmd/chub ./internal/ui ./internal/platform/files`、`go vet ./...`、`go build -o build/chub.exe ./cmd/chub` 和 `cmd /c build.bat` 均通过。确认打包产物 PE subsystem 为 Windows GUI(2);真实 STA `OpenFileDialog` smoke 已自动取消一次,并预填现有 `C:\Program Files\Google\Chrome\Application\chrome.exe` 后返回 `OK` 与该路径。
|
||||||
- 阻塞:无。
|
- 阻塞:无。
|
||||||
|
|||||||
@@ -7,3 +7,10 @@ import "context"
|
|||||||
type DirectoryPicker interface {
|
type DirectoryPicker interface {
|
||||||
ChooseDirectory(context.Context) (string, error)
|
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
@@ -65,6 +65,8 @@ type PathSearcher func(context.Context, PathField, string) (string, error)
|
|||||||
|
|
||||||
type DirectoryChooser func(context.Context) (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
|
// ProxyOption is a non-secret proxy item exposed to the UI. Server has already
|
||||||
// been validated as an unauthenticated scheme://host:port endpoint.
|
// been validated as an unauthenticated scheme://host:port endpoint.
|
||||||
type ProxyOption struct {
|
type ProxyOption struct {
|
||||||
@@ -210,6 +212,20 @@ type directoryPickResult struct {
|
|||||||
err error
|
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
|
type directoryPickTarget uint8
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -312,6 +328,7 @@ type Shell struct {
|
|||||||
editDiscardDrop widget.Clickable
|
editDiscardDrop widget.Clickable
|
||||||
editDiscardStay widget.Clickable
|
editDiscardStay widget.Clickable
|
||||||
pathFeedback string
|
pathFeedback string
|
||||||
|
pathFieldFeedback map[PathField]string
|
||||||
formFeedback string
|
formFeedback string
|
||||||
editFeedback string
|
editFeedback string
|
||||||
proxyServer widget.Editor
|
proxyServer widget.Editor
|
||||||
@@ -396,6 +413,9 @@ type Shell struct {
|
|||||||
invalidate func()
|
invalidate func()
|
||||||
searches map[PathField]*pathSearchState
|
searches map[PathField]*pathSearchState
|
||||||
searchResults chan pathSearchResult
|
searchResults chan pathSearchResult
|
||||||
|
executableChooser ExecutableChooser
|
||||||
|
executablePick executablePickState
|
||||||
|
executableResults chan executablePickResult
|
||||||
directoryChooser DirectoryChooser
|
directoryChooser DirectoryChooser
|
||||||
directoryPick directoryPickState
|
directoryPick directoryPickState
|
||||||
directoryResults chan directoryPickResult
|
directoryResults chan directoryPickResult
|
||||||
@@ -412,7 +432,7 @@ func NewShell(theme *material.Theme) *Shell {
|
|||||||
PathEdgeExecutable: {},
|
PathEdgeExecutable: {},
|
||||||
PathDefaultUserData: {},
|
PathDefaultUserData: {},
|
||||||
PathLogDirectory: {},
|
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.chromePath.SetText(`C:\Program Files\Google\Chrome\Application\chrome.exe`)
|
||||||
s.edgePath.SetText(`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`)
|
s.edgePath.SetText(`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`)
|
||||||
s.dataDir.SetText(`C:\Users\Public\chub\profiles`)
|
s.dataDir.SetText(`C:\Users\Public\chub\profiles`)
|
||||||
@@ -543,6 +563,11 @@ func (s *Shell) OnPathSearch(searcher PathSearcher, invalidate func()) {
|
|||||||
s.invalidate = invalidate
|
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()) {
|
func (s *Shell) OnChooseDirectory(chooser DirectoryChooser, invalidate func()) {
|
||||||
s.directoryChooser = chooser
|
s.directoryChooser = chooser
|
||||||
s.invalidate = invalidate
|
s.invalidate = invalidate
|
||||||
@@ -550,6 +575,7 @@ func (s *Shell) OnChooseDirectory(chooser DirectoryChooser, invalidate func()) {
|
|||||||
|
|
||||||
func (s *Shell) Layout(gtx layout.Context) layout.Dimensions {
|
func (s *Shell) Layout(gtx layout.Context) layout.Dimensions {
|
||||||
s.consumeSearchResults()
|
s.consumeSearchResults()
|
||||||
|
s.consumeExecutableResults()
|
||||||
s.consumeDirectoryResults()
|
s.consumeDirectoryResults()
|
||||||
s.consumeStartResults()
|
s.consumeStartResults()
|
||||||
s.consumeStopResults()
|
s.consumeStopResults()
|
||||||
@@ -1847,13 +1873,13 @@ func statusLabel(theme *material.Theme, status string) layout.Widget {
|
|||||||
|
|
||||||
func (s *Shell) settings(gtx layout.Context) layout.Dimensions {
|
func (s *Shell) settings(gtx layout.Context) layout.Dimensions {
|
||||||
for s.chromePick.Clicked(gtx) {
|
for s.chromePick.Clicked(gtx) {
|
||||||
s.pathFeedback = "请选择 Chrome 可执行文件"
|
s.chooseExecutable(PathChromeExecutable)
|
||||||
}
|
}
|
||||||
for s.chromeSearch.Clicked(gtx) {
|
for s.chromeSearch.Clicked(gtx) {
|
||||||
s.togglePathSearch(PathChromeExecutable)
|
s.togglePathSearch(PathChromeExecutable)
|
||||||
}
|
}
|
||||||
for s.edgePick.Clicked(gtx) {
|
for s.edgePick.Clicked(gtx) {
|
||||||
s.pathFeedback = "请选择 Edge 可执行文件"
|
s.chooseExecutable(PathEdgeExecutable)
|
||||||
}
|
}
|
||||||
for s.edgeSearch.Clicked(gtx) {
|
for s.edgeSearch.Clicked(gtx) {
|
||||||
s.togglePathSearch(PathEdgeExecutable)
|
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 {
|
return s.settingsField(func(gtx layout.Context) layout.Dimensions {
|
||||||
pathActions := func(gtx layout.Context) layout.Dimensions {
|
pathActions := func(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
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(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(material.Button(s.theme, search, s.searchButtonLabel(field)).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...)
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
||||||
})(gtx)
|
})(gtx)
|
||||||
}
|
}
|
||||||
@@ -2425,11 +2457,11 @@ func (s *Shell) togglePathSearch(field PathField) {
|
|||||||
state.cancel = nil
|
state.cancel = nil
|
||||||
state.running = false
|
state.running = false
|
||||||
state.request++
|
state.request++
|
||||||
s.pathFeedback = fmt.Sprintf("已取消%s搜索。", pathFieldLabel(field))
|
s.setPathFieldFeedback(field, fmt.Sprintf("已取消%s搜索。", pathFieldLabel(field)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if s.pathSearcher == nil {
|
if s.pathSearcher == nil {
|
||||||
s.pathFeedback = fmt.Sprintf("%s搜索服务尚未准备好。", pathFieldLabel(field))
|
s.setPathFieldFeedback(field, fmt.Sprintf("%s搜索服务尚未准备好。", pathFieldLabel(field)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
state.request++
|
state.request++
|
||||||
@@ -2438,7 +2470,7 @@ func (s *Shell) togglePathSearch(field PathField) {
|
|||||||
state.cancel = cancel
|
state.cancel = cancel
|
||||||
state.running = true
|
state.running = true
|
||||||
current := s.pathEditor(field).Text()
|
current := s.pathEditor(field).Text()
|
||||||
s.pathFeedback = fmt.Sprintf("正在搜索%s…", pathFieldLabel(field))
|
s.setPathFieldFeedback(field, fmt.Sprintf("正在搜索%s…", pathFieldLabel(field)))
|
||||||
go func() {
|
go func() {
|
||||||
path, err := s.pathSearcher(ctx, field, current)
|
path, err := s.pathSearcher(ctx, field, current)
|
||||||
s.searchResults <- pathSearchResult{field: field, request: request, path: path, err: err}
|
s.searchResults <- pathSearchResult{field: field, request: request, path: path, err: err}
|
||||||
@@ -2460,18 +2492,88 @@ func (s *Shell) consumeSearchResults() {
|
|||||||
state.cancel = nil
|
state.cancel = nil
|
||||||
if result.err != nil {
|
if result.err != nil {
|
||||||
if errors.Is(result.err, context.Canceled) {
|
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 {
|
} else {
|
||||||
s.pathFeedback = fmt.Sprintf("搜索%s失败:%v", pathFieldLabel(result.field), result.err)
|
s.setPathFieldFeedback(result.field, fmt.Sprintf("搜索%s失败,请重试或手动输入路径。", pathFieldLabel(result.field)))
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if result.path == "" {
|
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
|
continue
|
||||||
}
|
}
|
||||||
s.pathEditor(result.field).SetText(result.path)
|
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:
|
default:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -2485,6 +2587,20 @@ func (s *Shell) searchButtonLabel(field PathField) string {
|
|||||||
return "搜索"
|
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 {
|
func (s *Shell) pathEditor(field PathField) *widget.Editor {
|
||||||
switch field {
|
switch field {
|
||||||
case PathChromeExecutable:
|
case PathChromeExecutable:
|
||||||
|
|||||||
@@ -428,6 +428,81 @@ func TestShellAppliesLatestPathSearchResult(t *testing.T) {
|
|||||||
if got := shell.chromePath.Text(); got != `C:\Browser\chrome.exe` {
|
if got := shell.chromePath.Text(); got != `C:\Browser\chrome.exe` {
|
||||||
t.Fatalf("chrome path = %q", got)
|
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) {
|
func TestShellSettingsCanBeRestored(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user