Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions BaoLianDeng.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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 */; };
Expand Down Expand Up @@ -191,6 +195,7 @@
B10095 /* PerAppProxySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerAppProxySettings.swift; sourceTree = "<group>"; };
B10096 /* PerAppProxySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerAppProxySection.swift; sourceTree = "<group>"; };
B10097 /* LANSharingSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LANSharingSettings.swift; sourceTree = "<group>"; };
PGS10000 /* ProxyGroupSelections.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyGroupSelections.swift; sourceTree = "<group>"; };
B10098 /* LANSharingSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LANSharingSection.swift; sourceTree = "<group>"; };
B10099 /* LANSharingSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LANSharingSettingsTests.swift; sourceTree = "<group>"; };
LP100000 /* LocalProxyController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalProxyController.swift; sourceTree = "<group>"; };
Expand All @@ -212,6 +217,7 @@
T10034 /* MihomoAPITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MihomoAPITests.swift; sourceTree = "<group>"; };
T10040 /* ProxyEngineIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyEngineIntegrationTests.swift; sourceTree = "<group>"; };
T10052 /* ProxyGroupsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyGroupsViewModelTests.swift; sourceTree = "<group>"; };
PGS10004 /* ProxyGroupSelectionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyGroupSelectionsTests.swift; sourceTree = "<group>"; };
T10044 /* TestConfigs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestConfigs.swift; sourceTree = "<group>"; };
T10046 /* ProxyEngineHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyEngineHelper.swift; sourceTree = "<group>"; };
GEO001 /* Country.mmdb */ = {isa = PBXFileReference; lastKnownFileType = file; path = Country.mmdb; sourceTree = "<group>"; };
Expand Down Expand Up @@ -347,6 +353,7 @@
B10008 /* ConfigManager.swift */,
B10095 /* PerAppProxySettings.swift */,
B10097 /* LANSharingSettings.swift */,
PGS10000 /* ProxyGroupSelections.swift */,
LP100000 /* LocalProxyController.swift */,
B10009 /* VPNManager.swift */,
);
Expand Down Expand Up @@ -434,6 +441,7 @@
T10034 /* MihomoAPITests.swift */,
T10040 /* ProxyEngineIntegrationTests.swift */,
T10052 /* ProxyGroupsViewModelTests.swift */,
PGS10004 /* ProxyGroupSelectionsTests.swift */,
);
path = BaoLianDengTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
Expand All @@ -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;
};
Expand All @@ -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;
};
Expand All @@ -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 */,
);
Expand Down
44 changes: 19 additions & 25 deletions BaoLianDeng/Models/ProxyGroupsViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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 {
Expand Down
6 changes: 3 additions & 3 deletions BaoLianDeng/Models/TrafficStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = []
Expand Down
11 changes: 10 additions & 1 deletion BaoLianDeng/Views/HomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down
73 changes: 0 additions & 73 deletions BaoLianDengTests/BypassGroupDetectionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading
Loading