fix(rules): keep enablement feedback stable and accurate (#78)

Reserve one toolbar status area through saving, deployment, and collection
refresh. Show counted transient success and persistent actionable failures,
with distinct applied, unchanged-runtime, and deferred-save results.

Keep cached rows when collection reload fails. Require explicit refresh
and fresh selection after a version conflict.

Validation: 774 hostless tests passed with local ad-hoc signing. Format,
lint, two-axis review, and isolated Chinese/English view rendering checked.
This commit is contained in:
Qiu Yuzhou
2026-10-02 22:37:02 +08:00
parent 09686a80db
commit 63cb42da2a
14 changed files with 807 additions and 83 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 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 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 use one fixed toolbar status through persistence, deployment, and collection refresh, reject repeats while keeping browsing available, preserve the runtime switch intent, and deploy only if ACL identity changes. Success feedback counts actual changes, distinguishes applied, unchanged runtime, and deferred application, and expires after three seconds; failures remain dismissible with details and contextual refresh or runtime-inspection actions. A failed collection reload keeps cached rows and blocks edits until an explicit successful refresh; an explicit version-conflict refresh clears the old selection. 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.
+2 -2
View File
@@ -157,7 +157,7 @@ struct MainWindowView: View {
case .rules:
Button(RulesCopy.text("刷新"), systemImage: "arrow.clockwise") {
Task { await rulesWorkflow.refresh() }
}.disabled(rulesWorkflow.snapshot.isLoading)
}.disabled(rulesWorkflow.snapshot.isLoading || rulesWorkflow.snapshot.isCommitting)
case .diagnostics:
Button("导出诊断…", systemImage: "square.and.arrow.up") {
exportDiagnostics()
@@ -211,7 +211,7 @@ struct MainWindowView: View {
workflow: workflow,
onNodesRemoved: clearSelectionIfInvalidated)
case .rules:
RulesView(workflow: rulesWorkflow)
RulesView(workflow: rulesWorkflow, onShowRuntime: { route.navigate(to: .home) })
case .settings:
SettingsView(
workflow: settingsWorkflow, loginController: loginController,
@@ -4,18 +4,28 @@ import Foundation
/// 自定义规则更新结果(issue #66):持久化成功、校验拒绝、持久化失败或部署回滚。
enum CustomRuleUpdateOutcome: Equatable, Sendable {
/// 规则已保存并生效(或在非规则模式下仅持久化,ACL 不变)。
/// 规则已保存;运行规则模式时应用。
case saved
case applied
case runtimeUnchanged
/// 校验拒绝:整批不落地,旧规则保持不变;附可解释原因。
case rejected([RejectedCustomRule])
/// 持久化失败:旧规则保持不变。
case persistenceFailed
/// 保存后部署失败并已回滚到旧规则与旧运行时。
case rolledBack
case recoveryFailed(detail: String)
case recoveryFailed(detail: String, rulesRestored: Bool)
case runtimeChanged(rulesRestored: Bool)
case busy
case versionConflict
case invalidDocument(detail: String)
var isSuccess: Bool {
switch self {
case .saved, .applied, .runtimeUnchanged: true
default: false
}
}
}
// MARK: - 自定义规则命令面
@@ -61,7 +71,7 @@ extension ProxyRuntimeController {
previousRules: CustomRuleDocument,
snapshot: ModeTransitionSnapshotForRules
) async -> CustomRuleUpdateOutcome {
guard settings.agentEnabled, state != .off,
guard proxyMode == .rule, settings.agentEnabled, state != .off,
let currentDocument = lastDocument ?? runtimeFileStore.loadDocument()
else {
return .saved
@@ -76,11 +86,11 @@ extension ProxyRuntimeController {
try customRuleStore.saveDocument(previousRules)
return .rolledBack
} catch {
return .recoveryFailed(detail: String(describing: error))
return .recoveryFailed(detail: String(describing: error), rulesRestored: false)
}
}
guard nextDocument.aclRuntime != currentDocument.aclRuntime else {
return .saved
return .runtimeUnchanged
}
modeChangeGeneration += 1
@@ -92,7 +102,7 @@ extension ProxyRuntimeController {
guard generation == modeChangeGeneration, runtimeGeneration == flowGeneration,
settings.agentEnabled
else {
return .recoveryFailed(detail: "Runtime changed during rule deployment")
return .runtimeChanged(rulesRestored: false)
}
return await restoreCustomRules(previousRules, snapshot: snapshot)
}
@@ -100,7 +110,7 @@ extension ProxyRuntimeController {
guard generation == modeChangeGeneration, runtimeGeneration == flowGeneration,
settings.agentEnabled
else {
return .recoveryFailed(detail: "Runtime changed during rule deployment")
return .runtimeChanged(rulesRestored: false)
}
let healthy = await presentLaunchHealth(
nextDocument,
@@ -110,7 +120,7 @@ extension ProxyRuntimeController {
guard generation == modeChangeGeneration, runtimeGeneration == flowGeneration,
settings.agentEnabled
else {
return .recoveryFailed(detail: "Runtime changed during rule deployment")
return .runtimeChanged(rulesRestored: false)
}
guard healthy else {
return await restoreCustomRules(previousRules, snapshot: snapshot)
@@ -121,9 +131,9 @@ extension ProxyRuntimeController {
guard generation == modeChangeGeneration, runtimeGeneration == flowGeneration,
settings.agentEnabled
else {
return .recoveryFailed(detail: "Runtime changed during rule deployment")
return .runtimeChanged(rulesRestored: false)
}
return .saved
return .applied
}
/// 保存前校验(issue #66 AC2):固定本地冲突与重复整批拒绝并返回可解释原因。
@@ -152,12 +162,14 @@ extension ProxyRuntimeController {
failures.append(String(describing: error))
RuntimeLog.emit(.runtimePersistFailed(detail: String(describing: error)))
}
let rulesRestored = failures.isEmpty
guard let previousDocument = snapshot.document else {
return failures.isEmpty
? .rolledBack : .recoveryFailed(detail: failures.joined(separator: "; "))
? .rolledBack
: .recoveryFailed(detail: failures.joined(separator: "; "), rulesRestored: rulesRestored)
}
guard settings.agentEnabled else {
return .recoveryFailed(detail: "Agent disabled during rule recovery")
return .runtimeChanged(rulesRestored: rulesRestored)
}
let modeGeneration = modeChangeGeneration
let runtimeGeneration = flowGeneration + 1
@@ -167,19 +179,19 @@ extension ProxyRuntimeController {
guard modeGeneration == modeChangeGeneration, runtimeGeneration == flowGeneration,
settings.agentEnabled
else {
return .recoveryFailed(detail: "Runtime changed during rule recovery")
return .runtimeChanged(rulesRestored: rulesRestored)
}
guard executed else {
failures.append(String(describing: state))
await holdSystemProxyIntent()
return .recoveryFailed(detail: failures.joined(separator: "; "))
return .recoveryFailed(detail: failures.joined(separator: "; "), rulesRestored: rulesRestored)
}
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")
return .runtimeChanged(rulesRestored: rulesRestored)
}
if !healthy {
failures.append(String(describing: state))
@@ -188,7 +200,8 @@ extension ProxyRuntimeController {
state = snapshot.state
}
return failures.isEmpty
? .rolledBack : .recoveryFailed(detail: failures.joined(separator: "; "))
? .rolledBack
: .recoveryFailed(detail: failures.joined(separator: "; "), rulesRestored: rulesRestored)
}
private struct ModeTransitionSnapshotForRules {
+277
View File
@@ -727,6 +727,283 @@
}
}
}
},
"正在更新规则…": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Updating rules…"
}
}
}
},
"正在加载规则…": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Loading rules…"
}
}
}
},
"已应用": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Applied"
}
}
}
},
"无需重新应用": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "No runtime update needed"
}
}
}
},
"运行规则模式时应用": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Applies when running in Rules mode"
}
}
}
},
"保存失败,规则未更改": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Couldn't save; rules unchanged"
}
}
}
},
"应用失败,已恢复原规则": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Couldn't apply; previous rules restored"
}
}
}
},
"恢复失败,请检查运行状态": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Recovery failed; check runtime status"
}
}
}
},
"运行状态已变化,请检查当前状态": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Runtime state changed; check current status"
}
}
}
},
"规则已变化,请刷新后重试": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Rules changed; refresh and try again"
}
}
}
},
"无法更新规则": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Couldn't update rules"
}
}
}
},
"查看详情": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "View Details"
}
}
}
},
"关闭": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Dismiss"
}
}
}
},
"刷新规则": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Refresh Rules"
}
}
}
},
"查看运行状态": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "View Runtime Status"
}
}
}
},
"已恢复原规则": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Previous rules restored"
}
}
}
},
"已启用 %lld 条规则": {
"extractionState": "manual",
"localizations": {
"en": {
"variations": {
"plural": {
"one": {
"stringUnit": {
"state": "translated",
"value": "Enabled %lld rule"
}
},
"other": {
"stringUnit": {
"state": "translated",
"value": "Enabled %lld rules"
}
}
}
}
}
}
},
"已禁用 %lld 条规则": {
"extractionState": "manual",
"localizations": {
"en": {
"variations": {
"plural": {
"one": {
"stringUnit": {
"state": "translated",
"value": "Disabled %lld rule"
}
},
"other": {
"stringUnit": {
"state": "translated",
"value": "Disabled %lld rules"
}
}
}
}
}
}
},
"规则列表未更新,请刷新规则。": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Couldn't update the rule list. Refresh rules."
}
}
}
},
"请重新执行启用或禁用操作。": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Try enabling or disabling the rules again."
}
}
}
},
"请刷新规则,重新选择后再操作。": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Refresh rules, then select the rules again."
}
}
}
},
"请在首页检查当前代理运行状态。": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Check the current proxy runtime status on Home."
}
}
}
},
"请检查规则数据后再操作。": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Check the rule data before trying again."
}
}
}
}
},
"version": "1.0"
@@ -16,7 +16,9 @@ struct RulesAddressTestView: View {
.autocorrectionDisabled()
.onSubmit { submit() }
Button(RulesCopy.text("测试")) { submit() }
.disabled(workflow.snapshot.isLoading || workflow.snapshot.addressTest.isTesting)
.disabled(
workflow.snapshot.isLoading || workflow.snapshot.isCommitting
|| workflow.snapshot.addressTest.isTesting)
}
if workflow.snapshot.addressTest.isTesting {
ProgressView(RulesCopy.text("测试中…")).controlSize(.small)
@@ -44,7 +46,9 @@ struct RulesAddressTestView: View {
}
private func submit() {
guard !workflow.snapshot.isLoading, !workflow.snapshot.addressTest.isTesting else { return }
guard !workflow.snapshot.isLoading, !workflow.snapshot.isCommitting,
!workflow.snapshot.addressTest.isTesting
else { return }
Task { await workflow.testAddress() }
}
@@ -0,0 +1,171 @@
import SwiftUI
/// A reserved toolbar row keeps feedback from resizing or replacing the rule table.
struct RulesOperationStatusView: View {
@ObservedObject var workflow: RulesWorkflow
var onShowRuntime: () -> Void
@State private var showsDetails = false
var body: some View {
HStack(spacing: 8) {
Spacer(minLength: 0)
switch workflow.snapshot.operationStatus {
case .idle, .initialLoading:
EmptyView()
case .updating:
progress("正在更新规则…")
case .refreshing:
progress("正在加载规则…")
case .collectionIncomplete:
Label(RulesCopy.text("集合不完整"), systemImage: "exclamationmark.triangle")
.foregroundStyle(.orange)
detailsButton
refreshButton
case .feedback(let feedback):
Label {
Text(verbatim: feedback.summary).lineLimit(1).truncationMode(.middle)
} icon: {
Image(
systemName: feedback.outcome.isSuccess ? "checkmark.circle" : "exclamationmark.triangle"
)
.foregroundStyle(feedback.outcome.isSuccess ? Color.secondary : Color.orange)
}
.help(feedback.summary)
if !feedback.outcome.isSuccess {
detailsButton
if feedback.outcome == .versionConflict { refreshButton }
if feedback.outcome.needsRuntimeInspection {
Button(RulesCopy.text("查看运行状态"), action: onShowRuntime)
}
Button(RulesCopy.text("关闭"), systemImage: "xmark") {
workflow.dismissFeedback()
}.labelStyle(.iconOnly)
}
}
}
.font(.caption)
.controlSize(.small)
.frame(maxWidth: .infinity, minHeight: 22, maxHeight: 22)
.onChange(of: workflow.snapshot.operationStatus) { _, _ in showsDetails = false }
}
private func progress(_ key: String) -> some View {
HStack(spacing: 6) {
ProgressView().controlSize(.mini)
Text(RulesCopy.text(key))
}.accessibilityElement(children: .combine)
}
private var detailsButton: some View {
Button(RulesCopy.text("查看详情")) { showsDetails = true }
.popover(isPresented: $showsDetails) { details }
}
private var refreshButton: some View {
Button(RulesCopy.text("刷新规则")) {
showsDetails = false
Task { await workflow.refresh() }
}
}
private var details: some View {
ScrollView {
VStack(alignment: .leading, spacing: 10) {
if let feedback = workflow.snapshot.commitFeedback {
Text(verbatim: feedback.summary).font(.headline)
if let detail = feedback.outcome.failureDetail {
Text(verbatim: detail).textSelection(.enabled)
}
if let nextStep = feedback.outcome.nextStep {
Text(RulesCopy.text(nextStep))
}
}
if !workflow.snapshot.issues.isEmpty {
Text(RulesCopy.text("规则列表未更新,请刷新规则。"))
RulesCollectionIssuesView(issues: workflow.snapshot.issues)
}
}.frame(maxWidth: .infinity, alignment: .leading).padding()
}.frame(width: 380).frame(maxHeight: 300)
}
}
struct RulesCollectionIssuesView: View {
let issues: [RulesPageSnapshot.Issue]
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(RulesCopy.text("集合不完整时,覆盖解释仅基于已加载来源。"))
ForEach(issues, id: \.self) { issue in
Text(verbatim: description(issue)).textSelection(.enabled)
}
}.font(.caption)
}
private func description(_ issue: RulesPageSnapshot.Issue) -> String {
switch issue {
case .userDocument(let detail): "\(RulesCopy.text("自定义规则")): \(detail)"
case .builtin(let source, let detail): "\(source.label): \(detail)"
}
}
}
extension RulesCommitFeedback {
var summary: String {
guard outcome.isSuccess else { return outcome.rulesMessage }
let count = String.localizedStringWithFormat(
RulesCopy.text(enabled ? "已启用 %lld 条规则" : "已禁用 %lld 条规则"), Int64(changedCount))
let application: String
switch outcome {
case .applied: application = "已应用"
case .runtimeUnchanged: application = "无需重新应用"
default: application = "运行规则模式时应用"
}
return count + " · " + RulesCopy.text(application)
}
}
extension CustomRuleUpdateOutcome {
var rulesMessage: String {
switch self {
case .saved: RulesCopy.text("已保存")
case .applied: RulesCopy.text("已应用")
case .runtimeUnchanged: RulesCopy.text("无需重新应用")
case .persistenceFailed: RulesCopy.text("保存失败,规则未更改")
case .rolledBack: RulesCopy.text("应用失败,已恢复原规则")
case .recoveryFailed: RulesCopy.text("恢复失败,请检查运行状态")
case .runtimeChanged: RulesCopy.text("运行状态已变化,请检查当前状态")
case .busy: RulesCopy.text("正在更新规则…")
case .versionConflict: RulesCopy.text("规则已变化,请刷新后重试")
case .invalidDocument, .rejected: RulesCopy.text("无法更新规则")
}
}
var needsRuntimeInspection: Bool {
switch self {
case .recoveryFailed, .runtimeChanged: true
default: false
}
}
var failureDetail: String? {
switch self {
case .recoveryFailed(let detail, let rulesRestored):
RulesCopy.text(rulesRestored ? "已恢复原规则" : "已保存") + "\n" + detail
case .runtimeChanged(let rulesRestored):
RulesCopy.text(rulesRestored ? "已恢复原规则" : "已保存")
case .invalidDocument(let detail): detail
case .rejected(let rejected): rejected.map(\.explanation).joined(separator: "\n")
default: nil
}
}
var nextStep: String? {
switch self {
case .persistenceFailed, .rolledBack, .busy: "请重新执行启用或禁用操作。"
case .versionConflict: "请刷新规则,重新选择后再操作。"
case .recoveryFailed, .runtimeChanged: "请在首页检查当前代理运行状态。"
case .invalidDocument, .rejected: "请检查规则数据后再操作。"
default: nil
}
}
}
+25 -47
View File
@@ -4,31 +4,12 @@ import SwiftUI
struct RulesView: View {
@ObservedObject var workflow: RulesWorkflow
@Environment(\.openWindow) private var openWindow
var onShowRuntime: () -> Void
var body: some 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()
}
if !workflow.snapshot.issues.isEmpty {
VStack(alignment: .leading) {
Label(RulesCopy.text("集合不完整"), systemImage: "exclamationmark.triangle")
Text(RulesCopy.text("集合不完整时,覆盖解释仅基于已加载来源。")).font(.caption)
ForEach(workflow.snapshot.issues, id: \.self) { issue in
Text(verbatim: issueDescription(issue))
.font(.caption).textSelection(.enabled)
}
}
.frame(maxWidth: .infinity, alignment: .leading).padding()
}
HStack(spacing: 0) {
VStack(spacing: 0) {
sourceList
@@ -45,12 +26,11 @@ struct RulesView: View {
}.frame(width: 180)
Divider()
VStack(spacing: 0) {
filters.padding()
ruleTable
if workflow.snapshot.rows.isEmpty && !workflow.snapshot.isLoading {
ContentUnavailableView(
RulesCopy.text("没有符合条件的规则"), systemImage: "line.3.horizontal.decrease.circle")
}
VStack(spacing: 6) {
filters
RulesOperationStatusView(workflow: workflow, onShowRuntime: onShowRuntime)
}.padding()
ruleTable.overlay { emptyState }
if let row = workflow.selectedRelationshipRow {
Divider()
RulesRelationshipsView(row: row)
@@ -64,6 +44,25 @@ struct RulesView: View {
}
}
@ViewBuilder
private var emptyState: some View {
if workflow.snapshot.operationStatus == .initialLoading {
ProgressView(RulesCopy.text("正在加载规则…"))
} else if workflow.snapshot.rows.isEmpty && !workflow.snapshot.isLoading {
if workflow.snapshot.issues.isEmpty {
ContentUnavailableView(
RulesCopy.text("没有符合条件的规则"), systemImage: "line.3.horizontal.decrease.circle")
} else {
VStack(spacing: 10) {
Label(RulesCopy.text("集合不完整"), systemImage: "exclamationmark.triangle")
RulesCollectionIssuesView(issues: workflow.snapshot.issues)
Button(RulesCopy.text("刷新规则")) { Task { await workflow.refresh() } }
.disabled(workflow.snapshot.isCommitting)
}.padding()
}
}
}
private var sourceList: some View {
List(selection: sourceBinding) {
Text(RulesCopy.text("全部规则")).tag(RulesSourceChoice.all)
@@ -194,12 +193,6 @@ struct RulesView: View {
}
}
private func issueDescription(_ issue: RulesPageSnapshot.Issue) -> String {
switch issue {
case .userDocument(let detail): "\(RulesCopy.text("自定义规则")): \(detail)"
case .builtin(let source, let detail): "\(source.label): \(detail)"
}
}
}
private struct RulesRelationshipsView: View {
@@ -314,18 +307,3 @@ 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: "; ")
}
}
}
+45 -5
View File
@@ -14,11 +14,16 @@ final class RulesWorkflow: ObservableObject {
private var collection: RulesCollection?
private var refreshGeneration = 0
private var testGeneration = 0
private let feedbackDelay: @Sendable () async throws -> Void
private var feedbackTask: Task<Void, Never>?
init(
loadCustom: (@Sendable () throws -> [CustomRule])? = nil,
loadDocument: (@Sendable () throws -> CustomRuleDocument)? = nil,
commitDocument: (@MainActor (CustomRuleDocument) async -> CustomRuleUpdateOutcome)? = nil,
feedbackDelay: @escaping @Sendable () async throws -> Void = {
try await Task.sleep(for: .seconds(3))
},
loadBuiltin: @escaping @Sendable (RulesSource) throws -> RuleSnapshot = { source in
switch source {
case .geolocationCN: try BuiltinRuleCatalog.loadGeolocationCN()
@@ -36,12 +41,19 @@ final class RulesWorkflow: ObservableObject {
self.loadDocument = nil
}
self.commitDocument = commitDocument
self.feedbackDelay = feedbackDelay
self.loadCustom = loadCustom ?? { try CustomRuleStore().load() }
self.loadBuiltin = loadBuiltin
}
func refresh() async {
guard !snapshot.isCommitting else { return }
if snapshot.commitOutcome == .versionConflict {
snapshot.selection = []
dismissFeedback()
} else if snapshot.commitOutcome?.isSuccess == true && !snapshot.issues.isEmpty {
dismissFeedback()
}
await refreshCollection()
}
@@ -57,6 +69,11 @@ final class RulesWorkflow: ObservableObject {
RulesCollection.load(custom: custom, builtin: builtin, document: document)
}.value
guard generation == refreshGeneration else { return }
if !result.issues.isEmpty, collection != nil {
snapshot.issues = result.issues
snapshot.isLoading = false
return
}
collection = result
var next = snapshot
next.version = result.version
@@ -80,7 +97,7 @@ final class RulesWorkflow: ObservableObject {
let targets = identities.intersection(allowed)
guard !targets.isEmpty else { return }
snapshot.isCommitting = true
snapshot.commitOutcome = nil
dismissFeedback()
invalidateAddressTest()
defer { snapshot.isCommitting = false }
do {
@@ -88,19 +105,42 @@ final class RulesWorkflow: ObservableObject {
guard let prepared = collection?.userDocument,
old.rules == prepared.rules && old.disabledIdentities == prepared.disabledIdentities
else {
snapshot.commitOutcome = .versionConflict
await refreshCollection()
publishFeedback(.versionConflict, enabled: enabled, changedCount: 0)
return
}
var disabled = old.disabledIdentities
let changedCount =
enabled
? targets.intersection(disabled).count : targets.subtracting(disabled).count
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()
publishFeedback(outcome, enabled: enabled, changedCount: changedCount)
} catch {
snapshot.commitOutcome = .persistenceFailed
publishFeedback(.persistenceFailed, enabled: enabled, changedCount: 0)
}
}
func dismissFeedback() {
feedbackTask?.cancel()
feedbackTask = nil
snapshot.commitFeedback = nil
}
private func publishFeedback(
_ outcome: CustomRuleUpdateOutcome, enabled: Bool, changedCount: Int
) {
let feedback = RulesCommitFeedback(
outcome: outcome, enabled: enabled, changedCount: changedCount)
snapshot.commitFeedback = feedback
guard outcome.isSuccess, snapshot.issues.isEmpty else { return }
let delay = feedbackDelay
feedbackTask = Task { [weak self] in
do { try await delay() } catch { return }
guard !Task.isCancelled, self?.snapshot.commitFeedback?.id == feedback.id else { return }
self?.dismissFeedback()
}
}
+23 -1
View File
@@ -48,7 +48,8 @@ struct RulesPageSnapshot: Equatable, Sendable {
var version = ""
var isLoading = false
var isCommitting = false
var commitOutcome: CustomRuleUpdateOutcome?
var commitFeedback: RulesCommitFeedback?
var commitOutcome: CustomRuleUpdateOutcome? { commitFeedback?.outcome }
var issues: [Issue] = []
var sources: [RulesSourceSnapshot] = []
var rows: [RulesRow] = []
@@ -56,4 +57,25 @@ struct RulesPageSnapshot: Equatable, Sendable {
var query = RulesQuery()
var addressTest = RulesAddressTest()
var isComplete: Bool { !isLoading && !isCommitting && issues.isEmpty && !version.isEmpty }
var operationStatus: RulesOperationStatus {
if isCommitting { return .updating }
if isLoading { return version.isEmpty ? .initialLoading : .refreshing }
if !issues.isEmpty && commitFeedback?.outcome.isSuccess != false {
return .collectionIncomplete
}
if let commitFeedback { return .feedback(commitFeedback) }
return issues.isEmpty ? .idle : .collectionIncomplete
}
}
enum RulesOperationStatus: Equatable, Sendable {
case idle, initialLoading, refreshing, updating, collectionIncomplete
case feedback(RulesCommitFeedback)
}
struct RulesCommitFeedback: Equatable, Sendable, Identifiable {
let id = UUID()
let outcome: CustomRuleUpdateOutcome
let enabled: Bool
let changedCount: Int
}
@@ -56,7 +56,7 @@ extension ProxyRuntimeControllerTests {
action: .direct, match: try RuleMatch(domainSuffix: "internal.example"))
let outcome = await controller.updateCustomRules([rule])
XCTAssertEqual(outcome, .saved)
XCTAssertEqual(outcome, .applied)
XCTAssertEqual(try store.load(), [rule])
XCTAssertEqual(agent.unregisterCount, unregisterBefore + 1, "ACL 变化触发完整 agent 重启")
let runtimeStore = RuntimeFileStore(fileURL: runtime.contract)
@@ -82,7 +82,7 @@ extension ProxyRuntimeControllerTests {
CustomRule(action: .direct, match: .domainExact("a.order.example")),
]
let first = await controller.updateCustomRules(rules)
XCTAssertEqual(first, .saved)
XCTAssertEqual(first, .applied)
XCTAssertEqual(try store.load(), rules, "保存保留用户 UUID 和全部意图")
let validation = try controller.ruleModeValidation()
XCTAssertTrue(
@@ -95,7 +95,7 @@ extension ProxyRuntimeControllerTests {
let summary = controller.readCustomRuleSummary()
let reversed = Array(rules.reversed())
let reordered = await controller.updateCustomRules(reversed)
XCTAssertEqual(reordered, .saved)
XCTAssertEqual(reordered, .runtimeUnchanged)
XCTAssertEqual(try store.load(), reversed)
XCTAssertEqual(try activeACLContent(runtimeStore), before)
XCTAssertEqual(agent.unregisterCount, unregisterBefore)
@@ -18,7 +18,7 @@ extension ProxyRuntimeControllerTests {
let absent = RuleIdentity(action: .direct, match: .domainExact("absent.example"))
let outcome = await controller.updateRuleDocument(
CustomRuleDocument(rules: [], disabledIdentities: [cn, absent]))
XCTAssertEqual(outcome, .saved)
XCTAssertEqual(outcome, .applied)
XCTAssertEqual(agent.unregisterCount, before + 1)
let content = try activeACLContent(runtimeStore)
XCTAssertFalse(content.split(separator: "\n").contains("||cn"))
@@ -28,7 +28,7 @@ extension ProxyRuntimeControllerTests {
let noOpBefore = agent.unregisterCount
let unchanged = await controller.updateRuleDocument(
CustomRuleDocument(rules: [], disabledIdentities: [cn]))
XCTAssertEqual(unchanged, .saved)
XCTAssertEqual(unchanged, .runtimeUnchanged)
XCTAssertEqual(agent.unregisterCount, noOpBefore, "Absent identities change only persistence")
}
@@ -51,7 +51,7 @@ extension ProxyRuntimeControllerTests {
let before = agent.unregisterCount
let outcome = await controller.updateRuleDocument(
CustomRuleDocument(rules: [], disabledIdentities: blockers))
XCTAssertEqual(outcome, .saved)
XCTAssertEqual(outcome, .applied)
XCTAssertEqual(agent.unregisterCount, before + 1)
let candidates = try controller.ruleModeCandidateRules()
XCTAssertTrue(
@@ -125,9 +125,10 @@ extension ProxyRuntimeControllerTests {
}
let registrations = agent.registerCount
let outcome = await pending.value
guard case .recoveryFailed = outcome else {
guard case .runtimeChanged(let rulesRestored) = outcome else {
return XCTFail("Interrupted recovery must not report success: \(outcome)")
}
XCTAssertTrue(rulesRestored)
XCTAssertEqual(agent.registerCount, registrations)
if switchMode {
XCTAssertEqual(controller.proxyMode, .global)
@@ -156,9 +157,10 @@ extension ProxyRuntimeControllerTests {
}
let result = await controller.updateRuleDocument(
CustomRuleDocument(rules: [rule], disabledIdentities: [rule.identity]))
guard case .recoveryFailed(let detail) = result else {
guard case .recoveryFailed(let detail, let rulesRestored) = result else {
return XCTFail("Expected actual recovery failure, got \(result)")
}
XCTAssertTrue(rulesRestored)
XCTAssertFalse(detail.isEmpty)
XCTAssertEqual(try store.loadDocument().disabledIdentities, [])
XCTAssertNotEqual(controller.state, .running)
@@ -34,6 +34,8 @@ final class RuleEnablementWorkflowTests: XCTestCase {
let pending = Task { await workflow.setEnabled(false, identities: [rule.identity]) }
await fulfillment(of: [started], timeout: 3)
XCTAssertTrue(workflow.snapshot.isCommitting)
XCTAssertEqual(workflow.snapshot.operationStatus, .updating)
XCTAssertNil(workflow.snapshot.commitFeedback)
await workflow.setEnabled(false, identities: [rule.identity])
await workflow.refresh()
XCTAssertEqual(commits, 1)
@@ -41,6 +43,10 @@ final class RuleEnablementWorkflowTests: XCTestCase {
await pending.value
XCTAssertFalse(workflow.snapshot.isCommitting)
XCTAssertEqual(workflow.snapshot.commitOutcome, .saved)
let feedback = try XCTUnwrap(workflow.snapshot.commitFeedback)
XCTAssertEqual(feedback.changedCount, 1)
XCTAssertFalse(feedback.enabled)
XCTAssertEqual(workflow.snapshot.operationStatus, .feedback(feedback))
XCTAssertFalse(workflow.snapshot.rows.first { $0.identity == rule.identity }!.isEnabled)
}
@@ -64,10 +70,17 @@ final class RuleEnablementWorkflowTests: XCTestCase {
XCTAssertEqual(workflow.snapshot.commitOutcome, .persistenceFailed)
XCTAssertEqual(workflow.snapshot.version, version)
XCTAssertTrue(workflow.snapshot.rows.first { $0.identity == rule.identity }!.isEnabled)
let selected = try XCTUnwrap(workflow.snapshot.rows.first { $0.identity == rule.identity }?.id)
workflow.select([selected])
try store.saveDocument(CustomRuleDocument(rules: [rule], disabledIdentities: [rule.identity]))
await workflow.setEnabled(false, identities: [rule.identity])
XCTAssertEqual(workflow.snapshot.commitOutcome, .versionConflict)
XCTAssertEqual(commits, 1)
XCTAssertEqual(workflow.snapshot.version, version)
XCTAssertEqual(workflow.snapshot.selection, [selected])
XCTAssertTrue(workflow.snapshot.rows.first { $0.identity == rule.identity }!.isEnabled)
await workflow.refresh()
XCTAssertTrue(workflow.snapshot.selection.isEmpty)
XCTAssertFalse(workflow.snapshot.rows.first { $0.identity == rule.identity }!.isEnabled)
}
}
@@ -0,0 +1,201 @@
import Combine
import XCTest
@testable import ShadowsocksX_NG2
@MainActor
final class RuleStatusWorkflowTests: XCTestCase {
func testSavedRulesWithFailedReloadKeepOldRowsAndRequireExplicitRefresh() 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 reloadStarted = expectation(description: "failed reload")
let gate = RuleDocumentReloadGate(store: store, started: reloadStarted, failureOnReload: true)
let workflow = RulesWorkflow(
loadDocument: { try gate.load() },
commitDocument: { document in saveFixture(document, to: store, outcome: .applied) },
feedbackDelay: { XCTFail("An incomplete collection must not start the success timer") },
loadBuiltin: { rulesFixture($0) })
await workflow.refresh()
let rows = workflow.snapshot.rows
await workflow.setEnabled(false, identities: [rule.identity])
await fulfillment(of: [reloadStarted], timeout: 3)
XCTAssertEqual(workflow.snapshot.operationStatus, .collectionIncomplete)
XCTAssertEqual(workflow.snapshot.rows, rows)
XCTAssertFalse(workflow.snapshot.isComplete)
await workflow.refresh()
XCTAssertEqual(workflow.snapshot.operationStatus, .idle)
XCTAssertFalse(workflow.snapshot.rows.first { $0.identity == rule.identity }!.isEnabled)
}
func testSuccessCountsOnlyActualChangesAndExpiresAfterCollectionUpdate() 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 disabled = CustomRule(action: .proxy, match: .domainExact("disabled.example"))
let enabled = CustomRule(action: .proxy, match: .domainExact("enabled.example"))
try store.saveDocument(
CustomRuleDocument(rules: [disabled, enabled], disabledIdentities: [disabled.identity]))
let timerStarted = expectation(description: "success timer started")
let clock = RuleFeedbackClock(started: timerStarted)
let workflow = RulesWorkflow(
loadDocument: { try store.loadDocument() },
commitDocument: { document in
saveFixture(document, to: store, outcome: .applied)
},
feedbackDelay: { await clock.wait() },
loadBuiltin: { rulesFixture($0) })
await workflow.refresh()
let fixed = try XCTUnwrap(workflow.snapshot.rows.first { $0.isFixed }?.identity)
await workflow.setEnabled(true, identities: [disabled.identity, enabled.identity, fixed])
let feedback = try XCTUnwrap(workflow.snapshot.commitFeedback)
XCTAssertEqual(feedback.changedCount, 1)
XCTAssertTrue(feedback.enabled)
XCTAssertEqual(feedback.outcome, .applied)
XCTAssertTrue(workflow.snapshot.rows.first { $0.identity == disabled.identity }!.isEnabled)
await fulfillment(of: [timerStarted], timeout: 3)
let expired = expectation(description: "success dismissed")
let observation = workflow.$snapshot.filter { $0.commitFeedback == nil }.sink { _ in
expired.fulfill()
}
await clock.resume()
await fulfillment(of: [expired], timeout: 3)
XCTAssertEqual(workflow.snapshot.operationStatus, .idle)
withExtendedLifetime(observation) {}
}
func testCollectionRefreshRemainsOneBusyOperationAndAllowsBrowsing() 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 reloadStarted = expectation(description: "collection reload paused")
let gate = RuleDocumentReloadGate(store: store, started: reloadStarted)
defer { gate.release.signal() }
let workflow = RulesWorkflow(
loadDocument: { try gate.load() },
commitDocument: { document in
saveFixture(document, to: store, outcome: .runtimeUnchanged)
},
loadBuiltin: { rulesFixture($0) })
await workflow.refresh()
let pending = Task { await workflow.setEnabled(false, identities: [rule.identity]) }
await fulfillment(of: [reloadStarted], timeout: 3)
XCTAssertTrue(workflow.snapshot.isLoading)
XCTAssertEqual(workflow.snapshot.operationStatus, .updating)
XCTAssertNil(workflow.snapshot.commitFeedback)
XCTAssertTrue(workflow.snapshot.rows.first { $0.identity == rule.identity }!.isEnabled)
let query = RulesQuery(search: "example", source: .custom)
workflow.query(query)
let row = try XCTUnwrap(workflow.snapshot.rows.first)
workflow.select([row.id])
gate.release.signal()
await pending.value
XCTAssertEqual(workflow.snapshot.query, query)
XCTAssertEqual(workflow.snapshot.selection, [row.id])
XCTAssertFalse(workflow.snapshot.rows.first!.isEnabled)
XCTAssertEqual(workflow.snapshot.commitOutcome, .runtimeUnchanged)
}
func testOldSuccessTimerCannotDismissNewFailureAndClosingDoesNotChangeRules() 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 timerStarted = expectation(description: "success timer started")
let timerReturned = expectation(description: "old timer returned")
let clock = RuleFeedbackClock(started: timerStarted)
let commitState = RuleCommitState()
let workflow = RulesWorkflow(
loadDocument: { try store.loadDocument() },
commitDocument: { document in
commitState.outcome.isSuccess
? saveFixture(document, to: store, outcome: commitState.outcome) : commitState.outcome
},
feedbackDelay: {
await clock.wait()
timerReturned.fulfill()
},
loadBuiltin: { rulesFixture($0) })
await workflow.refresh()
await workflow.setEnabled(false, identities: [rule.identity])
await fulfillment(of: [timerStarted], timeout: 3)
commitState.outcome = .persistenceFailed
await workflow.setEnabled(true, identities: [rule.identity])
await clock.resume()
await fulfillment(of: [timerReturned], timeout: 3)
XCTAssertEqual(workflow.snapshot.commitOutcome, .persistenceFailed)
XCTAssertFalse(workflow.snapshot.rows.first { $0.identity == rule.identity }!.isEnabled)
workflow.dismissFeedback()
XCTAssertEqual(workflow.snapshot.operationStatus, .idle)
XCTAssertFalse(workflow.snapshot.rows.first { $0.identity == rule.identity }!.isEnabled)
}
}
@MainActor
private final class RuleCommitState {
var outcome: CustomRuleUpdateOutcome = .saved
}
private actor RuleFeedbackClock {
let started: XCTestExpectation
private var continuation: CheckedContinuation<Void, Never>?
init(started: XCTestExpectation) { self.started = started }
func wait() async {
await withCheckedContinuation {
continuation = $0
started.fulfill()
}
}
func resume() {
continuation?.resume()
continuation = nil
}
}
private final class RuleDocumentReloadGate: @unchecked Sendable {
let store: CustomRuleStore
let started: XCTestExpectation
let release = DispatchSemaphore(value: 0)
private let lock = NSLock()
private var reads = 0
private let failureOnReload: Bool
init(store: CustomRuleStore, started: XCTestExpectation, failureOnReload: Bool = false) {
self.store = store
self.started = started
self.failureOnReload = failureOnReload
}
func load() throws -> CustomRuleDocument {
let shouldPause = lock.withLock {
reads += 1
return reads == 3
}
if shouldPause {
started.fulfill()
if failureOnReload { throw CocoaError(.fileReadCorruptFile) }
release.wait()
}
return try store.loadDocument()
}
}
private func saveFixture(
_ document: CustomRuleDocument, to store: CustomRuleStore, outcome: CustomRuleUpdateOutcome
) -> CustomRuleUpdateOutcome {
do {
try store.saveDocument(document)
return outcome
} catch {
XCTFail("Fixture save failed: \(error)")
return .persistenceFailed
}
}
@@ -198,11 +198,14 @@ final class RulesWorkflowTests: XCTestCase {
observation.cancel()
XCTAssertNotEqual(workflow.snapshot.version, oldVersion)
XCTAssertEqual(workflow.snapshot.rows.map(\.identity), [new.identity])
let loadedVersion = workflow.snapshot.version
try Data("broken".utf8).write(to: store.fileURL)
await workflow.refresh()
XCTAssertFalse(workflow.snapshot.isComplete)
XCTAssertEqual(workflow.snapshot.issues.count, 1)
XCTAssertTrue(workflow.snapshot.rows.isEmpty)
XCTAssertEqual(workflow.snapshot.rows.map(\.identity), [new.identity])
XCTAssertEqual(workflow.snapshot.version, loadedVersion)
XCTAssertEqual(workflow.snapshot.operationStatus, .collectionIncomplete)
}
func testLateOldRefreshCannotReplaceTheNewCollection() async throws {