392 lines
11 KiB
Go
392 lines
11 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 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"`
|
|
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)
|
|
}
|
|
|
|
// 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 }
|
|
|
|
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 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) {
|
|
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 := normalizeInstanceRemoteDebugPorts(&result); err != nil {
|
|
return File{}, err
|
|
}
|
|
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.Settings.RemoteDebugStartPort == 0 {
|
|
value.Settings.RemoteDebugStartPort = domain.DefaultRemoteDebugPort
|
|
}
|
|
if value.Instances == nil {
|
|
value.Instances = []Instance{}
|
|
}
|
|
if value.Proxies == nil {
|
|
value.Proxies = []ProxyProfile{}
|
|
}
|
|
if err := normalizeInstanceRemoteDebugPorts(&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 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(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
|
|
}
|