diff --git a/cmd/chub/main.go b/cmd/chub/main.go index d2e8324..937ffcb 100644 --- a/cmd/chub/main.go +++ b/cmd/chub/main.go @@ -5,6 +5,7 @@ import ( "log/slog" "os" + "chub/internal/platform/config" "chub/internal/platform/logging" "chub/internal/ui" "gioui.org/app" @@ -29,6 +30,22 @@ func runWindow(logger *slog.Logger) { window.Option(app.Title("Chub 浏览器管理"), app.Size(unit.Dp(1100), unit.Dp(720))) theme := material.NewTheme() shell := ui.NewShell(theme) + if path, err := config.DefaultPath(); err == nil { + if store, err := config.New(path); err == nil { + if saved, err := store.Load(); err == nil { + shell.SetSettings(ui.SettingsState{ChromePath: saved.Settings.ChromePath, EdgePath: saved.Settings.EdgePath, DefaultDir: saved.Settings.DefaultDir, LogDir: saved.Settings.LogDir, CloseOnExit: saved.Settings.CloseOnExit}) + rows := make([]ui.InstanceRow, 0, len(saved.Instances)) + for _, item := range saved.Instances { + rows = append(rows, ui.InstanceRow{Name: item.Name, Browser: string(item.Launch.Kind), Profile: item.Launch.ProfileDirectory, Status: "已退出"}) + } + shell.SetInstances(rows) + shell.OnSave(func(value ui.SettingsState) { + saved.Settings = config.Settings{ChromePath: value.ChromePath, EdgePath: value.EdgePath, DefaultDir: value.DefaultDir, LogDir: value.LogDir, CloseOnExit: value.CloseOnExit} + go saveSettings(logger, store, saved) + }) + } + } + } var ops op.Ops for { switch event := window.Event().(type) { @@ -44,3 +61,10 @@ func runWindow(logger *slog.Logger) { } } } + +func saveSettings(logger *slog.Logger, store *config.Store, value config.File) { + err := store.Save(value) + if err != nil { + logger.Error("save settings", "error", err) + } +} diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 6f9d8e8..30e168a 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -24,7 +24,7 @@ | ID | 任务 | 依赖 | 状态 | | --- | --- | --- | --- | | T-201 | HTML 原型、Gio 外壳和实例列表/表单 | T-002 | DONE | -| T-202 | 本地配置持久化和实例恢复 | T-101 | TODO | +| T-202 | 本地配置持久化和实例恢复 | T-101 | DONE | | T-203 | CLI 合约和本地事件推送 | T-103 | TODO | | T-204 | UI 全状态验收与 Windows 打包 smoke | T-201,T-202,T-203 | TODO | diff --git a/docs/current-state.md b/docs/current-state.md index 5fe217d..cabc378 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -3,11 +3,11 @@ ## 快照 - 日期:2026-07-22 -- 阶段:Phase 2 产品外壳(T-201 已完成) -- 代码:已建立 Go module `chub`、`cmd/chub` 入口、logging 测试基座、浏览器 domain/application 合约、Chrome/Edge 参数/发现模块、启动 registry、Windows 身份/占用检查、优雅关闭、Job Object 和真实 Chrome/Edge smoke,T-001 至 T-003、T-101 至 T-104、T-201 已完成 -- UI:已接入 Gio v0.10.1 双页桌面壳,包含实例列表、状态展示、启动入口、设置表单;配置保存和真实服务接入由 T-202/T-203 完成 +- 阶段:Phase 2 产品外壳(T-201、T-202 已完成) +- 代码:已建立 Go module `chub`、`cmd/chub` 入口、logging 测试基座、浏览器 domain/application 合约、Chrome/Edge 参数/发现模块、启动 registry、Windows 身份/占用检查、优雅关闭、Job Object、真实 Chrome/Edge smoke、JSON 配置存储和启动恢复,T-001 至 T-003、T-101 至 T-104、T-201 至 T-202 已完成 +- UI:已接入 Gio v0.10.1 双页桌面壳,设置保存异步写入本地配置,启动时恢复设置和已保存实例;CLI/实时事件由 T-203 接入 - 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目 -- blocker:无;下一个任务为 T-202(本地配置持久化和实例恢复) +- blocker:无;下一个任务为 T-203(CLI 合约和本地事件推送) ## 当前目录 diff --git a/docs/tasks/T-202.md b/docs/tasks/T-202.md new file mode 100644 index 0000000..139fb88 --- /dev/null +++ b/docs/tasks/T-202.md @@ -0,0 +1,36 @@ +--- +id: T-202 +title: 实现本地配置持久化和实例恢复 +phase: 2 +deps: [T-101] +status: DONE +created: 2026-07-22 +owner: codex +--- + +## 需求与背景 + +用户配置和实例定义必须在 Chub 重启后保留,且不能因为 UI 帧刷新或设置保存而丢失其他实例。 + +## 方案与边界 + +- MVP 使用用户配置目录下的 `chub/config.json`,结构带 `version` 字段,便于后续迁移。 +- 只保存可复现启动所需的路径、目录、浏览器类型、Profile 和 URL 等配置;不保存密码、Cookie、Token。 +- 保存采用 0600 临时文件、同步后替换目标文件,并保留失败回滚路径。 +- 启动阶段加载设置和实例定义;实例恢复为“已退出”视图,真实进程重连由后续服务层完成。 +- Gio 点击保存只调度异步写入,不在 FrameEvent 内执行文件 I/O。 + +## 验收要点 + +- 缺少配置文件时返回带默认值的空配置。 +- 配置可保存、重新加载,实例定义不丢失。 +- JSON 损坏或版本不支持时返回明确错误。 +- `go test ./...`、`go vet ./...`、`go build -o build/chub.exe ./cmd/chub` 通过。 + +## 执行记录 + +- 状态:DONE +- 变更:新增 `internal/platform/config.Store`、JSON 版本结构、原子替换写入和 round-trip 测试;Gio 设置保存异步落盘,启动时恢复设置及已保存实例。 +- 验证:`gofmt`、`go test ./...`、`go vet ./...`、`go build -o build/chub.exe ./cmd/chub` 均通过。 +- 阻塞:无。 +- 残余风险:实例恢复目前只恢复配置视图,尚未自动重启浏览器或订阅实时进程事件;由 T-203 处理。 diff --git a/internal/platform/config/store.go b/internal/platform/config/store.go new file mode 100644 index 0000000..c3330d6 --- /dev/null +++ b/internal/platform/config/store.go @@ -0,0 +1,143 @@ +package config + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "chub/internal/domain" +) + +const currentVersion = 1 + +type Settings struct { + ChromePath string `json:"chromePath"` + EdgePath string `json:"edgePath"` + DefaultDir string `json:"defaultUserDataDir"` + LogDir string `json:"logDir"` + CloseOnExit bool `json:"closeOnExit"` +} + +type Instance struct { + ID string `json:"id"` + Name string `json:"name"` + Launch domain.LaunchSpec `json:"launch"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type File struct { + Version int `json:"version"` + Settings Settings `json:"settings"` + Instances []Instance `json:"instances"` +} + +type Store struct{ path string } + +func New(path string) (*Store, error) { + if path == "" { + return nil, errors.New("config path is required") + } + return &Store{path: filepath.Clean(path)}, nil +} + +func DefaultPath() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("resolve user config dir: %w", err) + } + return filepath.Join(dir, "chub", "config.json"), nil +} + +func (s *Store) Path() string { return s.path } + +func (s *Store) Load() (File, error) { + data, err := os.ReadFile(s.path) + if errors.Is(err, os.ErrNotExist) { + return File{Version: currentVersion, Settings: DefaultSettings()}, nil + } + if err != nil { + return File{}, fmt.Errorf("read config: %w", err) + } + var result File + if err := json.Unmarshal(data, &result); err != nil { + return File{}, fmt.Errorf("decode config: %w", err) + } + if result.Version == 0 { + result.Version = currentVersion + } + if result.Version != currentVersion { + return File{}, fmt.Errorf("unsupported config version %d", result.Version) + } + return result, nil +} + +func DefaultSettings() Settings { + return Settings{ + ChromePath: `C:\Program Files\Google\Chrome\Application\chrome.exe`, + EdgePath: `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`, + DefaultDir: `C:\Users\Public\chub\profiles`, + LogDir: `C:\Users\Public\chub\logs`, + CloseOnExit: true, + } +} + +func (s *Store) Save(value File) error { + value.Version = currentVersion + if value.Instances == nil { + value.Instances = []Instance{} + } + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + return fmt.Errorf("encode config: %w", err) + } + if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil { + return fmt.Errorf("create config dir: %w", err) + } + tmp, err := os.CreateTemp(filepath.Dir(s.path), ".config-*.tmp") + if err != nil { + return fmt.Errorf("create config temp: %w", err) + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if err := tmp.Chmod(0o600); err != nil { + _ = tmp.Close() + return fmt.Errorf("protect config temp: %w", err) + } + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return fmt.Errorf("write config temp: %w", err) + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return fmt.Errorf("sync config temp: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close config temp: %w", err) + } + if err := replace(s.path, tmpName); err != nil { + return err + } + return nil +} + +func replace(target, temp string) error { + backup := target + ".bak" + _, statErr := os.Stat(target) + if statErr == nil { + _ = os.Remove(backup) + if err := os.Rename(target, backup); err != nil { + return fmt.Errorf("backup config: %w", err) + } + } + if err := os.Rename(temp, target); err != nil { + if statErr == nil { + _ = os.Rename(backup, target) + } + return fmt.Errorf("replace config: %w", err) + } + _ = os.Remove(backup) + return nil +} diff --git a/internal/platform/config/store_test.go b/internal/platform/config/store_test.go new file mode 100644 index 0000000..14f1bf2 --- /dev/null +++ b/internal/platform/config/store_test.go @@ -0,0 +1,41 @@ +package config + +import ( + "path/filepath" + "testing" + + "chub/internal/domain" +) + +func TestStoreRoundTripAndCreatesPrivateFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "nested", "config.json") + store, err := New(path) + if err != nil { + t.Fatal(err) + } + want := File{Settings: Settings{ChromePath: "chrome.exe", CloseOnExit: true}, Instances: []Instance{{ID: "a", Name: "运营", Launch: domain.LaunchSpec{Kind: domain.BrowserChrome, UserDataDir: `C:\profiles\a`}}}} + if err := store.Save(want); err != nil { + t.Fatal(err) + } + got, err := store.Load() + if err != nil { + t.Fatal(err) + } + if got.Version != currentVersion || got.Settings != want.Settings || len(got.Instances) != 1 || got.Instances[0].ID != "a" { + t.Fatalf("round trip mismatch: %#v", got) + } +} + +func TestStoreMissingFileReturnsDefaults(t *testing.T) { + store, err := New(filepath.Join(t.TempDir(), "config.json")) + if err != nil { + t.Fatal(err) + } + got, err := store.Load() + if err != nil { + t.Fatal(err) + } + if got.Version != currentVersion || len(got.Instances) != 0 { + t.Fatalf("unexpected defaults: %#v", got) + } +} diff --git a/internal/ui/shell.go b/internal/ui/shell.go index a2196b9..ab4ce5b 100644 --- a/internal/ui/shell.go +++ b/internal/ui/shell.go @@ -25,6 +25,14 @@ type InstanceRow struct { Status string } +type SettingsState struct { + ChromePath string + EdgePath string + DefaultDir string + LogDir string + CloseOnExit bool +} + // Shell owns every interactive Gio widget. Keeping this state outside Layout // prevents focus, editor content, and list scroll position from resetting. type Shell struct { @@ -44,8 +52,9 @@ type Shell struct { logDir widget.Editor closeOnExit widget.Bool - list widget.List - rows []InstanceRow + list widget.List + rows []InstanceRow + onSave func(SettingsState) } func NewShell(theme *material.Theme) *Shell { @@ -63,6 +72,23 @@ func NewShell(theme *material.Theme) *Shell { return s } +func (s *Shell) SetInstances(rows []InstanceRow) { + if len(rows) == 0 { + return + } + s.rows = append([]InstanceRow(nil), rows...) +} + +func (s *Shell) SetSettings(value SettingsState) { + s.chromePath.SetText(value.ChromePath) + s.edgePath.SetText(value.EdgePath) + s.dataDir.SetText(value.DefaultDir) + s.logDir.SetText(value.LogDir) + s.closeOnExit.Value = value.CloseOnExit +} + +func (s *Shell) OnSave(fn func(SettingsState)) { s.onSave = fn } + func (s *Shell) Layout(gtx layout.Context) layout.Dimensions { for s.instancesClick.Clicked(gtx) { s.page = pageInstances @@ -136,7 +162,10 @@ func statusLabel(theme *material.Theme, status string) layout.Widget { } func (s *Shell) settings(gtx layout.Context) layout.Dimensions { - for s.saveClick.Clicked(gtx) { /* persistence is T-202; retain edited values for now */ + for s.saveClick.Clicked(gtx) { + if s.onSave != nil { + s.onSave(SettingsState{ChromePath: s.chromePath.Text(), EdgePath: s.edgePath.Text(), DefaultDir: s.dataDir.Text(), LogDir: s.logDir.Text(), CloseOnExit: s.closeOnExit.Value}) + } } for s.cancelClick.Clicked(gtx) { s.resetSettings()