feat: support merging multiple subscriptions
This commit is contained in:
@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user