feat: add portable directory safeguards
This commit is contained in:
+33
-8
@@ -29,8 +29,8 @@ import (
|
|||||||
const version = "0.1.0-dev"
|
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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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` 均通过。
|
||||||
- 阻塞:无。
|
- 阻塞:无。
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,13 +17,34 @@ 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"`
|
||||||
LogDir string `json:"logDir"`
|
DefaultDirSource DirectorySource `json:"defaultUserDataDirSource,omitempty"`
|
||||||
RemoteDebugStartPort int `json:"remoteDebugStartPort"`
|
LogDir string `json:"logDir"`
|
||||||
CloseOnExit bool `json:"closeOnExit"`
|
LogDirSource DirectorySource `json:"logDirSource,omitempty"`
|
||||||
|
RemoteDebugStartPort int `json:"remoteDebugStartPort"`
|
||||||
|
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 {
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user