feat: persist chub settings and instances
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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 |
|
||||
|
||||
|
||||
@@ -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 合约和本地事件推送)
|
||||
|
||||
## 当前目录
|
||||
|
||||
|
||||
@@ -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 处理。
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+32
-3
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user