// Package corecfg manages the user's core (mihomo/clash) configuration: // import, export, edit, reset, show, and applying a subscription to produce // the final generated config file. package corecfg import ( "fmt" "os" "path/filepath" "strings" "gopkg.in/yaml.v3" "gitea.epss.net.cn/klesh/ss/internal/editor" "gitea.epss.net.cn/klesh/ss/internal/model" "gitea.epss.net.cn/klesh/ss/internal/storage" "gitea.epss.net.cn/klesh/ss/internal/subscription" "gitea.epss.net.cn/klesh/ss/internal/yamlutil" "gitea.epss.net.cn/klesh/ss/templates" ) // Manager manages user configuration with import, export, and edit operations. type Manager struct { SubscriptionManager *subscription.Manager Storage *storage.Manager ConfigFile string GeneratedPath string SubscriptionName string } // New creates a CoreConfigManager. configFile/outputFile/subscriptionName may // be empty to use the defaults (mirrors CoreConfigManager.__init__). func New(subMgr *subscription.Manager, configFile, outputFile, subscriptionName string) (*Manager, error) { m := &Manager{ SubscriptionManager: subMgr, Storage: subMgr.Storage, SubscriptionName: subscriptionName, } if configFile != "" { abs, err := resolvePath(configFile) if err != nil { return nil, err } m.ConfigFile = abs } else { m.ConfigFile = filepath.Join(subMgr.Storage.ConfigDir, "core-config.yaml") } if outputFile != "" { abs, err := resolvePath(outputFile) if err != nil { return nil, err } m.GeneratedPath = abs } else { m.GeneratedPath = filepath.Join(subMgr.Storage.ConfigDir, "generated_config.yaml") } return m, nil } func resolvePath(p string) (string, error) { if strings.HasPrefix(p, "~") { home, err := os.UserHomeDir() if err != nil { return "", err } p = filepath.Join(home, strings.TrimPrefix(p, "~")) } return filepath.Abs(p) } func (m *Manager) ensureConfigExists() bool { if _, err := os.Stat(m.ConfigFile); err == nil { return true } if err := os.MkdirAll(m.Storage.ConfigDir, 0o755); err != nil { fmt.Printf("❌ Failed to create config directory: %v\n", err) return false } if err := os.WriteFile(m.ConfigFile, templates.DefaultCoreConfig, 0o644); err != nil { fmt.Printf("❌ Failed to create default config: %v\n", err) return false } fmt.Printf("✅ Created default config at: %s\n", m.ConfigFile) return true } // LoadConfig loads configuration from the YAML file as a raw map. func (m *Manager) LoadConfig() map[string]interface{} { raw, err := os.ReadFile(m.ConfigFile) if err != nil { return map[string]interface{}{} } var data map[string]interface{} if err := yamlutil.Unmarshal(raw, &data); err != nil { fmt.Printf("Warning: Failed to load config: %v\n", err) return map[string]interface{}{} } if data == nil { return map[string]interface{}{} } return data } // SaveConfig writes configuration to the YAML file. func (m *Manager) SaveConfig(config map[string]interface{}) bool { out, err := yaml.Marshal(config) if err != nil { fmt.Printf("Error: Failed to save config: %v\n", err) return false } if err := os.WriteFile(m.ConfigFile, out, 0o644); err != nil { fmt.Printf("Error: Failed to save config: %v\n", err) return false } return true } // ImportConfig imports configuration from a YAML file, validating it before // persisting. func (m *Manager) ImportConfig(sourcePath string) bool { raw, err := os.ReadFile(sourcePath) if err != nil { fmt.Printf("❌ Source file not found: %s\n", sourcePath) return false } var data map[string]interface{} if err := yamlutil.Unmarshal(raw, &data); err != nil { fmt.Printf("❌ Invalid YAML: %v\n", err) return false } if data == nil { fmt.Println("❌ Invalid YAML format") return false } if err := validateConfigMap(data); err != nil { fmt.Printf("❌ Failed to import: %v\n", err) return false } m.SaveConfig(data) fmt.Printf("✅ Imported configuration from: %s\n", sourcePath) return true } // ExportConfig exports the current configuration to a YAML file. func (m *Manager) ExportConfig(destinationPath string) bool { config := m.LoadConfig() if err := os.MkdirAll(filepath.Dir(destinationPath), 0o755); err != nil { fmt.Printf("❌ Failed to export: %v\n", err) return false } out, err := yaml.Marshal(config) if err != nil { fmt.Printf("❌ Failed to export: %v\n", err) return false } if err := os.WriteFile(destinationPath, out, 0o644); err != nil { fmt.Printf("❌ Failed to export: %v\n", err) return false } fmt.Printf("✅ Exported configuration to: %s\n", destinationPath) return true } // EditConfig opens the configuration in the system default editor, backing // it up first and restoring on failure to open the editor. func (m *Manager) EditConfig() bool { if !m.ensureConfigExists() { return false } backupPath := strings.TrimSuffix(m.ConfigFile, filepath.Ext(m.ConfigFile)) + ".yaml.backup" if _, err := os.Stat(m.ConfigFile); err == nil { if data, err := os.ReadFile(m.ConfigFile); err == nil { os.WriteFile(backupPath, data, 0o644) } } if !openInEditor(m.ConfigFile) { return false } m.LoadConfig() fmt.Println("✅ Configuration edited successfully") return true } // ResetConfig resets configuration to default values. func (m *Manager) ResetConfig() bool { if err := os.WriteFile(m.ConfigFile, templates.DefaultCoreConfig, 0o644); err != nil { fmt.Printf("❌ Failed to reset configuration: %v\n", err) return false } fmt.Println("✅ Configuration reset to default values") return true } // ShowConfig prints the current configuration. func (m *Manager) ShowConfig() { config := m.LoadConfig() out, _ := yaml.Marshal(config) fmt.Println(string(out)) } var essentialDefaults = map[string]interface{}{ "port": 7890, "socks-port": 7891, "mixed-port": 7890, "allow-lan": false, "mode": "rule", "log-level": "info", "external-controller": "127.0.0.1:9090", "ipv6": true, } func defaultDNSConfig() map[string]interface{} { return map[string]interface{}{ "enable": true, "listen": "0.0.0.0:53", "enhanced-mode": "fake-ip", "fake-ip-range": "198.18.0.1/16", "nameserver": []interface{}{ "https://doh.pub/dns-query", "https://dns.alidns.com/dns-query", }, "fallback": []interface{}{ "https://1.1.1.1/dns-query", "https://8.8.8.8/dns-query", }, } } // Apply applies the active (or explicitly selected) subscription to generate // the final config file, merging it with user configuration and filling in // essential defaults, then executes any registered hooks. func (m *Manager) Apply() bool { config := m.LoadConfig() var sub = m.resolveSubscription() if sub == nil { return false } filePath := sub.GetFilePath(m.Storage.ConfigDir) if _, err := os.Stat(filePath); err != nil { fmt.Printf("❌ Subscription file not found for '%s'. Please refresh the subscription first.\n", sub.Name) return false } subscriptionContent, err := os.ReadFile(filePath) if err != nil { fmt.Printf("❌ Failed to apply configuration: %v\n", err) return false } var subscriptionData map[string]interface{} if err := yamlutil.Unmarshal(subscriptionContent, &subscriptionData); err != nil { fmt.Printf("❌ Invalid YAML in subscription: %v\n", err) return false } if subscriptionData == nil { subscriptionData = map[string]interface{}{} } finalConfig := deepMerge(subscriptionData, config) for key, defaultValue := range essentialDefaults { if _, ok := finalConfig[key]; !ok { finalConfig[key] = defaultValue } } if _, ok := finalConfig["dns"]; !ok { finalConfig["dns"] = defaultDNSConfig() } out, err := yaml.Marshal(finalConfig) if err != nil { fmt.Printf("❌ Failed to apply configuration: %v\n", err) return false } if err := os.WriteFile(m.GeneratedPath, out, 0o644); err != nil { fmt.Printf("❌ Failed to apply configuration: %v\n", err) return false } fmt.Printf("✅ Generated final configuration: %s\n", m.GeneratedPath) fmt.Printf(" Active subscription: %s\n", sub.Name) m.executeHooks(m.GeneratedPath) return true } func (m *Manager) resolveSubscription() *model.Subscription { if m.SubscriptionName != "" { sub, ok := m.SubscriptionManager.Data.Subscriptions[m.SubscriptionName] if !ok { fmt.Printf("❌ Subscription '%s' not found\n", m.SubscriptionName) return nil } return sub } sub := m.SubscriptionManager.Data.GetActiveSubscription() if sub == nil { fmt.Println("❌ No active subscription found") return nil } return sub } func openInEditor(path string) bool { return editor.OpenFileInEditor(path) }