feat(rules): persist cross-source enablement and recover deployments (#78)

This commit is contained in:
Qiu Yuzhou
2026-10-02 21:38:03 +08:00
parent beaa92781a
commit 09686a80db
22 changed files with 821 additions and 78 deletions
+1 -1
View File
@@ -104,6 +104,6 @@
- Preferences have no single restore-all-factory-defaults action. Where a reset exists, it is scoped to one configuration item, never to the whole preference set at once.
- The proxy mode selector's choice and the rule default action persist with the settings snapshot and survive GUI restarts; a persistence failure keeps the previous mode and sub-option in force and names the reason. Rule, global, and direct mode are always available.
- The GUI is the user-facing manager; the Shadowsocks tunnel service is an external runtime boundary rather than part of the GUI's domain model.
- The Proxy Rules destination provides read-only browsing through `RulesWorkflow`: it prepares a versioned collection off the UI actor from fixed local policy, all three built-in snapshots (including preserved expressible absorbed candidates), and the custom-rule document. Equivalent identities retain all source memberships and custom UUIDs. Search, source, and action filters intersect; sorting affects presentation only; hidden selections are cleared and navigation preserves session query/selection. Source metadata and original conversion reports remain distinct from current coverage explanations. Missing/corrupt inputs explicitly mark the collection incomplete while retaining available sources. The page also tests domains, IP literals, and URL hosts offline against that complete saved collection, independently of browsing filters, runtime state, and the unmatched default action. Results retain all deciding identities and source memberships plus other target-specific hits; domain tests explicitly leave IP rules untested without DNS. Invalid input and incomplete collections are operation errors. Editing the target or refreshing clears prior results, and generation/version checks reject late tasks. The page has no add, edit, disable, or delete actions yet; it does not change runtime source selection.
- The Proxy Rules destination provides browsing through `RulesWorkflow`: it prepares a versioned collection off the UI actor from fixed local policy, all three built-in snapshots (including preserved expressible absorbed candidates), and the custom-rule document. Equivalent identities retain all source memberships and custom UUIDs. Search, source, and action filters intersect; sorting affects presentation only; hidden selections are cleared and navigation preserves session query/selection. Source metadata and original conversion reports remain distinct from current coverage explanations. Missing/corrupt inputs explicitly mark the collection incomplete while retaining available sources. The page also tests domains, IP literals, and URL hosts offline against that complete saved collection, independently of browsing filters, runtime state, and the unmatched default action. Results retain all deciding identities and source memberships plus other target-specific hits; domain tests explicitly leave IP rules untested without DNS. Invalid input and incomplete collections are operation errors. Editing the target or refreshing clears prior results, and generation/version checks reject late tasks. The page supports individual and batch enablement through the Workflow. A schema-2 user document atomically stores custom entries and source-independent disabled identities; schema 1 reads with no disabled records. Disabled identities survive source disappearance and are visible under all sources with the disabled filter, where enabling removes orphan rows. Filtering excludes disabled identities before current coverage analysis and offline matching; runtime analysis separately uses the existing default-action source subset and actual ACL skeleton, restoring preserved expressible absorbed candidates and GFWList exceptions when their blockers are disabled. Commits show progress and reject repeats, preserve the runtime switch intent, and deploy only if ACL identity changes. Deployment failures restore the entire prior document and runtime; recovery failures remain explicit errors. The page has no add, edit, or delete actions yet.
- The app presents in the adaptive app form: menu-bar-only after the last workspace or independent rule-report window closes, and with a Dock icon and app-switcher presence while either is open; hiding the app is not closing it and does not change the form; the GUI process keeps running with the status menu available regardless of the workspace window.
- At launch the GUI presents the workspace window unless silent launch is on; after the workspace window is closed, the status menu is the only way to reopen it. Silent launch changes only window presentation: proxy runtime restoration happens at every launch regardless of it.
+4 -1
View File
@@ -157,7 +157,10 @@ private struct AppComposition {
let workspaceRoute = WorkspaceRoute()
let silentLaunch = SilentLaunchController(store: dependencies.silentLaunchStore)
let expansion = CatalogExpansionState()
let rulesWorkflow = RulesWorkflow()
let rulesStore = controller.customRuleStore
let rulesWorkflow = RulesWorkflow(
loadDocument: { try rulesStore.loadDocument() },
commitDocument: { await controller.updateRuleDocument($0) })
let workspaceContent = MainWindowView(
rulesWorkflow: rulesWorkflow,
route: workspaceRoute,
@@ -270,6 +270,7 @@ extension ProxyRuntimeController {
func execute(_ intent: RuntimeIntent, document: SslocalRuntimeDocument?) async -> Bool {
cancelFirewallObservation()
flowGeneration += 1
let generation = flowGeneration
let actions = ProxyRuntimePlan.actions(
intent: intent,
agentStatus: agent.status,
@@ -279,9 +280,11 @@ extension ProxyRuntimeController {
RuntimeLog.emit(.contractUnchanged)
}
for action in actions {
guard await perform(action, document: document) else { return false }
guard generation == flowGeneration,
await perform(action, document: document)
else { return false }
}
return true
return generation == flowGeneration
}
/// 执行单个动作;返回 false 表示应终止后续动作(状态已呈现)。
@@ -12,6 +12,10 @@ enum CustomRuleUpdateOutcome: Equatable, Sendable {
case persistenceFailed
/// 保存后部署失败并已回滚到旧规则与旧运行时。
case rolledBack
case recoveryFailed(detail: String)
case busy
case versionConflict
case invalidDocument(detail: String)
}
// MARK: - 自定义规则命令面
@@ -21,29 +25,40 @@ extension ProxyRuntimeController {
/// 校验拒绝时整批不落地;部署失败时回滚旧规则、旧 ACL 与旧系统代理状态。
/// 全局和直连模式不加载自定义规则,但持久化仍然进行(切换到规则模式后生效)。
func updateCustomRules(_ rules: [CustomRule]) async -> CustomRuleUpdateOutcome {
let validation = validateCustomRulesForPersistence(rules)
guard validation.rejected.isEmpty else {
return .rejected(validation.rejected)
}
let previousRules = (try? customRuleStore.load()) ?? []
let snapshot = ModeTransitionSnapshotForRules(
document: lastDocument, state: state)
do {
try customRuleStore.save(validation.acceptedRules)
let previous = try customRuleStore.loadDocument()
return await updateRuleDocument(
CustomRuleDocument(rules: rules, disabledIdentities: previous.disabledIdentities))
} catch { return .persistenceFailed }
}
func updateRuleDocument(_ document: CustomRuleDocument) async -> CustomRuleUpdateOutcome {
guard !isUpdatingRules else { return .busy }
isUpdatingRules = true
defer { isUpdatingRules = false }
let validation = validateCustomRulesForPersistence(document.rules)
guard validation.rejected.isEmpty else { return .rejected(validation.rejected) }
guard
document.disabledIdentities.isDisjoint(
with:
Set(RuleCoverage.fixedLocalMatches.map { RuleIdentity(action: .direct, match: $0) }))
else { return .invalidDocument(detail: "Fixed local policy cannot be disabled") }
let previous: CustomRuleDocument
let snapshot = ModeTransitionSnapshotForRules(
document: lastDocument ?? runtimeFileStore.loadDocument(), state: state)
do {
previous = try customRuleStore.loadDocument()
try customRuleStore.saveDocument(document)
} catch {
RuntimeLog.emit(.runtimePersistFailed(detail: String(describing: error)))
state = .serviceFailed(.persistence)
return .persistenceFailed
}
return await deployCustomRuleChange(previousRules: previousRules, snapshot: snapshot)
return await deployCustomRuleChange(previousRules: previous, snapshot: snapshot)
}
/// 规则内容变化后重编译 ACL;非规则模式的 ACL 不含自定义规则,无变化即不重启。
private func deployCustomRuleChange(
previousRules: [CustomRule],
previousRules: CustomRuleDocument,
snapshot: ModeTransitionSnapshotForRules
) async -> CustomRuleUpdateOutcome {
guard settings.agentEnabled, state != .off,
@@ -57,8 +72,12 @@ extension ProxyRuntimeController {
nextDocument = try runtimeDocument(currentDocument, for: proxyMode)
} catch {
RuntimeLog.emit(.runtimePersistFailed(detail: String(describing: error)))
await restoreCustomRules(previousRules, snapshot: snapshot)
return .rolledBack
do {
try customRuleStore.saveDocument(previousRules)
return .rolledBack
} catch {
return .recoveryFailed(detail: String(describing: error))
}
}
guard nextDocument.aclRuntime != currentDocument.aclRuntime else {
return .saved
@@ -66,28 +85,44 @@ extension ProxyRuntimeController {
modeChangeGeneration += 1
let generation = modeChangeGeneration
let runtimeGeneration = flowGeneration + 1
lastDocument = nextDocument
state = .starting
guard await execute(.run(nextDocument), document: nextDocument) else {
guard generation == modeChangeGeneration else { return .rolledBack }
await restoreCustomRules(previousRules, snapshot: snapshot)
return .rolledBack
guard generation == modeChangeGeneration, runtimeGeneration == flowGeneration,
settings.agentEnabled
else {
return .recoveryFailed(detail: "Runtime changed during rule deployment")
}
return await restoreCustomRules(previousRules, snapshot: snapshot)
}
guard generation == modeChangeGeneration else { return .rolledBack }
guard generation == modeChangeGeneration, runtimeGeneration == flowGeneration,
settings.agentEnabled
else {
return .recoveryFailed(detail: "Runtime changed during rule deployment")
}
let healthy = await presentLaunchHealth(
nextDocument,
requiresReceipt: true,
convergeProxyOnSuccess: false,
preserveProxyOnFailure: true)
guard generation == modeChangeGeneration else { return .rolledBack }
guard generation == modeChangeGeneration, runtimeGeneration == flowGeneration,
settings.agentEnabled
else {
return .recoveryFailed(detail: "Runtime changed during rule deployment")
}
guard healthy else {
await restoreCustomRules(previousRules, snapshot: snapshot)
return .rolledBack
return await restoreCustomRules(previousRules, snapshot: snapshot)
}
lastDocument = nextDocument
await convergeSystemProxy()
guard generation == modeChangeGeneration, runtimeGeneration == flowGeneration,
settings.agentEnabled
else {
return .recoveryFailed(detail: "Runtime changed during rule deployment")
}
return .saved
}
@@ -107,27 +142,53 @@ extension ProxyRuntimeController {
}
private func restoreCustomRules(
_ previousRules: [CustomRule],
_ previousRules: CustomRuleDocument,
snapshot: ModeTransitionSnapshotForRules
) async {
) async -> CustomRuleUpdateOutcome {
var failures: [String] = []
do {
try customRuleStore.save(previousRules)
try customRuleStore.saveDocument(previousRules)
} catch {
failures.append(String(describing: error))
RuntimeLog.emit(.runtimePersistFailed(detail: String(describing: error)))
}
guard let previousDocument = snapshot.document else { return }
guard let previousDocument = snapshot.document else {
return failures.isEmpty
? .rolledBack : .recoveryFailed(detail: failures.joined(separator: "; "))
}
guard settings.agentEnabled else {
return .recoveryFailed(detail: "Agent disabled during rule recovery")
}
let modeGeneration = modeChangeGeneration
let runtimeGeneration = flowGeneration + 1
lastDocument = previousDocument
state = .starting
_ = await execute(.run(previousDocument), document: previousDocument)
let restored = await presentLaunchHealth(
previousDocument,
requiresReceipt: true,
convergeProxyOnSuccess: false)
if !restored {
let executed = await execute(.run(previousDocument), document: previousDocument)
guard modeGeneration == modeChangeGeneration, runtimeGeneration == flowGeneration,
settings.agentEnabled
else {
return .recoveryFailed(detail: "Runtime changed during rule recovery")
}
guard executed else {
failures.append(String(describing: state))
await holdSystemProxyIntent()
return .recoveryFailed(detail: failures.joined(separator: "; "))
}
let healthy = await presentLaunchHealth(
previousDocument, requiresReceipt: true, convergeProxyOnSuccess: false)
guard modeGeneration == modeChangeGeneration, runtimeGeneration == flowGeneration,
settings.agentEnabled
else {
return .recoveryFailed(detail: "Runtime changed during rule recovery")
}
if !healthy {
failures.append(String(describing: state))
await holdSystemProxyIntent()
} else {
state = snapshot.state
}
return failures.isEmpty
? .rolledBack : .recoveryFailed(detail: failures.joined(separator: "; "))
}
private struct ModeTransitionSnapshotForRules {
@@ -137,7 +198,6 @@ extension ProxyRuntimeController {
/// 自定义规则安全摘要(issue #66 AC5):数量 + 内容版本,不含原始域名。
func readCustomRuleSummary() -> CustomRuleSummary? {
guard let rules = try? customRuleStore.load() else { return nil }
return CustomRuleSummary.summarizing(rules)
try? customRuleStore.summary()
}
}
@@ -112,21 +112,28 @@ extension ProxyRuntimeController {
/// 规则模式的完整有效候选、硬拒绝项与当前 ACL 骨架下的覆盖说明。
func ruleModeValidation() throws -> CustomRuleValidationResult {
let custom = try customRuleStore.load()
let document = try customRuleStore.loadDocument()
let custom = document.rules.filter { !document.disabledIdentities.contains($0.identity) }
switch settings.ruleDefaultAction {
case .proxyWhenUnmatched:
let builtIn = try chinaDirectRules()
let builtIn = try chinaDirectRules().filter {
!document.disabledIdentities.contains($0.identity)
}
let validated = CustomRuleValidator.validate(
custom: custom, builtIn: builtIn, defaultAction: .proxyWhenUnmatched)
return CustomRuleValidationResult(
accepted: builtIn + validated.accepted, rejected: validated.rejected,
accepted: RuleAnalysis.runtimeCandidates(
builtIn + validated.accepted, defaultAction: settings.ruleDefaultAction),
rejected: validated.rejected,
relationships: validated.relationships)
case .directWhenUnmatched:
let builtIn = try gfwlistRules()
let builtIn = try gfwlistRules().filter { !document.disabledIdentities.contains($0.identity) }
let validated = CustomRuleValidator.validate(
custom: custom, builtIn: builtIn, defaultAction: .directWhenUnmatched)
return CustomRuleValidationResult(
accepted: builtIn + validated.accepted, rejected: validated.rejected,
accepted: RuleAnalysis.runtimeCandidates(
builtIn + validated.accepted, defaultAction: settings.ruleDefaultAction),
rejected: validated.rejected,
relationships: validated.relationships)
}
}
@@ -65,6 +65,7 @@ final class ProxyRuntimeController: ObservableObject {
let plugins: ManagedPluginProviding
let settingsStore: ProxySettingsStoring
/// 自定义规则持久化(issue #66):规则模式 ACL 合并的用户入口。
var isUpdatingRules = false
let customRuleStore: CustomRuleStore
/// 监听设置不可读时的点名原因(D8「任何路径不静默改端口」);非 nil 时
/// 设置只是占位出厂默认,禁止部署(见 `deploy`)。
+143
View File
@@ -584,6 +584,149 @@
}
}
}
},
"启用": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Enable"
}
}
}
},
"禁用": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Disable"
}
}
}
},
"全部状态": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "All states"
}
}
}
},
"已启用": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Enabled"
}
}
}
},
"已禁用": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Disabled"
}
}
}
},
"无当前来源": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "No current source"
}
}
}
},
"正在保存并应用…": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Saving and applying…"
}
}
}
},
"恢复失败": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Recovery failed"
}
}
}
},
"已保存": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Saved"
}
}
}
},
"保存失败": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Save failed"
}
}
}
},
"部署失败,已恢复": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Deployment failed; restored"
}
}
}
},
"版本已变化,请刷新后重试": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Rules changed; refresh and retry"
}
}
}
},
"规则文档无效": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Invalid rule document"
}
}
}
}
},
"version": "1.0"
+62 -1
View File
@@ -9,6 +9,12 @@ struct RulesView: View {
VStack(spacing: 0) {
RulesAddressTestView(workflow: workflow)
Divider()
if workflow.snapshot.isCommitting {
ProgressView(RulesCopy.text("正在保存并应用…")).padding()
}
if let outcome = workflow.snapshot.commitOutcome {
Text(verbatim: outcome.rulesMessage).textSelection(.enabled).padding(.horizontal)
}
if workflow.snapshot.isLoading {
ProgressView(RulesCopy.text("加载规则…")).padding()
}
@@ -94,6 +100,16 @@ struct RulesView: View {
Text(RulesCopy.text("直连")).tag(Optional(RuleAction.direct))
Text(RulesCopy.text("代理")).tag(Optional(RuleAction.proxy))
}.labelsHidden().fixedSize()
Picker(
RulesCopy.text("状态"),
selection: Binding(
get: { workflow.snapshot.query.enabled },
set: { value in updateQuery { $0.enabled = value } })
) {
Text(RulesCopy.text("全部状态")).tag(Optional<Bool>.none)
Text(RulesCopy.text("已启用")).tag(Optional(true))
Text(RulesCopy.text("已禁用")).tag(Optional(false))
}.labelsHidden().fixedSize()
Picker(
RulesCopy.text("匹配内容"),
selection: Binding(
@@ -113,6 +129,19 @@ struct RulesView: View {
get: { workflow.snapshot.selection },
set: { ids in DispatchQueue.main.async { workflow.select(ids) } })
) {
TableColumn(RulesCopy.text("启用")) { row in
Toggle(
RulesCopy.text("启用"),
isOn: Binding(
get: { row.isEnabled },
set: { enabled in
guard let identity = row.identity else { return }
Task { await workflow.setEnabled(enabled, identities: [identity]) }
})
)
.labelsHidden()
.disabled(row.isFixed || !workflow.snapshot.isComplete || workflow.snapshot.isCommitting)
}.width(45)
TableColumn(RulesCopy.text("匹配内容")) { row in Text(verbatim: row.displayContent) }
.width(min: 120, ideal: 170)
TableColumn(RulesCopy.text("类型")) { row in Text(verbatim: row.matchType) }
@@ -124,6 +153,21 @@ struct RulesView: View {
TableColumn(RulesCopy.text("状态")) { row in Text(row.statusLabel) }
.width(min: 80, ideal: 110)
}.frame(minWidth: 0, maxWidth: .infinity)
.contextMenu {
ForEach([true, false], id: \.self) { enabled in
Button {
let identities = workflow.actionableSelection
Task { await workflow.setEnabled(enabled, identities: identities) }
} label: {
Text(
RulesCopy.text(enabled ? "启用" : "禁用") + " ("
+ String(workflow.actionableSelection.count) + ")")
}
.disabled(
workflow.actionableSelection.isEmpty || !workflow.snapshot.isComplete
|| workflow.snapshot.isCommitting)
}
}
}
private var sourceBinding: Binding<RulesSourceChoice?> {
@@ -228,12 +272,14 @@ extension RulesSource {
extension RulesRow {
var displayContent: String { identity == nil ? RulesCopy.text("简单主机名") : content }
var sourceLabels: String {
RulesSource.allCases.filter { sources.contains($0) }.map(\.label).joined(separator: ", ")
guard hasCurrentSource else { return RulesCopy.text("无当前来源") }
return RulesSource.allCases.filter { sources.contains($0) }.map(\.label).joined(separator: ", ")
}
var actionLabel: String { RulesCopy.text(action == .direct ? "直连" : "代理") }
var matchType: String { identity?.match.browsingType ?? RulesCopy.text("简单主机名") }
var statusLabel: String {
if isFixed { return RulesCopy.text("固定本地策略") }
if !isEnabled { return RulesCopy.text("已禁用") }
var labels = relationships.map(\.browsingLabel)
if let fixedCoverage {
let label =
@@ -268,3 +314,18 @@ extension RuleMatch {
}
}
}
extension CustomRuleUpdateOutcome {
var rulesMessage: String {
switch self {
case .saved: RulesCopy.text("已保存")
case .persistenceFailed: RulesCopy.text("保存失败")
case .rolledBack: RulesCopy.text("部署失败,已恢复")
case .recoveryFailed(let detail): RulesCopy.text("恢复失败") + ": " + detail
case .busy: RulesCopy.text("正在保存并应用…")
case .versionConflict: RulesCopy.text("版本已变化,请刷新后重试")
case .invalidDocument(let detail): RulesCopy.text("规则文档无效") + ": " + detail
case .rejected(let rejected): rejected.map(\.explanation).joined(separator: "; ")
}
}
}
+61 -3
View File
@@ -9,12 +9,16 @@ final class RulesWorkflow: ObservableObject {
@Published private(set) var reportSource: RulesSourceSnapshot?
private let loadCustom: @Sendable () throws -> [CustomRule]
private let loadBuiltin: @Sendable (RulesSource) throws -> RuleSnapshot
private let loadDocument: (@Sendable () throws -> CustomRuleDocument)?
private let commitDocument: (@MainActor (CustomRuleDocument) async -> CustomRuleUpdateOutcome)?
private var collection: RulesCollection?
private var refreshGeneration = 0
private var testGeneration = 0
init(
loadCustom: @escaping @Sendable () throws -> [CustomRule] = { try CustomRuleStore().load() },
loadCustom: (@Sendable () throws -> [CustomRule])? = nil,
loadDocument: (@Sendable () throws -> CustomRuleDocument)? = nil,
commitDocument: (@MainActor (CustomRuleDocument) async -> CustomRuleUpdateOutcome)? = nil,
loadBuiltin: @escaping @Sendable (RulesSource) throws -> RuleSnapshot = { source in
switch source {
case .geolocationCN: try BuiltinRuleCatalog.loadGeolocationCN()
@@ -24,19 +28,33 @@ final class RulesWorkflow: ObservableObject {
}
}
) {
self.loadCustom = loadCustom
if let loadDocument {
self.loadDocument = loadDocument
} else if loadCustom == nil {
self.loadDocument = { try CustomRuleStore().loadDocument() }
} else {
self.loadDocument = nil
}
self.commitDocument = commitDocument
self.loadCustom = loadCustom ?? { try CustomRuleStore().load() }
self.loadBuiltin = loadBuiltin
}
func refresh() async {
guard !snapshot.isCommitting else { return }
await refreshCollection()
}
private func refreshCollection() async {
refreshGeneration += 1
let generation = refreshGeneration
invalidateAddressTest()
snapshot.isLoading = true
let custom = loadCustom
let builtin = loadBuiltin
let document = loadDocument
let result = await Task.detached(priority: .userInitiated) {
RulesCollection.load(custom: custom, builtin: builtin)
RulesCollection.load(custom: custom, builtin: builtin, document: document)
}.value
guard generation == refreshGeneration else { return }
collection = result
@@ -48,6 +66,44 @@ final class RulesWorkflow: ObservableObject {
snapshot = applyingQuery(to: next)
}
var actionableSelection: Set<RuleIdentity> {
Set(
snapshot.rows.filter { snapshot.selection.contains($0.id) && !$0.isFixed }
.compactMap(\.identity))
}
func setEnabled(_ enabled: Bool, identities: Set<RuleIdentity>) async {
guard snapshot.isComplete, !snapshot.isCommitting,
let commitDocument, let loadDocument
else { return }
let allowed = Set(snapshot.rows.filter { !$0.isFixed }.compactMap(\.identity))
let targets = identities.intersection(allowed)
guard !targets.isEmpty else { return }
snapshot.isCommitting = true
snapshot.commitOutcome = nil
invalidateAddressTest()
defer { snapshot.isCommitting = false }
do {
let old = try loadDocument()
guard let prepared = collection?.userDocument,
old.rules == prepared.rules && old.disabledIdentities == prepared.disabledIdentities
else {
snapshot.commitOutcome = .versionConflict
await refreshCollection()
return
}
var disabled = old.disabledIdentities
if enabled { disabled.subtract(targets) } else { disabled.formUnion(targets) }
guard disabled != old.disabledIdentities else { return }
let outcome = await commitDocument(
CustomRuleDocument(rules: old.rules, disabledIdentities: disabled))
snapshot.commitOutcome = outcome
await refreshCollection()
} catch {
snapshot.commitOutcome = .persistenceFailed
}
}
func setTestTarget(_ target: String) {
guard target != snapshot.addressTest.target else { return }
invalidateAddressTest()
@@ -124,6 +180,8 @@ final class RulesWorkflow: ObservableObject {
var rows = collection.rows.filter { row in
(query.action == nil || row.action == query.action)
&& (query.source.map { row.sources.contains($0) } ?? true)
&& (query.enabled.map { row.isEnabled == $0 } ?? true)
&& (row.hasCurrentSource || (query.source == nil && query.enabled == false))
&& (search.isEmpty || row.content.lowercased().contains(search))
}
if query.sort == .descending { rows.reverse() }
@@ -32,7 +32,7 @@ struct BuiltinRuleCatalog {
/// 供 ACL 编译的中国直连候选(`.cn` 后缀 + geolocation-cn + china-ipv4)。
static func chinaDirectRules(from snapshot: RuleSnapshot) -> [ProxyRule] {
snapshot.rules.filter { $0.action == .direct }
(snapshot.rules + snapshot.absorbed).filter { $0.action == .direct }
}
/// 合并多份快照的直连候选;保持输入顺序(域名在前,CIDR 在后)。
@@ -43,6 +43,6 @@ struct BuiltinRuleCatalog {
/// 供 ACL 编译的 GFWList 候选(issue #65):可准确表达且未被遮蔽的规则
/// (代理候选 + 未遮蔽例外),两种动作都参与编译。
static func gfwlistRules(from snapshot: RuleSnapshot) -> [ProxyRule] {
snapshot.rules
snapshot.rules + snapshot.absorbed
}
}
+42 -4
View File
@@ -43,14 +43,43 @@ struct CustomRule: Codable, Equatable, Hashable, Sendable, Identifiable {
/// 自定义规则持久化文档(issue #66):schema 版本 + 规则数组。
struct CustomRuleDocument: Codable, Equatable, Sendable {
static let currentSchemaVersion = 1
static let currentSchemaVersion = 2
let schemaVersion: Int
let rules: [CustomRule]
let disabledIdentities: Set<RuleIdentity>
init(rules: [CustomRule]) {
self.schemaVersion = Self.currentSchemaVersion
init(rules: [CustomRule], disabledIdentities: Set<RuleIdentity> = []) {
schemaVersion = Self.currentSchemaVersion
self.rules = rules
self.disabledIdentities = disabledIdentities
}
private enum CodingKeys: String, CodingKey { case schemaVersion, rules, disabledIdentities }
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
schemaVersion = try values.decode(Int.self, forKey: .schemaVersion)
guard [1, Self.currentSchemaVersion].contains(schemaVersion) else {
throw CustomRuleStoreError.schemaVersionMismatch(
found: schemaVersion, expected: Self.currentSchemaVersion)
}
rules = try values.decode([CustomRule].self, forKey: .rules)
let disabled =
schemaVersion == 1 ? [] : try values.decode([RuleIdentity].self, forKey: .disabledIdentities)
disabledIdentities = Set(disabled)
guard disabledIdentities.count == disabled.count,
Set(rules.map(\.id)).count == rules.count,
Set(rules.map(\.identity)).count == rules.count,
rules.allSatisfy({ $0.source.kind == .custom })
else { throw CustomRuleStoreError.corrupt(detail: "Duplicate or invalid user rule records") }
}
func encode(to encoder: Encoder) throws {
var values = encoder.container(keyedBy: CodingKeys.self)
try values.encode(Self.currentSchemaVersion, forKey: .schemaVersion)
try values.encode(rules, forKey: .rules)
try values.encode(
disabledIdentities.sorted { $0.contentToken < $1.contentToken }, forKey: .disabledIdentities)
}
}
@@ -71,6 +100,15 @@ struct CustomRuleSummary: Equatable, Sendable {
/// 内容版本:规则内容(动作+匹配)的稳定短摘要。
let contentVersion: String
static func summarizing(_ document: CustomRuleDocument) -> CustomRuleSummary {
let tokens =
document.rules.map(\.contentToken).sorted()
+ document.disabledIdentities.map { "disabled:" + $0.contentToken }.sorted()
return CustomRuleSummary(
count: document.rules.count,
contentVersion: String(ProxyACLDocument.digest(tokens.joined(separator: "\n")).prefix(12)))
}
static func summarizing(_ rules: [CustomRule]) -> CustomRuleSummary {
CustomRuleSummary(count: rules.count, contentVersion: contentVersion(of: rules))
}
+16 -9
View File
@@ -16,30 +16,37 @@ struct CustomRuleStore {
}
/// 读取规则集合;文件不存在视为无自定义规则。
func load() throws -> [CustomRule] {
guard FileManager.default.fileExists(atPath: fileURL.path) else { return [] }
func load() throws -> [CustomRule] { try loadDocument().rules }
func loadDocument() throws -> CustomRuleDocument {
let data: Data
do {
data = try Data(contentsOf: fileURL)
} catch let error as CocoaError where error.code == .fileReadNoSuchFile {
return CustomRuleDocument(rules: [])
} catch {
throw CustomRuleStoreError.ioFailure(detail: String(describing: error))
}
let document: CustomRuleDocument
do {
document = try Self.decode(data)
} catch let error as CustomRuleStoreError {
throw error
} catch {
throw CustomRuleStoreError.corrupt(detail: String(describing: error))
}
guard document.schemaVersion == CustomRuleDocument.currentSchemaVersion else {
throw CustomRuleStoreError.schemaVersionMismatch(
found: document.schemaVersion, expected: CustomRuleDocument.currentSchemaVersion)
}
return document.rules
return document
}
/// 原子写入完整规则集合。
func save(_ rules: [CustomRule]) throws {
let data = try Self.encode(CustomRuleDocument(rules: rules))
let old = try loadDocument()
try saveDocument(CustomRuleDocument(rules: rules, disabledIdentities: old.disabledIdentities))
}
func saveDocument(_ document: CustomRuleDocument) throws {
let data = try Self.encode(document)
_ = try Self.decode(data)
do {
try AtomicFileWriter.write(data, to: fileURL)
} catch {
@@ -49,7 +56,7 @@ struct CustomRuleStore {
/// 安全摘要:数量 + 内容版本(issue #66 AC5)。
func summary() throws -> CustomRuleSummary {
CustomRuleSummary.summarizing(try load())
CustomRuleSummary.summarizing(try loadDocument())
}
static func decode(_ data: Data) throws -> CustomRuleDocument {
@@ -22,6 +22,7 @@ enum OfflineRuleMatcher {
guard collection.issues.isEmpty else { throw Failure.incompleteCollection }
let target = try OfflineRuleTarget(address)
let matches = collection.rows.filter { row in
guard row.isEnabled else { return false }
guard let identity = row.identity else { return target.isSimpleHostname }
return RuleCoverage.domainCovers(identity.match, target.match)
|| RuleCoverage.ipCovers(identity.match, target.match)
@@ -88,3 +88,25 @@ extension RuleCoverage {
matches: matches, includesSimpleHostname: simple)
}
}
extension RuleAnalysis {
/// Select the actual ACL skeleton before calculating coverage.
static func runtimeCandidates(_ rules: [ProxyRule], defaultAction: RuleDefaultAction)
-> [ProxyRule]
{
let expressed = rules.filter { defaultAction != .proxyWhenUnmatched || $0.action == .direct }
let index = RulesOverlapIndex(rules: expressed)
return expressed.filter { rule in
let analysis = RuleAnalysis(rules: index.overlapping(rule.identity.match), subjects: [rule])
return !analysis.relationships.contains {
$0.extent == .full
&& ($0.kind == .shadowing
|| $0.covering.contains { other in
RuleCoverage.fullyCovered(rule.identity.match, by: [other.match])
&& (!RuleCoverage.fullyCovered(other.match, by: [rule.identity.match])
|| other.contentToken < rule.identity.contentToken)
})
}
}
}
}
+6 -1
View File
@@ -7,6 +7,7 @@ struct RulesQuery: Equatable, Sendable {
var action: RuleAction?
var source: RulesSource?
var sort: Sort = .ascending
var enabled: Bool?
}
enum RulesSource: String, CaseIterable, Identifiable, Sendable {
@@ -33,6 +34,8 @@ struct RulesRow: Equatable, Identifiable, Sendable {
let customIDs: Set<UUID>
let relationships: [RuleRelationship]
let fixedCoverage: FixedRuleCoverage?
var isEnabled = true
var hasCurrentSource: Bool { !sources.isEmpty }
var isFixed: Bool { sources.contains(.fixed) }
var action: RuleAction { identity?.action ?? .direct }
}
@@ -44,11 +47,13 @@ struct RulesPageSnapshot: Equatable, Sendable {
}
var version = ""
var isLoading = false
var isCommitting = false
var commitOutcome: CustomRuleUpdateOutcome?
var issues: [Issue] = []
var sources: [RulesSourceSnapshot] = []
var rows: [RulesRow] = []
var selection: Set<RulesRow.SelectionID> = []
var query = RulesQuery()
var addressTest = RulesAddressTest()
var isComplete: Bool { !isLoading && issues.isEmpty && !version.isEmpty }
var isComplete: Bool { !isLoading && !isCommitting && issues.isEmpty && !version.isEmpty }
}
+26 -8
View File
@@ -6,13 +6,15 @@ struct RulesCollection: Sendable {
let rows: [RulesRow]
let sources: [RulesSourceSnapshot]
let issues: [RulesPageSnapshot.Issue]
let userDocument: CustomRuleDocument?
static func load(
custom loadCustom: () throws -> [CustomRule],
builtin loadBuiltin: (RulesSource) throws -> RuleSnapshot
builtin loadBuiltin: (RulesSource) throws -> RuleSnapshot,
document loadDocument: (() throws -> CustomRuleDocument)? = nil
) -> RulesCollection {
var input = RulesCollectionInput()
input.readCustom(loadCustom)
input.readCustom { try loadDocument?() ?? CustomRuleDocument(rules: loadCustom()) }
for source in [RulesSource.geolocationCN, .chinaIPv4, .gfwlist] {
input.readBuiltin(source, using: loadBuiltin)
}
@@ -27,14 +29,19 @@ private struct RulesCandidate {
}
private struct RulesCollectionInput {
var userDocument: CustomRuleDocument?
var disabled: Set<RuleIdentity> = []
var entries: [RulesCandidate] = []
var sources: [RulesSourceSnapshot] = []
var issues: [RulesPageSnapshot.Issue] = []
var metadataTokens: [String] = []
mutating func readCustom(_ loadCustom: () throws -> [CustomRule]) {
mutating func readCustom(_ loadCustom: () throws -> CustomRuleDocument) {
do {
let custom = try loadCustom()
let document = try loadCustom()
let custom = document.rules
disabled = document.disabledIdentities
userDocument = document
let validation = CustomRuleValidator.validate(
custom: custom, defaultAction: .directWhenUnmatched)
guard validation.rejected.isEmpty else {
@@ -97,12 +104,21 @@ private struct RulesCollectionInput {
// Completely fixed-protected proxy candidates remain browsable but cannot
// cover or shadow another candidate in the effective collection.
let effective = rules.filter {
$0.action != .proxy || RuleCoverage.fixedLocalCoverage(of: $0.identity.match)?.extent != .full
!disabled.contains($0.identity)
&& ($0.action != .proxy
|| RuleCoverage.fixedLocalCoverage(of: $0.identity.match)?.extent != .full)
}
let index = RulesOverlapIndex(rules: effective)
var rows = grouped.map { identity, entries in
row(identity: identity, entries: entries, index: index)
}
for identity in disabled where grouped[identity] == nil {
rows.append(
RulesRow(
id: .rule(identity), identity: identity,
content: identity.match.browsingContent, sources: [], customIDs: [],
relationships: [], fixedCoverage: nil, isEnabled: false))
}
rows.append(
RulesRow(
id: .noDotHostname, identity: nil, content: "^[^.]+$", sources: [.fixed], customIDs: [],
@@ -115,11 +131,12 @@ private struct RulesCollectionInput {
rows.map { row in
(row.identity?.contentToken ?? "fixed:no-dot") + "|"
+ row.sources.map(\.rawValue).sorted().joined(separator: ",")
+ "|" + row.customIDs.map(\.uuidString).sorted().joined(separator: ",")
+ "|" + String(row.isEnabled) + "|"
+ row.customIDs.map(\.uuidString).sorted().joined(separator: ",")
} + metadataTokens.sorted() + issues.map { String(describing: $0) }
return RulesCollection(
version: String(ProxyACLDocument.digest(tokens.sorted().joined(separator: "\n")).prefix(16)),
rows: rows, sources: sources, issues: issues)
rows: rows, sources: sources, issues: issues, userDocument: userDocument)
}
private func row(
identity: RuleIdentity, entries: [RulesCandidate], index: RulesOverlapIndex
@@ -144,7 +161,8 @@ private struct RulesCollectionInput {
return RulesRow(
id: .rule(identity), identity: identity, content: identity.match.browsingContent,
sources: memberships, customIDs: Set(entries.compactMap { $0.customID }),
relationships: relationships, fixedCoverage: fixedCoverage)
relationships: disabled.contains(identity) ? [] : relationships, fixedCoverage: fixedCoverage,
isEnabled: memberships.contains(.fixed) || !disabled.contains(identity))
}
}
@@ -34,7 +34,7 @@ final class BuiltinRuleCatalogTests: XCTestCase {
rules.contains { $0.match == .domainSuffix("cn") },
"快照应含 .cn 后缀直连规则")
// .cn 吸收后不应再有独立 .cn 域名条目。
// 完整候选保留可恢复的独立 .cn 域名条目。
let independentCN = rules.filter {
switch $0.match {
case .domainSuffix(let value): return value.hasSuffix(".cn") && value != "cn"
@@ -42,7 +42,7 @@ final class BuiltinRuleCatalogTests: XCTestCase {
default: return false
}
}
XCTAssertTrue(independentCN.isEmpty, "同动作 .cn 域名应已被吸收")
XCTAssertFalse(independentCN.isEmpty, "候选保留被吸收的规则以便禁用宽规则后恢复")
}
/// china-ipv4 快照(issue #64):从 bundle 或源码树加载,直连候选为 IPv4 CIDR。
@@ -5,7 +5,7 @@ import XCTest
/// 自定义规则变更的完整重启与失败回滚(issue #66 AC1/AC4)。
extension ProxyRuntimeControllerTests {
private func makeCustomRuleStore() throws -> (store: CustomRuleStore, directory: URL) {
func makeCustomRuleStore() throws -> (store: CustomRuleStore, directory: URL) {
let directory = runtime.directory.appendingPathComponent(
"custom-rules-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
@@ -13,7 +13,7 @@ extension ProxyRuntimeControllerTests {
return (store, directory)
}
private func makeControllerWithCustomRules(
func makeControllerWithCustomRules(
store: CustomRuleStore,
probe: EndpointProbing = ProxyRuntimeFixture.FakeProbe.reachable(),
settings: ProxySettings? = nil,
@@ -31,7 +31,7 @@ extension ProxyRuntimeControllerTests {
}
/// 从链接解析读取当前 ACL 变体内容(契约不再内嵌 content)。
private func activeACLContent(_ store: RuntimeFileStore) throws -> String {
func activeACLContent(_ store: RuntimeFileStore) throws -> String {
let data = try Data(contentsOf: store.aclFileURL)
return String(bytes: data, encoding: .utf8) ?? ""
}
@@ -21,6 +21,25 @@ final class CustomRuleStoreTests: XCTestCase {
try super.tearDownWithError()
}
func testSchemaOneMigratesAndDisablementRoundTripsInSameDocument() throws {
let rule = CustomRule(action: .direct, match: .domainExact("private.example"))
let legacy = try JSONSerialization.data(withJSONObject: [
"schemaVersion": 1,
"rules": JSONSerialization.jsonObject(with: JSONEncoder().encode([rule])),
])
try legacy.write(to: store.fileURL)
let migrated = try store.loadDocument()
XCTAssertEqual(migrated.rules, [rule])
XCTAssertTrue(migrated.disabledIdentities.isEmpty)
try store.saveDocument(
CustomRuleDocument(rules: migrated.rules, disabledIdentities: [rule.identity]))
let saved = try store.loadDocument()
XCTAssertEqual(saved.schemaVersion, 2)
XCTAssertEqual(saved.disabledIdentities, [rule.identity])
XCTAssertNotEqual(
try store.summary().contentVersion, CustomRuleSummary.summarizing([rule]).contentVersion)
}
func testMissingFileLoadsAsEmpty() throws {
XCTAssertEqual(try store.load(), [])
}
@@ -63,7 +82,9 @@ final class CustomRuleStoreTests: XCTestCase {
func testMalformedStoredCIDRReportsCorruptionWithoutRewritingDocument() throws {
for match in [RuleMatch.ipv4CIDR("/"), .ipv6CIDR("/")] {
try store.save([CustomRule(action: .direct, match: match)])
try CustomRuleStore.encode(
CustomRuleDocument(rules: [CustomRule(action: .direct, match: match)])
).write(to: store.fileURL)
let before = try Data(contentsOf: store.fileURL)
XCTAssertThrowsError(try store.load()) { error in
guard case .corrupt = error as? CustomRuleStoreError else {
@@ -0,0 +1,166 @@
import XCTest
@testable import ShadowsocksX_NG2
extension ProxyRuntimeControllerTests {
func testBatchDisableRestartsOnceAndRestoresAbsorbedChinaCandidate() async throws {
let seeded = try makeSeededCatalog()
let (store, _) = try makeCustomRuleStore()
let settings = ProxySettings(
listen: ActivationFixture.listen, preferredMode: .rule,
ruleDefaultAction: .proxyWhenUnmatched, agentEnabled: true)
let controller = makeControllerWithCustomRules(
store: store, settings: settings, proxyMode: .rule)
try await controller.activate(seeded.server)
let runtimeStore = RuntimeFileStore(fileURL: runtime.contract)
let before = agent.unregisterCount
let cn = RuleIdentity(action: .direct, match: .domainSuffix("cn"))
let absent = RuleIdentity(action: .direct, match: .domainExact("absent.example"))
let outcome = await controller.updateRuleDocument(
CustomRuleDocument(rules: [], disabledIdentities: [cn, absent]))
XCTAssertEqual(outcome, .saved)
XCTAssertEqual(agent.unregisterCount, before + 1)
let content = try activeACLContent(runtimeStore)
XCTAssertFalse(content.split(separator: "\n").contains("||cn"))
let snapshot = try BuiltinRuleCatalog.loadGeolocationCN(from: AppArtifact.bundle)
let narrow = try XCTUnwrap(snapshot.absorbed.first)
XCTAssertTrue(content.contains(narrow.aclLine))
let noOpBefore = agent.unregisterCount
let unchanged = await controller.updateRuleDocument(
CustomRuleDocument(rules: [], disabledIdentities: [cn]))
XCTAssertEqual(unchanged, .saved)
XCTAssertEqual(agent.unregisterCount, noOpBefore, "Absent identities change only persistence")
}
func testDisablingGFWBlockersRestoresPreservedExpressibleException() async throws {
let seeded = try makeSeededCatalog()
let (store, _) = try makeCustomRuleStore()
let settings = ProxySettings(
listen: ActivationFixture.listen, preferredMode: .rule,
ruleDefaultAction: .directWhenUnmatched, agentEnabled: true)
let controller = makeControllerWithCustomRules(
store: store, settings: settings, proxyMode: .rule)
try await controller.activate(seeded.server)
let snapshot = try BuiltinRuleCatalog.loadGFWList(from: AppArtifact.bundle)
let exception = try XCTUnwrap(snapshot.absorbed.first)
let blockers = Set(
(snapshot.rules + snapshot.absorbed).filter {
$0.action == .proxy && RuleCoverage.domainCovers($0.match, exception.match)
}.map(\.identity))
XCTAssertFalse(blockers.isEmpty)
let before = agent.unregisterCount
let outcome = await controller.updateRuleDocument(
CustomRuleDocument(rules: [], disabledIdentities: blockers))
XCTAssertEqual(outcome, .saved)
XCTAssertEqual(agent.unregisterCount, before + 1)
let candidates = try controller.ruleModeCandidateRules()
XCTAssertTrue(
candidates.contains {
$0.action == .direct && RuleCoverage.domainCovers($0.match, exception.match)
})
}
func testDisableWhileOffPersistsWithoutStartingAndCorruptDocumentIsPreserved() async throws {
let (store, _) = try makeCustomRuleStore()
let controller = makeControllerWithCustomRules(
store: store, settings: ProxySettings(listen: ActivationFixture.listen, agentEnabled: false))
let identity = RuleIdentity(action: .proxy, match: .domainExact("saved.example"))
let outcome = await controller.updateRuleDocument(
CustomRuleDocument(rules: [], disabledIdentities: [identity]))
XCTAssertEqual(outcome, .saved)
XCTAssertEqual(agent.registerCount, 0)
XCTAssertEqual(try store.loadDocument().disabledIdentities, [identity])
let broken = Data("broken".utf8)
try broken.write(to: store.fileURL)
let rejected = await controller.updateRuleDocument(CustomRuleDocument(rules: []))
XCTAssertEqual(rejected, .persistenceFailed)
XCTAssertEqual(try Data(contentsOf: store.fileURL), broken)
XCTAssertEqual(agent.registerCount, 0)
}
func testAgentOffDuringRecoveryCannotBeOverwrittenByOldRuleTransaction() async throws {
try await interruptRuleRecovery(switchMode: false)
}
func testModeSwitchDuringRecoveryCannotBeOverwrittenByOldRuleTransaction() async throws {
try await interruptRuleRecovery(switchMode: true)
}
private func interruptRuleRecovery(switchMode: Bool) async throws {
let seeded = try makeSeededCatalog()
let (store, _) = try makeCustomRuleStore()
let rule = CustomRule(action: .direct, match: .domainExact("kept.example"))
try store.save([rule])
let settings = ProxySettings(
listen: ActivationFixture.listen, preferredMode: .rule,
ruleDefaultAction: .proxyWhenUnmatched, agentEnabled: true)
let controller = makeControllerWithCustomRules(
store: store, settings: settings, proxyMode: .rule,
launchHealthTimeoutSeconds: 1)
try await controller.activate(seeded.server)
let runtimeStore = RuntimeFileStore(fileURL: runtime.contract)
let recoveryStarted = expectation(description: "recovery awaiting health")
let initialRegistrations = agent.registerCount
agent.onRegister = { [agent] in
if agent?.registerCount == initialRegistrations + 2 {
recoveryStarted.fulfill()
}
if (agent?.registerCount ?? 0) <= initialRegistrations + 2 {
try? FileManager.default.removeItem(at: runtimeStore.runtimeStatusFileURL)
} else if let document = runtimeStore.loadDocument() {
try? runtimeStore.writeRuntimeReceipt(for: document, processID: 42)
}
}
let pending = Task {
await controller.updateRuleDocument(
CustomRuleDocument(
rules: [rule],
disabledIdentities: [rule.identity]))
}
await fulfillment(of: [recoveryStarted], timeout: 5)
if switchMode {
await controller.setProxyMode(.global)
} else {
await controller.setAgentEnabled(false)
}
let registrations = agent.registerCount
let outcome = await pending.value
guard case .recoveryFailed = outcome else {
return XCTFail("Interrupted recovery must not report success: \(outcome)")
}
XCTAssertEqual(agent.registerCount, registrations)
if switchMode {
XCTAssertEqual(controller.proxyMode, .global)
XCTAssertEqual(runtimeStore.loadDocument()?.aclRuntime?.summary, "global")
} else {
XCTAssertFalse(controller.settings.agentEnabled)
XCTAssertEqual(controller.state, .off)
XCTAssertNil(runtimeStore.loadDocument())
}
}
func testFailedDisableAndFailedRuntimeRecoveryReportsFailure() async throws {
let seeded = try makeSeededCatalog()
let (store, _) = try makeCustomRuleStore()
let rule = CustomRule(action: .direct, match: .domainExact("kept.example"))
try store.save([rule])
let settings = ProxySettings(
listen: ActivationFixture.listen, preferredMode: .rule,
ruleDefaultAction: .proxyWhenUnmatched, agentEnabled: true)
let controller = makeControllerWithCustomRules(
store: store, settings: settings, proxyMode: .rule)
try await controller.activate(seeded.server)
let runtimeStore = RuntimeFileStore(fileURL: runtime.contract)
agent.onRegister = {
try? FileManager.default.removeItem(at: runtimeStore.runtimeStatusFileURL)
}
let result = await controller.updateRuleDocument(
CustomRuleDocument(rules: [rule], disabledIdentities: [rule.identity]))
guard case .recoveryFailed(let detail) = result else {
return XCTFail("Expected actual recovery failure, got \(result)")
}
XCTAssertFalse(detail.isEmpty)
XCTAssertEqual(try store.loadDocument().disabledIdentities, [])
XCTAssertNotEqual(controller.state, .running)
}
}
@@ -0,0 +1,73 @@
import XCTest
@testable import ShadowsocksX_NG2
@MainActor
final class RuleEnablementWorkflowTests: XCTestCase {
func testInFlightBatchRejectsRepeatedCommandAndPublishesProgress() async throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let store = CustomRuleStore(fileURL: directory.appendingPathComponent("rules.json"))
let rule = CustomRule(action: .proxy, match: .domainExact("example.net"))
try store.save([rule])
let started = expectation(description: "commit started")
var continuation: CheckedContinuation<Void, Never>?
var commits = 0
let workflow = RulesWorkflow(
loadDocument: { try store.loadDocument() },
commitDocument: { document in
commits += 1
await withCheckedContinuation {
continuation = $0
started.fulfill()
}
do {
try store.saveDocument(document)
return .saved
} catch {
XCTFail("Fixture save failed: \(error)")
return .persistenceFailed
}
},
loadBuiltin: { rulesFixture($0) })
await workflow.refresh()
let pending = Task { await workflow.setEnabled(false, identities: [rule.identity]) }
await fulfillment(of: [started], timeout: 3)
XCTAssertTrue(workflow.snapshot.isCommitting)
await workflow.setEnabled(false, identities: [rule.identity])
await workflow.refresh()
XCTAssertEqual(commits, 1)
continuation?.resume()
await pending.value
XCTAssertFalse(workflow.snapshot.isCommitting)
XCTAssertEqual(workflow.snapshot.commitOutcome, .saved)
XCTAssertFalse(workflow.snapshot.rows.first { $0.identity == rule.identity }!.isEnabled)
}
func testFailedSaveKeepsSnapshotAndExternalDocumentChangeRejectsStaleCommand() async throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let store = CustomRuleStore(fileURL: directory.appendingPathComponent("rules.json"))
let rule = CustomRule(action: .direct, match: .domainExact("kept.example"))
try store.save([rule])
var commits = 0
let workflow = RulesWorkflow(
loadDocument: { try store.loadDocument() },
commitDocument: { _ in
commits += 1
return .persistenceFailed
},
loadBuiltin: { rulesFixture($0) })
await workflow.refresh()
let version = workflow.snapshot.version
await workflow.setEnabled(false, identities: [rule.identity])
XCTAssertEqual(workflow.snapshot.commitOutcome, .persistenceFailed)
XCTAssertEqual(workflow.snapshot.version, version)
XCTAssertTrue(workflow.snapshot.rows.first { $0.identity == rule.identity }!.isEnabled)
try store.saveDocument(CustomRuleDocument(rules: [rule], disabledIdentities: [rule.identity]))
await workflow.setEnabled(false, identities: [rule.identity])
XCTAssertEqual(workflow.snapshot.commitOutcome, .versionConflict)
XCTAssertEqual(commits, 1)
XCTAssertFalse(workflow.snapshot.rows.first { $0.identity == rule.identity }!.isEnabled)
}
}
@@ -4,6 +4,62 @@ import XCTest
@MainActor
final class RulesWorkflowTests: XCTestCase {
func testDisableAcrossSourcesRestoresCoverageAndOrphanCanBeEnabled() async throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let store = CustomRuleStore(fileURL: directory.appendingPathComponent("rules.json"))
let broad = CustomRule(action: .proxy, match: .domainSuffix("example.com"))
let narrow = CustomRule(action: .direct, match: .domainExact("safe.example.com"))
let orphan = RuleIdentity(action: .proxy, match: .domainExact("gone.example"))
try store.saveDocument(
CustomRuleDocument(rules: [broad, narrow], disabledIdentities: [orphan]))
var commits = 0
let workflow = RulesWorkflow(
loadDocument: { try store.loadDocument() },
commitDocument: { document in
commits += 1
do {
try store.saveDocument(document)
return .saved
} catch {
XCTFail("Fixture save failed: \(error)")
return .persistenceFailed
}
},
loadBuiltin: { source in
guard source == .gfwlist else { return rulesFixture(source) }
let identity = RuleSourceIdentity(kind: .gfwlist, upstreamVersion: "v1", label: "fixture")
return rulesFixture(
source,
rules: [
ProxyRule(action: .proxy, match: broad.match, source: identity)
])
})
await workflow.refresh()
let version = workflow.snapshot.version
let covered = try XCTUnwrap(workflow.snapshot.rows.first { $0.identity == narrow.identity })
XCTAssertTrue(covered.relationships.contains { $0.kind == .shadowing && $0.extent == .full })
workflow.select([.rule(broad.identity), .noDotHostname])
XCTAssertEqual(workflow.actionableSelection, [broad.identity])
await workflow.setEnabled(false, identities: workflow.actionableSelection)
XCTAssertEqual(commits, 1)
XCTAssertNotEqual(workflow.snapshot.version, version)
let disabled = try XCTUnwrap(workflow.snapshot.rows.first { $0.identity == broad.identity })
XCTAssertFalse(disabled.isEnabled)
XCTAssertEqual(disabled.sources, [.custom, .gfwlist])
XCTAssertTrue(
workflow.snapshot.rows.first { $0.identity == narrow.identity }!.relationships.isEmpty)
workflow.setTestTarget("safe.example.com")
await workflow.testAddress()
XCTAssertEqual(workflow.snapshot.addressTest.result?.outcome, .direct)
workflow.query(RulesQuery(enabled: false))
XCTAssertTrue(workflow.snapshot.rows.contains { $0.identity == orphan && !$0.hasCurrentSource })
await workflow.setEnabled(true, identities: [orphan])
XCTAssertFalse(workflow.snapshot.rows.contains { $0.identity == orphan })
XCTAssertNil(workflow.snapshot.addressTest.result)
XCTAssertFalse(try store.loadDocument().disabledIdentities.contains(orphan))
}
func testEquivalentSourcesMergeAndAbsorbedCandidatesRemainBrowsable() async throws {
let match = try RuleMatch(domainExact: "Example.COM")
let custom = CustomRule(action: .direct, match: match)