feat: support merging multiple subscriptions

This commit is contained in:
2026-07-19 23:58:02 +08:00
parent fb4d927220
commit 087869d84f
17 changed files with 1341 additions and 60 deletions

View File

@ -11,6 +11,7 @@ import (
"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"
@ -44,7 +45,7 @@ func New(subMgr *subscription.Manager, configFile, outputFile, subscriptionName
}
m.ConfigFile = abs
} else {
m.ConfigFile = filepath.Join(subMgr.Storage.ConfigDir, "core-config.yaml")
m.ConfigFile = filepath.Join(subMgr.Storage.ConfigDir, "config", "core-config.yaml")
}
if outputFile != "" {
@ -76,7 +77,7 @@ func (m *Manager) ensureConfigExists() bool {
return true
}
if err := os.MkdirAll(m.Storage.ConfigDir, 0o755); err != nil {
if err := os.MkdirAll(filepath.Dir(m.ConfigFile), 0o755); err != nil {
fmt.Printf("❌ Failed to create config directory: %v\n", err)
return false
}
@ -113,6 +114,10 @@ func (m *Manager) SaveConfig(config map[string]interface{}) bool {
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
@ -197,6 +202,10 @@ func (m *Manager) EditConfig() bool {
// 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
@ -240,38 +249,31 @@ func defaultDNSConfig() map[string]interface{} {
}
}
// 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.
// 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()
var sub = m.resolveSubscription()
if sub == nil {
// 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
}
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)
subscriptionData, proxies, ok := m.mergeSubscriptions(subs)
if !ok {
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 {
@ -284,6 +286,17 @@ func (m *Manager) Apply() bool {
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)
@ -294,29 +307,77 @@ func (m *Manager) Apply() bool {
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\n", sub.Name)
fmt.Printf(" Active subscription(s): %s\n", strings.Join(names, ", "))
m.executeHooks(m.GeneratedPath)
return true
}
func (m *Manager) resolveSubscription() *model.Subscription {
// 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 sub
return []*model.Subscription{sub}
}
sub := m.SubscriptionManager.Data.GetActiveSubscription()
if sub == nil {
subs := m.SubscriptionManager.Data.GetActiveSubscriptions()
if len(subs) == 0 {
fmt.Println("❌ No active subscription found")
return nil
}
return sub
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
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)...)
combined = deepMerge(combined, data)
}
return combined, proxies, true
}
func openInEditor(path string) bool {