diff --git a/BaoLianDeng.xcodeproj/project.pbxproj b/BaoLianDeng.xcodeproj/project.pbxproj index 2b3272f..22ac3da 100644 --- a/BaoLianDeng.xcodeproj/project.pbxproj +++ b/BaoLianDeng.xcodeproj/project.pbxproj @@ -37,6 +37,9 @@ LS100001 /* LANSharingSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10097 /* LANSharingSettings.swift */; }; LS100002 /* LANSharingSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10097 /* LANSharingSettings.swift */; }; LS100003 /* LANSharingSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10097 /* LANSharingSettings.swift */; }; + PGS10001 /* ProxyGroupSelections.swift in Sources */ = {isa = PBXBuildFile; fileRef = PGS10000 /* ProxyGroupSelections.swift */; }; + PGS10002 /* ProxyGroupSelections.swift in Sources */ = {isa = PBXBuildFile; fileRef = PGS10000 /* ProxyGroupSelections.swift */; }; + PGS10003 /* ProxyGroupSelections.swift in Sources */ = {isa = PBXBuildFile; fileRef = PGS10000 /* ProxyGroupSelections.swift */; }; LS100004 /* LANSharingSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10098 /* LANSharingSection.swift */; }; LS100005 /* LANSharingSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10099 /* LANSharingSettingsTests.swift */; }; LP100004 /* EngineModeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = LP100003 /* EngineModeTests.swift */; }; @@ -78,6 +81,7 @@ T10035 /* MihomoAPITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = T10034 /* MihomoAPITests.swift */; }; T10041 /* ProxyEngineIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = T10040 /* ProxyEngineIntegrationTests.swift */; }; T10053 /* ProxyGroupsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = T10052 /* ProxyGroupsViewModelTests.swift */; }; + PGS10005 /* ProxyGroupSelectionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = PGS10004 /* ProxyGroupSelectionsTests.swift */; }; T10045 /* TestConfigs.swift in Sources */ = {isa = PBXBuildFile; fileRef = T10044 /* TestConfigs.swift */; }; T10047 /* ProxyEngineHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = T10046 /* ProxyEngineHelper.swift */; }; GEO002 /* Country.mmdb in Resources */ = {isa = PBXBuildFile; fileRef = GEO001 /* Country.mmdb */; }; @@ -191,6 +195,7 @@ B10095 /* PerAppProxySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerAppProxySettings.swift; sourceTree = ""; }; B10096 /* PerAppProxySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerAppProxySection.swift; sourceTree = ""; }; B10097 /* LANSharingSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LANSharingSettings.swift; sourceTree = ""; }; + PGS10000 /* ProxyGroupSelections.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyGroupSelections.swift; sourceTree = ""; }; B10098 /* LANSharingSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LANSharingSection.swift; sourceTree = ""; }; B10099 /* LANSharingSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LANSharingSettingsTests.swift; sourceTree = ""; }; LP100000 /* LocalProxyController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalProxyController.swift; sourceTree = ""; }; @@ -212,6 +217,7 @@ T10034 /* MihomoAPITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MihomoAPITests.swift; sourceTree = ""; }; T10040 /* ProxyEngineIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyEngineIntegrationTests.swift; sourceTree = ""; }; T10052 /* ProxyGroupsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyGroupsViewModelTests.swift; sourceTree = ""; }; + PGS10004 /* ProxyGroupSelectionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyGroupSelectionsTests.swift; sourceTree = ""; }; T10044 /* TestConfigs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestConfigs.swift; sourceTree = ""; }; T10046 /* ProxyEngineHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyEngineHelper.swift; sourceTree = ""; }; GEO001 /* Country.mmdb */ = {isa = PBXFileReference; lastKnownFileType = file; path = Country.mmdb; sourceTree = ""; }; @@ -347,6 +353,7 @@ B10008 /* ConfigManager.swift */, B10095 /* PerAppProxySettings.swift */, B10097 /* LANSharingSettings.swift */, + PGS10000 /* ProxyGroupSelections.swift */, LP100000 /* LocalProxyController.swift */, B10009 /* VPNManager.swift */, ); @@ -434,6 +441,7 @@ T10034 /* MihomoAPITests.swift */, T10040 /* ProxyEngineIntegrationTests.swift */, T10052 /* ProxyGroupsViewModelTests.swift */, + PGS10004 /* ProxyGroupSelectionsTests.swift */, ); path = BaoLianDengTests; sourceTree = ""; @@ -635,6 +643,7 @@ 8D9BF44D7D340ADB5C09AEF9 /* VPNManager.swift in Sources */, A1B2C3D4E5F60718293A4B5C /* PerAppProxySettings.swift in Sources */, LS100001 /* LANSharingSettings.swift in Sources */, + PGS10001 /* ProxyGroupSelections.swift in Sources */, A1B2C3D4E5F60718293A4B5E /* PerAppProxySection.swift in Sources */, LS100004 /* LANSharingSection.swift in Sources */, B10400B /* SOCKS5Client.swift in Sources */, @@ -661,6 +670,7 @@ ADFBB07EC9AE7B14EE8CF08D /* VPNManager.swift in Sources */, A1B2C3D4E5F60718293A4B5D /* PerAppProxySettings.swift in Sources */, LS100002 /* LANSharingSettings.swift in Sources */, + PGS10002 /* ProxyGroupSelections.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -676,6 +686,7 @@ AX10025 /* ConfigManager.swift in Sources */, AX10027 /* PerAppProxySettings.swift in Sources */, LS100003 /* LANSharingSettings.swift in Sources */, + PGS10003 /* ProxyGroupSelections.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -692,6 +703,7 @@ T10035 /* MihomoAPITests.swift in Sources */, T10041 /* ProxyEngineIntegrationTests.swift in Sources */, T10053 /* ProxyGroupsViewModelTests.swift in Sources */, + PGS10005 /* ProxyGroupSelectionsTests.swift in Sources */, T10045 /* TestConfigs.swift in Sources */, T10047 /* ProxyEngineHelper.swift in Sources */, ); diff --git a/BaoLianDeng/Models/ProxyGroupsViewModel.swift b/BaoLianDeng/Models/ProxyGroupsViewModel.swift index 938ddd4..843b6ad 100644 --- a/BaoLianDeng/Models/ProxyGroupsViewModel.swift +++ b/BaoLianDeng/Models/ProxyGroupsViewModel.swift @@ -145,22 +145,11 @@ final class ProxyGroupsViewModel { /// `replaySelectionsToEngine()` never pins them. Groups absent from the /// loaded config (e.g. another subscription's) are left untouched. /// - /// Known limitation (issue #75 item 6): `selections` is keyed only by - /// group *name*, with no per-subscription/config scoping. The - /// `group.all.contains(current)` check below guards against replaying a - /// selection that no longer exists in the group, but it cannot detect - /// cross-subscription contamination: if two different subscriptions both - /// define a same-named `Selector` group and happen to share a node name, - /// a selection saved under one subscription will pass this containment - /// check and be silently replayed into the other subscription's - /// same-named group, even though the user never chose that node there. - /// TODO(#75): fix properly by keying `selections` (and the persisted - /// `proxyGroupSelections` blob) by a composite key of subscription/config - /// ID + group name instead of group name alone, so selections from one - /// subscription can never leak into a same-named group in another. This - /// requires new storage (tracking the active subscription ID alongside - /// selections) and touches `saveSelections`/`loadSelections`/ - /// `replaySelectionsToEngine` as well as this merge function. + /// `selections` holds only the active subscription's choices — persistence + /// is scoped by subscription ID (`ProxyGroupSelections`), so a selection + /// made under one subscription can never leak into a same-named group in + /// another. Call `reloadSelectionsForActiveSubscription()` after switching + /// subscriptions to swap the in-memory map to the new scope. static func mergedSelections( _ existing: [String: String], groups: [MihomoProxyGroup] @@ -182,21 +171,26 @@ final class ProxyGroupsViewModel { // MARK: - Persistence private func saveSelections() { - // Save selections to UserDefaults for replay on next VPN connect - if let data = try? JSONEncoder().encode(selections) { - AppConstants.sharedDefaults.set(data, forKey: "proxyGroupSelections") - } + // Persist under the active subscription's scope for replay on the next + // VPN connect. + ProxyGroupSelections.save(selections) } func loadSelections() { - if let data = AppConstants.sharedDefaults.data(forKey: "proxyGroupSelections"), - let saved = try? JSONDecoder().decode([String: String].self, from: data) { - for (group, name) in saved { - selections[group] = name - } + for (group, name) in ProxyGroupSelections.load() { + selections[group] = name } } + /// Swap the in-memory selections to the newly active subscription's scope. + /// Unlike `loadSelections()` this discards the previous subscription's + /// choices instead of merging them, so a group name shared by both + /// subscriptions shows the value the user picked *here*. + @MainActor + func reloadSelectionsForActiveSubscription() { + selections = ProxyGroupSelections.load() + } + /// Replay saved selections to the engine after VPN connects. @MainActor func replaySelectionsToEngine() async { diff --git a/BaoLianDeng/Models/TrafficStore.swift b/BaoLianDeng/Models/TrafficStore.swift index 256b82d..db5458a 100644 --- a/BaoLianDeng/Models/TrafficStore.swift +++ b/BaoLianDeng/Models/TrafficStore.swift @@ -277,9 +277,9 @@ final class TrafficStore: ObservableObject { // DIRECT/REJECT. For real outbounds that's a literal match; for // selector groups we walk the cached config-time group map via // isFirstDefaultBypass — which recursively follows each group's - // first listed member (the runtime default after applySelectedNode - // rewrites the config to put the chosen member at index 0). Empty - // chains are treated as direct defensively. + // first listed member (the engine's runtime default before the user's + // saved selections are replayed). Empty chains are treated as direct + // defensively. if chains.isEmpty { return true } for element in chains { var seen: Set = [] diff --git a/BaoLianDeng/Views/HomeView.swift b/BaoLianDeng/Views/HomeView.swift index 34ab7bc..0523b98 100644 --- a/BaoLianDeng/Views/HomeView.swift +++ b/BaoLianDeng/Views/HomeView.swift @@ -338,6 +338,10 @@ struct HomeView: View { selectedSubscriptionID = sub.id AppConstants.sharedDefaults .set(sub.id.uuidString, forKey: "selectedSubscriptionID") + // Selections are stored per subscription; swap to the new scope before + // anything reads or replays them, so the previous subscription's picks + // can't be pushed into same-named groups here. + proxyGroupsVM.reloadSelectionsForActiveSubscription() if let raw = sub.rawContent { Task { _ = await Task.detached { @@ -512,10 +516,15 @@ struct HomeView: View { } private func deleteSubscription(at offsets: IndexSet) { - for i in offsets where subscriptions[i].id == selectedSubscriptionID { + for i in offsets { + // Forget this subscription's per-group selections, so re-adding it + // later starts from the config's own defaults. + ProxyGroupSelections.removeScope(subscriptions[i].id.uuidString) + guard subscriptions[i].id == selectedSubscriptionID else { continue } selectedSubscriptionID = nil AppConstants.sharedDefaults .removeObject(forKey: "selectedSubscriptionID") + proxyGroupsVM.reloadSelectionsForActiveSubscription() } subscriptions.remove(atOffsets: offsets) saveSubscriptions() diff --git a/BaoLianDengTests/BypassGroupDetectionTests.swift b/BaoLianDengTests/BypassGroupDetectionTests.swift index a309a6a..9230869 100644 --- a/BaoLianDengTests/BypassGroupDetectionTests.swift +++ b/BaoLianDengTests/BypassGroupDetectionTests.swift @@ -82,76 +82,3 @@ struct BypassGroupDetectionTests { #expect(isBypassGroup(firstMember: "Both", groupMembers: groups)) } } - -@Suite("FirstBypassMember") -struct FirstBypassMemberTests { - - @Test("Returns nil when no members are bypass") - func noneBypass() { - #expect(firstBypassMember(in: ["🇭🇰 HK-01", "🇺🇸 US-01"], groupMembers: [:]) == nil) - } - - @Test("Returns literal DIRECT when present later in the list") - func directNotFirst() { - let members = ["🇺🇸 USA Seattle 01", "DIRECT", "🇭🇰 HK-01"] - #expect(firstBypassMember(in: members, groupMembers: [:]) == "DIRECT") - } - - @Test("Returns a nested direct-only group when present") - func nestedDirectGroup() { - let groups: [String: [String]] = ["🎯Direct": ["DIRECT"]] - let members = ["🇺🇸 USA Seattle 01", "🎯Direct", "Proxies"] - #expect(firstBypassMember(in: members, groupMembers: groups) == "🎯Direct") - } - - @Test("Returns a mixed nested group whose first member is DIRECT") - func mixedNestedDirectFirst() { - // Real-world layout: `🎯Direct` is itself a select group whose - // members are `[DIRECT, Proxies]`. Mihomo defaults to the first - // member, so this group routes to DIRECT until the user picks - // otherwise — `firstBypassMember` should treat it as a bypass. - let groups: [String: [String]] = [ - "🎯Direct": ["DIRECT", "Proxies"], - ] - let members = ["🇺🇸 USA Seattle 01", "🎯Direct", "Proxies"] - #expect(firstBypassMember(in: members, groupMembers: groups) == "🎯Direct") - } - - @Test("Skips a nested group whose first member is a real proxy") - func skipsNestedRealFirst() { - // `Mixed` lists a real node first, then DIRECT. Mihomo defaults to - // the real node, so this is NOT a bypass option. - let groups: [String: [String]] = [ - "Mixed": ["🇺🇸 US-02", "DIRECT"], - "PureDirect": ["DIRECT"], - ] - let members = ["🇺🇸 USA Seattle 01", "Mixed", "PureDirect", "🇭🇰 HK-01"] - #expect(firstBypassMember(in: members, groupMembers: groups) == "PureDirect") - } - - @Test("Returns REJECT when the only bypass is REJECT") - func rejectOnly() { - let members = ["🇭🇰 HK-01", "REJECT"] - #expect(firstBypassMember(in: members, groupMembers: [:]) == "REJECT") - } - - @Test("Returns first bypass when multiple are present") - func firstBypassWins() { - // Encodes the real-world Bilibili-group layout: first real proxy, - // then "🎯Direct" as a nested direct-only group. - let groups: [String: [String]] = ["🎯Direct": ["DIRECT"]] - let members = [ - "🇺🇸 USA Seattle 01", - "🎯Direct", - "Proxies", - "🇭🇰 Hong Kong 01", - "DIRECT" - ] - #expect(firstBypassMember(in: members, groupMembers: groups) == "🎯Direct") - } - - @Test("Empty member list returns nil") - func emptyList() { - #expect(firstBypassMember(in: [], groupMembers: [:]) == nil) - } -} diff --git a/BaoLianDengTests/ConfigParserTests.swift b/BaoLianDengTests/ConfigParserTests.swift index a16e051..523b3ac 100644 --- a/BaoLianDengTests/ConfigParserTests.swift +++ b/BaoLianDengTests/ConfigParserTests.swift @@ -598,11 +598,27 @@ struct ForceManagedDNSTests { } } -// Serialized: several tests here save/restore the shared "selectedNode" -// default, which races under parallel execution. +// Serialized: several tests here save/restore the shared proxy-group +// selections, which race under parallel execution. @Suite("Proxy group serialization", .serialized) struct ProxyGroupSerializationTests { + /// Run `body` with `GLOBAL` selected in the active scope, restoring the + /// previous stored selections afterwards. + private func withGlobalSelection(_ selection: String?, _ body: () -> Void) { + let defaults = AppConstants.sharedDefaults + let previous = defaults.dictionary(forKey: ProxyGroupSelections.storageKey) + defer { + if let previous { + defaults.set(previous, forKey: ProxyGroupSelections.storageKey) + } else { + defaults.removeObject(forKey: ProxyGroupSelections.storageKey) + } + } + ProxyGroupSelections.save(selection.map { ["GLOBAL": $0] } ?? [:]) + body() + } + @Test("Quotes editable proxy group string values") func quotesEditableProxyGroupStringValues() { let groups = [ @@ -631,122 +647,129 @@ struct ProxyGroupSerializationTests { #expect(parsed[0].proxies == ["proxy: one", "line\nbreak"]) } - @Test("Global proxy group quotes selected node and replaces previous group") - func globalProxyGroupQuotesSelectedNodeAndReplacesPreviousGroup() { - let defaults = AppConstants.sharedDefaults - let previous = defaults.string(forKey: "selectedNode") - defer { - if let previous { - defaults.set(previous, forKey: "selectedNode") - } else { - defaults.removeObject(forKey: "selectedNode") - } - } - defaults.set("node #1\nnext", forKey: "selectedNode") - - let yaml = """ - proxy-groups: - - name: "GLOBAL" - type: select - proxies: - - old - - name: PROXY - type: select + @Test("Global proxy group quotes the saved selection and replaces previous group") + func globalProxyGroupQuotesSelectionAndReplacesPreviousGroup() { + withGlobalSelection("node #1") { + let yaml = """ + proxy-groups: + - name: "GLOBAL" + type: select + proxies: + - old + - name: PROXY + type: select + proxies: + - "node #1" + rules: + - MATCH,PROXY proxies: - - node #1 - rules: - - MATCH,PROXY - """ - - let updated = ConfigManager.shared.updateGlobalProxyGroup(yaml, enabled: true) - let globalNameCount = updated.components(separatedBy: "name: \"GLOBAL\"").count - 1 - - #expect(globalNameCount == 1) - #expect(updated.contains(" - \"node #1\\nnext\"")) - #expect(!updated.contains(" - old")) - // Fallback members: the first non-bypass select group, then DIRECT, - // in case the saved node name is stale. - #expect(updated.contains(" - \"PROXY\"")) - #expect(updated.contains(" - \"DIRECT\"")) + - name: "node #1" + type: socks5 + server: 127.0.0.1 + port: 1080 + """ + + let updated = ConfigManager.shared.updateGlobalProxyGroup(yaml, enabled: true) + let globalNameCount = updated.components(separatedBy: "name: \"GLOBAL\"").count - 1 + + #expect(globalNameCount == 1) + #expect(updated.contains(" - \"node #1\"")) + #expect(!updated.contains(" - old")) + // Fallback members: the first non-bypass select group, then DIRECT, + // in case the saved selection stops resolving. + #expect(updated.contains(" - \"PROXY\"")) + #expect(updated.contains(" - \"DIRECT\"")) + } } - @Test("Global proxy group falls back to select group when no node saved") + @Test("Global proxy group falls back to select group when nothing saved") func globalProxyGroupFallsBackToSelectGroup() { - let defaults = AppConstants.sharedDefaults - let previous = defaults.string(forKey: "selectedNode") - defer { - if let previous { - defaults.set(previous, forKey: "selectedNode") - } else { - defaults.removeObject(forKey: "selectedNode") - } + withGlobalSelection(nil) { + let yaml = """ + proxy-groups: + - name: Bypass + type: select + proxies: + - DIRECT + - node1 + - name: Choice + type: select + proxies: + - node1 + - DIRECT + rules: + - MATCH,Choice + """ + + let updated = ConfigManager.shared.updateGlobalProxyGroup(yaml, enabled: true) + let parsed = ConfigManager.shared.parseProxyGroups(from: updated) + let global = parsed.first { $0.name == "GLOBAL" } + + // Bypass (DIRECT-first) is skipped; GLOBAL heads with the first + // non-bypass select group, then DIRECT. + #expect(global?.proxies == ["Choice", "DIRECT"]) } - defaults.removeObject(forKey: "selectedNode") - - let yaml = """ - proxy-groups: - - name: Bypass - type: select - proxies: - - DIRECT - - node1 - - name: Choice - type: select - proxies: - - node1 - - DIRECT - rules: - - MATCH,Choice - """ - - let updated = ConfigManager.shared.updateGlobalProxyGroup(yaml, enabled: true) - let parsed = ConfigManager.shared.parseProxyGroups(from: updated) - let global = parsed.first { $0.name == "GLOBAL" } - - // Bypass (DIRECT-first) is skipped; GLOBAL heads with the first - // non-bypass select group, then DIRECT. - #expect(global?.proxies == ["Choice", "DIRECT"]) } - @Test("Global proxy group falls back to MATCH target when every group is DIRECT-first") - func globalProxyGroupFallsBackToMatchTarget() { - let defaults = AppConstants.sharedDefaults - let previous = defaults.string(forKey: "selectedNode") - defer { - if let previous { - defaults.set(previous, forKey: "selectedNode") - } else { - defaults.removeObject(forKey: "selectedNode") - } + @Test("Global proxy group drops a saved selection this config doesn't define") + func globalProxyGroupDropsUnknownSelection() { + // A selection carried over from another subscription: the name is not + // a proxy or a group here, so listing it would let global mode route + // through a member the user never chose in this config (and the engine + // silently drops unknown members rather than erroring). + withGlobalSelection("Old Node 05") { + let yaml = """ + proxy-groups: + - name: Choice + type: select + proxies: + - DIRECT + - node1 + - name: Fish + type: select + proxies: + - DIRECT + - Choice + rules: + - MATCH,Fish + """ + + let updated = ConfigManager.shared.updateGlobalProxyGroup(yaml, enabled: true) + let parsed = ConfigManager.shared.parseProxyGroups(from: updated) + let global = parsed.first { $0.name == "GLOBAL" } + + // Every select group lists DIRECT first, so the bypass heuristic + // matches all of them: GLOBAL falls through to the MATCH target, + // then DIRECT. + #expect(global?.proxies == ["Fish", "DIRECT"]) } - // Stale node: saved name no longer exists in the subscription. - defaults.set("Old Node 05", forKey: "selectedNode") + } - // Common subscription shape: every select group lists DIRECT first, - // so the bypass heuristic matches all of them. - let yaml = """ - proxy-groups: - - name: Choice - type: select - proxies: - - DIRECT - - node1 - - name: Fish - type: select - proxies: - - DIRECT - - Choice - rules: - - MATCH,Fish - """ + @Test("Global proxy group keeps a saved selection naming a group in this config") + func globalProxyGroupKeepsKnownGroupSelection() { + withGlobalSelection("Choice") { + let yaml = """ + proxy-groups: + - name: Choice + type: select + proxies: + - DIRECT + - node1 + - name: Fish + type: select + proxies: + - DIRECT + - Choice + rules: + - MATCH,Fish + """ - let updated = ConfigManager.shared.updateGlobalProxyGroup(yaml, enabled: true) - let parsed = ConfigManager.shared.parseProxyGroups(from: updated) - let global = parsed.first { $0.name == "GLOBAL" } + let updated = ConfigManager.shared.updateGlobalProxyGroup(yaml, enabled: true) + let parsed = ConfigManager.shared.parseProxyGroups(from: updated) + let global = parsed.first { $0.name == "GLOBAL" } - // Stale node stays listed (the engine's lenient parser drops it), - // followed by the MATCH rule's target group, then DIRECT. - #expect(global?.proxies == ["Old Node 05", "Fish", "DIRECT"]) + #expect(global?.proxies == ["Choice", "Fish", "DIRECT"]) + } } } diff --git a/BaoLianDengTests/ProxyGroupSelectionsTests.swift b/BaoLianDengTests/ProxyGroupSelectionsTests.swift new file mode 100644 index 0000000..0dbaf0a --- /dev/null +++ b/BaoLianDengTests/ProxyGroupSelectionsTests.swift @@ -0,0 +1,150 @@ +// Copyright (c) 2026 Max Lv +// +// Licensed under the MIT License. See the LICENSE file for details. + +import Testing +import Foundation +@testable import BaoLianDeng + +/// Serialized: these tests mutate the shared defaults keys the store owns. +@Suite("ProxyGroupSelections", .serialized) +struct ProxyGroupSelectionsTests { + + /// Run `body` against a scratch `UserDefaults` suite, wiping it afterwards + /// so nothing leaks into the app's real defaults or the next test. + private func withScratchDefaults(_ body: (UserDefaults) -> Void) { + let name = "ProxyGroupSelectionsTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: name) else { + Issue.record("could not create scratch UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: name) } + body(defaults) + } + + private func setSubscription(_ id: String?, in defaults: UserDefaults) { + if let id { + defaults.set(id, forKey: ProxyGroupSelections.selectedSubscriptionIDKey) + } else { + defaults.removeObject(forKey: ProxyGroupSelections.selectedSubscriptionIDKey) + } + } + + @Test("Selections round-trip within a subscription scope") + func roundTrip() { + withScratchDefaults { defaults in + setSubscription("sub-a", in: defaults) + ProxyGroupSelections.save(["PROXY": "HK-01"], to: defaults) + #expect(ProxyGroupSelections.load(from: defaults) == ["PROXY": "HK-01"]) + } + } + + @Test("Selections do not leak between subscriptions") + func scopedPerSubscription() { + withScratchDefaults { defaults in + setSubscription("sub-a", in: defaults) + ProxyGroupSelections.save(["PROXY": "Taiwan 05"], to: defaults) + + // Same group name, same node name available in the other + // subscription: the old unscoped storage would replay "Taiwan 05" + // here even though the user never picked it under sub-b. + setSubscription("sub-b", in: defaults) + #expect(ProxyGroupSelections.load(from: defaults).isEmpty) + + ProxyGroupSelections.save(["PROXY": "JP-02"], to: defaults) + #expect(ProxyGroupSelections.load(from: defaults) == ["PROXY": "JP-02"]) + + setSubscription("sub-a", in: defaults) + #expect(ProxyGroupSelections.load(from: defaults) == ["PROXY": "Taiwan 05"]) + } + } + + @Test("No selected subscription gets its own scope") + func noSubscriptionScope() { + withScratchDefaults { defaults in + setSubscription(nil, in: defaults) + ProxyGroupSelections.save(["PROXY": "local"], to: defaults) + #expect(ProxyGroupSelections.allScopes(in: defaults).keys + .contains(ProxyGroupSelections.noSubscriptionScope)) + + setSubscription("sub-a", in: defaults) + #expect(ProxyGroupSelections.load(from: defaults).isEmpty) + } + } + + @Test("Empty selections clear the scope instead of storing an empty map") + func emptyClearsScope() { + withScratchDefaults { defaults in + setSubscription("sub-a", in: defaults) + ProxyGroupSelections.save(["PROXY": "HK-01"], to: defaults) + ProxyGroupSelections.save([:], to: defaults) + #expect(ProxyGroupSelections.allScopes(in: defaults)["sub-a"] == nil) + } + } + + @Test("Removing a scope forgets only that subscription") + func removeScope() { + withScratchDefaults { defaults in + setSubscription("sub-a", in: defaults) + ProxyGroupSelections.save(["PROXY": "HK-01"], to: defaults) + setSubscription("sub-b", in: defaults) + ProxyGroupSelections.save(["PROXY": "JP-02"], to: defaults) + + ProxyGroupSelections.removeScope("sub-a", from: defaults) + + #expect(ProxyGroupSelections.load(from: defaults) == ["PROXY": "JP-02"]) + setSubscription("sub-a", in: defaults) + #expect(ProxyGroupSelections.load(from: defaults).isEmpty) + } + } + + @Test("Legacy selectedNode is dropped, never migrated into a group") + func legacySelectedNodeIsDropped() { + withScratchDefaults { defaults in + setSubscription("sub-a", in: defaults) + defaults.set("🇨🇳 Taiwan 05", forKey: ProxyGroupSelections.legacySelectedNodeKey) + + #expect(ProxyGroupSelections.load(from: defaults).isEmpty) + #expect(defaults.object(forKey: ProxyGroupSelections.legacySelectedNodeKey) == nil) + } + } + + @Test("Legacy unscoped selections migrate into the active subscription") + func legacyBlobMigrates() { + withScratchDefaults { defaults in + setSubscription("sub-a", in: defaults) + let legacy = try? JSONEncoder().encode(["PROXY": "HK-01"]) + defaults.set(legacy, forKey: ProxyGroupSelections.legacyStorageKey) + + #expect(ProxyGroupSelections.load(from: defaults) == ["PROXY": "HK-01"]) + #expect(defaults.object(forKey: ProxyGroupSelections.legacyStorageKey) == nil) + + // Migrated into sub-a only. + setSubscription("sub-b", in: defaults) + #expect(ProxyGroupSelections.load(from: defaults).isEmpty) + } + } + + @Test("Migration never overwrites selections already saved for the scope") + func legacyBlobDoesNotOverwrite() { + withScratchDefaults { defaults in + setSubscription("sub-a", in: defaults) + ProxyGroupSelections.save(["PROXY": "JP-02"], to: defaults) + let legacy = try? JSONEncoder().encode(["PROXY": "HK-01"]) + defaults.set(legacy, forKey: ProxyGroupSelections.legacyStorageKey) + + #expect(ProxyGroupSelections.load(from: defaults) == ["PROXY": "JP-02"]) + } + } + + @Test("Malformed scope entries are skipped, not fatal") + func malformedEntriesSkipped() { + withScratchDefaults { defaults in + defaults.set(["sub-a": "not-a-dictionary", "sub-b": ["PROXY": "JP-02"]], + forKey: ProxyGroupSelections.storageKey) + let scopes = ProxyGroupSelections.allScopes(in: defaults) + #expect(scopes["sub-a"] == nil) + #expect(scopes["sub-b"] == ["PROXY": "JP-02"]) + } + } +} diff --git a/Shared/ConfigManager.swift b/Shared/ConfigManager.swift index c2f9668..d8810b7 100644 --- a/Shared/ConfigManager.swift +++ b/Shared/ConfigManager.swift @@ -209,6 +209,22 @@ final class ConfigManager { try? saveConfig(config) } + /// True when `name` is something this config can actually select: a + /// built-in outbound, a declared proxy, or a proxy group. Used to reject + /// selections carried over from another subscription before they reach + /// the engine, which silently drops unknown members instead of erroring. + static func configDefinesProxyName( + _ name: String, + in yaml: String, + groups: [EditableProxyGroup] + ) -> Bool { + if name == "DIRECT" || name == "PASS" || name.hasPrefix("REJECT") { return true } + if groups.contains(where: { $0.name == name }) { return true } + guard let dict = (try? Yams.load(yaml: yaml)) as? [String: Any], + let proxies = dict["proxies"] as? [[String: Any]] else { return false } + return proxies.contains { ($0["name"] as? String) == name } + } + /// Return all proxy group names with type "select" from config.yaml. func selectProxyGroupNames() -> [String] { guard let yaml = try? loadConfig() else { return [] } @@ -217,62 +233,10 @@ final class ConfigManager { .map(\.name) } - /// Update every select-type proxy group to put the user's selected node first - /// — except groups whose existing default (first listed proxy) is a bypass - /// member (`DIRECT`, `REJECT`, or a nested group that resolves to those). - /// Those are treated as bypass / blocklist groups (e.g. CN-direct categories - /// or ad-blockers) and left untouched. The selected node is only injected - /// into groups that already list it as a member, so we never invent - /// membership the user didn't grant. - func applySelectedNode() { - let defaults = AppConstants.sharedDefaults - guard let selectedNode = defaults.string(forKey: "selectedNode"), !selectedNode.isEmpty else { - return - } - guard var yaml = try? loadConfig() else { return } - - var groups = parseProxyGroups(from: yaml) - let groupMembers = Dictionary(uniqueKeysWithValues: groups.map { ($0.name, $0.proxies) }) - var changed = false - for i in groups.indices where groups[i].type == "select" { - var members = groups[i].proxies - // Skip bypass groups: the first listed proxy resolves to DIRECT or - // REJECT (directly or via a nested group), meaning the group is - // configured to bypass the proxy by default. - guard let first = members.first else { continue } - if isBypassGroup(firstMember: first, groupMembers: groupMembers) { continue } - - let promoted: String - if members.contains(selectedNode) { - // Explicit user choice wins: move the selected node to index 0. - promoted = selectedNode - } else if let bypass = firstBypassMember(in: members, groupMembers: groupMembers) { - // Group exposes a Direct/REJECT option but the user's selected - // node isn't a member. The subscription author listed the - // bypass entry for a reason (CN-direct category, ad-block, …), - // so prefer it over mihomo's "first listed proxy wins" default. - promoted = bypass - } else { - continue - } - - // Move the promoted member to index 0 so SelectorGroup defaults to - // it, but keep all other members so the user can still switch at - // runtime. - members.removeAll { $0 == promoted } - members.insert(promoted, at: 0) - groups[i].proxies = members - changed = true - } - guard changed else { return } - - yaml = updateProxyGroups(groups, in: yaml) - try? saveConfig(yaml) - } - - /// Add or remove a GLOBAL proxy group with the selected node. + /// Add or remove a GLOBAL proxy group with the user's GLOBAL selection. /// Mihomo's `mode: global` routes all traffic through the built-in GLOBAL selector, - /// so we need to define it with the user's selected proxy node. + /// so we need to define it with a real target — the engine's auto-created + /// GLOBAL sorts DIRECT first, which would send everything direct. func updateGlobalProxyGroup(_ yaml: String, enabled: Bool) -> String { // First, strip any existing GLOBAL group var lines = yaml.components(separatedBy: "\n") @@ -303,28 +267,29 @@ final class ConfigManager { guard enabled else { return lines.joined(separator: "\n") } - // Read selected node from shared UserDefaults - let defaults = AppConstants.sharedDefaults - let selectedNode = defaults.string(forKey: "selectedNode") - // Find proxy-groups: line and insert GLOBAL group right after it guard let pgIdx = lines.firstIndex(where: { $0.trimmingCharacters(in: .whitespaces).hasPrefix("proxy-groups:") }) else { return lines.joined(separator: "\n") } - // The saved node name can be stale after a subscription refresh renames - // nodes (the engine's lenient parser silently drops missing members), - // so list fallbacks after it: the MATCH rule's target group — global - // mode then routes everything the way rule mode routes unmatched - // traffic, including the user's persisted group selections — then the - // first non-bypass select group, then DIRECT as a last resort. - var members: [String] = [] - if let node = selectedNode, !node.isEmpty { - members.append(node) - } let stripped = lines.joined(separator: "\n") let groups = parseProxyGroups(from: stripped) let groupMembers = Dictionary(groups.map { ($0.name, $0.proxies) }) { first, _ in first } + + // Head with whatever the user last picked inside GLOBAL itself, but + // only when that name still exists in this config — a selection saved + // under a different subscription (or a node the subscription has since + // renamed) must not be listed, or global mode silently routes through + // a member the user never chose here. Fallbacks follow: the MATCH + // rule's target group — global mode then routes everything the way + // rule mode routes unmatched traffic, including the user's persisted + // group selections — then the first non-bypass select group, then + // DIRECT as a last resort. + var members: [String] = [] + if let saved = ProxyGroupSelections.load()["GLOBAL"], !saved.isEmpty, + Self.configDefinesProxyName(saved, in: stripped, groups: groups) { + members.append(saved) + } if let matchTarget = parseRules(from: stripped).last(where: { $0.type == "MATCH" })?.target, groups.contains(where: { $0.name == matchTarget }), !members.contains(matchTarget) { @@ -1597,24 +1562,6 @@ func isFirstDefaultBypass( return isFirstDefaultBypass(first, groupMembers: groupMembers, seen: &seen) } -/// Return the first member of `members` whose mihomo runtime default -/// resolves to DIRECT/REJECT, or nil if none. Used to promote a -/// direct-like option when the user's selected node is not a member of -/// this group — matches the subscription author's intent that "this group -/// exposes a Direct option because it's meant to bypass". -func firstBypassMember( - in members: [String], - groupMembers: [String: [String]] -) -> String? { - for member in members { - var seen: Set = [] - if isFirstDefaultBypass(member, groupMembers: groupMembers, seen: &seen) { - return member - } - } - return nil -} - enum ConfigError: LocalizedError { case sharedContainerUnavailable case configNotFound diff --git a/Shared/ProxyGroupSelections.swift b/Shared/ProxyGroupSelections.swift new file mode 100644 index 0000000..58b4d33 --- /dev/null +++ b/Shared/ProxyGroupSelections.swift @@ -0,0 +1,133 @@ +// Copyright (c) 2026 Max Lv +// +// Licensed under the MIT License. See the LICENSE file for details. + +import Foundation + +/// Per-group proxy selections, scoped to the subscription they were made under. +/// +/// The app used to keep two competing pieces of selection state: +/// +/// * `selectedNode` — ONE global node name that the main app pushed into +/// *every* non-bypass `Selector` group at tunnel start, and that +/// `ConfigManager.applySelectedNode()` also promoted to index 0 of every +/// such group in the effective YAML. Nothing has written that key since node +/// selection moved to the REST API (#23), so the only values still in the +/// wild are leftovers from older builds — a node name from a subscription +/// the user may no longer even have. When that stale name happened to also +/// exist in the current subscription it passed the membership check and got +/// pinned into every group, routing all traffic through a node the user +/// never picked; if that node was dead, everything black-holed. +/// +/// * `proxyGroupSelections` — the real per-group choices, but keyed by group +/// name alone. Two subscriptions that share a group name and a node name +/// leaked selections into each other (issue #75 item 6). +/// +/// Selections now live here, keyed by subscription scope first and group name +/// second, so a choice made under one subscription can never be replayed into +/// another. `selectedNode` is retired: `migrateLegacyStorageIfNeeded` removes +/// it so it can never be pushed into a group again. +/// +/// Stored as a plain plist dictionary (not JSON `Data`) so the value stays +/// readable and writable with `defaults`, which the E2E scripts rely on. +enum ProxyGroupSelections { + /// UserDefaults key holding `[subscriptionScope: [groupName: proxyName]]`. + static let storageKey = "proxyGroupSelectionsBySubscription" + + /// Legacy unscoped `[groupName: proxyName]` JSON blob. Migrated into the + /// active subscription's scope on first access, then removed. + static let legacyStorageKey = "proxyGroupSelections" + + /// Legacy single global node name. Read-only leftover from builds before + /// #23; removed on first access. + static let legacySelectedNodeKey = "selectedNode" + + /// UserDefaults key holding the active subscription's UUID string. + static let selectedSubscriptionIDKey = "selectedSubscriptionID" + + /// Scope used when no subscription is selected (hand-edited config). + static let noSubscriptionScope = "__none__" + + /// Scope key for the currently selected subscription. + static func currentScope(in defaults: UserDefaults = AppConstants.sharedDefaults) -> String { + let id = defaults.string(forKey: selectedSubscriptionIDKey) ?? "" + return id.isEmpty ? noSubscriptionScope : id + } + + /// Saved selections for the active subscription, `[groupName: proxyName]`. + static func load(from defaults: UserDefaults = AppConstants.sharedDefaults) -> [String: String] { + migrateLegacyStorageIfNeeded(in: defaults) + return allScopes(in: defaults)[currentScope(in: defaults)] ?? [:] + } + + /// Replace the active subscription's selections. Other scopes are kept. + static func save( + _ selections: [String: String], + to defaults: UserDefaults = AppConstants.sharedDefaults + ) { + migrateLegacyStorageIfNeeded(in: defaults) + var scopes = allScopes(in: defaults) + scopes[currentScope(in: defaults)] = selections.isEmpty ? nil : selections + write(scopes, to: defaults) + } + + /// Forget a deleted subscription's selections so re-adding the same + /// subscription later starts from the config's own defaults. + static func removeScope( + _ subscriptionID: String, + from defaults: UserDefaults = AppConstants.sharedDefaults + ) { + var scopes = allScopes(in: defaults) + guard scopes.removeValue(forKey: subscriptionID) != nil else { return } + write(scopes, to: defaults) + } + + /// Every scope's selections. Entries that aren't `[String: String]` (hand + /// edits, corrupt writes) are dropped rather than failing the whole read. + static func allScopes( + in defaults: UserDefaults = AppConstants.sharedDefaults + ) -> [String: [String: String]] { + guard let raw = defaults.dictionary(forKey: storageKey) else { return [:] } + var result: [String: [String: String]] = [:] + for (scope, value) in raw { + guard let entries = value as? [String: String] else { continue } + result[scope] = entries + } + return result + } + + private static func write(_ scopes: [String: [String: String]], to defaults: UserDefaults) { + if scopes.isEmpty { + defaults.removeObject(forKey: storageKey) + } else { + defaults.set(scopes, forKey: storageKey) + } + } + + /// Fold the pre-scoping storage into the current scope and drop the two + /// legacy keys. Idempotent: once `selectedNode` and the unscoped blob are + /// gone this is a no-op, so it is safe to call on every access. + /// + /// The legacy blob is attributed to the active subscription because that + /// is the one it was last used with. It is only adopted when the active + /// scope has no selections yet, so a migration can never overwrite choices + /// the user already made under the new storage. + static func migrateLegacyStorageIfNeeded(in defaults: UserDefaults = AppConstants.sharedDefaults) { + // The stale global node is never migrated — it is exactly the value + // that silently overrode real per-group choices. + if defaults.object(forKey: legacySelectedNodeKey) != nil { + defaults.removeObject(forKey: legacySelectedNodeKey) + } + + guard let data = defaults.data(forKey: legacyStorageKey) else { return } + defaults.removeObject(forKey: legacyStorageKey) + + guard let legacy = try? JSONDecoder().decode([String: String].self, from: data), + !legacy.isEmpty else { return } + var scopes = allScopes(in: defaults) + let scope = currentScope(in: defaults) + guard scopes[scope] == nil else { return } + scopes[scope] = legacy + write(scopes, to: defaults) + } +} diff --git a/Shared/VPNManager.swift b/Shared/VPNManager.swift index 886b7c0..5a207c8 100644 --- a/Shared/VPNManager.swift +++ b/Shared/VPNManager.swift @@ -324,7 +324,7 @@ final class VPNManager: NSObject, ObservableObject { } if connection.status == .connected { self?.extensionEnabled = true - self?.selectSavedProxyNode() + self?.replaySavedGroupSelections() } if connection.status == .disconnected { VPNManager.clearTunnelLog() @@ -498,7 +498,7 @@ final class VPNManager: NSObject, ObservableObject { DispatchQueue.main.async { self?.isProcessing = false self?.status = .connected - self?.selectSavedProxyNode() + self?.replaySavedGroupSelections() } } catch { DispatchQueue.main.async { @@ -598,30 +598,36 @@ final class VPNManager: NSObject, ObservableObject { return true } - /// Select a specific proxy node via Mihomo's REST API. - func selectNode(_ nodeName: String) { - selectNodeViaRestAPI(nodeName) - } - - /// Select the user's saved proxy node via Mihomo's REST API. - private func selectSavedProxyNode() { - let defaults = AppConstants.sharedDefaults - guard let nodeName = defaults.string(forKey: "selectedNode"), !nodeName.isEmpty else { - dbg("selectSavedProxyNode: no saved node") + /// Replay the user's saved per-group selections to the engine via the REST + /// API. The engine resets every `Selector` group to its config default on + /// each start, so without this the user's choices are lost on reconnect. + /// + /// Only groups the user actually chose in are touched, and only with a + /// value the group still lists — selections are scoped per subscription + /// (`ProxyGroupSelections`), so nothing from another subscription can be + /// pushed here. This replaces the old `selectedNode` path, which pushed a + /// single global node name into *every* non-bypass Selector group. + private func replaySavedGroupSelections() { + let saved = ProxyGroupSelections.load() + guard !saved.isEmpty else { + dbg("replayGroupSelections: nothing saved for this subscription") return } // Delay to let Mihomo's external controller finish initializing DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in - self?.selectNodeViaRestAPI(nodeName, retriesLeft: 5) + self?.replayGroupSelectionsViaRestAPI(saved, retriesLeft: 5) } } - private func selectNodeViaRestAPI(_ nodeName: String, retriesLeft: Int = 0) { + private func replayGroupSelectionsViaRestAPI( + _ saved: [String: String], + retriesLeft: Int = 0 + ) { guard let url = AppConstants.externalControllerURL(pathSegments: ["proxies"]) else { // Controller addr not yet published by the extension; retry. if retriesLeft > 0 { DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in - self?.selectNodeViaRestAPI(nodeName, retriesLeft: retriesLeft - 1) + self?.replayGroupSelectionsViaRestAPI(saved, retriesLeft: retriesLeft - 1) } } return @@ -630,57 +636,26 @@ final class VPNManager: NSObject, ObservableObject { guard let data = data, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let proxies = json["proxies"] as? [String: Any] else { - self?.dbg("selectNode: failed to fetch proxy list: \(error?.localizedDescription ?? "unknown") (retries=\(retriesLeft))") + self?.dbg("replayGroupSelections: failed to fetch proxy list: \(error?.localizedDescription ?? "unknown") (retries=\(retriesLeft))") if retriesLeft > 0 { DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - self?.selectNodeViaRestAPI(nodeName, retriesLeft: retriesLeft - 1) + self?.replayGroupSelectionsViaRestAPI(saved, retriesLeft: retriesLeft - 1) } } return } - // Build a name → members map across every group-like type that - // exposes an `all` array. We need this to recursively resolve - // bypass groups whose first member is itself another group. - let groupTypes: Set = ["Selector", "URLTest", "Fallback", "LoadBalance", "Relay"] - var groupMembers: [String: [String]] = [:] - for (name, value) in proxies { - guard let info = value as? [String: Any], - let type = info["type"] as? String, - groupTypes.contains(type), - let all = info["all"] as? [String] else { continue } - groupMembers[name] = all - } - // For every non-bypass Selector group, decide what to push: - // - if the group lists the user's selected node → push the node - // - else if the group contains a Direct/REJECT member → push - // that bypass member (matches applySelectedNode's config-time - // rewrite: subscription authors list Direct in a group to - // indicate it should bypass the proxy). - // - else → leave alone + // Push a saved choice only into a Selector group that still exists + // and still lists it. Engine-managed groups (URLTest, Fallback, …) + // pick their own member and are never pinned. var targets: [(group: String, selection: String)] = [] - for (name, value) in proxies { - guard let info = value as? [String: Any], + for (group, selection) in saved { + guard let info = proxies[group] as? [String: Any], (info["type"] as? String) == "Selector", let all = info["all"] as? [String], - let first = all.first else { continue } - // GLOBAL is the engine's auto-created all-proxies selector; its - // sorted member list puts DIRECT first, which the bypass - // heuristic below would misread. Always push the node so - // global mode routes through it. - if name == "GLOBAL" { - if all.contains(nodeName) { - targets.append((name, nodeName)) - } - continue - } - guard !isBypassGroup(firstMember: first, groupMembers: groupMembers) else { continue } - if all.contains(nodeName) { - targets.append((name, nodeName)) - } else if let bypass = firstBypassMember(in: all, groupMembers: groupMembers) { - targets.append((name, bypass)) - } + all.contains(selection) else { continue } + targets.append((group, selection)) } - self?.dbg("selectNode: \(nodeName) -> \(targets.map { "\($0.group)=\($0.selection)" })") + self?.dbg("replayGroupSelections: \(targets.map { "\($0.group)=\($0.selection)" })") for (groupName, selection) in targets { guard let putURL = AppConstants.externalControllerURL(pathSegments: ["proxies", groupName]), let body = try? JSONSerialization.data(withJSONObject: ["name": selection]) else { continue } @@ -689,9 +664,9 @@ final class VPNManager: NSObject, ObservableObject { request.httpBody = body URLSession.shared.dataTask(with: request) { [weak self] _, response, putError in if let putError = putError { - self?.dbg("selectNode \(groupName): \(putError.localizedDescription)") + self?.dbg("replayGroupSelections \(groupName): \(putError.localizedDescription)") } else if let http = response as? HTTPURLResponse { - self?.dbg("selectNode \(groupName): \(http.statusCode)") + self?.dbg("replayGroupSelections \(groupName): \(http.statusCode)") } }.resume() } @@ -700,10 +675,11 @@ final class VPNManager: NSObject, ObservableObject { /// Point the engine's GLOBAL selector at a real target so global mode /// routes through the proxy instead of GLOBAL's default (DIRECT, the - /// first entry of its sorted member list). Prefers the saved node, but - /// that name can be stale after a subscription refresh renames nodes — - /// then falls back to the largest non-bypass selector group (the - /// subscription's node-choice group), which tracks future node changes. + /// first entry of its sorted member list). Prefers the user's own GLOBAL + /// selection for this subscription, but that name can be stale after a + /// subscription refresh renames nodes — then falls back to the largest + /// non-bypass selector group (the subscription's node-choice group), + /// which tracks future node changes. func syncGlobalSelector() { guard let url = AppConstants.externalControllerURL(pathSegments: ["proxies"]) else { return } URLSession.shared.dataTask(with: AppConstants.authorizedControllerRequest(url: url)) { [weak self] data, _, _ in @@ -724,7 +700,7 @@ final class VPNManager: NSObject, ObservableObject { } var target: String? - let saved = AppConstants.sharedDefaults.string(forKey: "selectedNode") + let saved = ProxyGroupSelections.load()["GLOBAL"] if let saved = saved, !saved.isEmpty, globalMembers.contains(saved) { target = saved } else { @@ -799,14 +775,13 @@ final class VPNManager: NSObject, ObservableObject { } } - // Apply selected proxy node: save to disk, rewrite groups, read back - if let selectedNode = defaults.string(forKey: "selectedNode"), !selectedNode.isEmpty { - try? ConfigManager.shared.saveConfig(yaml) - ConfigManager.shared.applySelectedNode() - if let updated = try? ConfigManager.shared.loadConfig() { - yaml = updated - } - } + // No group rewriting here. The engine starts each Selector group on + // the config's own first member and `replaySavedGroupSelections()` + // pushes the user's per-group choices once the controller is up. The + // old `applySelectedNode()` rewrite was both wrong (one global node + // forced into every group) and lossy: it round-tripped proxy-groups + // through the editable parser, which drops `use:`, `filter:`, `lazy:` + // and friends, emptying provider-backed groups. // Apply user settings if let logLevel = defaults.string(forKey: "logLevel") { diff --git a/tests/e2e/vm-stability-test.sh b/tests/e2e/vm-stability-test.sh index b4da540..7e726ac 100755 --- a/tests/e2e/vm-stability-test.sh +++ b/tests/e2e/vm-stability-test.sh @@ -29,7 +29,10 @@ echo "--- Setting up VPN ---" mkdir -p "$CONFIG_DIR" sed "s/__HOST_IP__/$HOST_IP/g" /tmp/e2e-test-config.yaml > "$CONFIG_DIR/config.yaml" defaults write "$BUNDLE_ID" proxyMode -string "global" -defaults write "$BUNDLE_ID" selectedNode -string "e2e-trojan" +# Per-group selections are scoped by subscription; the E2E config is +# hand-written with no subscription selected, hence the __none__ scope. +defaults write "$BUNDLE_ID" proxyGroupSelectionsBySubscription \ + -dict-add "__none__" '{ "GLOBAL" = "e2e-trojan"; }' open "$APP_PATH" 2>&1 || true sleep 5 diff --git a/tests/e2e/vm-stress-test.sh b/tests/e2e/vm-stress-test.sh index 0e0ef78..53fb90e 100755 --- a/tests/e2e/vm-stress-test.sh +++ b/tests/e2e/vm-stress-test.sh @@ -32,7 +32,10 @@ echo "Config written" # --- Step 2: Set UserDefaults --- echo "--- Step 2: Set UserDefaults ---" defaults write "$BUNDLE_ID" proxyMode -string "global" -defaults write "$BUNDLE_ID" selectedNode -string "e2e-trojan" +# Per-group selections are scoped by subscription; the E2E config is +# hand-written with no subscription selected, hence the __none__ scope. +defaults write "$BUNDLE_ID" proxyGroupSelectionsBySubscription \ + -dict-add "__none__" '{ "GLOBAL" = "e2e-trojan"; }' echo "Proxy mode: global, node: e2e-trojan" # --- Step 3: Launch app ---