Files
cdp_hub/internal/platform/config/store.go
T

244 lines
6.8 KiB
Go

package config
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"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"`
RemoteDebugStartPort int `json:"remoteDebugStartPort"`
CloseOnExit bool `json:"closeOnExit"`
}
type Instance struct {
ID string `json:"id"`
Name string `json:"name"`
ProxyID string `json:"proxyId,omitempty"`
Launch domain.LaunchSpec `json:"launch"`
UpdatedAt time.Time `json:"updatedAt"`
}
// ProxyProfile is deliberately limited to a display name and a validated,
// non-authenticated endpoint. Credentials must never enter local config.
type ProxyProfile struct {
ID string `json:"id"`
Name string `json:"name"`
Server string `json:"server"`
UpdatedAt time.Time `json:"updatedAt"`
}
type File struct {
Version int `json:"version"`
Settings Settings `json:"settings"`
Instances []Instance `json:"instances"`
Proxies []ProxyProfile `json:"proxies"`
}
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)
}
if result.Settings.RemoteDebugStartPort == 0 {
result.Settings.RemoteDebugStartPort = domain.DefaultRemoteDebugPort
}
if err := normalizeProxyConfig(&result); err != nil {
return File{}, err
}
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`,
RemoteDebugStartPort: domain.DefaultRemoteDebugPort,
CloseOnExit: true,
}
}
func (s *Store) Save(value File) error {
value.Version = currentVersion
if value.Instances == nil {
value.Instances = []Instance{}
}
if value.Proxies == nil {
value.Proxies = []ProxyProfile{}
}
if err := normalizeProxyConfig(&value); err != nil {
return err
}
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 normalizeProxyConfig(value *File) error {
if value == nil {
return errors.New("config is required")
}
byID := make(map[string]ProxyProfile, len(value.Proxies))
names := make(map[string]struct{}, len(value.Proxies))
for i := range value.Proxies {
profile := &value.Proxies[i]
profile.ID = strings.TrimSpace(profile.ID)
profile.Name = strings.TrimSpace(profile.Name)
if profile.ID == "" || profile.Name == "" {
return errors.New("invalid proxy configuration")
}
if _, exists := byID[profile.ID]; exists {
return errors.New("duplicate proxy configuration")
}
nameKey := strings.ToLower(profile.Name)
if _, exists := names[nameKey]; exists {
return errors.New("duplicate proxy configuration")
}
server, err := domain.NormalizeProxyServer(profile.Server)
if err != nil || server == "" {
return errors.New("invalid proxy configuration")
}
profile.Server = server
byID[profile.ID] = *profile
names[nameKey] = struct{}{}
}
legacyByServer := make(map[string]string, len(value.Proxies))
for _, profile := range value.Proxies {
legacyByServer[profile.Server] = profile.ID
}
for i := range value.Instances {
instance := &value.Instances[i]
instance.ProxyID = strings.TrimSpace(instance.ProxyID)
if instance.ProxyID != "" {
if _, exists := byID[instance.ProxyID]; !exists {
return errors.New("instance refers to an unavailable proxy")
}
instance.Launch.ProxyServer = ""
continue
}
if strings.TrimSpace(instance.Launch.ProxyServer) == "" {
continue
}
server, err := domain.NormalizeProxyServer(instance.Launch.ProxyServer)
if err != nil || server == "" {
return errors.New("invalid proxy configuration")
}
proxyID := legacyByServer[server]
if proxyID == "" {
proxyID = nextLegacyProxyID(byID)
profile := ProxyProfile{ID: proxyID, Name: "导入代理 " + strconv.Itoa(len(value.Proxies)+1), Server: server}
value.Proxies = append(value.Proxies, profile)
byID[proxyID] = profile
legacyByServer[server] = proxyID
}
instance.ProxyID = proxyID
instance.Launch.ProxyServer = ""
}
return nil
}
func nextLegacyProxyID(existing map[string]ProxyProfile) string {
for index := 1; ; index++ {
candidate := "legacy-proxy-" + strconv.Itoa(index)
if _, exists := existing[candidate]; !exists {
return candidate
}
}
}
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
}