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 }