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

631 lines
19 KiB
Go

package config
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"chub/internal/domain"
)
const currentVersion = 1
var ErrDefaultConfigUnavailable = errors.New("default config is unavailable")
type DirectorySource string
const (
DirectorySourcePortable DirectorySource = "portable"
DirectorySourceCustom DirectorySource = "custom"
)
const (
legacyDefaultUserDataDir = `C:\Users\Public\chub\profiles`
legacyLogDir = `C:\Users\Public\chub\logs`
)
type Settings struct {
ChromePath string `json:"chromePath"`
EdgePath string `json:"edgePath"`
DefaultDir string `json:"defaultUserDataDir"`
DefaultDirSource DirectorySource `json:"defaultUserDataDirSource,omitempty"`
LogDir string `json:"logDir"`
LogDirSource DirectorySource `json:"logDirSource,omitempty"`
RemoteDebugStartPort int `json:"remoteDebugStartPort"`
CloseOnExit bool `json:"closeOnExit"`
}
// PortableDirectories are resolved from the actual executable location for
// the default store, never from the process working directory.
type PortableDirectories struct {
DefaultDir string
LogDir string
}
type Instance struct {
ID string `json:"id"`
Name string `json:"name"`
ProxyID string `json:"proxyId,omitempty"`
PreferredRemoteDebugPort int `json:"preferredRemoteDebugPort,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) {
executable, err := os.Executable()
if err != nil {
return "", fmt.Errorf("%w: resolve executable", ErrDefaultConfigUnavailable)
}
return defaultPathForExecutable(executable)
}
// DefaultPortableDirectories resolves the portable defaults beside the actual
// executable. It is useful before config.json can be opened so the UI can
// still offer a recoverable manual-path flow.
func DefaultPortableDirectories() (PortableDirectories, error) {
executable, err := os.Executable()
if err != nil {
return PortableDirectories{}, fmt.Errorf("%w: resolve executable", ErrDefaultConfigUnavailable)
}
return portableDirectoriesForExecutable(executable)
}
// DefaultInstanceUserDataRoot returns the portable root for new browser
// profiles. It resolves the actual executable location and never uses cwd.
func DefaultInstanceUserDataRoot() (string, error) {
directories, err := DefaultPortableDirectories()
if err != nil {
return "", err
}
return directories.DefaultDir, nil
}
// DefaultInstanceUserDataDir returns a unique-instance profile path below the
// executable-side user_data_dirs root. It only computes a path; it does not
// create directories or validate their write permissions.
func DefaultInstanceUserDataDir(instanceID string) (string, error) {
root, err := DefaultInstanceUserDataRoot()
if err != nil {
return "", err
}
return defaultInstanceUserDataDir(root, instanceID)
}
// LegacyPath is the pre-portable configuration location. It is read only when
// the executable-side config does not exist yet.
func LegacyPath() (string, error) {
dir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("%w: resolve legacy config directory", ErrDefaultConfigUnavailable)
}
return filepath.Join(dir, "chub", "config.json"), nil
}
// OpenDefault returns the config store beside the currently executing binary.
// A valid legacy user config is copied there once only when the target does not
// already exist. The legacy file is deliberately retained for recovery.
func OpenDefault() (*Store, error) {
target, err := DefaultPath()
if err != nil {
return nil, err
}
store, targetExists, err := defaultStore(target)
if err != nil || targetExists {
return store, err
}
legacy, err := LegacyPath()
if err != nil {
return nil, err
}
return migrateLegacyConfig(store, legacy)
}
func (s *Store) Path() string { return s.path }
// PortableDirectories resolves defaults relative to this store's config.json
// location. OpenDefault always places that file beside chub.exe.
func (s *Store) PortableDirectories() (PortableDirectories, error) {
return portableDirectoriesForConfigPath(s.path)
}
func defaultPathForExecutable(executable string) (string, error) {
path := strings.TrimSpace(executable)
if path == "" || !filepath.IsAbs(path) {
return "", fmt.Errorf("%w: executable path must be absolute", ErrDefaultConfigUnavailable)
}
return filepath.Join(filepath.Dir(filepath.Clean(path)), "config.json"), nil
}
func defaultInstanceUserDataRootForExecutable(executable string) (string, error) {
directories, err := portableDirectoriesForExecutable(executable)
if err != nil {
return "", err
}
return directories.DefaultDir, nil
}
func portableDirectoriesForExecutable(executable string) (PortableDirectories, error) {
path := strings.TrimSpace(executable)
if path == "" || !filepath.IsAbs(path) {
return PortableDirectories{}, fmt.Errorf("%w: executable path must be absolute", ErrDefaultConfigUnavailable)
}
return portableDirectoriesForBase(filepath.Dir(filepath.Clean(path)))
}
func portableDirectoriesForConfigPath(configPath string) (PortableDirectories, error) {
path := strings.TrimSpace(configPath)
if path == "" || !filepath.IsAbs(path) || !strings.EqualFold(filepath.Base(path), "config.json") {
return PortableDirectories{}, fmt.Errorf("%w: config path must be an absolute config.json path", ErrDefaultConfigUnavailable)
}
return portableDirectoriesForBase(filepath.Dir(filepath.Clean(path)))
}
func portableDirectoriesForBase(base string) (PortableDirectories, error) {
base = strings.TrimSpace(base)
if base == "" || !filepath.IsAbs(base) {
return PortableDirectories{}, fmt.Errorf("%w: portable base path must be absolute", ErrDefaultConfigUnavailable)
}
base = filepath.Clean(base)
return PortableDirectories{
DefaultDir: filepath.Join(base, "user_data_dirs"),
LogDir: filepath.Join(base, "logs"),
}, nil
}
func defaultInstanceUserDataDir(root, instanceID string) (string, error) {
root = strings.TrimSpace(root)
instanceID = strings.TrimSpace(instanceID)
if root == "" || !filepath.IsAbs(root) || instanceID == "" || filepath.Base(instanceID) != instanceID || instanceID == "." {
return "", fmt.Errorf("%w: invalid default instance directory", ErrDefaultConfigUnavailable)
}
return filepath.Join(filepath.Clean(root), instanceID), nil
}
func openDefaultAt(target, legacy string) (*Store, error) {
store, targetExists, err := defaultStore(target)
if err != nil || targetExists {
return store, err
}
return migrateLegacyConfig(store, legacy)
}
func defaultStore(target string) (*Store, bool, error) {
store, err := New(target)
if err != nil {
return nil, false, fmt.Errorf("%w: initialize target store", ErrDefaultConfigUnavailable)
}
info, err := os.Stat(store.Path())
if errors.Is(err, os.ErrNotExist) {
return store, false, nil
}
if err != nil {
return nil, false, fmt.Errorf("%w: inspect target config", ErrDefaultConfigUnavailable)
}
if info.IsDir() {
return nil, false, fmt.Errorf("%w: target config is a directory", ErrDefaultConfigUnavailable)
}
return store, true, nil
}
func migrateLegacyConfig(target *Store, legacyPath string) (*Store, error) {
if target == nil {
return nil, fmt.Errorf("%w: target store is unavailable", ErrDefaultConfigUnavailable)
}
legacy, err := New(legacyPath)
if err != nil {
return nil, fmt.Errorf("%w: initialize legacy store", ErrDefaultConfigUnavailable)
}
if filepath.Clean(target.Path()) == filepath.Clean(legacy.Path()) {
return target, nil
}
info, err := os.Stat(legacy.Path())
if errors.Is(err, os.ErrNotExist) {
return target, nil
}
if err != nil || info.IsDir() {
return nil, fmt.Errorf("%w: inspect legacy config", ErrDefaultConfigUnavailable)
}
value, err := legacy.Load()
if err != nil {
return nil, fmt.Errorf("%w: validate legacy config", ErrDefaultConfigUnavailable)
}
if err := target.Save(value); err != nil {
return nil, fmt.Errorf("%w: migrate legacy config", ErrDefaultConfigUnavailable)
}
return target, nil
}
func (s *Store) Load() (File, error) {
data, err := os.ReadFile(s.path)
if errors.Is(err, os.ErrNotExist) {
settings, settingsErr := s.defaultSettings()
if settingsErr != nil {
return File{}, settingsErr
}
return File{Version: currentVersion, Settings: settings}, 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 err := s.normalizeSettings(&result.Settings); err != nil {
return File{}, err
}
if err := normalizeInstanceRemoteDebugPorts(&result); err != nil {
return File{}, err
}
if err := normalizeInstanceNames(&result); err != nil {
return File{}, err
}
if err := normalizeProxyConfig(&result); err != nil {
return File{}, err
}
return result, nil
}
func DefaultSettings() Settings {
directories, err := DefaultPortableDirectories()
if err != nil {
return Settings{RemoteDebugStartPort: domain.DefaultRemoteDebugPort, CloseOnExit: true}
}
return defaultSettingsForDirectories(directories)
}
func (s *Store) defaultSettings() (Settings, error) {
directories, err := s.PortableDirectories()
if err != nil {
return Settings{}, err
}
return defaultSettingsForDirectories(directories), nil
}
func defaultSettingsForDirectories(directories PortableDirectories) Settings {
return Settings{
ChromePath: `C:\Program Files\Google\Chrome\Application\chrome.exe`,
EdgePath: `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`,
DefaultDir: directories.DefaultDir,
DefaultDirSource: DirectorySourcePortable,
LogDir: directories.LogDir,
LogDirSource: DirectorySourcePortable,
RemoteDebugStartPort: domain.DefaultRemoteDebugPort,
CloseOnExit: true,
}
}
func (s *Store) Save(value File) error {
value.Version = currentVersion
if err := s.normalizeSettings(&value.Settings); err != nil {
return err
}
if value.Instances == nil {
value.Instances = []Instance{}
}
if value.Proxies == nil {
value.Proxies = []ProxyProfile{}
}
if err := normalizeInstanceRemoteDebugPorts(&value); err != nil {
return err
}
if err := normalizeInstanceNames(&value); err != nil {
return err
}
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 (s *Store) normalizeSettings(settings *Settings) error {
if settings == nil {
return errors.New("settings are required")
}
directories, err := s.PortableDirectories()
if err != nil {
return err
}
defaultDir, defaultSource, err := normalizeDirectorySetting(settings.DefaultDir, settings.DefaultDirSource, directories.DefaultDir, legacyDefaultUserDataDir)
if err != nil {
return fmt.Errorf("invalid default user data directory: %w", err)
}
logDir, logSource, err := normalizeDirectorySetting(settings.LogDir, settings.LogDirSource, directories.LogDir, legacyLogDir)
if err != nil {
return fmt.Errorf("invalid log directory: %w", err)
}
settings.DefaultDir = defaultDir
settings.DefaultDirSource = defaultSource
settings.LogDir = logDir
settings.LogDirSource = logSource
if settings.RemoteDebugStartPort == 0 {
settings.RemoteDebugStartPort = domain.DefaultRemoteDebugPort
}
return nil
}
func normalizeDirectorySetting(value string, source DirectorySource, portableDefault, legacyDefault string) (string, DirectorySource, error) {
value = strings.TrimSpace(value)
source = DirectorySource(strings.ToLower(strings.TrimSpace(string(source))))
if source == "" {
if value == "" || sameDirectory(value, portableDefault) || sameDirectory(value, legacyDefault) {
source = DirectorySourcePortable
} else {
source = DirectorySourceCustom
}
}
switch source {
case DirectorySourcePortable:
return filepath.Clean(portableDefault), DirectorySourcePortable, nil
case DirectorySourceCustom:
if value == "" || !filepath.IsAbs(value) {
return "", "", errors.New("custom directory must be absolute")
}
return filepath.Clean(value), DirectorySourceCustom, nil
default:
return "", "", errors.New("directory source must be portable or custom")
}
}
func sameDirectory(left, right string) bool {
left = strings.TrimSpace(left)
right = strings.TrimSpace(right)
if left == "" || right == "" {
return false
}
return strings.EqualFold(filepath.Clean(left), filepath.Clean(right))
}
func normalizeInstanceNames(value *File) error {
if value == nil {
return errors.New("config is required")
}
seen := make(map[string]struct{}, len(value.Instances))
for index := range value.Instances {
instance := &value.Instances[index]
name, err := domain.NormalizeInstanceName(instance.Name)
if err != nil {
return errors.New("invalid instance name configuration")
}
if _, err := domain.InstanceProfileDirectoryName(name, instance.ID); err != nil {
return errors.New("invalid instance name configuration")
}
key := strings.ToUpper(name)
if _, exists := seen[key]; exists {
return errors.New("duplicate instance name configuration")
}
seen[key] = struct{}{}
instance.Name = name
}
return nil
}
func normalizeInstanceRemoteDebugPorts(value *File) error {
if value == nil {
return errors.New("config is required")
}
start := value.Settings.RemoteDebugStartPort
if !domain.ValidRemoteDebugPort(start) {
return fmt.Errorf("invalid remote debug start port %d", start)
}
used := make(map[int]struct{}, len(value.Instances))
for _, instance := range value.Instances {
port := instance.PreferredRemoteDebugPort
if port == 0 {
continue
}
if !domain.ValidRemoteDebugPort(port) {
return fmt.Errorf("invalid preferred remote debug port %d", port)
}
if _, exists := used[port]; exists {
return fmt.Errorf("duplicate preferred remote debug port %d", port)
}
used[port] = struct{}{}
}
for index := range value.Instances {
if value.Instances[index].PreferredRemoteDebugPort != 0 {
continue
}
port, err := nextPreferredRemoteDebugPort(start, used)
if err != nil {
return err
}
value.Instances[index].PreferredRemoteDebugPort = port
used[port] = struct{}{}
}
return nil
}
func nextPreferredRemoteDebugPort(start int, used map[int]struct{}) (int, error) {
for port := start; port <= domain.MaxRemoteDebugPort; port++ {
if _, exists := used[port]; !exists {
return port, nil
}
}
return 0, errors.New("no preferred remote debug port is available")
}
func normalizeProxyConfig(value *File) error {
if value == nil {
return errors.New("config is required")
}
byID := make(map[string]ProxyProfile, len(value.Proxies))
names := make([]string, 0, len(value.Proxies))
servers := make(map[string]struct{}, len(value.Proxies))
for i := range value.Proxies {
profile := &value.Proxies[i]
profile.ID = strings.TrimSpace(profile.ID)
name, err := domain.NormalizeProxyName(profile.Name)
if profile.ID == "" || err != nil {
return errors.New("invalid proxy configuration")
}
profile.Name = name
if _, exists := byID[profile.ID]; exists {
return errors.New("duplicate proxy configuration")
}
for _, existing := range names {
if strings.EqualFold(existing, profile.Name) {
return errors.New("duplicate proxy configuration")
}
}
server, err := domain.NormalizeProxyServer(profile.Server)
if err != nil || server == "" {
return errors.New("invalid proxy configuration")
}
if _, exists := servers[server]; exists {
return errors.New("duplicate proxy configuration")
}
profile.Server = server
byID[profile.ID] = *profile
names = append(names, profile.Name)
servers[server] = 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: nextLegacyProxyName(names), Server: server}
value.Proxies = append(value.Proxies, profile)
byID[proxyID] = profile
legacyByServer[server] = proxyID
names = append(names, profile.Name)
servers[server] = struct{}{}
}
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 nextLegacyProxyName(existing []string) string {
for index := 1; ; index++ {
candidate := "导入代理 " + strconv.Itoa(index)
duplicate := false
for _, name := range existing {
if strings.EqualFold(name, candidate) {
duplicate = true
break
}
}
if !duplicate {
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
}