feat: persist chub settings and instances

This commit is contained in:
QiuSW
2026-07-22 15:17:08 +08:00
parent 28fefc76f7
commit 82f3c1be0e
7 changed files with 281 additions and 8 deletions
+143
View File
@@ -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
}
+41
View File
@@ -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
View File
@@ -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()