feat(rules): add custom rule editor (#79)

Add versioned sheet-local drafts, normalized previews, custom identity validation,
and explicit saves through the existing rule-document transaction. Preserve UUIDs,
builtin memberships, disabled identities, and deployment recovery outcomes.

Include approved Chinese and English editor copy. Verify public Workflow commands,
the production Controller adapter with hostless tests, and compiled bilingual
resources. Visual acceptance remains manual.
This commit is contained in:
Qiu Yuzhou
2026-10-03 00:47:06 +08:00
parent e345d772eb
commit 17672ff655
14 changed files with 1082 additions and 56 deletions
+1 -1
View File
@@ -113,6 +113,6 @@
- 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.
- Runtime file deployment prepares contract bytes once and still reads the actual disk contract before choosing actions. ACL digest-manifest hits avoid reading the variant body; a miss reads the existing variant once and reuses those bytes if rollback is needed. An unchanged active-link target is preserved. The synchronous transaction records only completed mutations and attempts every recorded restoration on failure, reporting any restoration failure explicitly. A decoded contract with no ACL body can restore only an existing regular variant file; a missing or unsafe variant fails before publishing its link or contract. Digest metadata is updated only after contract persistence succeeds.
- 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 retrying 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 return the final saved document after deployment or rollback and use one fixed toolbar status through persistence, deployment, and in-memory collection preparation. They reject repeats while keeping browsing available, preserve the runtime switch intent, and deploy only if ACL identity changes. A changed final document rebuilds browsing analysis from the retained built-in inputs off the UI actor; an unchanged document retains the collection. There is no ordinary toolbar reload or post-commit file reread. Initial loading shares bundled source facts with runtime preparation; only failure actions explicitly retry failed sources. Success feedback counts actual changes, distinguishes applied, unchanged runtime, and deferred application, and expires after three seconds; failures remain dismissible with details and contextual retry or runtime-inspection actions. A failed preparation retains cached rows and blocks edits until an explicit successful retry. Runtime projection and ACL compilation use immutable captured inputs off the UI actor, reject superseded intent before deployment, and reuse the compiled document for convergence checks and deployment. 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 retrying 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 return the final saved document after deployment or rollback and use one fixed toolbar status through persistence, deployment, and in-memory collection preparation. They reject repeats while keeping browsing available, preserve the runtime switch intent, and deploy only if ACL identity changes. A changed final document rebuilds browsing analysis from the retained built-in inputs off the UI actor; an unchanged document retains the collection. There is no ordinary toolbar reload or post-commit file reread. Initial loading shares bundled source facts with runtime preparation; only failure actions explicitly retry failed sources. Success feedback counts actual changes, distinguishes applied, unchanged runtime, and deferred application, and expires after three seconds; failures remain dismissible with details and contextual retry or runtime-inspection actions. A failed preparation retains cached rows and blocks edits until an explicit successful retry. Runtime projection and ACL compilation use immutable captured inputs off the UI actor, reject superseded intent before deployment, and reuse the compiled document for convergence checks and deployment. Deployment failures restore the entire prior document and runtime; recovery failures remain explicit errors. The window toolbar adds a single custom rule and custom memberships expose per-row editing through a native sheet. Sheet-local versioned drafts support IPv4/IPv6 IP literals and CIDRs, ASCII/Punycode exact domains and suffixes (including the existing national suffix), normalized previews, and enabled-collection coverage explanations. Previews run outside the UI actor and prepare only the prospective row; explicit saves revalidate the saved version and reuse the same document transaction and feedback path as enablement. Duplicate custom identities and fixed-local proxy conflicts block saving; builtin equivalence and explanatory absorption or shadowing do not. Editing preserves the custom UUID and source metadata, moves only the custom membership, inherits disablement onto the new identity, and retains old disabled records. Additions do not clear an existing disabled identity; edits that would inherit disablement onto an immutable fixed identity are rejected. Cancellation and navigation discard the draft; in-flight commits block repeated saves and interactive dismissal. The page has no delete action 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.
@@ -0,0 +1,160 @@
import SwiftUI
/// The draft belongs to this presentation; only an explicit save enters the Workflow.
struct CustomRuleEditorSheet: View {
@ObservedObject var workflow: RulesWorkflow
@Environment(\.dismiss) private var dismiss
@State private var draft: CustomRuleDraft
@State private var preview: CustomRulePreview?
@State private var isSaving = false
@State private var saveFailure: CustomRuleUpdateOutcome?
var onShowRuntime: () -> Void
init(workflow: RulesWorkflow, draft: CustomRuleDraft, onShowRuntime: @escaping () -> Void) {
self.workflow = workflow
_draft = State(initialValue: draft)
self.onShowRuntime = onShowRuntime
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text(RulesCopy.text(draft.editingID == nil ? "新增规则" : "编辑规则"))
.font(.headline)
Form {
Picker(RulesCopy.text("类型"), selection: $draft.kind) {
ForEach(CustomRuleDraft.Kind.allCases, id: \.self) { kind in
Text(verbatim: kind.label).tag(kind)
}
}
TextField(RulesCopy.text("匹配内容"), text: $draft.content)
.autocorrectionDisabled()
Picker(RulesCopy.text("行动"), selection: $draft.action) {
Text(RulesCopy.text("代理")).tag(RuleAction.proxy)
Text(RulesCopy.text("直连")).tag(RuleAction.direct)
}
}.disabled(isSaving || workflow.snapshot.isCommitting)
ScrollView {
VStack(alignment: .leading, spacing: 10) {
if let preview {
if let content = preview.displayContent {
LabeledContent(RulesCopy.text("规范化预览")) {
Text(verbatim: content).textSelection(.enabled)
}
}
if let failure = preview.failure {
Text(verbatim: failure.message).foregroundStyle(.orange)
} else if let row = preview.row {
if !row.sources.subtracting([.custom]).isEmpty {
Text(RulesCopy.text("将与已有来源合并显示。"))
Text(verbatim: row.sourceLabels).foregroundStyle(.secondary)
}
if preview.inheritsDisablement {
Text(RulesCopy.text("新身份将继承禁用状态;旧禁用记录保留。"))
} else if !row.isEnabled {
Text(RulesCopy.text("此身份已禁用;保存不会重新启用。"))
}
RulesRelationshipsContent(row: row)
Text(RulesCopy.text("覆盖关系仅解释已启用规则之间的关系,不代表当前流量行为。"))
.font(.caption).foregroundStyle(.secondary)
}
} else {
ProgressView().controlSize(.small)
}
if let saveFailure {
Label(saveFailure.rulesMessage, systemImage: "exclamationmark.triangle")
.foregroundStyle(.orange)
if let detail = saveFailure.failureDetail {
Text(verbatim: detail).textSelection(.enabled)
}
if let nextStep = saveFailure.nextStep { Text(RulesCopy.text(nextStep)) }
if saveFailure.needsRuntimeInspection {
Button(RulesCopy.text("查看运行状态")) {
dismiss()
onShowRuntime()
}
}
}
}.frame(maxWidth: .infinity, alignment: .leading)
}.frame(height: 210)
HStack {
if isSaving || workflow.snapshot.isCommitting {
ProgressView().controlSize(.small)
Text(RulesCopy.text("正在更新规则…")).font(.caption)
}
Spacer()
Button("取消", role: .cancel) { dismiss() }
.keyboardShortcut(.cancelAction)
.disabled(isSaving || workflow.snapshot.isCommitting)
Button("保存") { save() }
.keyboardShortcut(.defaultAction)
.disabled(preview?.document == nil || isSaving || !workflow.snapshot.isComplete)
}
}
.onChange(of: draft) { _, _ in saveFailure = nil }
.padding(20)
.frame(width: 530)
.interactiveDismissDisabled(isSaving || workflow.snapshot.isCommitting)
.task(
id: PreviewRequest(
draft: draft, version: workflow.snapshot.version, isComplete: workflow.snapshot.isComplete)
) {
preview = nil
let result = await workflow.previewCustomRule(draft)
guard !Task.isCancelled else { return }
preview = result
}
}
private func save() {
guard !isSaving, workflow.snapshot.isComplete else { return }
isSaving = true
saveFailure = nil
Task {
let result = await workflow.saveCustomRule(draft)
isSaving = false
switch result {
case .unavailable(let failure): preview = CustomRulePreview(failure: failure)
case .committed(let outcome):
if outcome.isSuccess { dismiss() } else { saveFailure = outcome }
}
}
}
private struct PreviewRequest: Equatable {
let draft: CustomRuleDraft
let version: String
let isComplete: Bool
}
}
extension CustomRuleDraft.Kind {
var label: String {
switch self {
case .ipAddress: RulesCopy.text("IP 地址")
case .cidr: RulesCopy.text("CIDR 范围")
case .domainSuffix: RulesCopy.text("域名后缀")
case .domainExact: RulesCopy.text("精确域名")
}
}
}
extension CustomRulePreview.Failure {
var message: String {
switch self {
case .incompleteCollection: RulesCopy.text("集合不完整")
case .busy: RulesCopy.text("正在更新规则…")
case .staleDraft: RulesCopy.text("规则集合已变化,请重新打开编辑器。")
case .duplicate: RulesCopy.text("相同匹配条件和行动的自定义规则已存在。")
case .fixedPolicyDisablement:
RulesCopy.text("无法更新规则") + " · " + RulesCopy.text("固定策略优先,以下范围始终直连。")
case .fixedLocalConflict: RulesCopy.text("代理规则与固定本地策略冲突,无法保存。")
case .invalidInput(let kind):
switch kind {
case .ipAddress: RulesCopy.text("请输入有效的 IPv4 或 IPv6 地址。")
case .cidr: RulesCopy.text("请输入有效的 IPv4 或 IPv6 CIDR 范围。")
case .domainExact, .domainSuffix:
RulesCopy.text("请输入有效的 ASCII 域名;不支持通配符、URL 或路径。")
}
}
}
}
+13 -2
View File
@@ -45,6 +45,7 @@ struct MainWindowView: View {
@StateObject private var homeServerList = HomeServerListState()
@State private var selection: NodeID?
@State private var ruleDraft: CustomRuleDraft?
/// 全局添加菜单打开的表单,以及诊断导出由窗口壳持有。
@State private var presentedWorkspaceSheet: WorkspaceSheet?
@State private var exportedDiagnosticsPath: String?
@@ -86,6 +87,12 @@ struct MainWindowView: View {
AddSubscriptionSheet(workflow: workflow, errors: shellActionErrors)
}
}
.sheet(item: $ruleDraft) { draft in
CustomRuleEditorSheet(workflow: rulesWorkflow, draft: draft) { route.navigate(to: .home) }
}
.onChange(of: route.destination) { _, destination in
if destination != .rules { ruleDraft = nil }
}
.alert(
"操作失败",
isPresented: Binding(
@@ -155,7 +162,9 @@ struct MainWindowView: View {
.disabled(workflow.subscriptions.isEmpty)
}
case .rules:
EmptyView()
Button(RulesCopy.text("新增规则"), systemImage: "plus") {
ruleDraft = rulesWorkflow.makeCustomRuleDraft()
}.disabled(!rulesWorkflow.snapshot.isComplete)
case .diagnostics:
Button("导出诊断…", systemImage: "square.and.arrow.up") {
exportDiagnostics()
@@ -209,7 +218,9 @@ struct MainWindowView: View {
workflow: workflow,
onNodesRemoved: clearSelectionIfInvalidated)
case .rules:
RulesView(workflow: rulesWorkflow, onShowRuntime: { route.navigate(to: .home) })
RulesView(
workflow: rulesWorkflow, onShowRuntime: { route.navigate(to: .home) },
onEditRule: { ruleDraft = rulesWorkflow.makeCustomRuleDraft(editing: $0) })
case .settings:
SettingsView(
workflow: settingsWorkflow, loginController: loginController,
+198
View File
@@ -1004,6 +1004,204 @@
}
}
}
},
"新增规则": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Add Rule"
}
}
}
},
"编辑规则": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Edit Rule"
}
}
}
},
"IP 地址": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "IP Address"
}
}
}
},
"CIDR 范围": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "CIDR Range"
}
}
}
},
"规范化预览": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Normalized Preview"
}
}
}
},
"请输入有效的 IPv4 或 IPv6 地址。": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Enter a valid IPv4 or IPv6 address."
}
}
}
},
"请输入有效的 IPv4 或 IPv6 CIDR 范围。": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Enter a valid IPv4 or IPv6 CIDR range."
}
}
}
},
"请输入有效的 ASCII 域名;不支持通配符、URL 或路径。": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Enter a valid ASCII domain; wildcards, URLs, and paths are not supported."
}
}
}
},
"相同匹配条件和行动的自定义规则已存在。": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "A custom rule with the same match and action already exists."
}
}
}
},
"代理规则与固定本地策略冲突,无法保存。": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "This proxy rule conflicts with the fixed local policy and cannot be saved."
}
}
}
},
"将与已有来源合并显示。": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "This rule will appear with its existing sources."
}
}
}
},
"此身份已禁用;保存不会重新启用。": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "This identity is disabled; saving will not enable it."
}
}
}
},
"新身份将继承禁用状态;旧禁用记录保留。": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "The new identity will remain disabled; the previous disabled record will be kept."
}
}
}
},
"覆盖关系仅解释已启用规则之间的关系,不代表当前流量行为。": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Coverage describes relationships among enabled rules; it does not predict current traffic."
}
}
}
},
"规则集合已变化,请重新打开编辑器。": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "The rule collection has changed. Reopen the editor."
}
}
}
},
"已新增规则": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Rule added"
}
}
}
},
"已编辑规则": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Rule edited"
}
}
}
},
"请重试规则操作。": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Try the rule operation again."
}
}
}
}
},
"version": "1.0"
@@ -111,8 +111,14 @@ struct RulesCollectionIssuesView: View {
extension RulesCommitFeedback {
var summary: String {
guard outcome.isSuccess else { return outcome.rulesMessage }
let count = String.localizedStringWithFormat(
RulesCopy.text(enabled ? "已启用 %lld 条规则" : "已禁用 %lld 条规则"), Int64(changedCount))
let count: String
switch operation {
case .enablement(let enabled):
count = String.localizedStringWithFormat(
RulesCopy.text(enabled ? "已启用 %lld 条规则" : "已禁用 %lld 条规则"), Int64(changedCount))
case .add: count = RulesCopy.text("已新增规则")
case .edit: count = RulesCopy.text("已编辑规则")
}
let application: String
switch outcome {
case .applied: application = "已应用"
@@ -159,7 +165,7 @@ extension CustomRuleUpdateOutcome {
var nextStep: String? {
switch self {
case .persistenceFailed, .rolledBack, .busy: "请重新执行启用或禁用操作。"
case .persistenceFailed, .rolledBack, .busy: "请重试规则操作。"
case .recoveryFailed, .runtimeChanged: "请在首页检查当前代理运行状态。"
case .invalidDocument, .rejected: "请检查规则数据后再操作。"
default: nil
+52 -37
View File
@@ -5,6 +5,7 @@ struct RulesView: View {
@ObservedObject var workflow: RulesWorkflow
@Environment(\.openWindow) private var openWindow
var onShowRuntime: () -> Void
var onEditRule: (UUID) -> Void
var body: some View {
VStack(spacing: 0) {
@@ -153,6 +154,14 @@ struct RulesView: View {
.width(min: 75, ideal: 90)
TableColumn(RulesCopy.text("状态")) { row in Text(row.statusLabel) }
.width(min: 80, ideal: 110)
TableColumn("") { row in
if let id = row.customIDs.first {
Button(RulesCopy.text("编辑规则"), systemImage: "pencil") { onEditRule(id) }
.labelStyle(.iconOnly)
.help(RulesCopy.text("编辑规则"))
.disabled(!workflow.snapshot.isComplete)
}
}.width(30)
}.frame(minWidth: 0, maxWidth: .infinity)
.contextMenu {
ForEach([true, false], id: \.self) { enabled in
@@ -197,52 +206,57 @@ struct RulesView: View {
}
private struct RulesRelationshipsView: View {
struct RulesRelationshipsView: View {
let row: RulesRow
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 10) {
ForEach([RuleRelationship.Kind.absorption, .shadowing], id: \.self) { kind in
let relationships = row.relationships.filter { $0.kind == kind }
let fixed = row.fixedCoverage.flatMap { coverage in
row.action == (kind == .absorption ? .direct : .proxy) ? coverage : nil
}
if !relationships.isEmpty || fixed != nil {
VStack(alignment: .leading, spacing: 4) {
Text(RulesCopy.text(kind == .absorption ? "被同行动规则覆盖" : "被相反行动规则遮蔽"))
ForEach(relationships, id: \.kind) { relationship in
let covering = relationship.covering.filter { identity in
!(identity.action == .direct
&& (fixed?.matches.contains(identity.match) ?? false))
}
if !covering.isEmpty && relationship.extent == .partial {
Text(RulesCopy.text("部分重叠"))
}
ForEach(covering, id: \.self) { identity in
Text(
verbatim: identity.match.browsingContent + " · "
+ identity.match.browsingType + " · "
+ RulesCopy.text(identity.action == .direct ? "直连" : "代理"))
}
ScrollView { RulesRelationshipsContent(row: row).padding() }
}
}
struct RulesRelationshipsContent: View {
let row: RulesRow
var body: some View {
VStack(alignment: .leading, spacing: 10) {
ForEach([RuleRelationship.Kind.absorption, .shadowing], id: \.self) { kind in
let relationships = row.relationships.filter { $0.kind == kind }
let fixed = row.fixedCoverage.flatMap { coverage in
row.action == (kind == .absorption ? .direct : .proxy) ? coverage : nil
}
if !relationships.isEmpty || fixed != nil {
VStack(alignment: .leading, spacing: 4) {
Text(RulesCopy.text(kind == .absorption ? "被同行动规则覆盖" : "被相反行动规则遮蔽"))
ForEach(relationships, id: \.kind) { relationship in
let covering = relationship.covering.filter { identity in
!(identity.action == .direct
&& (fixed?.matches.contains(identity.match) ?? false))
}
if let fixed {
if !covering.isEmpty && relationship.extent == .partial {
Text(RulesCopy.text("部分重叠"))
}
ForEach(covering, id: \.self) { identity in
Text(
RulesCopy.text("固定本地策略")
+ (fixed.extent == .partial ? " · " + RulesCopy.text("部分重叠") : ""))
ForEach(fixed.matches, id: \.self) { match in
Text(verbatim: match.browsingContent)
}
if fixed.includesSimpleHostname { Text(RulesCopy.text("简单主机名")) }
verbatim: identity.match.browsingContent + " · "
+ identity.match.browsingType + " · "
+ RulesCopy.text(identity.action == .direct ? "直连" : "代理"))
}
}
if let fixed {
Text(
RulesCopy.text("固定本地策略")
+ (fixed.extent == .partial ? " · " + RulesCopy.text("部分重叠") : ""))
ForEach(fixed.matches, id: \.self) { match in
Text(verbatim: match.browsingContent)
}
if fixed.includesSimpleHostname { Text(RulesCopy.text("简单主机名")) }
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.textSelection(.enabled)
}
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
}
@@ -265,7 +279,7 @@ extension RulesSource {
}
extension RulesRow {
var displayContent: String { identity == nil ? RulesCopy.text("简单主机名") : content }
var displayContent: String { identity?.match.editingContent ?? RulesCopy.text("简单主机名") }
var sourceLabels: String {
guard hasCurrentSource else { return RulesCopy.text("无当前来源") }
return RulesSource.allCases.filter { sources.contains($0) }.map(\.label).joined(separator: ", ")
@@ -301,7 +315,8 @@ extension RuleRelationship {
extension RuleMatch {
var browsingType: String {
switch self {
if editingKind == .ipAddress { return RulesCopy.text("IP 地址") }
return switch self {
case .domainExact: RulesCopy.text("精确域名")
case .domainSuffix: RulesCopy.text("域名后缀")
case .ipv4CIDR: "IPv4 CIDR"
+52 -6
View File
@@ -98,7 +98,7 @@ final class RulesWorkflow: ObservableObject {
func setEnabled(_ enabled: Bool, identities: Set<RuleIdentity>) async {
guard snapshot.isComplete, !snapshot.isCommitting,
let commitDocument
commitDocument != nil
else { return }
let allowed = Set(snapshot.rows.filter { !$0.isFixed }.compactMap(\.identity))
let targets = identities.intersection(allowed)
@@ -110,11 +110,19 @@ final class RulesWorkflow: ObservableObject {
? targets.intersection(disabled).count : targets.subtracting(disabled).count
if enabled { disabled.subtract(targets) } else { disabled.formUnion(targets) }
guard disabled != old.disabledIdentities else { return }
_ = await commit(
CustomRuleDocument(rules: old.rules, disabledIdentities: disabled),
operation: .enablement(enabled), changedCount: changedCount)
}
private func commit(
_ document: CustomRuleDocument, operation: RulesCommitFeedback.Operation, changedCount: Int = 1
) async -> CustomRuleUpdateOutcome {
guard let commitDocument else { return .busy }
snapshot.isCommitting = true
dismissFeedback()
invalidateAddressTest()
let result = await commitDocument(
CustomRuleDocument(rules: old.rules, disabledIdentities: disabled))
let result = await commitDocument(document)
var next = snapshot
if let document = result.document, let previous = collection,
document != previous.userDocument
@@ -132,7 +140,8 @@ final class RulesWorkflow: ObservableObject {
next.issues = [.userDocument("Saved rule document is unavailable")]
}
next.isCommitting = false
publishFeedback(result.outcome, enabled: enabled, changedCount: changedCount, page: next)
publishFeedback(result.outcome, operation: operation, changedCount: changedCount, page: next)
return result.outcome
}
func dismissFeedback() {
@@ -142,10 +151,11 @@ final class RulesWorkflow: ObservableObject {
}
private func publishFeedback(
_ outcome: CustomRuleUpdateOutcome, enabled: Bool, changedCount: Int, page: RulesPageSnapshot
_ outcome: CustomRuleUpdateOutcome, operation: RulesCommitFeedback.Operation, changedCount: Int,
page: RulesPageSnapshot
) {
let feedback = RulesCommitFeedback(
outcome: outcome, enabled: enabled, changedCount: changedCount)
outcome: outcome, operation: operation, changedCount: changedCount)
var next = page
next.commitFeedback = feedback
snapshot = next
@@ -245,3 +255,39 @@ final class RulesWorkflow: ObservableObject {
return next
}
}
extension RulesWorkflow {
func makeCustomRuleDraft(editing id: UUID? = nil) -> CustomRuleDraft? {
guard snapshot.isComplete, commitDocument != nil, let document = collection?.userDocument
else { return nil }
if let id {
guard let rule = document.rules.first(where: { $0.id == id }) else { return nil }
return CustomRuleDraft(
id: id, editingID: id, version: snapshot.version,
kind: rule.identity.match.editingKind, content: rule.identity.match.editingContent,
action: rule.action)
}
return CustomRuleDraft(id: UUID(), editingID: nil, version: snapshot.version)
}
func previewCustomRule(_ draft: CustomRuleDraft) async -> CustomRulePreview {
guard !snapshot.isCommitting else { return CustomRulePreview(failure: .busy) }
guard snapshot.isComplete, let collection, collection.userDocument != nil
else { return CustomRulePreview(failure: .incompleteCollection) }
guard draft.version == snapshot.version else { return CustomRulePreview(failure: .staleDraft) }
let prepared = await Task.detached(priority: .userInitiated) {
collection.previewCustomRule(draft)
}.value
guard !snapshot.isCommitting else { return CustomRulePreview(failure: .busy) }
guard snapshot.isComplete else { return CustomRulePreview(failure: .incompleteCollection) }
guard snapshot.version == draft.version else { return CustomRulePreview(failure: .staleDraft) }
return prepared
}
func saveCustomRule(_ draft: CustomRuleDraft) async -> CustomRuleSaveResult {
let preview = await previewCustomRule(draft)
if let failure = preview.failure { return .unavailable(failure) }
guard snapshot.isComplete, let document = preview.document else { return .unavailable(.busy) }
return .committed(await commit(document, operation: draft.editingID == nil ? .add : .edit))
}
}
@@ -0,0 +1,99 @@
import Foundation
/// A sheet-local draft bound to saved facts, never part of the browsing snapshot.
struct CustomRuleDraft: Equatable, Sendable, Identifiable {
enum Kind: CaseIterable, Sendable { case ipAddress, cidr, domainSuffix, domainExact }
let id: UUID
let editingID: UUID?
let version: String
var kind: Kind = .domainSuffix
var content = ""
var action: RuleAction = .proxy
func normalizedMatch() throws -> RuleMatch {
let value = content.trimmingCharacters(in: .whitespacesAndNewlines)
switch kind {
case .ipAddress, .cidr:
guard
kind == .ipAddress
? !value.contains("/")
: value.split(separator: "/", omittingEmptySubsequences: false).count == 2,
!value.hasPrefix("/"), !value.hasSuffix("/")
else { throw RuleMatchError.invalidCIDR(value) }
return try value.contains(":") ? RuleMatch(ipv6CIDR: value) : RuleMatch(ipv4CIDR: value)
case .domainExact: return try RuleMatch(domainExact: value)
case .domainSuffix: return try RuleMatch(nationalDomainSuffix: value)
}
}
}
struct CustomRulePreview: Equatable, Sendable {
enum Failure: Equatable, Sendable {
case incompleteCollection, busy, staleDraft
case invalidInput(CustomRuleDraft.Kind)
case duplicate, fixedLocalConflict, fixedPolicyDisablement
}
var failure: Failure?
var rule: CustomRule?
var row: RulesRow?
var inheritsDisablement = false
var document: CustomRuleDocument?
var displayContent: String? { rule?.identity.match.editingContent }
}
enum CustomRuleSaveResult: Equatable, Sendable {
case unavailable(CustomRulePreview.Failure)
case committed(CustomRuleUpdateOutcome)
}
extension RuleMatch {
var editingKind: CustomRuleDraft.Kind {
switch self {
case .domainExact: .domainExact
case .domainSuffix: .domainSuffix
case .ipv4CIDR(let value): value.hasSuffix("/32") ? .ipAddress : .cidr
case .ipv6CIDR(let value): value.hasSuffix("/128") ? .ipAddress : .cidr
}
}
var editingContent: String {
if editingKind == .ipAddress { return String(browsingContent.split(separator: "/")[0]) }
return browsingContent
}
}
extension RulesCollection {
func previewCustomRule(_ draft: CustomRuleDraft) -> CustomRulePreview {
guard let document = userDocument else {
return CustomRulePreview(failure: .incompleteCollection)
}
let match: RuleMatch
do { match = try draft.normalizedMatch() } catch {
return CustomRulePreview(failure: .invalidInput(draft.kind))
}
let previous = document.rules.first { $0.id == draft.editingID }
guard draft.editingID == nil || previous != nil else {
return CustomRulePreview(failure: .staleDraft)
}
let rule = CustomRule(
id: draft.id, action: draft.action, match: match, source: previous?.source)
let others = document.rules.filter { $0.id != draft.editingID }
guard !others.contains(where: { $0.identity == rule.identity }) else {
return CustomRulePreview(failure: .duplicate, rule: rule)
}
guard CustomRuleValidator.fixedLocalRejection(for: rule) == nil else {
return CustomRulePreview(failure: .fixedLocalConflict, rule: rule)
}
var disabled = document.disabledIdentities
let inherits = previous.map { disabled.contains($0.identity) } ?? false
if inherits {
guard rule.action != .direct || !RuleCoverage.fixedLocalMatches.contains(rule.identity.match)
else { return CustomRulePreview(failure: .fixedPolicyDisablement, rule: rule) }
disabled.insert(rule.identity)
}
let updated = CustomRuleDocument(rules: others + [rule], disabledIdentities: disabled)
return CustomRulePreview(
rule: rule, row: previewRow(identity: rule.identity, document: updated),
inheritsDisablement: inherits && previous?.identity != rule.identity, document: updated)
}
}
+5 -1
View File
@@ -74,8 +74,12 @@ enum RulesOperationStatus: Equatable, Sendable {
}
struct RulesCommitFeedback: Equatable, Sendable, Identifiable {
enum Operation: Equatable, Sendable {
case enablement(Bool)
case add, edit
}
let id = UUID()
let outcome: CustomRuleUpdateOutcome
let enabled: Bool
let operation: Operation
let changedCount: Int
}
+31 -4
View File
@@ -23,6 +23,14 @@ struct RulesCollection: Sendable {
return input.collection(builtinInput: builtin)
}
/// A single prospective row uses the same analysis as browsing without sorting
/// or recomputing every saved row on each editor keystroke.
func previewRow(identity: RuleIdentity, document: CustomRuleDocument) -> RulesRow? {
var input = builtinInput
input.readCustom { document }
return input.previewRow(identity: identity)
}
func replacingUserDocument(_ document: CustomRuleDocument) -> RulesCollection {
var input = builtinInput
input.readCustom { document }
@@ -96,7 +104,7 @@ private struct RulesCollectionInput: Sendable {
}
}
mutating func collection(builtinInput: RulesCollectionInput) -> RulesCollection {
private mutating func appendFixedPolicy() {
let fixedSource = RuleSourceIdentity(kind: .custom, upstreamVersion: "fixed", label: "fixed")
let fixedMatches = RuleCoverage.fixedLocalMatches
entries += fixedMatches.map {
@@ -107,6 +115,26 @@ private struct RulesCollectionInput: Sendable {
sources.append(
RulesSourceSnapshot(
id: .fixed, count: fixedMatches.count + 1, metadata: nil, conversionReport: nil))
}
mutating func previewRow(identity: RuleIdentity) -> RulesRow? {
appendFixedPolicy()
let memberships = entries.filter { $0.rule.identity == identity }
guard !memberships.isEmpty else { return nil }
let overlapping = entries.compactMap { candidate -> ProxyRule? in
let rule = candidate.rule
guard !disabled.contains(rule.identity),
RuleCoverage.intersects(rule.identity.match, identity.match),
rule.action != .proxy
|| RuleCoverage.fixedLocalCoverage(of: rule.identity.match)?.extent != .full
else { return nil }
return rule
}
return row(identity: identity, entries: memberships, overlapping: overlapping)
}
mutating func collection(builtinInput: RulesCollectionInput) -> RulesCollection {
appendFixedPolicy()
let grouped = Dictionary(grouping: entries, by: { $0.rule.identity })
let rules = grouped.values.compactMap { $0.first?.rule }
// Completely fixed-protected proxy candidates remain browsable but cannot
@@ -118,7 +146,7 @@ private struct RulesCollectionInput: Sendable {
}
let index = RulesOverlapIndex(rules: effective)
var rows = grouped.map { identity, entries in
row(identity: identity, entries: entries, index: index)
row(identity: identity, entries: entries, overlapping: index.overlapping(identity.match))
}
for identity in disabled where grouped[identity] == nil {
rows.append(
@@ -149,11 +177,10 @@ private struct RulesCollectionInput: Sendable {
builtinInput: builtinInput)
}
private func row(
identity: RuleIdentity, entries: [RulesCandidate], index: RulesOverlapIndex
identity: RuleIdentity, entries: [RulesCandidate], overlapping: [ProxyRule]
) -> RulesRow {
let memberships = Set(entries.map { $0.source })
let representative = entries[0].rule
let overlapping = index.overlapping(identity.match)
var relationships =
memberships.contains(.fixed)
? [] : RuleAnalysis(rules: overlapping, subjects: [representative]).relationships
@@ -0,0 +1,103 @@
import XCTest
@testable import ShadowsocksX_NG2
/// Verify the editor's public commands against the production commit adapter,
/// using the existing hostless runtime and system-proxy fixtures.
extension ProxyRuntimeControllerTests {
func testEditorSaveWhileOffPersistsWithoutStartingRuntime() async throws {
let (store, _) = try makeCustomRuleStore()
let controller = makeControllerWithCustomRules(
store: store, settings: ProxySettings(listen: ActivationFixture.listen, agentEnabled: false))
let workflow = editingWorkflow(controller)
await workflow.refresh()
var draft = try XCTUnwrap(workflow.makeCustomRuleDraft())
draft.kind = .ipAddress
draft.content = "8.8.8.8"
draft.action = .direct
let result = await workflow.saveCustomRule(draft)
XCTAssertEqual(result, .committed(.saved))
XCTAssertEqual(try store.load().first?.id, draft.id)
XCTAssertEqual(controller.state, .off)
XCTAssertEqual(agent.registerCount, 0)
XCTAssertEqual(workflow.snapshot.commitFeedback?.operation, .add)
}
func testEditorChangesRuntimeACLOnceAndRetainsUUIDAcrossEdits() async throws {
let seeded = try makeSeededCatalog()
let (store, _) = try makeCustomRuleStore()
let controller = makeControllerWithCustomRules(
store: store,
settings: ProxySettings(
listen: ActivationFixture.listen, preferredMode: .rule,
ruleDefaultAction: .proxyWhenUnmatched, agentEnabled: true), proxyMode: .rule)
try await controller.activate(seeded.server)
let workflow = editingWorkflow(controller)
await workflow.refresh()
let registrations = agent.registerCount
var draft = try XCTUnwrap(workflow.makeCustomRuleDraft())
draft.content = "new-rule.example"
draft.action = .direct
let added = await workflow.saveCustomRule(draft)
XCTAssertEqual(added, .committed(.applied))
XCTAssertEqual(agent.registerCount, registrations + 1)
let runtimeStore = RuntimeFileStore(fileURL: runtime.contract)
XCTAssertTrue(try activeACLContent(runtimeStore).contains("||new-rule.example"))
var edited = try XCTUnwrap(workflow.makeCustomRuleDraft(editing: draft.id))
edited.content = "edited-rule.example"
let saved = await workflow.saveCustomRule(edited)
XCTAssertEqual(saved, .committed(.applied))
XCTAssertEqual(agent.registerCount, registrations + 2)
XCTAssertEqual(try store.load().map(\.id), [draft.id])
XCTAssertFalse(try activeACLContent(runtimeStore).contains("||new-rule.example"))
XCTAssertTrue(try activeACLContent(runtimeStore).contains("||edited-rule.example"))
XCTAssertEqual(workflow.snapshot.commitFeedback?.operation, .edit)
}
func testEditorFailedDeploymentKeepsDraftAndRestoresSavedSnapshotAndACL() async throws {
let seeded = try makeSeededCatalog()
let (store, _) = try makeCustomRuleStore()
let existing = CustomRule(action: .direct, match: .domainSuffix("kept.example"))
try store.save([existing])
let controller = makeControllerWithCustomRules(
store: store,
settings: ProxySettings(
listen: ActivationFixture.listen, preferredMode: .rule,
ruleDefaultAction: .proxyWhenUnmatched, agentEnabled: true), proxyMode: .rule)
try await controller.activate(seeded.server)
let workflow = editingWorkflow(controller)
await workflow.refresh()
let version = workflow.snapshot.version
let runtimeStore = RuntimeFileStore(fileURL: runtime.contract)
let originalRuntime = try XCTUnwrap(runtimeStore.loadDocument())
agent.onRegister = {
guard let requested = runtimeStore.loadDocument() else { return }
let content =
(try? Data(contentsOf: runtimeStore.aclFileURL))
.flatMap { String(bytes: $0, encoding: .utf8) } ?? ""
try? runtimeStore.writeRuntimeReceipt(
for: content.contains("new-rule.example") ? originalRuntime : requested, processID: 42)
}
var draft = try XCTUnwrap(workflow.makeCustomRuleDraft(editing: existing.id))
draft.content = "new-rule.example"
let result = await workflow.saveCustomRule(draft)
XCTAssertEqual(result, .committed(.rolledBack))
XCTAssertEqual(try store.load(), [existing])
XCTAssertEqual(workflow.snapshot.version, version)
XCTAssertEqual(
workflow.snapshot.rows.first { $0.customIDs.contains(existing.id) }?.identity,
existing.identity)
XCTAssertEqual(runtimeStore.loadDocument(), originalRuntime)
XCTAssertEqual(controller.state, .running)
let preview = await workflow.previewCustomRule(draft)
XCTAssertNil(preview.failure, "The user may retry the same draft after a complete rollback")
XCTAssertEqual(preview.displayContent, "new-rule.example")
}
private func editingWorkflow(_ controller: ProxyRuntimeController) -> RulesWorkflow {
RulesWorkflow(
loadDocument: { try controller.ruleDocuments.load() },
commitDocument: { await controller.commitRuleDocument($0) },
builtinSnapshots: controller.ruleSnapshots)
}
}
@@ -0,0 +1,357 @@
import XCTest
@testable import ShadowsocksX_NG2
@MainActor
final class CustomRuleEditingWorkflowTests: XCTestCase {
func testIPPreviewNormalizesWithoutSavingAndSavePublishesNewRow() async throws {
let workflow = RulesWorkflow(
loadDocument: { CustomRuleDocument(rules: []) },
commitDocument: { RuleDocumentCommit(outcome: .saved, document: $0) },
loadBuiltin: { rulesFixture($0) })
await workflow.refresh()
let version = workflow.snapshot.version
var draft = try XCTUnwrap(workflow.makeCustomRuleDraft())
draft.kind = .ipAddress
draft.content = " 2001:DB8::1 "
draft.action = .direct
let preview = await workflow.previewCustomRule(draft)
XCTAssertNil(preview.failure)
XCTAssertEqual(preview.rule?.match, .ipv6CIDR("2001:db8::1/128"))
XCTAssertEqual(preview.displayContent, "2001:db8::1")
XCTAssertEqual(workflow.snapshot.version, version)
XCTAssertFalse(workflow.snapshot.rows.contains { $0.customIDs.contains(draft.id) })
let outcome = await workflow.saveCustomRule(draft)
XCTAssertEqual(outcome, .committed(.saved))
let row = try XCTUnwrap(workflow.snapshot.rows.first { $0.customIDs.contains(draft.id) })
XCTAssertEqual(row.identity?.match, .ipv6CIDR("2001:db8::1/128"))
XCTAssertEqual(row.sources, [.custom])
XCTAssertNotEqual(workflow.snapshot.version, version)
}
func testEditingDisabledMergedRuleMovesOnlyCustomMembershipAndKeepsUUID() async throws {
let original = CustomRule(
action: .proxy, match: .domainSuffix("old.example"),
source: RuleSourceIdentity(kind: .custom, upstreamVersion: "imported", label: "User import"))
let source = RuleSourceIdentity(kind: .gfwlist, upstreamVersion: "fixture", label: "GFWList")
let builtin = ProxyRule(action: .proxy, match: original.match, source: source)
var saved = CustomRuleDocument(rules: [original], disabledIdentities: [original.identity])
let workflow = RulesWorkflow(
loadDocument: { saved },
commitDocument: {
saved = $0
return RuleDocumentCommit(outcome: .saved, document: saved)
},
loadBuiltin: { rulesFixture($0, rules: $0 == .gfwlist ? [builtin] : []) })
await workflow.refresh()
var draft = try XCTUnwrap(workflow.makeCustomRuleDraft(editing: original.id))
let unchanged = await workflow.previewCustomRule(draft)
XCTAssertNil(unchanged.failure, "Editing excludes its own UUID")
draft.content = "new.example"
draft.action = .direct
let preview = await workflow.previewCustomRule(draft)
XCTAssertTrue(preview.inheritsDisablement)
XCTAssertEqual(preview.row?.isEnabled, false)
let result = await workflow.saveCustomRule(draft)
XCTAssertEqual(result, .committed(.saved))
XCTAssertEqual(saved.rules.count, 1)
XCTAssertEqual(saved.rules.first?.id, original.id)
XCTAssertEqual(saved.rules.first?.source, original.source)
XCTAssertEqual(
saved.disabledIdentities,
[original.identity, RuleIdentity(action: .direct, match: .domainSuffix("new.example"))])
let oldRow = try XCTUnwrap(workflow.snapshot.rows.first { $0.identity == original.identity })
XCTAssertEqual(oldRow.sources, [.gfwlist])
XCTAssertTrue(oldRow.customIDs.isEmpty)
XCTAssertFalse(oldRow.isEnabled)
let newRow = try XCTUnwrap(workflow.snapshot.rows.first { $0.customIDs.contains(original.id) })
XCTAssertEqual(newRow.sources, [.custom])
XCTAssertFalse(newRow.isEnabled)
}
func testCIDRRequiresAddressAndPrefixAndShowsMaskedNetwork() async throws {
let workflow = RulesWorkflow(
loadDocument: { CustomRuleDocument(rules: []) },
commitDocument: { RuleDocumentCommit(outcome: .saved, document: $0) },
loadBuiltin: { rulesFixture($0) })
await workflow.refresh()
var draft = try XCTUnwrap(workflow.makeCustomRuleDraft())
draft.kind = .cidr
draft.action = .direct
for invalid in ["/8.8.8.8", "8.8.8.8/", "8.8.8.8", "8.8.8.8/33", "2001:db8::1/129"] {
draft.content = invalid
let preview = await workflow.previewCustomRule(draft)
XCTAssertEqual(preview.failure, .invalidInput(.cidr), invalid)
}
for (input, expected) in [
("8.8.8.9/24", "8.8.8.0/24"), ("2001:db8:1::123/48", "2001:db8:1::/48"),
] {
draft.content = input
let preview = await workflow.previewCustomRule(draft)
XCTAssertNil(preview.failure)
XCTAssertEqual(preview.displayContent, expected)
}
}
func testDuplicateAndFixedLocalConflictAreRejectedWithoutCommit() async throws {
let existing = CustomRule(action: .direct, match: .ipv4CIDR("8.8.8.8/32"))
var commits = 0
let workflow = RulesWorkflow(
loadDocument: { CustomRuleDocument(rules: [existing]) },
commitDocument: {
commits += 1
return RuleDocumentCommit(outcome: .saved, document: $0)
},
loadBuiltin: { rulesFixture($0) })
await workflow.refresh()
var draft = try XCTUnwrap(workflow.makeCustomRuleDraft())
draft.kind = .ipAddress
draft.content = "8.8.8.8"
draft.action = .direct
let duplicate = await workflow.previewCustomRule(draft)
XCTAssertEqual(duplicate.failure, .duplicate)
let duplicateSave = await workflow.saveCustomRule(draft)
XCTAssertEqual(duplicateSave, .unavailable(.duplicate))
draft.kind = .cidr
draft.content = "8.8.8.8/32"
let cidr = await workflow.previewCustomRule(draft)
XCTAssertEqual(cidr.failure, .duplicate, "IP and full CIDR have one identity")
draft.action = .proxy
draft.kind = .ipAddress
for input in ["192.168.1.1", "::ffff:192.168.1.1", "::1"] {
draft.content = input
let conflict = await workflow.previewCustomRule(draft)
XCTAssertEqual(conflict.failure, .fixedLocalConflict, input)
let save = await workflow.saveCustomRule(draft)
XCTAssertEqual(save, .unavailable(.fixedLocalConflict))
}
XCTAssertEqual(commits, 0)
}
func testEquivalentBuiltinAndDisabledOrphanCanBeAddedWithoutEnabling() async throws {
let source = RuleSourceIdentity(kind: .gfwlist, upstreamVersion: "fixture", label: "GFWList")
let builtin = ProxyRule(action: .proxy, match: .domainSuffix("merge.example"), source: source)
let orphan = RuleIdentity(action: .proxy, match: .domainExact("orphan.example"))
let workflow = RulesWorkflow(
loadDocument: {
CustomRuleDocument(rules: [], disabledIdentities: [builtin.identity, orphan])
},
commitDocument: { RuleDocumentCommit(outcome: .saved, document: $0) },
loadBuiltin: { rulesFixture($0, rules: $0 == .gfwlist ? [builtin] : []) })
await workflow.refresh()
var draft = try XCTUnwrap(workflow.makeCustomRuleDraft())
draft.content = ".MERGE.Example"
let preview = await workflow.previewCustomRule(draft)
XCTAssertNil(preview.failure)
XCTAssertEqual(preview.displayContent, "merge.example")
XCTAssertEqual(preview.row?.sources, [.custom, .gfwlist])
XCTAssertEqual(preview.row?.isEnabled, false)
let result = await workflow.saveCustomRule(draft)
XCTAssertEqual(result, .committed(.saved))
let row = try XCTUnwrap(workflow.snapshot.rows.first { $0.customIDs.contains(draft.id) })
XCTAssertEqual(row.sources, [.custom, .gfwlist])
XCTAssertFalse(row.isEnabled)
var next = try XCTUnwrap(workflow.makeCustomRuleDraft())
next.kind = .domainExact
next.content = "orphan.example"
let orphanPreview = await workflow.previewCustomRule(next)
XCTAssertEqual(orphanPreview.row?.isEnabled, false)
let orphanSave = await workflow.saveCustomRule(next)
XCTAssertEqual(orphanSave, .committed(.saved))
XCTAssertFalse(
try XCTUnwrap(workflow.snapshot.rows.first { $0.customIDs.contains(next.id) }).isEnabled)
}
func testDomainBoundaryAndNationalSuffixUseExistingNormalization() async throws {
let workflow = RulesWorkflow(
loadDocument: { CustomRuleDocument(rules: []) },
commitDocument: { RuleDocumentCommit(outcome: .saved, document: $0) },
loadBuiltin: { rulesFixture($0) })
await workflow.refresh()
var draft = try XCTUnwrap(workflow.makeCustomRuleDraft())
draft.action = .direct
for kind in [CustomRuleDraft.Kind.domainExact, .domainSuffix] {
draft.kind = kind
for input in [
"", "例子.cn", "*.example.com", "https://example.com", "example.com/path", "^example$",
] {
draft.content = input
let preview = await workflow.previewCustomRule(draft)
XCTAssertEqual(preview.failure, .invalidInput(kind), input)
}
draft.content = " XN--FIQS8S.Example "
let valid = await workflow.previewCustomRule(draft)
XCTAssertNil(valid.failure)
XCTAssertEqual(valid.displayContent, "xn--fiqs8s.example")
}
draft.kind = .domainSuffix
draft.content = "CN"
let national = await workflow.previewCustomRule(draft)
XCTAssertNil(national.failure)
XCTAssertEqual(national.rule?.match, .domainSuffix("cn"))
draft.kind = .ipAddress
for input in ["8.8.8.8/32", "[2001:db8::1]", "8.8.8.8:80", "example.com"] {
draft.content = input
let invalid = await workflow.previewCustomRule(draft)
XCTAssertEqual(invalid.failure, .invalidInput(.ipAddress))
}
}
func testPreviewExplainsAbsorptionShadowingAndPartialOverlapButAllowsSave() async throws {
let broad = CustomRule(action: .direct, match: .domainSuffix("coverage.example"))
let proxy = CustomRule(action: .proxy, match: .domainExact("a.coverage.example"))
let workflow = RulesWorkflow(
loadDocument: { CustomRuleDocument(rules: [broad, proxy]) },
commitDocument: { RuleDocumentCommit(outcome: .saved, document: $0) },
loadBuiltin: { rulesFixture($0) })
await workflow.refresh()
var draft = try XCTUnwrap(workflow.makeCustomRuleDraft())
draft.kind = .domainExact
draft.content = "a.coverage.example"
draft.action = .direct
let preview = await workflow.previewCustomRule(draft)
XCTAssertNil(preview.failure)
XCTAssertTrue(
preview.row?.relationships.contains {
$0.kind == .absorption && $0.extent == .full && $0.covering == [broad.identity]
} == true)
XCTAssertTrue(
preview.row?.relationships.contains {
$0.kind == .shadowing && $0.extent == .full && $0.covering == [proxy.identity]
} == true)
let saved = await workflow.saveCustomRule(draft)
XCTAssertEqual(saved, .committed(.saved))
XCTAssertEqual(
workflow.snapshot.rows.first { $0.customIDs.contains(draft.id) }?.relationships,
preview.row?.relationships)
var edited = try XCTUnwrap(workflow.makeCustomRuleDraft(editing: broad.id))
edited.content = "coverage.example"
let partial = await workflow.previewCustomRule(edited)
XCTAssertTrue(
partial.row?.relationships.contains { $0.kind == .shadowing && $0.extent == .partial } == true
)
}
func testStaleDraftCannotOverwriteAnotherSavedChangeAndDiscardingDraftDoesNothing() async throws {
let rule = CustomRule(action: .direct, match: .domainExact("kept.example"))
var commits = 0
let workflow = RulesWorkflow(
loadDocument: { CustomRuleDocument(rules: [rule]) },
commitDocument: {
commits += 1
return RuleDocumentCommit(outcome: .saved, document: $0)
},
loadBuiltin: { rulesFixture($0) })
await workflow.refresh()
var discarded = try XCTUnwrap(workflow.makeCustomRuleDraft(editing: rule.id))
discarded.content = "discarded.example"
_ = await workflow.previewCustomRule(discarded)
XCTAssertEqual(commits, 0)
XCTAssertEqual(
workflow.snapshot.rows.first { $0.customIDs.contains(rule.id) }?.identity, rule.identity)
var stale = try XCTUnwrap(workflow.makeCustomRuleDraft())
stale.content = "new.example"
await workflow.setEnabled(false, identities: [rule.identity])
let result = await workflow.saveCustomRule(stale)
XCTAssertEqual(result, .unavailable(.staleDraft))
XCTAssertEqual(commits, 1)
XCTAssertFalse(workflow.snapshot.rows.contains { $0.customIDs.contains(stale.id) })
}
}
extension CustomRuleEditingWorkflowTests {
func testPendingSaveBlocksRepeatsAndFailureKeepsSnapshotAvailableForRetry() async throws {
let original = CustomRuleDocument(rules: [])
let started = expectation(description: "save awaiting adapter")
var continuation: CheckedContinuation<Void, Never>?
var commits = 0
let workflow = RulesWorkflow(
loadDocument: { original },
commitDocument: { _ in
commits += 1
await withCheckedContinuation {
continuation = $0
started.fulfill()
}
return RuleDocumentCommit(outcome: .persistenceFailed, document: original)
},
loadBuiltin: { rulesFixture($0) })
await workflow.refresh()
let version = workflow.snapshot.version
var draft = try XCTUnwrap(workflow.makeCustomRuleDraft())
draft.content = "pending.example"
let pending = Task { await workflow.saveCustomRule(draft) }
await fulfillment(of: [started], timeout: 3)
XCTAssertTrue(workflow.snapshot.isCommitting)
XCTAssertEqual(workflow.snapshot.operationStatus, .updating)
let repeated = await workflow.saveCustomRule(draft)
XCTAssertEqual(repeated, .unavailable(.busy))
XCTAssertNil(workflow.makeCustomRuleDraft())
continuation?.resume()
let result = await pending.value
XCTAssertEqual(result, .committed(.persistenceFailed))
XCTAssertEqual(commits, 1)
XCTAssertEqual(workflow.snapshot.version, version)
XCTAssertEqual(workflow.snapshot.commitOutcome, .persistenceFailed)
XCTAssertFalse(workflow.snapshot.rows.contains { $0.customIDs.contains(draft.id) })
let retry = await workflow.previewCustomRule(draft)
XCTAssertNil(retry.failure)
}
func testIncompleteCollectionAndFailedRecoveryCannotSilentlyUseOldFacts() async throws {
let rule = CustomRule(action: .direct, match: .domainSuffix("old.example"))
let original = CustomRuleDocument(rules: [rule])
let outcome = CustomRuleUpdateOutcome.recoveryFailed(
detail: "fixture recovery error", rulesRestored: false)
let workflow = RulesWorkflow(
loadDocument: { original },
commitDocument: { RuleDocumentCommit(outcome: outcome, document: $0) },
loadBuiltin: { rulesFixture($0) })
XCTAssertNil(workflow.makeCustomRuleDraft())
await workflow.refresh()
var draft = try XCTUnwrap(workflow.makeCustomRuleDraft(editing: rule.id))
draft.content = "new.example"
let result = await workflow.saveCustomRule(draft)
XCTAssertEqual(result, .committed(outcome))
XCTAssertEqual(workflow.snapshot.commitOutcome, outcome)
XCTAssertEqual(
workflow.snapshot.rows.first { $0.customIDs.contains(rule.id) }?.content, "new.example")
let stale = await workflow.previewCustomRule(draft)
XCTAssertEqual(stale.failure, .staleDraft)
let incomplete = RulesWorkflow(
loadDocument: { throw CustomRuleStoreError.corrupt(detail: "fixture error") },
commitDocument: { RuleDocumentCommit(outcome: .saved, document: $0) },
loadBuiltin: { rulesFixture($0) })
await incomplete.refresh()
XCTAssertNil(incomplete.makeCustomRuleDraft())
let refused = await incomplete.saveCustomRule(draft)
XCTAssertEqual(refused, .unavailable(.incompleteCollection))
}
}
extension CustomRuleEditingWorkflowTests {
func testDisabledEditCannotInheritDisablementOntoImmutableFixedIdentity() async throws {
let original = CustomRule(action: .direct, match: .domainExact("disabled.example"))
var commits = 0
let workflow = RulesWorkflow(
loadDocument: {
CustomRuleDocument(rules: [original], disabledIdentities: [original.identity])
},
commitDocument: {
commits += 1
return RuleDocumentCommit(outcome: .saved, document: $0)
},
loadBuiltin: { rulesFixture($0) })
await workflow.refresh()
var draft = try XCTUnwrap(workflow.makeCustomRuleDraft(editing: original.id))
draft.kind = .cidr
draft.content = "127.0.0.0/8"
let preview = await workflow.previewCustomRule(draft)
XCTAssertEqual(preview.failure, .fixedPolicyDisablement)
XCTAssertNil(preview.document)
let result = await workflow.saveCustomRule(draft)
guard case .unavailable = result else { return XCTFail("Must reject fixed-policy disablement") }
XCTAssertEqual(commits, 0)
XCTAssertFalse(workflow.snapshot.rows.filter(\.isFixed).contains { !$0.isEnabled })
}
}
@@ -67,7 +67,7 @@ final class RuleEnablementWorkflowTests: XCTestCase {
XCTAssertEqual(workflow.snapshot.commitOutcome, .saved)
let feedback = try XCTUnwrap(workflow.snapshot.commitFeedback)
XCTAssertEqual(feedback.changedCount, 1)
XCTAssertFalse(feedback.enabled)
XCTAssertEqual(feedback.operation, .enablement(false))
XCTAssertEqual(workflow.snapshot.operationStatus, .feedback(feedback))
XCTAssertFalse(workflow.snapshot.rows.first { $0.identity == rule.identity }!.isEnabled)
}
@@ -52,7 +52,7 @@ final class RuleStatusWorkflowTests: XCTestCase {
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.operation, .enablement(true))
XCTAssertEqual(feedback.outcome, .applied)
XCTAssertTrue(workflow.snapshot.rows.first { $0.identity == disabled.identity }!.isEnabled)
await fulfillment(of: [timerStarted], timeout: 3)