// 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/automation" "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, "config", "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(filepath.Dir(m.ConfigFile), 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.MkdirAll(filepath.Dir(m.ConfigFile), 0o755); err != nil { fmt.Printf("Error: Failed to create config directory: %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.MkdirAll(filepath.Dir(m.ConfigFile), 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 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 every active (or the explicitly --subscription-selected) // subscription to generate the final config file: merging all of them // together (each proxy name prefixed with its source subscription's name to // avoid collisions), merging that with user configuration, filling in // essential defaults, running any `ssm:` automation rules, and finally // executing any registered hooks. func (m *Manager) Apply() bool { config := m.LoadConfig() // The "ssm" key is this tool's own automation metadata, not a real // mihomo/clash config key — pull it out before anything else touches // config, so it never leaks into the generated file. ssmRaw, hasSSM := config["ssm"] delete(config, "ssm") subs := m.resolveSubscriptions() if len(subs) == 0 { return false } subscriptionData, proxies, ok := m.mergeSubscriptions(subs) if !ok { return false } 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() } if hasSSM { ssmConfig, err := automation.ParseConfig(ssmRaw) if err != nil { fmt.Printf("❌ Invalid ssm automation config: %v\n", err) } else { for _, err := range automation.Apply(finalConfig, ssmConfig, proxies) { fmt.Printf("⚠️ ssm automation: %v\n", err) } } } 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 } names := make([]string, len(subs)) for i, sub := range subs { names[i] = sub.Name } fmt.Printf("✅ Generated final configuration: %s\n", m.GeneratedPath) fmt.Printf(" Active subscription(s): %s\n", strings.Join(names, ", ")) m.executeHooks(m.GeneratedPath) return true } // resolveSubscriptions returns the subscription(s) to apply: just the // explicitly --subscription-selected one if set, otherwise every currently // active subscription. func (m *Manager) resolveSubscriptions() []*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 []*model.Subscription{sub} } subs := m.SubscriptionManager.Data.GetActiveSubscriptions() if len(subs) == 0 { fmt.Println("❌ No active subscription found") return nil } return subs } // mergeSubscriptions reads and parses every resolved subscription's // downloaded YAML, prefixes each proxy's name with its source subscription's // name (avoiding collisions when multiple subscriptions are merged), and // deep-merges them all together (later subscriptions' scalar/map values win // over earlier ones on conflicting keys; lists — notably "proxies" — are // concatenated). Returns the combined data, the full proxy pool for ssm // automation matching, and false if any resolved subscription's file is // missing or invalid. func (m *Manager) mergeSubscriptions(subs []*model.Subscription) (map[string]interface{}, []automation.ProxyRef, bool) { combined := map[string]interface{}{} var proxies []automation.ProxyRef var selectionGroups []map[string]interface{} for _, sub := range subs { 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 nil, nil, false } content, err := os.ReadFile(filePath) if err != nil { fmt.Printf("❌ Failed to read subscription '%s': %v\n", sub.Name, err) return nil, nil, false } var data map[string]interface{} if err := yamlutil.Unmarshal(content, &data); err != nil { fmt.Printf("❌ Invalid YAML in subscription '%s': %v\n", sub.Name, err) return nil, nil, false } if data == nil { data = map[string]interface{}{} } proxies = append(proxies, automation.PrefixProxyNames(sub.Name, data)...) if group := popFirstProxyGroup(data); group != nil { selectionGroups = append(selectionGroups, group) } combined = deepMerge(combined, data) } combined["rules"] = dedupeMatchRule(combined["rules"]) mergeProxySelection(combined, selectionGroups) return combined, proxies, true } // dedupeMatchRule drops every "MATCH,..." catch-all rule contributed by the // merged subscriptions — each one covers all their own proxies, so once // concatenated they'd shadow all subsequent subscriptions' rules — and // appends a single "MATCH,all proxies" catch-all in their place. func dedupeMatchRule(rulesRaw interface{}) interface{} { rules, ok := rulesRaw.([]interface{}) if !ok { return rulesRaw } filtered := make([]interface{}, 0, len(rules)+1) for _, item := range rules { if line, ok := item.(string); ok && strings.HasPrefix(line, "MATCH,") { continue } filtered = append(filtered, item) } return append(filtered, "MATCH,all proxies") } func openInEditor(path string) bool { return editor.OpenFileInEditor(path) }