Files
CrossRelay/internal/config/store.go
T

323 lines
7.7 KiB
Go

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
}
// ActiveCodex returns the provider currently applied to Codex.
func (s *Store) ActiveCodex() *Provider {
s.mu.Lock()
defer s.mu.Unlock()
if s.cfg.ActiveCodexID == "" {
return nil
}
for i := range s.cfg.Providers {
if s.cfg.Providers[i].ID == s.cfg.ActiveCodexID {
return s.cfg.Providers[i].clone()
}
}
return nil
}
// AddProvider validates and appends a provider for the given target.
func (s *Store) AddProvider(name, preset, baseURL, apiKey string, models Models, target string) (*Provider, error) {
s.mu.Lock()
defer s.mu.Unlock()
p, err := newProvider(name, preset, baseURL, apiKey, models, target)
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; an empty
// target keeps the stored one.
func (s *Store) UpdateProvider(id, name, preset, baseURL, apiKey string, models Models, target 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)
}
existing := s.cfg.Providers[idx]
if apiKey == "" {
apiKey = existing.APIKey
}
if target == "" {
target = existing.Target
}
updated, err := newProvider(name, preset, baseURL, apiKey, models, target)
if err != nil {
return nil, err
}
updated.ID = existing.ID
updated.EnabledForClaude = existing.EnabledForClaude
updated.EnabledForCodex = existing.EnabledForCodex
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 = ""
}
if s.cfg.ActiveCodexID == id {
s.cfg.ActiveCodexID = ""
}
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)
}
if s.cfg.Providers[idx].Target != TargetClaude {
return nil, fmt.Errorf("provider %q is not a Claude provider", 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()
}
// ActivateCodex marks a provider as the one written into Codex's
// config.toml and records it as active.
func (s *Store) ActivateCodex(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)
}
if s.cfg.Providers[idx].Target != TargetCodex {
return nil, fmt.Errorf("provider %q is not a Codex provider", id)
}
s.cfg.Providers[idx].EnabledForCodex = true
s.cfg.ActiveCodexID = id
if err := s.saveLocked(); err != nil {
return nil, err
}
return s.cfg.Providers[idx].clone(), nil
}
// ClearActiveCodex forgets the active Codex provider without touching
// the provider list (used after restoring the official config).
func (s *Store) ClearActiveCodex() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.cfg.ActiveCodexID == "" {
return nil
}
s.cfg.ActiveCodexID = ""
return s.saveLocked()
}
// SetCodexPriorProvider records (or clears) the model_provider value
// that config.toml carried before CrossRelay took it over.
func (s *Store) SetCodexPriorProvider(prior string) error {
s.mu.Lock()
defer s.mu.Unlock()
s.cfg.CodexPriorProvider = strings.TrimSpace(prior)
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
}