refactor(rules): require explicit preset selection

Remove lossy default-action writes that can replace china-proxy
with gfwlist. Preserve legacy preference reads and derive the
default action from the selected preset.

Update tests to use explicit presets and verify identity,
legacy compatibility, and no-op reselection.
This commit is contained in:
Qiu Yuzhou
2026-10-25 14:32:12 +08:00
committed by Qiu Yuzhou
parent 168ff2e93e
commit 32709333f5
15 changed files with 78 additions and 82 deletions
@@ -31,7 +31,6 @@ final class ControllerProxyRuntimeAdapter: ProxyRuntimeAdapting {
var proxyMode: ProxyMode { controller.proxyMode }
var ruleDefaultAction: RuleDefaultAction { controller.ruleDefaultAction }
var rulePreset: RulePreset { controller.rulePreset }
var skippedInvalidServerCount: Int { controller.skippedServers.count }
@@ -96,10 +95,6 @@ final class ControllerProxyRuntimeAdapter: ProxyRuntimeAdapting {
func setRulePreset(_ preset: RulePreset) async {
await controller.setRulePreset(preset)
}
func setRuleDefaultAction(_ action: RuleDefaultAction) async {
await controller.setRuleDefaultAction(action)
}
}
/// HTTP 导出能力的唯一派生点:监听设置 → 可复制的 http/https 双导出行。
@@ -73,7 +73,7 @@ struct ProxyControlSnapshot: Equatable, Sendable {
var systemProxyInspection = SystemProxyInspectionFacts()
/// 当前已应用的代理模式(持久化成功的模式)。
let proxyMode: ProxyMode
/// 规则模式子选项:未匹配默认动作;仅规则模式呈现。
/// 规则模式内选择的规则预设;仅规则模式呈现。
var rulePreset: RulePreset = .gfwlist
var ruleDefaultAction: RuleDefaultAction { rulePreset.defaultAction }
/// 可用模式由 Domain 单点策略投影;UI 不自行判断可用性。
@@ -119,8 +119,7 @@ protocol ProxyRuntimeAdapting: AnyObject {
var systemProxyInspection: SystemProxyInspectionFacts { get }
/// 当前已应用的代理模式。
var proxyMode: ProxyMode { get }
/// 规则模式子选项。
var ruleDefaultAction: RuleDefaultAction { get }
/// 规则模式内选择的规则预设。
var rulePreset: RulePreset { get }
/// 最近一次激活预检或目录收敛跳过的无效服务器数量。
var skippedInvalidServerCount: Int { get }
@@ -149,7 +148,6 @@ protocol ProxyRuntimeAdapting: AnyObject {
func recheckSystemProxy() async
func setProxyMode(_ mode: ProxyMode) async
/// 规则模式子选项命令。
func setRuleDefaultAction(_ action: RuleDefaultAction) async
func setRulePreset(_ preset: RulePreset) async
}
@@ -259,13 +257,7 @@ final class ProxyControlWorkflow: ObservableObject {
return republish()
}
/// 切换规则模式子选项:完成后整体重发布。
@discardableResult
func setRuleDefaultAction(_ action: RuleDefaultAction) async -> ProxyControlSnapshot {
await runtime.setRuleDefaultAction(action)
return republish()
}
/// 切换规则预设:完成后整体重发布。
@discardableResult
func setRulePreset(_ preset: RulePreset) async -> ProxyControlSnapshot {
await runtime.setRulePreset(preset)
@@ -9,12 +9,7 @@ extension ProxyRuntimeController {
await transitionMode(mode, preset: settings.rulePreset)
}
/// 规则模式子选项:切换「未匹配时代理/直连」并重部署 ACL。
/// 失败保留最新模式与子选项,发布后失败停止服务并暂停系统代理。
func setRuleDefaultAction(_ action: RuleDefaultAction) async {
await setRulePreset(RulePreset(legacy: action))
}
/// 切换规则预设;失败保留最新意图,发布后失败停止服务并暂停系统代理。
func setRulePreset(_ preset: RulePreset) async {
guard preset != settings.rulePreset else { return }
await transitionMode(proxyMode, preset: preset)
@@ -123,7 +123,6 @@ final class ProxyRuntimeController {
var proxyMode: ProxyMode { didSet { factChanges.markChanged() } }
/// 规则模式子选项:从设置快照投影。
var ruleDefaultAction: RuleDefaultAction { settings.ruleDefaultAction }
var rulePreset: RulePreset { settings.rulePreset }
init(
@@ -29,11 +29,6 @@ struct ProxyControlPreferences {
nonmutating set { defaults.set(newValue.rawValue, forKey: "proxyControl.rulePreset") }
}
var ruleDefaultAction: RuleDefaultAction {
get { rulePreset.defaultAction }
nonmutating set { rulePreset = RulePreset(legacy: newValue) }
}
var agentEnabled: Bool {
get { defaults.object(forKey: "proxyControl.agentEnabled") as? Bool ?? false }
nonmutating set { defaults.set(newValue, forKey: "proxyControl.agentEnabled") }
@@ -14,8 +14,7 @@ struct ProxyRuntimeSettings: Equatable, Sendable {
/// 规则模式预设,首次启动默认为仅 GFW 列表内目标代理。
var rulePreset: RulePreset
var ruleDefaultAction: RuleDefaultAction {
get { rulePreset.defaultAction }
set { rulePreset = RulePreset(legacy: newValue) }
rulePreset.defaultAction
}
/// 用户保存的代理服务意图,默认关闭,与当前运行健康状态独立。
var agentEnabled: Bool
@@ -27,15 +26,14 @@ struct ProxyRuntimeSettings: Equatable, Sendable {
listen: ProxyListeningConfiguration = ProxyListeningConfiguration(),
proxyExceptions: String = ProxyRuntimeSettings.defaultProxyExceptions,
preferredMode: ProxyModeKind = .rule,
ruleDefaultAction: RuleDefaultAction = .directWhenUnmatched,
rulePreset: RulePreset? = nil,
rulePreset: RulePreset = .gfwlist,
agentEnabled: Bool = false,
systemProxyEnabled: Bool = false
) {
self.listen = listen
self.proxyExceptions = proxyExceptions
self.preferredMode = preferredMode
self.rulePreset = rulePreset ?? RulePreset(legacy: ruleDefaultAction)
self.rulePreset = rulePreset
self.agentEnabled = agentEnabled
self.systemProxyEnabled = systemProxyEnabled
}
@@ -19,10 +19,6 @@ final class FakeProxyRuntime: ProxyRuntimeAdapting {
var systemProxyInspection = SystemProxyInspectionFacts()
var proxyMode: ProxyMode
var rulePreset: RulePreset = .chinaDirect
var ruleDefaultAction: RuleDefaultAction {
get { rulePreset.defaultAction }
set { rulePreset = RulePreset(legacy: newValue) }
}
var skippedInvalidServerCount = 0
var activeTargetID: NodeID?
var httpExportCapability = HTTPExportCapability(
@@ -39,7 +35,6 @@ final class FakeProxyRuntime: ProxyRuntimeAdapting {
private(set) var approvalCommandCount = 0
private(set) var modeCommands: [ProxyMode] = []
private(set) var rulePresetCommands: [RulePreset] = []
private(set) var ruleDefaultActionCommands: [RuleDefaultAction] = []
init(
facts: ProxyRuntimeFacts = ProxyRuntimeFacts(status: .off, isOn: false),
@@ -74,10 +69,6 @@ final class FakeProxyRuntime: ProxyRuntimeAdapting {
func setRulePreset(_ preset: RulePreset) async {
rulePresetCommands.append(preset)
}
func setRuleDefaultAction(_ action: RuleDefaultAction) async {
ruleDefaultActionCommands.append(action)
}
}
/// 可编程目标事实替身:路径摘要可编程、查询全记录。
@@ -260,27 +251,20 @@ final class ProxyControlWorkflowTests: XCTestCase {
XCTAssertEqual(after.runtime, ProxyRuntimeFacts(status: .running, isOn: true))
}
func testRuleDefaultActionCommandForwardsAndRepublishes() async {
runtime.ruleDefaultAction = .proxyWhenUnmatched
_ = await workflow.setRuleDefaultAction(.directWhenUnmatched)
XCTAssertEqual(runtime.ruleDefaultActionCommands, [.directWhenUnmatched])
// 命令完成后 workflow 从 runtime 重读事实。
XCTAssertEqual(workflow.snapshot.ruleDefaultAction, .proxyWhenUnmatched)
runtime.ruleDefaultAction = .directWhenUnmatched
runtime.emitChange()
XCTAssertEqual(workflow.snapshot.ruleDefaultAction, .directWhenUnmatched)
XCTAssertEqual(workflow.snapshot.proxyMode, runtime.proxyMode)
}
func testExplicitChinaProxyPresetCommandPreservesItsIdentityInPublishedFacts() async {
_ = await workflow.setRulePreset(.chinaProxy)
XCTAssertEqual(runtime.rulePresetCommands, [.chinaProxy])
XCTAssertEqual(workflow.snapshot.rulePreset, .chinaDirect)
runtime.rulePreset = .chinaProxy
runtime.emitChange()
XCTAssertEqual(workflow.snapshot.rulePreset, .chinaProxy)
XCTAssertEqual(workflow.snapshot.ruleDefaultAction, .directWhenUnmatched)
XCTAssertEqual(workflow.snapshot.proxyMode, runtime.proxyMode)
func testRulePresetCommandsForwardAndRepublishWithoutLosingIdentity() async {
for preset in RulePreset.allCases {
let before = runtime.rulePreset
let after = await workflow.setRulePreset(preset)
XCTAssertEqual(runtime.rulePresetCommands.last, preset)
// 命令完成后从 runtime 重读事实,不提前发布请求值。
XCTAssertEqual(after.rulePreset, before)
runtime.rulePreset = preset
runtime.emitChange()
XCTAssertEqual(workflow.snapshot.rulePreset, preset)
XCTAssertEqual(workflow.snapshot.ruleDefaultAction, preset.defaultAction)
XCTAssertEqual(workflow.snapshot.proxyMode, runtime.proxyMode)
}
XCTAssertEqual(runtime.rulePresetCommands, RulePreset.allCases)
}
func testResyncOnLaunchForwardsAndRepublishes() async {
@@ -115,9 +115,9 @@ struct RuntimeConvergenceTests {
#expect(fixture.agent.registerCount > count)
}
@Test(arguments: [false, true])
@Test(arguments: [false, true], RulePreset.allCases)
func unchangedModeACLDoesNotReprobeHealthyListeners(
systemProxyEnabled: Bool
systemProxyEnabled: Bool, preset: RulePreset
) async throws {
let fixture = try Fixture(systemProxyEnabled: systemProxyEnabled)
defer { fixture.cleanUp() }
@@ -129,10 +129,16 @@ struct RuntimeConvergenceTests {
let probes = fixture.probe.ports.count
let signals = fixture.signals.signalsSent.filter { $0.signal != 0 }.count
// 全局模式仍保存规则默认动作,但它不改变全局 ACL。
await fixture.controller.setRuleDefaultAction(.directWhenUnmatched)
// 全局模式仍保存规则预设,但它不改变全局 ACL。
await fixture.controller.setRulePreset(preset)
let generation = fixture.controller.modeChangeGeneration
await fixture.controller.setRulePreset(preset)
#expect(fixture.controller.controlPreferences.ruleDefaultAction == .directWhenUnmatched)
#expect(fixture.controller.rulePreset == preset)
#expect(fixture.controller.modeChangeGeneration == generation)
if preset != .gfwlist {
#expect(fixture.controller.controlPreferences.rulePreset == preset)
}
#expect(fixture.controller.proxyMode == .global)
#expect(fixture.controller.state == .running)
#expect(try Data(contentsOf: fixture.runtimeStore.fileURL) == bytes)
@@ -62,7 +62,7 @@ final class CustomRuleControllerTests: ProxyRuntimeControllerTests {
let settings = ProxyRuntimeSettings(
listen: ActivationFixture.listen,
preferredMode: .rule,
ruleDefaultAction: .proxyWhenUnmatched,
rulePreset: .chinaDirect,
agentEnabled: true)
let controller = makeControllerWithCustomRules(
store: store, settings: settings, proxyMode: .rule)
@@ -92,7 +92,7 @@ final class CustomRuleControllerTests: ProxyRuntimeControllerTests {
let (store, _) = try makeCustomRuleStore(preset: .gfwlist)
let settings = ProxyRuntimeSettings(
listen: ActivationFixture.listen, preferredMode: .rule,
ruleDefaultAction: .directWhenUnmatched, agentEnabled: true)
rulePreset: .gfwlist, agentEnabled: true)
let controller = makeControllerWithCustomRules(
store: store, settings: settings, proxyMode: .rule)
try await controller.activate(seeded.server)
@@ -130,7 +130,7 @@ final class CustomRuleControllerTests: ProxyRuntimeControllerTests {
let settings = ProxyRuntimeSettings(
listen: ActivationFixture.listen,
preferredMode: .rule,
ruleDefaultAction: .proxyWhenUnmatched,
rulePreset: .chinaDirect,
agentEnabled: true)
let controller = makeControllerWithCustomRules(
store: store, settings: settings, proxyMode: .rule)
@@ -160,7 +160,7 @@ final class CustomRuleControllerTests: ProxyRuntimeControllerTests {
let settings = ProxyRuntimeSettings(
listen: ActivationFixture.listen,
preferredMode: .rule,
ruleDefaultAction: .proxyWhenUnmatched,
rulePreset: .chinaDirect,
agentEnabled: true,
systemProxyEnabled: true)
let controller = makeControllerWithCustomRules(
@@ -62,7 +62,7 @@ final class CustomRuleDeletionControllerTests: ProxyRuntimeControllerTests {
store: store,
settings: ProxyRuntimeSettings(
listen: ActivationFixture.listen, preferredMode: .rule,
ruleDefaultAction: .proxyWhenUnmatched, agentEnabled: true), proxyMode: .rule)
rulePreset: .chinaDirect, agentEnabled: true), proxyMode: .rule)
try await controller.activate(seeded.server)
let workflow = deletionWorkflow(controller)
await workflow.refresh()
@@ -95,7 +95,7 @@ final class CustomRuleDeletionControllerTests: ProxyRuntimeControllerTests {
store: store,
settings: ProxyRuntimeSettings(
listen: ActivationFixture.listen, preferredMode: mode.kind,
ruleDefaultAction: .proxyWhenUnmatched, agentEnabled: true), proxyMode: mode)
rulePreset: .chinaDirect, agentEnabled: true), proxyMode: mode)
try await controller.activate(seeded.server)
let workflow = deletionWorkflow(controller)
await workflow.refresh()
@@ -121,7 +121,7 @@ final class CustomRuleDeletionControllerTests: ProxyRuntimeControllerTests {
store: store,
settings: ProxyRuntimeSettings(
listen: ActivationFixture.listen, preferredMode: .rule,
ruleDefaultAction: .proxyWhenUnmatched, agentEnabled: true), proxyMode: .rule)
rulePreset: .chinaDirect, agentEnabled: true), proxyMode: .rule)
try await controller.activate(seeded.server)
let workflow = deletionWorkflow(controller)
await workflow.refresh()
@@ -35,7 +35,7 @@ final class CustomRuleEditingControllerTests: ProxyRuntimeControllerTests {
store: store,
settings: ProxyRuntimeSettings(
listen: ActivationFixture.listen, preferredMode: .rule,
ruleDefaultAction: .proxyWhenUnmatched, agentEnabled: true), proxyMode: .rule)
rulePreset: .chinaDirect, agentEnabled: true), proxyMode: .rule)
try await controller.activate(seeded.server)
let workflow = editingWorkflow(controller)
await workflow.refresh()
@@ -71,7 +71,7 @@ final class CustomRuleEditingControllerTests: ProxyRuntimeControllerTests {
store: store,
settings: ProxyRuntimeSettings(
listen: ActivationFixture.listen, preferredMode: .rule,
ruleDefaultAction: .proxyWhenUnmatched, agentEnabled: true), proxyMode: .rule)
rulePreset: .chinaDirect, agentEnabled: true), proxyMode: .rule)
try await controller.activate(seeded.server)
let workflow = editingWorkflow(controller)
await workflow.refresh()
@@ -272,7 +272,7 @@ extension RuleEnablementControllerTests {
try store.save([rule])
let settings = ProxyRuntimeSettings(
listen: ActivationFixture.listen, preferredMode: .rule,
ruleDefaultAction: .proxyWhenUnmatched, agentEnabled: true)
rulePreset: .chinaDirect, agentEnabled: true)
let deploymentWaiting = expectation(description: "deployment health retry paused")
let deploymentGate = RuleRuntimeDelayGate(started: deploymentWaiting)
defer { deploymentGate.resume() }
@@ -338,7 +338,7 @@ extension RuleEnablementControllerTests {
try store.save([rule])
let settings = ProxyRuntimeSettings(
listen: ActivationFixture.listen, preferredMode: .rule,
ruleDefaultAction: .proxyWhenUnmatched, agentEnabled: true)
rulePreset: .chinaDirect, agentEnabled: true)
let controller = makeRuleEnablementController(
store: store, settings: settings, proxyMode: .rule)
try await controller.activate(seeded.server)
@@ -8,7 +8,8 @@ extension RuleEnablementControllerTests {
let (store, _) = try makeCustomRuleStore()
let controller = makeControllerWithCustomRules(
store: store,
settings: ProxyRuntimeSettings(listen: ActivationFixture.listen, agentEnabled: true),
settings: ProxyRuntimeSettings(
listen: ActivationFixture.listen, rulePreset: .chinaDirect, agentEnabled: true),
proxyMode: .rule, ruleApplicationDelay: { await Task.yield() })
try await controller.activate(seeded.server)
let runtimeStore = RuntimeFileStore(fileURL: runtime.contract)
@@ -29,6 +30,11 @@ extension RuleEnablementControllerTests {
XCTAssertTrue(china.contains("preset-only.example"))
XCTAssertTrue(china.contains("[proxy_list]"))
XCTAssertEqual(controller.controlPreferences.rulePreset, .chinaProxy)
let generation = controller.modeChangeGeneration
await controller.setRulePreset(.chinaProxy)
XCTAssertEqual(controller.rulePreset, .chinaProxy)
XCTAssertEqual(controller.modeChangeGeneration, generation)
XCTAssertEqual(try activeACLContent(runtimeStore), china)
await controller.setRulePreset(.chinaDirect)
XCTAssertEqual(try activeACLContent(runtimeStore), before)
await controller.setRulePreset(.gfwlist)
@@ -117,6 +117,7 @@ extension RulePresetWorkflowTests {
return RuleDocumentCommit(outcome: .saved, document: document)
}, builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) }))
}
let originalPreset = workspaces.selectedPreset
let original = workspaces.current
await original.refresh()
var draft = try #require(original.makeCustomRuleDraft())
@@ -136,7 +137,7 @@ extension RulePresetWorkflowTests {
#expect(await save.value == .committed(.saved))
await original.analysisTask?.value
#expect(other.snapshot == untouched)
workspaces.selectedPreset = .chinaDirect
workspaces.selectedPreset = originalPreset
#expect(workspaces.current.snapshot.rows.first?.content == "delayed.example")
#expect(workspaces.current.snapshot.rows.first?.action == .direct)
#expect(workspaces.current.snapshot.commitOutcome == .saved)
@@ -16,6 +16,7 @@ struct ProxyControlPreferencesTests {
preferences: ProxyControlPreferences(defaults: defaults))
#expect(settings.preferredMode == .rule)
#expect(settings.rulePreset == .gfwlist)
#expect(settings.ruleDefaultAction == .directWhenUnmatched)
#expect(!settings.agentEnabled)
#expect(!settings.systemProxyEnabled)
@@ -32,7 +33,7 @@ struct ProxyControlPreferencesTests {
defer { defaults.removePersistentDomain(forName: suite) }
let preferences = ProxyControlPreferences(defaults: defaults)
preferences.preferredMode = .direct
preferences.ruleDefaultAction = .directWhenUnmatched
preferences.rulePreset = .chinaProxy
preferences.agentEnabled = true
preferences.systemProxyEnabled = true
preferences.proxyExceptions = "example.com, localhost"
@@ -44,6 +45,7 @@ struct ProxyControlPreferencesTests {
let settings = ProxyRuntimeSettings(listen: listen, preferences: reopened)
#expect(settings.preferredMode == .direct)
#expect(settings.rulePreset == .chinaProxy)
#expect(settings.ruleDefaultAction == .directWhenUnmatched)
#expect(settings.agentEnabled)
#expect(settings.systemProxyEnabled)
@@ -51,4 +53,27 @@ struct ProxyControlPreferencesTests {
#expect(settings.listen.socksPort == 23001)
#expect(settings.listen.httpPort == 23002)
}
@Test(arguments: RuleDefaultAction.allCases)
func legacyDefaultActionIsReadUntilAnExplicitPresetIsSaved(_ action: RuleDefaultAction) throws {
let suite = "proxy-control-legacy-\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
defaults.set(action.rawValue, forKey: "proxyControl.ruleDefaultAction")
let preferences = ProxyControlPreferences(defaults: defaults)
let expected: RulePreset = action == .proxyWhenUnmatched ? .chinaDirect : .gfwlist
#expect(preferences.rulePreset == expected)
#expect(defaults.object(forKey: "proxyControl.rulePreset") == nil)
for preset in RulePreset.allCases {
preferences.rulePreset = preset
let reopened = ProxyControlPreferences(defaults: defaults)
let settings = ProxyRuntimeSettings(
listen: ProxyListeningConfiguration(), preferences: reopened)
#expect(settings.rulePreset == preset)
#expect(settings.ruleDefaultAction == preset.defaultAction)
#expect(defaults.string(forKey: "proxyControl.ruleDefaultAction") == action.rawValue)
}
}
}