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 }