feat: add portable directory safeguards

This commit is contained in:
QiuSW
2026-07-28 00:12:29 +08:00
parent 92091a6690
commit 733954ddc8
15 changed files with 914 additions and 95 deletions
+32 -7
View File
@@ -30,7 +30,7 @@ const version = "0.1.0-dev"
var ( var (
openDefaultConfig = config.OpenDefault openDefaultConfig = config.OpenDefault
defaultInstanceUserDataRoot = config.DefaultInstanceUserDataRoot defaultPortableDirectories = config.DefaultPortableDirectories
) )
func main() { func main() {
@@ -105,11 +105,11 @@ func runWindow(logger *slog.Logger) {
window.Option(app.Title("Chub"), app.Size(unit.Dp(1100), unit.Dp(720))) window.Option(app.Title("Chub"), app.Size(unit.Dp(1100), unit.Dp(720)))
theme := material.NewTheme() theme := material.NewTheme()
shell := ui.NewShell(theme) shell := ui.NewShell(theme)
if root, err := defaultInstanceUserDataRoot(); err != nil { if directories, err := defaultPortableDirectories(); err != nil {
logger.Error("resolve default instance user data root", "error", err) logger.Error("resolve portable directories", "error", err)
shell.ReportStartupIssue("无法确定程序目录中的默认 User Data Dir;新建实例时请选择其他绝对路径。") shell.ReportStartupIssue("无法确定程序目录中的默认 User Data Dir 和日志目录;请检查程序位置后重试。")
} else { } else {
shell.SetDefaultInstanceUserDataRoot(root) shell.SetPortableDirectoryDefaults(directories.DefaultDir, directories.LogDir)
} }
directoryPicker := files.NewDirectoryPicker() directoryPicker := files.NewDirectoryPicker()
shell.OnChooseDirectory(directoryPicker.ChooseDirectory, window.Invalidate) shell.OnChooseDirectory(directoryPicker.ChooseDirectory, window.Invalidate)
@@ -131,6 +131,7 @@ func runWindow(logger *slog.Logger) {
portAllocator: cdp, portAllocator: cdp,
proxyResolver: proxies, proxyResolver: proxies,
monitor: exitMonitor, monitor: exitMonitor,
dataPreparer: files.NewInstanceDataPreparer(),
}.Start, window.Invalidate) }.Start, window.Invalidate)
shell.OnStopInstance(instanceStopper{launcher: launcher, managedProfiles: launcher, monitor: exitMonitor}.Stop, window.Invalidate) shell.OnStopInstance(instanceStopper{launcher: launcher, managedProfiles: launcher, monitor: exitMonitor}.Stop, window.Invalidate)
dataRemover := files.NewInstanceDataRemover() dataRemover := files.NewInstanceDataRemover()
@@ -179,7 +180,24 @@ func runWindow(logger *slog.Logger) {
logger.Error("load default configuration", "error", err) logger.Error("load default configuration", "error", err)
shell.ReportStartupIssue("程序目录中的 config.json 无法读取;请恢复有效配置后重试。") shell.ReportStartupIssue("程序目录中的 config.json 无法读取;请恢复有效配置后重试。")
} else { } else {
shell.SetSettings(ui.SettingsState{ChromePath: saved.Settings.ChromePath, EdgePath: saved.Settings.EdgePath, DefaultDir: saved.Settings.DefaultDir, LogDir: saved.Settings.LogDir, RemoteDebugStartPort: saved.Settings.RemoteDebugStartPort, CloseOnExit: saved.Settings.CloseOnExit}) logPreparer := files.NewDirectoryPreparer()
if err := logPreparer.PrepareDirectory(context.Background(), saved.Settings.LogDir); err != nil {
logger.Error("prepare log directory", "error", err)
shell.ReportStartupIssue("日志目录不可写;请在设置中选择可写目录后重试。")
} else if fileLogger, closer, logErr := logging.OpenFile(saved.Settings.LogDir); logErr != nil {
logger.Error("open diagnostic log", "error", logErr)
shell.ReportStartupIssue("日志目录不可写;请在设置中选择可写目录后重试。")
} else {
logger = fileLogger
defer closer.Close()
logger.Info("diagnostic log opened", "version", version)
}
if directories, directoriesErr := store.PortableDirectories(); directoriesErr != nil {
logger.Error("resolve portable settings directories", "error", directoriesErr)
} else {
shell.SetPortableDirectoryDefaults(directories.DefaultDir, directories.LogDir)
}
shell.SetSettings(ui.SettingsState{ChromePath: saved.Settings.ChromePath, EdgePath: saved.Settings.EdgePath, DefaultDir: saved.Settings.DefaultDir, DefaultDirMode: string(saved.Settings.DefaultDirSource), LogDir: saved.Settings.LogDir, LogDirMode: string(saved.Settings.LogDirSource), RemoteDebugStartPort: saved.Settings.RemoteDebugStartPort, CloseOnExit: saved.Settings.CloseOnExit})
proxies.Set(saved.Proxies) proxies.Set(saved.Proxies)
shell.SetProxies(proxyOptions(saved.Proxies)) shell.SetProxies(proxyOptions(saved.Proxies))
rows := make([]ui.InstanceRow, 0, len(saved.Instances)) rows := make([]ui.InstanceRow, 0, len(saved.Instances))
@@ -188,7 +206,7 @@ func runWindow(logger *slog.Logger) {
} }
shell.SetInstances(rows) shell.SetInstances(rows)
shell.OnSave(func(value ui.SettingsState) { shell.OnSave(func(value ui.SettingsState) {
saved.Settings = config.Settings{ChromePath: value.ChromePath, EdgePath: value.EdgePath, DefaultDir: value.DefaultDir, LogDir: value.LogDir, RemoteDebugStartPort: value.RemoteDebugStartPort, CloseOnExit: value.CloseOnExit} saved.Settings = config.Settings{ChromePath: value.ChromePath, EdgePath: value.EdgePath, DefaultDir: value.DefaultDir, DefaultDirSource: config.DirectorySource(value.DefaultDirMode), LogDir: value.LogDir, LogDirSource: config.DirectorySource(value.LogDirMode), RemoteDebugStartPort: value.RemoteDebugStartPort, CloseOnExit: value.CloseOnExit}
go saveSettings(logger, store, configSnapshot(saved)) go saveSettings(logger, store, configSnapshot(saved))
}) })
shell.OnInstancesChanged(func(rows []ui.InstanceRow) { shell.OnInstancesChanged(func(rows []ui.InstanceRow) {
@@ -372,6 +390,7 @@ type instanceStarter struct {
portAllocator browser.RemoteDebugPortAllocator portAllocator browser.RemoteDebugPortAllocator
proxyResolver proxyServerResolver proxyResolver proxyServerResolver
monitor managedExitObserver monitor managedExitObserver
dataPreparer files.InstanceDataPreparer
} }
type instanceStatusRefresher struct { type instanceStatusRefresher struct {
@@ -561,6 +580,12 @@ func (s instanceStarter) Start(ctx context.Context, row ui.InstanceRow, settings
return ui.InstanceStartOutcome{PID: use.PID, Source: use.Source}, &browser.ProfileOccupiedError{UserDataDir: row.UserDataDir, PID: use.PID} return ui.InstanceStartOutcome{PID: use.PID, Source: use.Source}, &browser.ProfileOccupiedError{UserDataDir: row.UserDataDir, PID: use.PID}
} }
} }
if s.dataPreparer == nil {
return ui.InstanceStartOutcome{}, errors.New("User Data Dir 预检服务尚未准备好")
}
if err := s.dataPreparer.PrepareInstanceDataDir(ctx, row.UserDataDir); err != nil {
return ui.InstanceStartOutcome{}, fmt.Errorf("User Data Dir 不可写,请选择其他目录:%w", err)
}
proxyServer := "" proxyServer := ""
if row.ProxyID != "" { if row.ProxyID != "" {
if s.proxyResolver == nil { if s.proxyResolver == nil {
+43 -3
View File
@@ -54,7 +54,7 @@ func TestInstanceStarterBuildsLaunchSpecFromInstanceAndSettings(t *testing.T) {
launcher := &fakeProcessLauncher{handle: fakeProcessHandle{pid: 4242}} launcher := &fakeProcessLauncher{handle: fakeProcessHandle{pid: 4242}}
resolver := &fakeExecutableResolver{path: `C:\Browser\msedge.exe`} resolver := &fakeExecutableResolver{path: `C:\Browser\msedge.exe`}
remote := &fakeRemoteDebugInspector{endpoint: browser.RemoteDebugEndpoint{Port: 9666}, inspectErr: browser.ErrRemoteDebugEndpointUnavailable} remote := &fakeRemoteDebugInspector{endpoint: browser.RemoteDebugEndpoint{Port: 9666}, inspectErr: browser.ErrRemoteDebugEndpointUnavailable}
starter := instanceStarter{launcher: launcher, resolver: resolver, managedProfiles: fakeManagedProfileInspector{}, externalProfiles: fakeExternalProfileInspector{}, remoteDebug: remote, portAllocator: remote} starter := instanceStarter{launcher: launcher, resolver: resolver, managedProfiles: fakeManagedProfileInspector{}, externalProfiles: fakeExternalProfileInspector{}, remoteDebug: remote, portAllocator: remote, dataPreparer: &fakeInstanceDataPreparer{}}
outcome, err := starter.Start(context.Background(), ui.InstanceRow{ID: "edge-a", Browser: "Edge", UserDataDir: `C:\profiles\edge-a`}, ui.SettingsState{EdgePath: `C:\Configured\msedge.exe`, RemoteDebugStartPort: 9666}, 1) outcome, err := starter.Start(context.Background(), ui.InstanceRow{ID: "edge-a", Browser: "Edge", UserDataDir: `C:\profiles\edge-a`}, ui.SettingsState{EdgePath: `C:\Configured\msedge.exe`, RemoteDebugStartPort: 9666}, 1)
if err != nil || outcome.PID != 4242 || outcome.RemoteDebugPort != 9666 { if err != nil || outcome.PID != 4242 || outcome.RemoteDebugPort != 9666 {
t.Fatalf("Start() = %#v, %v", outcome, err) t.Fatalf("Start() = %#v, %v", outcome, err)
@@ -67,6 +67,28 @@ func TestInstanceStarterBuildsLaunchSpecFromInstanceAndSettings(t *testing.T) {
} }
} }
func TestInstanceStarterRejectsUnwritableDataDirectoryBeforeLaunch(t *testing.T) {
launcher := &fakeProcessLauncher{handle: fakeProcessHandle{pid: 4242}}
preparer := &fakeInstanceDataPreparer{err: files.ErrInstanceDataDirectoryUnavailable}
remote := &fakeRemoteDebugInspector{endpoint: browser.RemoteDebugEndpoint{Port: 9666}, inspectErr: browser.ErrRemoteDebugEndpointUnavailable}
starter := instanceStarter{
launcher: launcher,
resolver: &fakeExecutableResolver{path: `C:\Browser\chrome.exe`},
managedProfiles: fakeManagedProfileInspector{},
externalProfiles: fakeExternalProfileInspector{},
remoteDebug: remote,
portAllocator: remote,
dataPreparer: preparer,
}
err := func() error {
_, err := starter.Start(context.Background(), ui.InstanceRow{ID: "chrome-a", Browser: "Chrome", UserDataDir: `C:\profiles\chrome-a`}, ui.SettingsState{RemoteDebugStartPort: 9666}, 1)
return err
}()
if !errors.Is(err, files.ErrInstanceDataDirectoryUnavailable) || launcher.calls != 0 || preparer.calls != 1 {
t.Fatalf("Start() error = %v, launcher calls = %d, preparer calls = %d", err, launcher.calls, preparer.calls)
}
}
func TestInstanceStarterUsesPreferredPortAndSkipsOtherInstancesReservations(t *testing.T) { func TestInstanceStarterUsesPreferredPortAndSkipsOtherInstancesReservations(t *testing.T) {
launcher := &fakeProcessLauncher{handle: fakeProcessHandle{pid: 4242}} launcher := &fakeProcessLauncher{handle: fakeProcessHandle{pid: 4242}}
remote := &fakeRemoteDebugInspector{ remote := &fakeRemoteDebugInspector{
@@ -81,6 +103,7 @@ func TestInstanceStarterUsesPreferredPortAndSkipsOtherInstancesReservations(t *t
externalProfiles: fakeExternalProfileInspector{}, externalProfiles: fakeExternalProfileInspector{},
remoteDebug: remote, remoteDebug: remote,
portAllocator: remote, portAllocator: remote,
dataPreparer: &fakeInstanceDataPreparer{},
} }
outcome, err := starter.Start(context.Background(), ui.InstanceRow{ID: "chrome-a", Browser: "Chrome", UserDataDir: `C:\profiles\chrome-a`, PreferredRemoteDebugPort: 9666}, ui.SettingsState{RemoteDebugStartPort: 9777, ReservedRemoteDebugPorts: []int{9667}}, 1) outcome, err := starter.Start(context.Background(), ui.InstanceRow{ID: "chrome-a", Browser: "Chrome", UserDataDir: `C:\profiles\chrome-a`, PreferredRemoteDebugPort: 9666}, ui.SettingsState{RemoteDebugStartPort: 9777, ReservedRemoteDebugPorts: []int{9667}}, 1)
if err != nil || outcome.RemoteDebugPort != 9668 || launcher.spec.RemoteDebugPort != 9668 { if err != nil || outcome.RemoteDebugPort != 9668 || launcher.spec.RemoteDebugPort != 9668 {
@@ -97,7 +120,7 @@ func TestInstanceStarterResolvesSelectedProxyAtLaunch(t *testing.T) {
remote := &fakeRemoteDebugInspector{endpoint: browser.RemoteDebugEndpoint{Port: 9666}, inspectErr: browser.ErrRemoteDebugEndpointUnavailable} remote := &fakeRemoteDebugInspector{endpoint: browser.RemoteDebugEndpoint{Port: 9666}, inspectErr: browser.ErrRemoteDebugEndpointUnavailable}
proxies := newProxyDirectory() proxies := newProxyDirectory()
proxies.Set([]config.ProxyProfile{{ID: "proxy-sg", Name: "SG", Server: "http://127.0.0.1:8080"}}) proxies.Set([]config.ProxyProfile{{ID: "proxy-sg", Name: "SG", Server: "http://127.0.0.1:8080"}})
starter := instanceStarter{launcher: launcher, resolver: resolver, managedProfiles: fakeManagedProfileInspector{}, externalProfiles: fakeExternalProfileInspector{}, remoteDebug: remote, portAllocator: remote, proxyResolver: proxies} starter := instanceStarter{launcher: launcher, resolver: resolver, managedProfiles: fakeManagedProfileInspector{}, externalProfiles: fakeExternalProfileInspector{}, remoteDebug: remote, portAllocator: remote, proxyResolver: proxies, dataPreparer: &fakeInstanceDataPreparer{}}
_, err := starter.Start(context.Background(), ui.InstanceRow{ID: "chrome-a", Browser: "Chrome", UserDataDir: `C:\profiles\chrome-a`, ProxyID: "proxy-sg"}, ui.SettingsState{RemoteDebugStartPort: 9666}, 1) _, err := starter.Start(context.Background(), ui.InstanceRow{ID: "chrome-a", Browser: "Chrome", UserDataDir: `C:\profiles\chrome-a`, ProxyID: "proxy-sg"}, ui.SettingsState{RemoteDebugStartPort: 9666}, 1)
if err != nil || launcher.spec.ProxyServer != "http://127.0.0.1:8080" { if err != nil || launcher.spec.ProxyServer != "http://127.0.0.1:8080" {
t.Fatalf("proxy launch spec = %#v, error = %v", launcher.spec, err) t.Fatalf("proxy launch spec = %#v, error = %v", launcher.spec, err)
@@ -112,7 +135,7 @@ func TestInstanceStarterRegistersOnlyLaunchedProcessWithExitMonitor(t *testing.T
launcher := &fakeProcessLauncher{handle: fakeProcessHandle{pid: 4242}} launcher := &fakeProcessLauncher{handle: fakeProcessHandle{pid: 4242}}
remote := &fakeRemoteDebugInspector{endpoint: browser.RemoteDebugEndpoint{Port: 9666}, inspectErr: browser.ErrRemoteDebugEndpointUnavailable} remote := &fakeRemoteDebugInspector{endpoint: browser.RemoteDebugEndpoint{Port: 9666}, inspectErr: browser.ErrRemoteDebugEndpointUnavailable}
monitor := &fakeManagedExitObserver{} monitor := &fakeManagedExitObserver{}
starter := instanceStarter{launcher: launcher, resolver: &fakeExecutableResolver{path: `C:\Browser\chrome.exe`}, managedProfiles: fakeManagedProfileInspector{}, externalProfiles: fakeExternalProfileInspector{}, remoteDebug: remote, portAllocator: remote, monitor: monitor} starter := instanceStarter{launcher: launcher, resolver: &fakeExecutableResolver{path: `C:\Browser\chrome.exe`}, managedProfiles: fakeManagedProfileInspector{}, externalProfiles: fakeExternalProfileInspector{}, remoteDebug: remote, portAllocator: remote, monitor: monitor, dataPreparer: &fakeInstanceDataPreparer{}}
_, err := starter.Start(context.Background(), ui.InstanceRow{ID: "chrome-a", Browser: "Chrome", UserDataDir: `C:\profiles\chrome-a`}, ui.SettingsState{RemoteDebugStartPort: 9666}, 7) _, err := starter.Start(context.Background(), ui.InstanceRow{ID: "chrome-a", Browser: "Chrome", UserDataDir: `C:\profiles\chrome-a`}, ui.SettingsState{RemoteDebugStartPort: 9666}, 7)
if err != nil || monitor.watchID != "chrome-a" || monitor.watchGeneration != 7 || monitor.watchPID != 4242 { if err != nil || monitor.watchID != "chrome-a" || monitor.watchGeneration != 7 || monitor.watchPID != 4242 {
t.Fatalf("monitor watch = %#v, error = %v", monitor, err) t.Fatalf("monitor watch = %#v, error = %v", monitor, err)
@@ -190,6 +213,7 @@ func TestInstanceStarterAssociatesVerifiedExternalProfileWithoutLaunching(t *tes
externalProfiles: fakeExternalProfileInspector{use: browser.ProfileUse{Occupied: true, PID: 16108, Source: browser.ProfileSourceMessageWindow}}, externalProfiles: fakeExternalProfileInspector{use: browser.ProfileUse{Occupied: true, PID: 16108, Source: browser.ProfileSourceMessageWindow}},
remoteDebug: remote, remoteDebug: remote,
portAllocator: remote, portAllocator: remote,
dataPreparer: &fakeInstanceDataPreparer{},
} }
outcome, err := starter.Start(context.Background(), ui.InstanceRow{ID: "chrome-a", Browser: "Chrome", UserDataDir: `C:\profiles\chrome-a`}, ui.SettingsState{RemoteDebugStartPort: 9666}, 1) outcome, err := starter.Start(context.Background(), ui.InstanceRow{ID: "chrome-a", Browser: "Chrome", UserDataDir: `C:\profiles\chrome-a`}, ui.SettingsState{RemoteDebugStartPort: 9666}, 1)
if err != nil || !outcome.External || outcome.PID != 16108 || outcome.RemoteDebugPort != 9668 || outcome.Source != browser.ProfileSourceMessageWindow { if err != nil || !outcome.External || outcome.PID != 16108 || outcome.RemoteDebugPort != 9668 || outcome.Source != browser.ProfileSourceMessageWindow {
@@ -210,6 +234,7 @@ func TestInstanceStarterRejectsUnverifiedExternalProfile(t *testing.T) {
externalProfiles: fakeExternalProfileInspector{use: browser.ProfileUse{Occupied: true, PID: 16108, Source: browser.ProfileSourceLock}}, externalProfiles: fakeExternalProfileInspector{use: browser.ProfileUse{Occupied: true, PID: 16108, Source: browser.ProfileSourceLock}},
remoteDebug: remote, remoteDebug: remote,
portAllocator: remote, portAllocator: remote,
dataPreparer: &fakeInstanceDataPreparer{},
} }
_, err := starter.Start(context.Background(), ui.InstanceRow{ID: "chrome-a", Browser: "Chrome", UserDataDir: `C:\profiles\chrome-a`}, ui.SettingsState{RemoteDebugStartPort: 9666}, 1) _, err := starter.Start(context.Background(), ui.InstanceRow{ID: "chrome-a", Browser: "Chrome", UserDataDir: `C:\profiles\chrome-a`}, ui.SettingsState{RemoteDebugStartPort: 9666}, 1)
if !errors.Is(err, domain.ErrProfileOccupied) { if !errors.Is(err, domain.ErrProfileOccupied) {
@@ -482,6 +507,21 @@ func (r *fakeInstanceDataRemover) RemoveInstanceData(ctx context.Context, userDa
return r.err return r.err
} }
type fakeInstanceDataPreparer struct {
calls int
userDataDir string
err error
}
func (p *fakeInstanceDataPreparer) PrepareInstanceDataDir(ctx context.Context, userDataDir string) error {
if err := ctx.Err(); err != nil {
return err
}
p.calls++
p.userDataDir = userDataDir
return p.err
}
type fakeManagedStopper struct { type fakeManagedStopper struct {
use browser.ProfileUse use browser.ProfileUse
stopCalls int stopCalls int
+1 -1
View File
@@ -57,7 +57,7 @@
| 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 | DONE | | T-405 | 设置浏览器 executable 选择与可感知搜索反馈 | T-205,T-206,T-207 | DONE |
| T-406 | 实例删除确认中的 User Data Dir 显式删除 | T-301,T-303,T-305 | DONE | | T-406 | 实例删除确认中的 User Data Dir 显式删除 | T-301,T-303,T-305 | DONE |
| T-407 | 便携默认目录、实例名称唯一与启动目录预检 | T-403,T-404,T-405 | DOING | | T-407 | 便携默认目录、实例名称唯一与启动目录预检 | T-403,T-404,T-405 | DONE |
## Backlog ## Backlog
+5 -5
View File
@@ -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-406 已完成;T-402 已规划;T-407 进行中) - 阶段:Phase 4 CDP 只读观察与后台入口(T-301 至 T-310、T-201 至 T-208、T-401、T-403 至 T-407 已完成;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 至 T-406 已完成 - 代码:已建立 Go module `chub`、`cmd/chub` 入口、结构化文件日志、浏览器 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-407 已完成
- 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,后台会复核 Chub registry、外部占用与安全目录边界,成功删除数据后才移除配置;失败或过期结果保留配置和目录。新建、编辑和删除实例会保存到本地配置;四个设置路径有独立异步可取消搜索,切换页面后输入和任务状态保留;设置页 Chrome/Edge 路径已接入后台 Windows 原生 `.exe` 选择器,选择和搜索结果始终显示在对应字段旁;命中已填路径会明确确认而非静默完成。新建实例表单使用 Label、Windows 原生目录选择器和 Chrome/Edge RadioButton;CLI `list/events` 已可用,`start/stop/restart` 等待 BrowserManager adapter - UI:Gio 双页 Shell 使用左侧“实例/设置”导航;实例页以等宽“新建实例 / 刷新实例状态”命令区开始,窄内容区自动堆叠。列表固定显示实例名称、浏览器类型、用户数据目录、代理名称、调试端口、状态和带边框的操作列;代理列只由稳定 `ProxyID` 解析当前名称(无代理时显示“无代理”),长名称省略且不挤压操作列。端口列在未启动/已退出时显示“建议 {端口}”、启动中显示“准备 {端口}”、托管运行显示“实际 {端口}”、外部关联显示“外部 {端口}”,避免将持久首选端口误作监听事实。新建表单从设置的起始端口推荐最低未冲突端口,并基于设置的默认目录预填 `instance-<安全实例名>-<短 ID>`;portable 默认目录始终跟随实际 exe 同级 `user_data_dirs`,自定义目录只影响后续新建实例。实例名称会在保存前按 Windows 规则校验,并以大小写无关方式保持唯一;改名不会移动已有 profile。编辑停止实例可修改首选端口;启动后台会先创建并验证指定 User Data Dir 可写,再优先该端口并避开其他实例的首选/实际端口,成功回退后才保存新的首选值。操作顺序固定为启动/停止(或重新检测)、编辑、删除,删除与相邻操作保持更大间距以降低误点。主操作在已退出时启动,在 Chub 托管运行或调试不可用时显示停止,在启动/停止中禁用,在外部关联、外部占用或未知占用时仅重新检测;优雅停止通过后台 adapter 验证 registry 的 PID/profile 身份并等待其释放,绝不按进程名关闭或接管外部 Chrome/Edge。刷新通过后台回调只检查已保存实例,以配置快照丢弃编辑或删除后的过期结果;它结合 Chub registry、指定 profile 的外部占用证据和 loopback CDP 端点更新状态,但不扫描、接管或关闭其他 Chrome/Edge。双击、Enter 或编辑图标打开实例编辑弹层,支持名称、浏览器类型、User Data Dir、启动 URL、首选端口、以“名称 · 地址”显示的代理选择和只读实际端口;保存保留未公开启动选项,Escape 对脏表单先请求确认,活跃/外部关联实例锁定身份约束字段。设置页使用完整代理地址选择器、代理名称与地址输入及保存/删除操作;选择会回填名称和地址,保存按稳定 `proxyId` 新增或更新并校验名称/地址唯一性,删除未引用代理前必须确认,仍被实例引用的代理会被拒绝。设置页的默认 User Data Dir 与日志目录显示 portable/custom 来源,可通过原生选择器改为自定义路径或恢复为程序目录默认;日志目录在后台探测成功后写入 `chub.log`,不会回退 cwd。新建、编辑和删除实例会保存到本地配置;四个设置路径有独立异步可取消搜索,切换页面后输入和任务状态保留;设置页 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`,尚未复制或接入本项目
@@ -17,8 +17,8 @@
- 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:已完成 Windows `OpenFileDialog` executable picker、Chrome/Edge 后台结果通道与字段级状态反馈。搜索同路径时明确确认当前路径可用;选择取消或错误保留输入,选择成功只更新目标字段。已通过单元测试、竞态检测、vet、无控制台打包,以及真实原生对话框取消/预选 Chrome `.exe` 返回 smoke。 - T-405:已完成 Windows `OpenFileDialog` executable picker、Chrome/Edge 后台结果通道与字段级状态反馈。搜索同路径时明确确认当前路径可用;选择取消或错误保留输入,选择成功只更新目标字段。已通过单元测试、竞态检测、vet、无控制台打包,以及真实原生对话框取消/预选 Chrome `.exe` 返回 smoke。
- T-406:已完成实例删除确认中的 User Data Dir 显式删除。默认仅删除配置;数据删除必须显式勾选且只对已退出/启动失败实例开放。后台在删除前复核 Chub/external profile 占用,目录删除拒绝不安全路径、卷根、文件、链接和 Windows 重解析点;失败与过期结果均保留配置和目录。 - T-406:已完成实例删除确认中的 User Data Dir 显式删除。默认仅删除配置;数据删除必须显式勾选且只对已退出/启动失败实例开放。后台在删除前复核 Chub/external profile 占用,目录删除拒绝不安全路径、卷根、文件、链接和 Windows 重解析点;失败与过期结果均保留配置和目录。
- T-407:进行中。将把默认 User Data Dir 与日志目录改为实际程序目录下的 portable 默认值,增加自定义模式、实例名称唯一校验、安全化默认 profile 名称以及启动前目录可写预检。 - T-407:已完成 portable/custom 默认目录来源持久化、实际 exe 同级 `user_data_dirs`/`logs` 解析、历史内置目录迁移、目录选择和恢复默认操作。新实例默认 profile 为安全化名称加短 ID,名称按 Windows 规则和大小写无关方式校验;启动前后台创建并探测 User Data Dir,失败时不启动浏览器。日志目录在后台验证后写入 `chub.log`,不会退回 cwd。已通过单元测试、竞态检测、vet 与无控制台 GUI 打包。
- blocker:无;当前任务为 T-407,随后恢复 T-402。 - blocker:无;下一任务为 T-402。
## 当前目录 ## 当前目录
+4 -4
View File
@@ -3,7 +3,7 @@ id: T-407
title: 便携默认目录、实例名称唯一与启动目录预检 title: 便携默认目录、实例名称唯一与启动目录预检
phase: 4 phase: 4
deps: [T-403, T-404, T-405] deps: [T-403, T-404, T-405]
status: DOING status: DONE
created: 2026-07-27 created: 2026-07-27
owner: codex owner: codex
--- ---
@@ -29,7 +29,7 @@ owner: codex
## 执行记录 ## 执行记录
- 状态:DOING - 状态:DONE
- 变更:已完成需求、架构、API、用户故事、交互清单和原型验收场景定义;待提交文档后实现。 - 变更:配置保存 portable/custom 目录来源,并按实际 `chub.exe` 同级目录解析 `user_data_dirs` 与 `logs`;历史内置目录安全迁移 portable。设置页展示来源、支持目录选择与“恢复程序目录默认”,日志目录会在后台创建、探测并写入 `chub.log`,不回退 cwd。新建默认目录改为 `instance-<安全实例名>-<短 ID>`;新建/编辑和配置存储均校验名称的 Windows 安全性及大小写无关唯一性。启动 adapter 在 launcher 前创建并验证指定 User Data Dir 可写,失败时不启动浏览器。
- 验证:待执行。 - 验证:`go test ./...`、`go vet ./...`、`go test -race ./cmd/chub ./internal/ui ./internal/platform/config ./internal/platform/files`、`cmd /c build.bat` 均通过。
- 阻塞:无。 - 阻塞:无。
+27
View File
@@ -3,6 +3,7 @@ package domain
import ( import (
"errors" "errors"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
) )
@@ -90,3 +91,29 @@ func TestNormalizeProxyNameRequiresNonEmptyDisplayText(t *testing.T) {
t.Fatalf("empty proxy name error = %v", err) t.Fatalf("empty proxy name error = %v", err)
} }
} }
func TestNormalizeInstanceNameRejectsUnsafeWindowsNames(t *testing.T) {
if got, err := NormalizeInstanceName(" 审核 Edge "); err != nil || got != "审核 Edge" {
t.Fatalf("NormalizeInstanceName() = %q, %v", got, err)
}
for _, value := range []string{"", "CON", "aux.txt", "审核?", "审核.", "审核\\Edge"} {
if _, err := NormalizeInstanceName(value); err == nil {
t.Fatalf("unsafe instance name %q was accepted", value)
}
}
}
func TestInstanceProfileDirectoryNameIsReadableAndStablePerID(t *testing.T) {
first, err := InstanceProfileDirectoryName("审核 Edge", "instance-one")
if err != nil || !strings.HasPrefix(first, "instance-审核 Edge-") {
t.Fatalf("first directory name = %q, %v", first, err)
}
second, err := InstanceProfileDirectoryName("审核 Edge", "instance-two")
if err != nil || second == first {
t.Fatalf("second directory name = %q, %v", second, err)
}
long, err := InstanceProfileDirectoryName(strings.Repeat("审", 80), "instance-long")
if err != nil || len(long) > len("instance-")+64+1+8 {
t.Fatalf("long directory name = %q, %v", long, err)
}
}
+83
View File
@@ -0,0 +1,83 @@
package domain
import (
"errors"
"fmt"
"hash/fnv"
"strings"
"unicode/utf8"
)
const maxInstanceNameRunes = 80
var errInvalidInstanceName = errors.New("invalid instance name")
// NormalizeInstanceName validates the user-visible instance name before it is
// stored or used to derive a Windows profile directory. The validation keeps
// the display name readable instead of silently rewriting user input.
func NormalizeInstanceName(value string) (string, error) {
name := strings.TrimSpace(value)
if name == "" {
return "", fmt.Errorf("%w: name is required", errInvalidInstanceName)
}
if utf8.RuneCountInString(name) > maxInstanceNameRunes {
return "", fmt.Errorf("%w: name is too long", errInvalidInstanceName)
}
if strings.HasSuffix(name, ".") {
return "", fmt.Errorf("%w: name cannot end with a period", errInvalidInstanceName)
}
for _, value := range name {
if value < 0x20 || strings.ContainsRune(`<>:"/\\|?*`, value) {
return "", fmt.Errorf("%w: name contains a Windows-reserved character", errInvalidInstanceName)
}
}
base := strings.ToUpper(strings.Split(name, ".")[0])
if isReservedWindowsDeviceName(base) {
return "", fmt.Errorf("%w: name is a reserved Windows device name", errInvalidInstanceName)
}
return name, nil
}
// InstanceProfileDirectoryName derives a compact, readable, and unique
// profile directory component. The stable hash keeps paths distinct even
// when display names share a truncated prefix.
func InstanceProfileDirectoryName(name, instanceID string) (string, error) {
name, err := NormalizeInstanceName(name)
if err != nil {
return "", err
}
instanceID = strings.TrimSpace(instanceID)
if instanceID == "" {
return "", fmt.Errorf("%w: instance ID is required", errInvalidInstanceName)
}
segment := truncateUTF8(name, 64)
hash := fnv.New32a()
_, _ = hash.Write([]byte(instanceID))
return fmt.Sprintf("instance-%s-%08x", segment, hash.Sum32()), nil
}
func truncateUTF8(value string, limit int) string {
if len(value) <= limit {
return value
}
count := 0
for index := range value {
if index > limit {
break
}
count = index
}
if count == 0 {
return value
}
return value[:count]
}
func isReservedWindowsDeviceName(value string) bool {
switch value {
case "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9":
return true
default:
return false
}
}
+188 -12
View File
@@ -17,15 +17,36 @@ const currentVersion = 1
var ErrDefaultConfigUnavailable = errors.New("default config is unavailable") var ErrDefaultConfigUnavailable = errors.New("default config is unavailable")
type DirectorySource string
const (
DirectorySourcePortable DirectorySource = "portable"
DirectorySourceCustom DirectorySource = "custom"
)
const (
legacyDefaultUserDataDir = `C:\Users\Public\chub\profiles`
legacyLogDir = `C:\Users\Public\chub\logs`
)
type Settings struct { type Settings struct {
ChromePath string `json:"chromePath"` ChromePath string `json:"chromePath"`
EdgePath string `json:"edgePath"` EdgePath string `json:"edgePath"`
DefaultDir string `json:"defaultUserDataDir"` DefaultDir string `json:"defaultUserDataDir"`
DefaultDirSource DirectorySource `json:"defaultUserDataDirSource,omitempty"`
LogDir string `json:"logDir"` LogDir string `json:"logDir"`
LogDirSource DirectorySource `json:"logDirSource,omitempty"`
RemoteDebugStartPort int `json:"remoteDebugStartPort"` RemoteDebugStartPort int `json:"remoteDebugStartPort"`
CloseOnExit bool `json:"closeOnExit"` CloseOnExit bool `json:"closeOnExit"`
} }
// PortableDirectories are resolved from the actual executable location for
// the default store, never from the process working directory.
type PortableDirectories struct {
DefaultDir string
LogDir string
}
type Instance struct { type Instance struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -68,14 +89,25 @@ func DefaultPath() (string, error) {
return defaultPathForExecutable(executable) return defaultPathForExecutable(executable)
} }
// DefaultPortableDirectories resolves the portable defaults beside the actual
// executable. It is useful before config.json can be opened so the UI can
// still offer a recoverable manual-path flow.
func DefaultPortableDirectories() (PortableDirectories, error) {
executable, err := os.Executable()
if err != nil {
return PortableDirectories{}, fmt.Errorf("%w: resolve executable", ErrDefaultConfigUnavailable)
}
return portableDirectoriesForExecutable(executable)
}
// DefaultInstanceUserDataRoot returns the portable root for new browser // DefaultInstanceUserDataRoot returns the portable root for new browser
// profiles. It resolves the actual executable location and never uses cwd. // profiles. It resolves the actual executable location and never uses cwd.
func DefaultInstanceUserDataRoot() (string, error) { func DefaultInstanceUserDataRoot() (string, error) {
executable, err := os.Executable() directories, err := DefaultPortableDirectories()
if err != nil { if err != nil {
return "", fmt.Errorf("%w: resolve executable", ErrDefaultConfigUnavailable) return "", err
} }
return defaultInstanceUserDataRootForExecutable(executable) return directories.DefaultDir, nil
} }
// DefaultInstanceUserDataDir returns a unique-instance profile path below the // DefaultInstanceUserDataDir returns a unique-instance profile path below the
@@ -120,6 +152,12 @@ func OpenDefault() (*Store, error) {
func (s *Store) Path() string { return s.path } func (s *Store) Path() string { return s.path }
// PortableDirectories resolves defaults relative to this store's config.json
// location. OpenDefault always places that file beside chub.exe.
func (s *Store) PortableDirectories() (PortableDirectories, error) {
return portableDirectoriesForConfigPath(s.path)
}
func defaultPathForExecutable(executable string) (string, error) { func defaultPathForExecutable(executable string) (string, error) {
path := strings.TrimSpace(executable) path := strings.TrimSpace(executable)
if path == "" || !filepath.IsAbs(path) { if path == "" || !filepath.IsAbs(path) {
@@ -129,11 +167,39 @@ func defaultPathForExecutable(executable string) (string, error) {
} }
func defaultInstanceUserDataRootForExecutable(executable string) (string, error) { func defaultInstanceUserDataRootForExecutable(executable string) (string, error) {
directories, err := portableDirectoriesForExecutable(executable)
if err != nil {
return "", err
}
return directories.DefaultDir, nil
}
func portableDirectoriesForExecutable(executable string) (PortableDirectories, error) {
path := strings.TrimSpace(executable) path := strings.TrimSpace(executable)
if path == "" || !filepath.IsAbs(path) { if path == "" || !filepath.IsAbs(path) {
return "", fmt.Errorf("%w: executable path must be absolute", ErrDefaultConfigUnavailable) return PortableDirectories{}, fmt.Errorf("%w: executable path must be absolute", ErrDefaultConfigUnavailable)
} }
return filepath.Join(filepath.Dir(filepath.Clean(path)), "user_data_dirs"), nil return portableDirectoriesForBase(filepath.Dir(filepath.Clean(path)))
}
func portableDirectoriesForConfigPath(configPath string) (PortableDirectories, error) {
path := strings.TrimSpace(configPath)
if path == "" || !filepath.IsAbs(path) || !strings.EqualFold(filepath.Base(path), "config.json") {
return PortableDirectories{}, fmt.Errorf("%w: config path must be an absolute config.json path", ErrDefaultConfigUnavailable)
}
return portableDirectoriesForBase(filepath.Dir(filepath.Clean(path)))
}
func portableDirectoriesForBase(base string) (PortableDirectories, error) {
base = strings.TrimSpace(base)
if base == "" || !filepath.IsAbs(base) {
return PortableDirectories{}, fmt.Errorf("%w: portable base path must be absolute", ErrDefaultConfigUnavailable)
}
base = filepath.Clean(base)
return PortableDirectories{
DefaultDir: filepath.Join(base, "user_data_dirs"),
LogDir: filepath.Join(base, "logs"),
}, nil
} }
func defaultInstanceUserDataDir(root, instanceID string) (string, error) { func defaultInstanceUserDataDir(root, instanceID string) (string, error) {
@@ -202,7 +268,11 @@ func migrateLegacyConfig(target *Store, legacyPath string) (*Store, error) {
func (s *Store) Load() (File, error) { func (s *Store) Load() (File, error) {
data, err := os.ReadFile(s.path) data, err := os.ReadFile(s.path)
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
return File{Version: currentVersion, Settings: DefaultSettings()}, nil settings, settingsErr := s.defaultSettings()
if settingsErr != nil {
return File{}, settingsErr
}
return File{Version: currentVersion, Settings: settings}, nil
} }
if err != nil { if err != nil {
return File{}, fmt.Errorf("read config: %w", err) return File{}, fmt.Errorf("read config: %w", err)
@@ -217,12 +287,15 @@ func (s *Store) Load() (File, error) {
if result.Version != currentVersion { if result.Version != currentVersion {
return File{}, fmt.Errorf("unsupported config version %d", result.Version) return File{}, fmt.Errorf("unsupported config version %d", result.Version)
} }
if result.Settings.RemoteDebugStartPort == 0 { if err := s.normalizeSettings(&result.Settings); err != nil {
result.Settings.RemoteDebugStartPort = domain.DefaultRemoteDebugPort return File{}, err
} }
if err := normalizeInstanceRemoteDebugPorts(&result); err != nil { if err := normalizeInstanceRemoteDebugPorts(&result); err != nil {
return File{}, err return File{}, err
} }
if err := normalizeInstanceNames(&result); err != nil {
return File{}, err
}
if err := normalizeProxyConfig(&result); err != nil { if err := normalizeProxyConfig(&result); err != nil {
return File{}, err return File{}, err
} }
@@ -230,11 +303,29 @@ func (s *Store) Load() (File, error) {
} }
func DefaultSettings() Settings { func DefaultSettings() Settings {
directories, err := DefaultPortableDirectories()
if err != nil {
return Settings{RemoteDebugStartPort: domain.DefaultRemoteDebugPort, CloseOnExit: true}
}
return defaultSettingsForDirectories(directories)
}
func (s *Store) defaultSettings() (Settings, error) {
directories, err := s.PortableDirectories()
if err != nil {
return Settings{}, err
}
return defaultSettingsForDirectories(directories), nil
}
func defaultSettingsForDirectories(directories PortableDirectories) Settings {
return Settings{ return Settings{
ChromePath: `C:\Program Files\Google\Chrome\Application\chrome.exe`, ChromePath: `C:\Program Files\Google\Chrome\Application\chrome.exe`,
EdgePath: `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`, EdgePath: `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`,
DefaultDir: `C:\Users\Public\chub\profiles`, DefaultDir: directories.DefaultDir,
LogDir: `C:\Users\Public\chub\logs`, DefaultDirSource: DirectorySourcePortable,
LogDir: directories.LogDir,
LogDirSource: DirectorySourcePortable,
RemoteDebugStartPort: domain.DefaultRemoteDebugPort, RemoteDebugStartPort: domain.DefaultRemoteDebugPort,
CloseOnExit: true, CloseOnExit: true,
} }
@@ -242,8 +333,8 @@ func DefaultSettings() Settings {
func (s *Store) Save(value File) error { func (s *Store) Save(value File) error {
value.Version = currentVersion value.Version = currentVersion
if value.Settings.RemoteDebugStartPort == 0 { if err := s.normalizeSettings(&value.Settings); err != nil {
value.Settings.RemoteDebugStartPort = domain.DefaultRemoteDebugPort return err
} }
if value.Instances == nil { if value.Instances == nil {
value.Instances = []Instance{} value.Instances = []Instance{}
@@ -254,6 +345,9 @@ func (s *Store) Save(value File) error {
if err := normalizeInstanceRemoteDebugPorts(&value); err != nil { if err := normalizeInstanceRemoteDebugPorts(&value); err != nil {
return err return err
} }
if err := normalizeInstanceNames(&value); err != nil {
return err
}
if err := normalizeProxyConfig(&value); err != nil { if err := normalizeProxyConfig(&value); err != nil {
return err return err
} }
@@ -291,6 +385,88 @@ func (s *Store) Save(value File) error {
return nil return nil
} }
func (s *Store) normalizeSettings(settings *Settings) error {
if settings == nil {
return errors.New("settings are required")
}
directories, err := s.PortableDirectories()
if err != nil {
return err
}
defaultDir, defaultSource, err := normalizeDirectorySetting(settings.DefaultDir, settings.DefaultDirSource, directories.DefaultDir, legacyDefaultUserDataDir)
if err != nil {
return fmt.Errorf("invalid default user data directory: %w", err)
}
logDir, logSource, err := normalizeDirectorySetting(settings.LogDir, settings.LogDirSource, directories.LogDir, legacyLogDir)
if err != nil {
return fmt.Errorf("invalid log directory: %w", err)
}
settings.DefaultDir = defaultDir
settings.DefaultDirSource = defaultSource
settings.LogDir = logDir
settings.LogDirSource = logSource
if settings.RemoteDebugStartPort == 0 {
settings.RemoteDebugStartPort = domain.DefaultRemoteDebugPort
}
return nil
}
func normalizeDirectorySetting(value string, source DirectorySource, portableDefault, legacyDefault string) (string, DirectorySource, error) {
value = strings.TrimSpace(value)
source = DirectorySource(strings.ToLower(strings.TrimSpace(string(source))))
if source == "" {
if value == "" || sameDirectory(value, portableDefault) || sameDirectory(value, legacyDefault) {
source = DirectorySourcePortable
} else {
source = DirectorySourceCustom
}
}
switch source {
case DirectorySourcePortable:
return filepath.Clean(portableDefault), DirectorySourcePortable, nil
case DirectorySourceCustom:
if value == "" || !filepath.IsAbs(value) {
return "", "", errors.New("custom directory must be absolute")
}
return filepath.Clean(value), DirectorySourceCustom, nil
default:
return "", "", errors.New("directory source must be portable or custom")
}
}
func sameDirectory(left, right string) bool {
left = strings.TrimSpace(left)
right = strings.TrimSpace(right)
if left == "" || right == "" {
return false
}
return strings.EqualFold(filepath.Clean(left), filepath.Clean(right))
}
func normalizeInstanceNames(value *File) error {
if value == nil {
return errors.New("config is required")
}
seen := make(map[string]struct{}, len(value.Instances))
for index := range value.Instances {
instance := &value.Instances[index]
name, err := domain.NormalizeInstanceName(instance.Name)
if err != nil {
return errors.New("invalid instance name configuration")
}
if _, err := domain.InstanceProfileDirectoryName(name, instance.ID); err != nil {
return errors.New("invalid instance name configuration")
}
key := strings.ToUpper(name)
if _, exists := seen[key]; exists {
return errors.New("duplicate instance name configuration")
}
seen[key] = struct{}{}
instance.Name = name
}
return nil
}
func normalizeInstanceRemoteDebugPorts(value *File) error { func normalizeInstanceRemoteDebugPorts(value *File) error {
if value == nil { if value == nil {
return errors.New("config is required") return errors.New("config is required")
+52 -7
View File
@@ -23,7 +23,7 @@ func TestStoreRoundTripAndCreatesPrivateFile(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got.Version != currentVersion || got.Settings != want.Settings || len(got.Instances) != 1 || got.Instances[0].ID != "a" { if got.Version != currentVersion || got.Settings.ChromePath != want.Settings.ChromePath || got.Settings.RemoteDebugStartPort != want.Settings.RemoteDebugStartPort || got.Settings.DefaultDirSource != DirectorySourcePortable || got.Settings.LogDirSource != DirectorySourcePortable || len(got.Instances) != 1 || got.Instances[0].ID != "a" {
t.Fatalf("round trip mismatch: %#v", got) t.Fatalf("round trip mismatch: %#v", got)
} }
} }
@@ -45,7 +45,7 @@ func TestStoreAddsDefaultRemoteDebugPortForExistingConfig(t *testing.T) {
func TestStoreAssignsPreferredPortsForLegacyInstancesInStableOrder(t *testing.T) { func TestStoreAssignsPreferredPortsForLegacyInstancesInStableOrder(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json") path := filepath.Join(t.TempDir(), "config.json")
contents := `{"version":1,"settings":{"remoteDebugStartPort":9777},"instances":[{"id":"first","launch":{"Kind":"chrome","UserDataDir":"C:\\profiles\\first"}},{"id":"second","launch":{"Kind":"edge","UserDataDir":"C:\\profiles\\second"}}]}` contents := `{"version":1,"settings":{"remoteDebugStartPort":9777},"instances":[{"id":"first","name":"第一实例","launch":{"Kind":"chrome","UserDataDir":"C:\\profiles\\first"}},{"id":"second","name":"第二实例","launch":{"Kind":"edge","UserDataDir":"C:\\profiles\\second"}}]}`
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -75,13 +75,13 @@ func TestStoreRejectsInvalidOrDuplicatePreferredPorts(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
duplicate := File{Instances: []Instance{ duplicate := File{Instances: []Instance{
{ID: "one", PreferredRemoteDebugPort: 9666}, {ID: "one", Name: "一号", PreferredRemoteDebugPort: 9666},
{ID: "two", PreferredRemoteDebugPort: 9666}, {ID: "two", Name: "二号", PreferredRemoteDebugPort: 9666},
}} }}
if err := store.Save(duplicate); err == nil { if err := store.Save(duplicate); err == nil {
t.Fatal("duplicate preferred port was accepted") t.Fatal("duplicate preferred port was accepted")
} }
invalid := File{Instances: []Instance{{ID: "one", PreferredRemoteDebugPort: domain.MinRemoteDebugPort - 1}}} invalid := File{Instances: []Instance{{ID: "one", Name: "一号", PreferredRemoteDebugPort: domain.MinRemoteDebugPort - 1}}}
if err := store.Save(invalid); err == nil { if err := store.Save(invalid); err == nil {
t.Fatal("invalid preferred port was accepted") t.Fatal("invalid preferred port was accepted")
} }
@@ -134,6 +134,51 @@ func TestDefaultInstanceUserDataDirectoryUsesExecutableDirectoryAndStableID(t *t
} }
} }
func TestStoreMigratesHistoricalDirectoriesToPortableDefaults(t *testing.T) {
base := filepath.Join(t.TempDir(), "published")
store, err := New(filepath.Join(base, "config.json"))
if err != nil {
t.Fatal(err)
}
if err := store.Save(File{Settings: Settings{DefaultDir: legacyDefaultUserDataDir, LogDir: legacyLogDir}}); err != nil {
t.Fatal(err)
}
got, err := store.Load()
if err != nil {
t.Fatal(err)
}
if got.Settings.DefaultDir != filepath.Join(base, "user_data_dirs") || got.Settings.LogDir != filepath.Join(base, "logs") || got.Settings.DefaultDirSource != DirectorySourcePortable || got.Settings.LogDirSource != DirectorySourcePortable {
t.Fatalf("portable migration = %#v", got.Settings)
}
custom := filepath.Join(t.TempDir(), "custom-profiles")
got.Settings.DefaultDir = custom
got.Settings.DefaultDirSource = DirectorySourceCustom
if err := store.Save(got); err != nil {
t.Fatal(err)
}
reloaded, err := store.Load()
if err != nil || reloaded.Settings.DefaultDir != custom || reloaded.Settings.DefaultDirSource != DirectorySourceCustom {
t.Fatalf("custom directory = %#v, error = %v", reloaded.Settings, err)
}
}
func TestStoreRejectsInvalidAndDuplicateInstanceNames(t *testing.T) {
store, err := New(filepath.Join(t.TempDir(), "config.json"))
if err != nil {
t.Fatal(err)
}
for _, instances := range [][]Instance{
{{ID: "one", Name: "审核"}, {ID: "two", Name: "审核"}},
{{ID: "one", Name: "Audit"}, {ID: "two", Name: "aUDIT"}},
{{ID: "one", Name: "CON"}},
} {
if err := store.Save(File{Instances: instances}); err == nil {
t.Fatalf("unsafe instance configuration persisted: %#v", instances)
}
}
}
func TestOpenDefaultAtMigratesValidLegacyWithoutRemovingIt(t *testing.T) { func TestOpenDefaultAtMigratesValidLegacyWithoutRemovingIt(t *testing.T) {
directory := t.TempDir() directory := t.TempDir()
target := filepath.Join(directory, "portable", "config.json") target := filepath.Join(directory, "portable", "config.json")
@@ -176,7 +221,7 @@ func TestOpenDefaultAtPrefersExistingTargetOverLegacy(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := targetStore.Save(File{Instances: []Instance{{ID: "target", PreferredRemoteDebugPort: 9666}}}); err != nil { if err := targetStore.Save(File{Instances: []Instance{{ID: "target", Name: "目标实例", PreferredRemoteDebugPort: 9666}}}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := os.MkdirAll(filepath.Dir(legacy), 0o700); err != nil { if err := os.MkdirAll(filepath.Dir(legacy), 0o700); err != nil {
@@ -241,7 +286,7 @@ func TestOpenDefaultAtRejectsUncreatableTargetWithoutTouchingLegacy(t *testing.T
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := legacyStore.Save(File{Instances: []Instance{{ID: "legacy", PreferredRemoteDebugPort: 9666}}}); err != nil { if err := legacyStore.Save(File{Instances: []Instance{{ID: "legacy", Name: "旧实例", PreferredRemoteDebugPort: 9666}}}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := openDefaultAt(target, legacy); !errors.Is(err, ErrDefaultConfigUnavailable) { if _, err := openDefaultAt(target, legacy); !errors.Is(err, ErrDefaultConfigUnavailable) {
@@ -0,0 +1,104 @@
package files
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
)
var ErrInstanceDataDirectoryUnavailable = errors.New("instance data directory is unavailable")
// DirectoryPreparer verifies one explicitly supplied absolute directory.
// It is used for program-owned locations such as the configured log folder.
type DirectoryPreparer interface {
PrepareDirectory(context.Context, string) error
}
// InstanceDataPreparer creates and verifies only the User Data Dir belonging
// to the explicitly requested instance. It never discovers or changes other
// browser data directories.
type InstanceDataPreparer interface {
PrepareInstanceDataDir(context.Context, string) error
}
type instanceDataPreparer struct {
mkdirAll func(string, os.FileMode) error
lstat func(string) (os.FileInfo, error)
createTemp func(string, string) (*os.File, error)
remove func(string) error
}
func NewInstanceDataPreparer() InstanceDataPreparer {
return newInstanceDataPreparer(os.MkdirAll, os.Lstat, os.CreateTemp, os.Remove)
}
func NewDirectoryPreparer() DirectoryPreparer {
return newInstanceDataPreparer(os.MkdirAll, os.Lstat, os.CreateTemp, os.Remove)
}
func newInstanceDataPreparer(
mkdirAll func(string, os.FileMode) error,
lstat func(string) (os.FileInfo, error),
createTemp func(string, string) (*os.File, error),
remove func(string) error,
) instanceDataPreparer {
return instanceDataPreparer{mkdirAll: mkdirAll, lstat: lstat, createTemp: createTemp, remove: remove}
}
func (p instanceDataPreparer) PrepareInstanceDataDir(ctx context.Context, userDataDir string) error {
return p.PrepareDirectory(ctx, userDataDir)
}
func (p instanceDataPreparer) PrepareDirectory(ctx context.Context, directory string) error {
if err := ctx.Err(); err != nil {
return err
}
path, err := normalizePreparatoryInstanceDataDir(directory)
if err != nil {
return err
}
if err := p.mkdirAll(path, 0o700); err != nil {
return fmt.Errorf("%w: create", ErrInstanceDataDirectoryUnavailable)
}
if err := ctx.Err(); err != nil {
return err
}
info, err := p.lstat(path)
if err != nil {
return fmt.Errorf("%w: inspect", ErrInstanceDataDirectoryUnavailable)
}
if info.Mode()&(os.ModeSymlink|os.ModeIrregular) != 0 || isReparsePoint(info) {
return fmt.Errorf("%w: links and reparse points are not supported", ErrUnsafeInstanceDataDirectory)
}
if !info.IsDir() {
return ErrInstanceDataNotDirectory
}
probe, err := p.createTemp(path, ".chub-write-*")
if err != nil {
return fmt.Errorf("%w: write", ErrInstanceDataDirectoryUnavailable)
}
probePath := probe.Name()
if err := probe.Close(); err != nil {
_ = p.remove(probePath)
return fmt.Errorf("%w: close", ErrInstanceDataDirectoryUnavailable)
}
if err := p.remove(probePath); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("%w: cleanup", ErrInstanceDataDirectoryUnavailable)
}
if err := ctx.Err(); err != nil {
return err
}
return nil
}
// normalizePreparatoryInstanceDataDir is kept separate from deletion's
// helper so callers can use a stable, intention-revealing validation entry.
func normalizePreparatoryInstanceDataDir(value string) (string, error) {
path, err := normalizeRemovableInstanceDataDir(value)
if err != nil {
return "", err
}
return filepath.Clean(path), nil
}
@@ -81,6 +81,36 @@ func TestInstanceDataRemoverHonorsCanceledContext(t *testing.T) {
} }
} }
func TestInstanceDataPreparerCreatesAndVerifiesWritableDirectory(t *testing.T) {
target := filepath.Join(t.TempDir(), "new-profile")
if err := NewInstanceDataPreparer().PrepareInstanceDataDir(context.Background(), target); err != nil {
t.Fatal(err)
}
info, err := os.Stat(target)
if err != nil || !info.IsDir() {
t.Fatalf("prepared directory = %#v, error = %v", info, err)
}
probes, err := filepath.Glob(filepath.Join(target, ".chub-write-*"))
if err != nil || len(probes) != 0 {
t.Fatalf("write probes = %#v, error = %v", probes, err)
}
}
func TestInstanceDataPreparerRejectsUnavailableDirectoryBeforeBrowserLaunch(t *testing.T) {
target := filepath.Join(t.TempDir(), "profile")
preparer := newInstanceDataPreparer(os.MkdirAll, os.Lstat, func(string, string) (*os.File, error) {
return nil, errors.New("denied")
}, os.Remove)
if err := preparer.PrepareInstanceDataDir(context.Background(), target); !errors.Is(err, ErrInstanceDataDirectoryUnavailable) {
t.Fatalf("unwritable directory error = %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := NewInstanceDataPreparer().PrepareInstanceDataDir(ctx, filepath.Join(t.TempDir(), "canceled")); !errors.Is(err, context.Canceled) {
t.Fatalf("canceled preparation error = %v", err)
}
}
type fakeFileInfo struct { type fakeFileInfo struct {
mode os.FileMode mode os.FileMode
sys any sys any
+16
View File
@@ -3,6 +3,8 @@ package logging
import ( import (
"io" "io"
"log/slog" "log/slog"
"os"
"path/filepath"
) )
// New creates the process logger. Callers provide the sink so tests and the // New creates the process logger. Callers provide the sink so tests and the
@@ -13,3 +15,17 @@ func New(w io.Writer) *slog.Logger {
} }
return slog.New(slog.NewJSONHandler(w, &slog.HandlerOptions{Level: slog.LevelInfo})) return slog.New(slog.NewJSONHandler(w, &slog.HandlerOptions{Level: slog.LevelInfo}))
} }
// OpenFile creates an append-only local diagnostic log in the configured
// directory. It never falls back to cwd, so a bad configured directory stays
// visible to the caller instead of scattering logs beside a shortcut.
func OpenFile(directory string) (*slog.Logger, io.Closer, error) {
if err := os.MkdirAll(directory, 0o700); err != nil {
return nil, nil, err
}
file, err := os.OpenFile(filepath.Join(directory, "chub.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return nil, nil, err
}
return New(file), file, nil
}
+18
View File
@@ -2,6 +2,8 @@ package logging
import ( import (
"bytes" "bytes"
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
) )
@@ -20,3 +22,19 @@ func TestNewWritesStructuredLog(t *testing.T) {
func TestNewNilWriterDoesNotPanic(t *testing.T) { func TestNewNilWriterDoesNotPanic(t *testing.T) {
New(nil).Info("discarded") New(nil).Info("discarded")
} }
func TestOpenFileWritesInsideConfiguredDirectory(t *testing.T) {
directory := filepath.Join(t.TempDir(), "logs")
logger, closer, err := OpenFile(directory)
if err != nil {
t.Fatal(err)
}
logger.Info("file event")
if err := closer.Close(); err != nil {
t.Fatal(err)
}
contents, err := os.ReadFile(filepath.Join(directory, "chub.log"))
if err != nil || !strings.Contains(string(contents), `"msg":"file event"`) {
t.Fatalf("log contents = %q, error = %v", contents, err)
}
}
+255 -47
View File
@@ -242,6 +242,8 @@ type directoryPickTarget uint8
const ( const (
directoryPickCreate directoryPickTarget = iota directoryPickCreate directoryPickTarget = iota
directoryPickEdit directoryPickEdit
directoryPickDefaultUserData
directoryPickLogDirectory
) )
type proxyPickerTarget uint8 type proxyPickerTarget uint8
@@ -278,7 +280,9 @@ type SettingsState struct {
ChromePath string ChromePath string
EdgePath string EdgePath string
DefaultDir string DefaultDir string
DefaultDirMode string
LogDir string LogDir string
LogDirMode string
RemoteDebugStartPort int RemoteDebugStartPort int
ReservedRemoteDebugPorts []int ReservedRemoteDebugPorts []int
CloseOnExit bool CloseOnExit bool
@@ -304,8 +308,10 @@ type Shell struct {
edgeSearch widget.Clickable edgeSearch widget.Clickable
dataPick widget.Clickable dataPick widget.Clickable
dataSearch widget.Clickable dataSearch widget.Clickable
dataRestore widget.Clickable
logPick widget.Clickable logPick widget.Clickable
logSearch widget.Clickable logSearch widget.Clickable
logRestore widget.Clickable
saveClick widget.Clickable saveClick widget.Clickable
cancelClick widget.Clickable cancelClick widget.Clickable
@@ -399,6 +405,13 @@ type Shell struct {
nextInstance uint64 nextInstance uint64
pendingCreateID string pendingCreateID string
defaultInstanceDirRoot string defaultInstanceDirRoot string
portableDefaultDir string
portableLogDir string
defaultDirMode string
logDirMode string
lastSavedSettings SettingsState
defaultCreateDirActive bool
lastSuggestedCreateDir string
instanceFeedback string instanceFeedback string
pendingDeleteID string pendingDeleteID string
editingID string editingID string
@@ -455,6 +468,10 @@ func NewShell(theme *material.Theme) *Shell {
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`)
s.logDir.SetText(`C:\Users\Public\chub\logs`) 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.remoteDebugPort.SetText(strconv.Itoa(domain.DefaultRemoteDebugPort))
s.instancePort.SetText(strconv.Itoa(s.recommendRemoteDebugPort(""))) s.instancePort.SetText(strconv.Itoa(s.recommendRemoteDebugPort("")))
s.closeOnExit.Value = true s.closeOnExit.Value = true
@@ -464,6 +481,7 @@ func NewShell(theme *material.Theme) *Shell {
s.list.Axis = layout.Vertical s.list.Axis = layout.Vertical
s.settingsList.Axis = layout.Vertical s.settingsList.Axis = layout.Vertical
s.tabsList.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 return s
} }
@@ -518,17 +536,52 @@ func (s *Shell) SetDefaultInstanceUserDataRoot(root string) {
s.defaultInstanceDirRoot = filepath.Clean(root) 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) { func (s *Shell) SetSettings(value SettingsState) {
s.chromePath.SetText(value.ChromePath) s.chromePath.SetText(value.ChromePath)
s.edgePath.SetText(value.EdgePath) s.edgePath.SetText(value.EdgePath)
s.dataDir.SetText(value.DefaultDir) s.dataDir.SetText(value.DefaultDir)
s.logDir.SetText(value.LogDir) 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 port := value.RemoteDebugStartPort
if port == 0 { if port == 0 {
port = domain.DefaultRemoteDebugPort port = domain.DefaultRemoteDebugPort
} }
s.remoteDebugPort.SetText(strconv.Itoa(port)) s.remoteDebugPort.SetText(strconv.Itoa(port))
s.closeOnExit.Value = value.CloseOnExit 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. // ReportStartupIssue records a safe, user-recoverable configuration message.
@@ -620,6 +673,7 @@ func (s *Shell) Layout(gtx layout.Context) layout.Dimensions {
s.consumeControls(gtx) s.consumeControls(gtx)
} }
s.presentUnexpectedExitIfReady() s.presentUnexpectedExitIfReady()
s.syncDefaultCreateDirectory()
mainLayout := func(gtx layout.Context) layout.Dimensions { mainLayout := func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(s.sidebar), layout.Rigid(s.sidebar),
@@ -730,12 +784,15 @@ func (s *Shell) beginCreate() {
s.page = pageCreate s.page = pageCreate
s.formFeedback = "" s.formFeedback = ""
s.pendingCreateID = s.allocateInstanceID() s.pendingCreateID = s.allocateInstanceID()
s.defaultCreateDirActive = true
s.lastSuggestedCreateDir = ""
s.instanceName.SetText("") s.instanceName.SetText("")
s.instanceURL.SetText("") s.instanceURL.SetText("")
s.createProxyID = "" s.createProxyID = ""
s.browserKind.Value = "chrome" s.browserKind.Value = "chrome"
if defaultDir := s.defaultCreateInstanceDir(s.pendingCreateID); defaultDir != "" { if defaultDir := s.defaultCreateInstanceDir("新实例", s.pendingCreateID); defaultDir != "" {
s.instanceDir.SetText(defaultDir) s.instanceDir.SetText(defaultDir)
s.lastSuggestedCreateDir = defaultDir
} else { } else {
s.instanceDir.SetText("") s.instanceDir.SetText("")
s.formFeedback = "无法确定程序目录中的默认 User Data Dir;请选择或输入一个绝对路径。" s.formFeedback = "无法确定程序目录中的默认 User Data Dir;请选择或输入一个绝对路径。"
@@ -904,9 +961,9 @@ func (s *Shell) saveEdit() {
s.closeEdit("要编辑的实例已不存在。") s.closeEdit("要编辑的实例已不存在。")
return return
} }
name := strings.TrimSpace(s.editName.Text()) name, err := s.validateInstanceName(s.editName.Text(), row.ID)
if name == "" { if err != nil {
s.editFeedback = "请输入实例名称。" s.editFeedback = err.Error()
return return
} }
targetURL, err := normalizeTargetURL(s.editURL.Text()) targetURL, err := normalizeTargetURL(s.editURL.Text())
@@ -1358,7 +1415,7 @@ func (s *Shell) editDirectoryField(gtx layout.Context, locked bool) layout.Dimen
} }
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.Body2(s.theme, "User Data Dir").Layout), layout.Rigid(material.Body2(s.theme, "User Data Dir").Layout),
layout.Rigid(material.Caption(s.theme, "浏览器独立数据目录;必须是绝对路径").Layout), layout.Rigid(material.Caption(s.theme, "浏览器独立数据目录;修改实例名称不会移动已有目录").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx, return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
@@ -1962,17 +2019,23 @@ func (s *Shell) settings(gtx layout.Context) layout.Dimensions {
s.togglePathSearch(PathEdgeExecutable) s.togglePathSearch(PathEdgeExecutable)
} }
for s.dataPick.Clicked(gtx) { for s.dataPick.Clicked(gtx) {
s.pathFeedback = "请选择默认 User Data Dir" s.chooseDirectory(directoryPickDefaultUserData)
} }
for s.dataSearch.Clicked(gtx) { for s.dataSearch.Clicked(gtx) {
s.togglePathSearch(PathDefaultUserData) s.togglePathSearch(PathDefaultUserData)
} }
for s.logPick.Clicked(gtx) { for s.logPick.Clicked(gtx) {
s.pathFeedback = "请选择日志目录" s.chooseDirectory(directoryPickLogDirectory)
} }
for s.logSearch.Clicked(gtx) { for s.logSearch.Clicked(gtx) {
s.togglePathSearch(PathLogDirectory) s.togglePathSearch(PathLogDirectory)
} }
for s.dataRestore.Clicked(gtx) {
s.restorePortableDirectory(PathDefaultUserData)
}
for s.logRestore.Clicked(gtx) {
s.restorePortableDirectory(PathLogDirectory)
}
for s.proxySave.Clicked(gtx) { for s.proxySave.Clicked(gtx) {
s.saveProxy() s.saveProxy()
} }
@@ -1986,6 +2049,10 @@ func (s *Shell) settings(gtx layout.Context) layout.Dimensions {
continue continue
} }
s.remoteDebugPort.SetText(strconv.Itoa(settings.RemoteDebugStartPort)) 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 { if s.onSave != nil {
s.onSave(settings) s.onSave(settings)
} }
@@ -2018,9 +2085,9 @@ func (s *Shell) settingsWorkspace(gtx layout.Context) layout.Dimensions {
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(s.settingsSection("默认目录", "这些目录只作为新建实例和诊断日志的默认位置,不会移动或删除已有 profile。", func(gtx layout.Context) layout.Dimensions { layout.Rigid(s.settingsSection("默认目录", "这些目录只作为新建实例和诊断日志的默认位置,不会移动或删除已有 profile。", func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(s.pathField(PathDefaultUserData, "默认 User Data Dir", &s.dataDir, &s.dataPick, &s.dataSearch)), 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(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(s.pathField(PathLogDirectory, "日志目录", &s.logDir, &s.logPick, &s.logSearch)), layout.Rigid(s.portableDirectoryField(PathLogDirectory, "日志目录", &s.logDir, &s.logPick, &s.logSearch, &s.logRestore)),
) )
})), })),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
@@ -2182,6 +2249,26 @@ func (s *Shell) pathField(field PathField, label string, editor *widget.Editor,
} }
} }
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 { func (s *Shell) createInstance(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.H4(s.theme, "新建实例").Layout), layout.Rigid(material.H4(s.theme, "新建实例").Layout),
@@ -2446,7 +2533,7 @@ func (s *Shell) proxyPickerNoneLabel() string {
func (s *Shell) instanceDirField(gtx layout.Context) layout.Dimensions { func (s *Shell) instanceDirField(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.Body2(s.theme, "User Data Dir").Layout), layout.Rigid(material.Body2(s.theme, "User Data Dir").Layout),
layout.Rigid(material.Caption(s.theme, "浏览器独立数据目录;必须是绝对路径").Layout), layout.Rigid(material.Caption(s.theme, "默认目录会使用 instance-实例名-短 ID;必须是绝对路径").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx, return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
@@ -2459,11 +2546,20 @@ func (s *Shell) instanceDirField(gtx layout.Context) layout.Dimensions {
} }
func (s *Shell) createInstanceFromForm() { func (s *Shell) createInstanceFromForm() {
name := strings.TrimSpace(s.instanceName.Text()) name, err := s.validateInstanceName(s.instanceName.Text(), "")
if name == "" { if err != nil {
s.formFeedback = "请输入实例名称后再创建。" s.formFeedback = err.Error()
return 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()) userDataDir := strings.TrimSpace(s.instanceDir.Text())
if userDataDir == "" || !filepath.IsAbs(userDataDir) { if userDataDir == "" || !filepath.IsAbs(userDataDir) {
s.formFeedback = "请选择或输入绝对路径的 User Data Dir 后再创建。" s.formFeedback = "请选择或输入绝对路径的 User Data Dir 后再创建。"
@@ -2495,14 +2591,66 @@ func (s *Shell) createInstanceFromForm() {
s.page = pageInstances s.page = pageInstances
s.formFeedback = "" s.formFeedback = ""
s.pendingCreateID = "" s.pendingCreateID = ""
s.defaultCreateDirActive = false
s.lastSuggestedCreateDir = ""
s.notifyInstancesChanged() s.notifyInstancesChanged()
} }
func (s *Shell) defaultCreateInstanceDir(instanceID string) string { func (s *Shell) defaultCreateInstanceDir(name, instanceID string) string {
if s.defaultInstanceDirRoot == "" || instanceID == "" { if s.defaultInstanceDirRoot == "" || instanceID == "" {
return "" return ""
} }
return filepath.Join(s.defaultInstanceDirRoot, instanceID) 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 { func (s *Shell) allocateInstanceID() string {
@@ -2668,6 +2816,9 @@ func (s *Shell) pathPickButtonLabel(field PathField) string {
if s.executablePick.running && s.executablePick.field == field { if s.executablePick.running && s.executablePick.field == field {
return "选择中…" return "选择中…"
} }
if s.directoryPick.running && ((field == PathDefaultUserData && s.directoryPick.target == directoryPickDefaultUserData) || (field == PathLogDirectory && s.directoryPick.target == directoryPickLogDirectory)) {
return "选择中…"
}
return "选择" return "选择"
} }
@@ -3442,16 +3593,75 @@ func (s *Shell) settingsState() (SettingsState, error) {
if err != nil { if err != nil {
return SettingsState{}, err 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{ return SettingsState{
ChromePath: s.chromePath.Text(), ChromePath: s.chromePath.Text(),
EdgePath: s.edgePath.Text(), EdgePath: s.edgePath.Text(),
DefaultDir: s.dataDir.Text(), DefaultDir: defaultDir,
LogDir: s.logDir.Text(), DefaultDirMode: s.selectedDirectoryMode(defaultDir, s.portableDefaultDir, s.defaultDirMode),
LogDir: logDir,
LogDirMode: s.selectedDirectoryMode(logDir, s.portableLogDir, s.logDirMode),
RemoteDebugStartPort: port, RemoteDebugStartPort: port,
CloseOnExit: s.closeOnExit.Value, CloseOnExit: s.closeOnExit.Value,
}, nil }, 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) { func normalizeRemoteDebugStartPort(value string) (int, error) {
value = strings.TrimSpace(value) value = strings.TrimSpace(value)
if value == "" { if value == "" {
@@ -3584,19 +3794,11 @@ func (s *Shell) chooseInstanceDirectory() {
func (s *Shell) chooseDirectory(target directoryPickTarget) { func (s *Shell) chooseDirectory(target directoryPickTarget) {
if s.directoryPick.running { if s.directoryPick.running {
if target == directoryPickEdit { s.setDirectoryPickFeedback(target, "目录选择器已打开,请在系统窗口中选择或取消。")
s.editFeedback = "目录选择器已打开,请在系统窗口中选择或取消。"
} else {
s.formFeedback = "目录选择器已打开,请在系统窗口中选择或取消。"
}
return return
} }
if s.directoryChooser == nil { if s.directoryChooser == nil {
if target == directoryPickEdit { s.setDirectoryPickFeedback(target, "目录选择器尚未准备好。")
s.editFeedback = "目录选择器尚未准备好。"
} else {
s.formFeedback = "目录选择器尚未准备好。"
}
return return
} }
s.directoryPick.request++ s.directoryPick.request++
@@ -3605,11 +3807,7 @@ func (s *Shell) chooseDirectory(target directoryPickTarget) {
s.directoryPick.cancel = cancel s.directoryPick.cancel = cancel
s.directoryPick.running = true s.directoryPick.running = true
s.directoryPick.target = target s.directoryPick.target = target
if target == directoryPickEdit { s.setDirectoryPickFeedback(target, "正在打开目录选择器…")
s.editFeedback = "正在打开目录选择器…"
} else {
s.formFeedback = "正在打开目录选择器…"
}
go func() { go func() {
path, err := s.directoryChooser(ctx) path, err := s.directoryChooser(ctx)
s.directoryResults <- directoryPickResult{request: request, path: path, err: err} s.directoryResults <- directoryPickResult{request: request, path: path, err: err}
@@ -3631,22 +3829,30 @@ func (s *Shell) consumeDirectoryResults() {
s.directoryPick.cancel = nil s.directoryPick.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.setDirectoryPickFeedback(target, "已取消选择 User Data Dir。") s.setDirectoryPickFeedback(target, "已取消选择目录。")
} else { } else {
s.setDirectoryPickFeedback(target, fmt.Sprintf("选择 User Data Dir 失败:%v", result.err)) s.setDirectoryPickFeedback(target, fmt.Sprintf("选择目录失败:%v", result.err))
} }
continue continue
} }
if result.path == "" { if result.path == "" {
s.setDirectoryPickFeedback(target, "未选择 User Data Dir。") s.setDirectoryPickFeedback(target, "未选择目录。")
continue continue
} }
if target == directoryPickEdit { switch target {
case directoryPickEdit:
s.editDir.SetText(result.path) s.editDir.SetText(result.path)
} else { 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.instanceDir.SetText(result.path)
s.defaultCreateDirActive = false
} }
s.setDirectoryPickFeedback(target, "已更新 User Data Dir。") s.setDirectoryPickFeedback(target, "已更新目录。")
default: default:
return return
} }
@@ -3654,24 +3860,26 @@ func (s *Shell) consumeDirectoryResults() {
} }
func (s *Shell) setDirectoryPickFeedback(target directoryPickTarget, message string) { func (s *Shell) setDirectoryPickFeedback(target directoryPickTarget, message string) {
if target == directoryPickEdit { switch target {
case directoryPickEdit:
s.editFeedback = message s.editFeedback = message
return case directoryPickDefaultUserData:
} s.setPathFieldFeedback(PathDefaultUserData, message)
case directoryPickLogDirectory:
s.setPathFieldFeedback(PathLogDirectory, message)
default:
s.formFeedback = message s.formFeedback = message
} }
}
func (s *Shell) instanceDirButtonLabel() string { func (s *Shell) instanceDirButtonLabel() string {
if s.directoryPick.running { if s.directoryPick.running && s.directoryPick.target == directoryPickCreate {
return "选择中…" return "选择中…"
} }
return "选择路径" return "选择路径"
} }
func (s *Shell) resetSettings() { func (s *Shell) resetSettings() {
s.chromePath.SetText(`C:\Program Files\Google\Chrome\Application\chrome.exe`) s.SetSettings(s.lastSavedSettings)
s.edgePath.SetText(`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`) s.pathFeedback = "已恢复未保存前的设置。"
s.dataDir.SetText(`C:\Users\Public\chub\profiles`)
s.logDir.SetText(`C:\Users\Public\chub\logs`)
s.remoteDebugPort.SetText(strconv.Itoa(domain.DefaultRemoteDebugPort))
} }
+49 -2
View File
@@ -618,6 +618,48 @@ func TestShellSettingsCanBeRestored(t *testing.T) {
} }
} }
func TestShellTracksPortableSettingsDirectoriesAndRestoresThem(t *testing.T) {
shell := NewShell(material.NewTheme())
root := t.TempDir()
portableData := filepath.Join(root, "user_data_dirs")
portableLogs := filepath.Join(root, "logs")
shell.SetPortableDirectoryDefaults(portableData, portableLogs)
shell.SetSettings(SettingsState{DefaultDir: portableData, DefaultDirMode: "portable", LogDir: portableLogs, LogDirMode: "portable", RemoteDebugStartPort: 9666, CloseOnExit: true})
shell.dataDir.SetText(filepath.Join(root, "custom"))
state, err := shell.settingsState()
if err != nil || state.DefaultDirMode != "custom" {
t.Fatalf("custom settings state = %#v, error = %v", state, err)
}
shell.restorePortableDirectory(PathDefaultUserData)
state, err = shell.settingsState()
if err != nil || state.DefaultDir != portableData || state.DefaultDirMode != "portable" {
t.Fatalf("restored settings state = %#v, error = %v", state, err)
}
}
func TestShellRejectsDuplicateInstanceNamesButAllowsEditingSelf(t *testing.T) {
shell := NewShell(material.NewTheme())
shell.SetInstances([]InstanceRow{
{ID: "one", Name: "Audit", Browser: "Chrome", UserDataDir: `C:\profiles\one`, Status: "已退出", PreferredRemoteDebugPort: 9666},
{ID: "two", Name: "投放", Browser: "Edge", UserDataDir: `C:\profiles\two`, Status: "已退出", PreferredRemoteDebugPort: 9667},
})
shell.instanceName.SetText("AUDIT")
shell.instanceDir.SetText(`C:\profiles\three`)
shell.instancePort.SetText("9668")
shell.createInstanceFromForm()
if len(shell.rows) != 2 || !strings.Contains(shell.formFeedback, "实例名称") {
t.Fatalf("duplicate instance create = rows %#v, feedback %q", shell.rows, shell.formFeedback)
}
shell.beginEdit("one")
shell.editName.SetText("Audit")
shell.editURL.SetText("https://example.com")
shell.saveEdit()
row, _ := shell.instanceRow("one")
if row.TargetURL != "https://example.com" || shell.editingID != "" {
t.Fatalf("self edit = %#v, feedback %q", row, shell.editFeedback)
}
}
func TestShellNormalizesRemoteDebugStartPort(t *testing.T) { func TestShellNormalizesRemoteDebugStartPort(t *testing.T) {
shell := NewShell(material.NewTheme()) shell := NewShell(material.NewTheme())
shell.remoteDebugPort.SetText("") shell.remoteDebugPort.SetText("")
@@ -1011,10 +1053,15 @@ func TestShellUsesDistinctDefaultDirectoriesForNewInstances(t *testing.T) {
shell.beginCreate() shell.beginCreate()
firstID := shell.pendingCreateID firstID := shell.pendingCreateID
firstDir := shell.instanceDir.Text() firstDir := shell.instanceDir.Text()
if firstID == "" || firstDir != filepath.Join(root, firstID) { if firstID == "" || filepath.Dir(firstDir) != root || !strings.HasPrefix(filepath.Base(firstDir), "instance-新实例-") {
t.Fatalf("first default directory = %q for id %q", firstDir, firstID) t.Fatalf("first default directory = %q for id %q", firstDir, firstID)
} }
shell.instanceName.SetText("第一个实例") shell.instanceName.SetText("第一个实例")
shell.syncDefaultCreateDirectory()
firstDir = shell.instanceDir.Text()
if !strings.HasPrefix(filepath.Base(firstDir), "instance-第一个实例-") {
t.Fatalf("named default directory = %q", firstDir)
}
shell.createInstanceFromForm() shell.createInstanceFromForm()
created := shell.rows[len(shell.rows)-1] created := shell.rows[len(shell.rows)-1]
if created.ID != firstID || created.UserDataDir != firstDir { if created.ID != firstID || created.UserDataDir != firstDir {
@@ -1024,7 +1071,7 @@ func TestShellUsesDistinctDefaultDirectoriesForNewInstances(t *testing.T) {
shell.beginCreate() shell.beginCreate()
secondID := shell.pendingCreateID secondID := shell.pendingCreateID
secondDir := shell.instanceDir.Text() secondDir := shell.instanceDir.Text()
if secondID == "" || secondID == firstID || secondDir != filepath.Join(root, secondID) || secondDir == firstDir { if secondID == "" || secondID == firstID || filepath.Dir(secondDir) != root || secondDir == firstDir {
t.Fatalf("second default directory = %q for id %q, first %q", secondDir, secondID, firstDir) t.Fatalf("second default directory = %q for id %q, first %q", secondDir, secondID, firstDir)
} }
} }