feat: persist chub settings and instances
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user