mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-28 05:03:49 +00:00
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:
+1
-1
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: "; ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user