package config import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "strings" "sync" ) // Store loads and persists the configuration file. All methods are safe // for concurrent use. type Store struct { mu sync.Mutex path string cfg *Config } // Dir returns the CrossRelay data directory. CROSSRELAY_HOME overrides // the default location (~/.crossrelay), which tests rely on. func Dir() string { if v := strings.TrimSpace(os.Getenv("CROSSRELAY_HOME")); v != "" { return v } home, err := os.UserHomeDir() if err != nil { return ".crossrelay" } return filepath.Join(home, ".crossrelay") } // Path returns the config file location. func Path() string { return filepath.Join(Dir(), "config.json") } // Load reads the config file, creating defaults when it does not exist. func Load() (*Store, error) { s := &Store{path: Path()} data, err := os.ReadFile(s.path) if err != nil { if !errors.Is(err, os.ErrNotExist) { return nil, err } s.cfg = DefaultConfig() return s, s.save() } var cfg Config if err := json.Unmarshal(data, &cfg); err != nil { return nil, fmt.Errorf("parse %s: %w", s.path, err) } cfg.normalize() s.cfg = &cfg return s, nil } // Config returns a snapshot of the configuration. func (s *Store) Config() *Config { s.mu.Lock() defer s.mu.Unlock() return s.cfg.clone() } // FindProvider returns a copy of the provider with the given id. func (s *Store) FindProvider(id string) *Provider { s.mu.Lock() defer s.mu.Unlock() for i := range s.cfg.Providers { if s.cfg.Providers[i].ID == id { return s.cfg.Providers[i].clone() } } return nil } // ActiveClaude returns the provider currently applied to Claude Code. func (s *Store) ActiveClaude() *Provider { s.mu.Lock() defer s.mu.Unlock() if s.cfg.ActiveClaudeID == "" { return nil } for i := range s.cfg.Providers { if s.cfg.Providers[i].ID == s.cfg.ActiveClaudeID { return s.cfg.Providers[i].clone() } } return nil } // AddProvider validates and appends a provider. func (s *Store) AddProvider(name, preset, baseURL, apiKey string, models Models) (*Provider, error) { s.mu.Lock() defer s.mu.Unlock() p, err := newProvider(name, preset, baseURL, apiKey, models) if err != nil { return nil, err } s.cfg.Providers = append(s.cfg.Providers, *p) if err := s.saveLocked(); err != nil { return nil, err } return p.clone(), nil } // UpdateProvider replaces a provider's editable fields. An empty apiKey // keeps the stored key so edits never wipe it by accident. func (s *Store) UpdateProvider(id, name, preset, baseURL, apiKey string, models Models) (*Provider, error) { s.mu.Lock() defer s.mu.Unlock() idx := -1 for i := range s.cfg.Providers { if s.cfg.Providers[i].ID == id { idx = i break } } if idx < 0 { return nil, fmt.Errorf("provider %q not found", id) } existing := s.cfg.Providers[idx] if apiKey == "" { apiKey = existing.APIKey } updated, err := newProvider(name, preset, baseURL, apiKey, models) if err != nil { return nil, err } updated.ID = existing.ID updated.EnabledForClaude = existing.EnabledForClaude updated.CreatedAt = existing.CreatedAt s.cfg.Providers[idx] = *updated if err := s.saveLocked(); err != nil { return nil, err } return updated.clone(), nil } // DeleteProvider removes a provider and clears it when active. func (s *Store) DeleteProvider(id string) error { s.mu.Lock() defer s.mu.Unlock() idx := -1 for i := range s.cfg.Providers { if s.cfg.Providers[i].ID == id { idx = i break } } if idx < 0 { return fmt.Errorf("provider %q not found", id) } s.cfg.Providers = append(s.cfg.Providers[:idx], s.cfg.Providers[idx+1:]...) if s.cfg.ActiveClaudeID == id { s.cfg.ActiveClaudeID = "" } return s.saveLocked() } // ActivateClaude marks a provider as the one written into Claude Code's // settings and records it as active. func (s *Store) ActivateClaude(id string) (*Provider, error) { s.mu.Lock() defer s.mu.Unlock() idx := -1 for i := range s.cfg.Providers { if s.cfg.Providers[i].ID == id { idx = i break } } if idx < 0 { return nil, fmt.Errorf("provider %q not found", id) } s.cfg.Providers[idx].EnabledForClaude = true s.cfg.ActiveClaudeID = id if err := s.saveLocked(); err != nil { return nil, err } return s.cfg.Providers[idx].clone(), nil } // ClearActiveClaude forgets the active provider without touching the // provider list (used after restoring the official settings). func (s *Store) ClearActiveClaude() error { s.mu.Lock() defer s.mu.Unlock() if s.cfg.ActiveClaudeID == "" { return nil } s.cfg.ActiveClaudeID = "" return s.saveLocked() } // SetGateway updates listener settings. func (s *Store) SetGateway(g Gateway) error { g.PortMode = strings.TrimSpace(g.PortMode) switch g.PortMode { case PortModeAuto: g.FixedPort = 0 case PortModeFixed: if g.FixedPort < 1 || g.FixedPort > 65535 { return errors.New("fixed port must be between 1 and 65535") } default: return errors.New("port mode must be \"auto\" or \"fixed\"") } s.mu.Lock() defer s.mu.Unlock() s.cfg.Gateway = g return s.saveLocked() } // SetLanguage updates the UI language. func (s *Store) SetLanguage(lang string) error { if lang != LanguageChinese && lang != LanguageEnglish { return errors.New("language must be zh-CN or en-US") } s.mu.Lock() defer s.mu.Unlock() s.cfg.Language = lang return s.saveLocked() } func (s *Store) save() error { s.mu.Lock() defer s.mu.Unlock() return s.saveLocked() } func (s *Store) saveLocked() error { data, err := marshalConfig(s.cfg) if err != nil { return err } data = append(data, '\n') if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil { return err } tmp := s.path + ".tmp" if err := os.WriteFile(tmp, data, 0o600); err != nil { return err } if err := os.Rename(tmp, s.path); err != nil { _ = os.Remove(tmp) return err } return nil }