feat(rules): add read-only rule browsing (#76)

This commit is contained in:
Qiu Yuzhou
2026-10-02 17:18:08 +08:00
parent 280a3fe6fe
commit 3db66f5fff
14 changed files with 1363 additions and 3 deletions
+2 -1
View File
@@ -51,7 +51,7 @@
- **System-level shortcut**: A user-configurable keyboard action registered by the app that can trigger an app action even when the app has no focus.
_Avoid_: global hotkey, global shortcut.
- **Window-local default action shortcut**: A keyboard action bound to the focused app window or form context, such as confirming a dialog; it is distinct from a system-level shortcut.
- **Workspace destination**: A top-level location in the single 2.0 GUI workspace, such as home, servers, subscriptions, settings, or diagnostics. It is navigation state only; it does not contain a node selection, sheet, alert, or feature draft.
- **Workspace destination**: A top-level location in the single 2.0 GUI workspace, such as home, servers, subscriptions, proxy rules, settings, or diagnostics. It is navigation state only; it does not contain a node selection, sheet, alert, or feature draft.
- **Workspace route**: The in-process navigation choice and intent used to move between workspace destinations from the workspace or another app entry point. It does not persist user configuration or own feature-local state.
- **Workspace window**: The single main window hosting the workspace. It is presented at every launch unless silent launch is on; while it is open the app presents in its regular form, and once it is closed the status menu is the only way to reopen it.
- **Adaptive app form**: The app's presentation, which follows the workspace window: a menu-bar-only accessory app with the status menu always available and no Dock icon or app-switcher presence while the workspace window is closed, and a regular app with a Dock icon while it is open. _Avoid_: menu bar form (the presentation is no longer constant).
@@ -102,5 +102,6 @@
- Preferences have no single restore-all-factory-defaults action. Where a reset exists, it is scoped to one configuration item, never to the whole preference set at once.
- The proxy mode selector's choice and the rule default action persist with the settings snapshot and survive GUI restarts; a persistence failure keeps the previous mode and sub-option in force and names the reason. Rule, global, and direct mode are always available.
- The GUI is the user-facing manager; the Shadowsocks tunnel service is an external runtime boundary rather than part of the GUI's domain model.
- The Proxy Rules destination provides read-only browsing through `RulesWorkflow`: it prepares a versioned collection off the UI actor from fixed local policy, all three built-in snapshots (including preserved expressible absorbed candidates), and the custom-rule document. Equivalent identities retain all source memberships and custom UUIDs. Search, source, and action filters intersect; sorting affects presentation only; hidden selections are cleared and navigation preserves session query/selection. Source metadata and original conversion reports remain distinct from current coverage explanations. Missing/corrupt inputs explicitly mark the collection incomplete while retaining available sources. This page has no add, edit, disable, delete, or address-test actions yet; it does not change runtime source selection.
- The app presents in the adaptive app form: menu-bar-only while the workspace window is closed, and with a Dock icon and app-switcher presence while it 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.
@@ -42,6 +42,7 @@ struct MainWindowView: View {
let diagnosticReportExporter: any DiagnosticReportExporter
let configurationGroupFileExporter: any ConfigurationGroupFileExporter
@StateObject private var rulesWorkflow = RulesWorkflow()
@StateObject private var homeServerList = HomeServerListState()
@State private var selection: NodeID?
/// 全局添加菜单打开的表单,以及诊断导出由窗口壳持有。
@@ -153,6 +154,10 @@ struct MainWindowView: View {
}
.disabled(workflow.subscriptions.isEmpty)
}
case .rules:
Button(RulesCopy.text("刷新"), systemImage: "arrow.clockwise") {
Task { await rulesWorkflow.refresh() }
}.disabled(rulesWorkflow.snapshot.isLoading)
case .diagnostics:
Button("导出诊断…", systemImage: "square.and.arrow.up") {
exportDiagnostics()
@@ -205,6 +210,8 @@ struct MainWindowView: View {
WorkspaceSubscriptionsView(
workflow: workflow,
onNodesRemoved: clearSelectionIfInvalidated)
case .rules:
RulesView(workflow: rulesWorkflow)
case .settings:
SettingsView(
workflow: settingsWorkflow, loginController: loginController,
@@ -231,6 +238,7 @@ extension WorkspaceDestination {
case .home: "首页"
case .servers: "服务器"
case .subscriptions: "订阅"
case .rules: RulesCopy.text("代理规则")
case .settings: "设置"
case .diagnostics: "诊断"
}
@@ -241,6 +249,7 @@ extension WorkspaceDestination {
case .home: "house"
case .servers: "server.rack"
case .subscriptions: "arrow.triangle.2.circlepath"
case .rules: "list.bullet.rectangle"
case .settings: "gearshape"
case .diagnostics: "chart.bar"
}
+369
View File
@@ -0,0 +1,369 @@
{
"sourceLanguage": "zh-Hans",
"strings": {
"代理规则": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Proxy Rules"
}
}
}
},
"全部规则": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "All Rules"
}
}
}
},
"自定义规则": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Custom Rules"
}
}
}
},
"固定本地策略": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Fixed Local Policy"
}
}
}
},
"搜索规则": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Search Rules"
}
}
}
},
"全部行动": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "All Actions"
}
}
}
},
"直连": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Direct"
}
}
}
},
"代理": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Proxy"
}
}
}
},
"匹配内容": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Match"
}
}
}
},
"类型": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Type"
}
}
}
},
"行动": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Action"
}
}
}
},
"来源": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Sources"
}
}
}
},
"状态": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Status"
}
}
}
},
"集合不完整": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Incomplete Collection"
}
}
}
},
"没有符合条件的规则": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "No Matching Rules"
}
}
}
},
"加载规则…": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Loading Rules…"
}
}
}
},
"刷新": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Refresh"
}
}
}
},
"覆盖详情": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Coverage Details"
}
}
}
},
"被同行动规则覆盖": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Covered by Same-Action Rules"
}
}
}
},
"被相反行动规则遮蔽": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Shadowed by Opposite-Action Rules"
}
}
}
},
"部分重叠": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Partial Overlap"
}
}
}
},
"独立候选": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Independent Candidate"
}
}
}
},
"快照制作时的转换报告": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Conversion Report at Snapshot Creation"
}
}
}
},
"精确域名": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Exact Domain"
}
}
}
},
"域名后缀": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Domain Suffix"
}
}
}
},
"简单主机名": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Simple Hostname"
}
}
}
},
"固定策略优先,以下范围始终直连。": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Fixed policy takes precedence; these targets always connect directly."
}
}
}
},
"覆盖分析仅描述当前规则集合,不表示运行时已生效。": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Coverage analysis describes this rule collection; it does not indicate runtime activation."
}
}
}
},
"集合不完整时,覆盖解释仅基于已加载来源。": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "When the collection is incomplete, coverage explanations use only loaded sources."
}
}
}
},
"已转换": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Converted"
}
}
}
},
"已吸收": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Absorbed"
}
}
}
},
"已跳过": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Skipped"
}
}
}
},
"已拒绝": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Rejected"
}
}
}
}
},
"version": "1.0"
}
@@ -0,0 +1,30 @@
import SwiftUI
struct RulesSourceDetailsView: View {
let source: RulesSourceSnapshot
var body: some View {
VStack(alignment: .leading, spacing: 3) {
if let metadata = source.metadata {
Text(verbatim: "\(source.id.label) · \(metadata.source.upstreamVersion)")
Text(verbatim: metadata.upstreamReference)
Text(verbatim: "\(metadata.license) · \(metadata.attribution)")
if let report = source.conversionReport {
DisclosureGroup(RulesCopy.text("快照制作时的转换报告")) {
Text(
verbatim:
"\(RulesCopy.text("已转换")): \(report.convertedCount), \(RulesCopy.text("已吸收")): \(report.absorbedCount)"
)
Text(verbatim: "\(RulesCopy.text("已跳过")): \(counts(report.skipped))")
Text(verbatim: "\(RulesCopy.text("已拒绝")): \(counts(report.rejected))")
ForEach(report.notes, id: \.self) { Text(verbatim: $0) }
}
}
}
}.font(.caption).textSelection(.enabled)
}
private func counts(_ values: [String: Int]) -> String {
values.sorted { $0.key < $1.key }.map { "\($0.key): \($0.value)" }.joined(separator: ", ")
}
}
+240
View File
@@ -0,0 +1,240 @@
import SwiftUI
/// Read-only first delivery. Every interaction goes through the workflow; no
/// filesystem, runtime control, or placeholder mutation actions live here.
struct RulesView: View {
@ObservedObject var workflow: RulesWorkflow
var body: some View {
VStack(spacing: 0) {
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()
}
HSplitView {
sourceList.frame(minWidth: 160, idealWidth: 180, maxWidth: 240)
VStack(spacing: 0) {
filters.padding()
if let source = workflow.snapshot.sources.first(where: {
$0.id == workflow.snapshot.query.source
}) {
RulesSourceDetailsView(source: source).padding(.horizontal)
}
ruleTable
if workflow.snapshot.rows.isEmpty && !workflow.snapshot.isLoading {
ContentUnavailableView(
RulesCopy.text("没有符合条件的规则"), systemImage: "line.3.horizontal.decrease.circle")
}
if let row = workflow.snapshot.rows.first(where: {
workflow.snapshot.selection.contains($0.id)
}) {
Divider()
RulesDetailsView(row: row, sources: workflow.snapshot.sources)
.frame(minHeight: 110, idealHeight: 180, maxHeight: 260)
}
}.frame(minWidth: 500)
}
}
.task {
if workflow.snapshot.version.isEmpty { await workflow.refresh() }
}
}
private var sourceList: some View {
List(selection: sourceBinding) {
Text(RulesCopy.text("全部规则")).tag(RulesSourceChoice.all)
ForEach(RulesSource.allCases) { source in
HStack {
Text(source.label)
Spacer()
if let metadata = workflow.snapshot.sources.first(where: { $0.id == source }) {
Text(metadata.count, format: .number).foregroundStyle(.secondary)
} else if !workflow.snapshot.isLoading && !workflow.snapshot.version.isEmpty {
Image(systemName: "exclamationmark.triangle").foregroundStyle(.secondary)
}
}.tag(RulesSourceChoice.source(source))
}
}
}
private var filters: some View {
HStack {
TextField(
RulesCopy.text("搜索规则"),
text: Binding(
get: { workflow.snapshot.query.search },
set: { value in updateQuery { $0.search = value } })
)
.textFieldStyle(.roundedBorder)
Picker(
RulesCopy.text("行动"),
selection: Binding(
get: { workflow.snapshot.query.action },
set: { value in updateQuery { $0.action = value } })
) {
Text(RulesCopy.text("全部行动")).tag(Optional<RuleAction>.none)
Text(RulesCopy.text("直连")).tag(Optional(RuleAction.direct))
Text(RulesCopy.text("代理")).tag(Optional(RuleAction.proxy))
}.labelsHidden().fixedSize()
Picker(
RulesCopy.text("匹配内容"),
selection: Binding(
get: { workflow.snapshot.query.sort },
set: { value in updateQuery { $0.sort = value } })
) {
Text("A → Z").tag(RulesQuery.Sort.ascending)
Text("Z → A").tag(RulesQuery.Sort.descending)
}.labelsHidden().fixedSize()
}
}
private var ruleTable: some View {
Table(
workflow.snapshot.rows,
selection: Binding(
get: { workflow.snapshot.selection }, set: { workflow.select($0) })
) {
TableColumn(RulesCopy.text("匹配内容")) { row in Text(verbatim: row.displayContent) }
.width(min: 170, ideal: 260)
TableColumn(RulesCopy.text("类型")) { row in Text(verbatim: row.matchType) }
TableColumn(RulesCopy.text("行动")) { row in Text(row.actionLabel) }
TableColumn(RulesCopy.text("来源")) { row in Text(row.sourceLabels) }
TableColumn(RulesCopy.text("状态")) { row in Text(row.statusLabel) }
}
}
private var sourceBinding: Binding<RulesSourceChoice?> {
Binding(
get: {
workflow.snapshot.query.source.map(RulesSourceChoice.source) ?? .all
},
set: { choice in
updateQuery {
if case .source(let source) = choice { $0.source = source } else { $0.source = nil }
}
})
}
private func updateQuery(_ update: (inout RulesQuery) -> Void) {
var query = workflow.snapshot.query
update(&query)
workflow.query(query)
}
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 RulesDetailsView: View {
let row: RulesRow
let sources: [RulesSourceSnapshot]
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 8) {
Text(verbatim: row.displayContent).font(.headline)
Text(row.sourceLabels)
Text(row.statusLabel)
ForEach(row.relationships, id: \.kind) { relationship in
VStack(alignment: .leading) {
Text(relationship.browsingLabel)
ForEach(relationship.covering, id: \.self) { identity in
Text(
verbatim: identity.match.browsingContent + " · "
+ RulesCopy.text(identity.action == .direct ? "直连" : "代理"))
}
}
}
if let coverage = row.fixedCoverage {
Text(RulesCopy.text("固定本地策略"))
ForEach(coverage.matches, id: \.self) { match in
Text(verbatim: match.browsingContent)
}
if coverage.includesSimpleHostname { Text(RulesCopy.text("简单主机名")) }
Text(RulesCopy.text("固定策略优先,以下范围始终直连。"))
}
if row.isFixed {
Text(RulesCopy.text("固定策略优先,以下范围始终直连。"))
} else {
Text(RulesCopy.text("覆盖分析仅描述当前规则集合,不表示运行时已生效。"))
}
ForEach(sources.filter { row.sources.contains($0.id) }) { source in
RulesSourceDetailsView(source: source)
}
}.frame(maxWidth: .infinity, alignment: .leading).padding().textSelection(.enabled)
}
}
}
enum RulesCopy {
static func text(_ key: String) -> String {
NSLocalizedString(key, tableName: "Rules", bundle: .main, value: key, comment: "")
}
}
extension RulesSource {
var label: String {
switch self {
case .custom: RulesCopy.text("自定义规则")
case .fixed: RulesCopy.text("固定本地策略")
case .geolocationCN: "geolocation-cn"
case .chinaIPv4: "china-ipv4"
case .gfwlist: "GFWList"
}
}
}
extension RulesRow {
var displayContent: String { identity == nil ? RulesCopy.text("简单主机名") : content }
var sourceLabels: String {
RulesSource.allCases.filter { sources.contains($0) }.map(\.label).joined(separator: ", ")
}
var actionLabel: String { RulesCopy.text(action == .direct ? "直连" : "代理") }
var matchType: String {
switch identity?.match {
case .domainExact: RulesCopy.text("精确域名")
case .domainSuffix: RulesCopy.text("域名后缀")
case .ipv4CIDR: "IPv4 CIDR"
case .ipv6CIDR: "IPv6 CIDR"
case nil: RulesCopy.text("简单主机名")
}
}
var statusLabel: String {
if isFixed { return RulesCopy.text("固定本地策略") }
var labels = relationships.map(\.browsingLabel)
if let fixedCoverage {
let label =
RulesCopy.text(action == .direct ? "被同行动规则覆盖" : "被相反行动规则遮蔽")
+ " · " + RulesCopy.text("固定本地策略")
labels.append(
fixedCoverage.extent == .partial ? label + " · " + RulesCopy.text("部分重叠") : label)
}
return labels.isEmpty ? RulesCopy.text("独立候选") : labels.joined(separator: "; ")
}
}
private enum RulesSourceChoice: Hashable {
case all
case source(RulesSource)
}
extension RuleRelationship {
var browsingLabel: String {
let label = RulesCopy.text(kind == .absorption ? "被同行动规则覆盖" : "被相反行动规则遮蔽")
return extent == .partial ? label + " · " + RulesCopy.text("部分重叠") : label
}
}
+73
View File
@@ -0,0 +1,73 @@
import Combine
import Foundation
/// File reads and collection preparation run outside the UI actor; queries use
/// the prepared collection and do not rebuild semantic analysis on each keypress.
@MainActor
final class RulesWorkflow: ObservableObject {
@Published private(set) var snapshot = RulesPageSnapshot()
private let loadCustom: @Sendable () throws -> [CustomRule]
private let loadBuiltin: @Sendable (RulesSource) throws -> RuleSnapshot
private var collection: RulesCollection?
private var refreshGeneration = 0
init(
loadCustom: @escaping @Sendable () throws -> [CustomRule] = { try CustomRuleStore().load() },
loadBuiltin: @escaping @Sendable (RulesSource) throws -> RuleSnapshot = { source in
switch source {
case .geolocationCN: try BuiltinRuleCatalog.loadGeolocationCN()
case .chinaIPv4: try BuiltinRuleCatalog.loadChinaIPv4()
case .gfwlist: try BuiltinRuleCatalog.loadGFWList()
case .custom, .fixed: throw RuleSnapshotError.missing
}
}
) {
self.loadCustom = loadCustom
self.loadBuiltin = loadBuiltin
}
func refresh() async {
refreshGeneration += 1
let generation = refreshGeneration
snapshot.isLoading = true
let custom = loadCustom
let builtin = loadBuiltin
let result = await Task.detached(priority: .userInitiated) {
RulesCollection.load(custom: custom, builtin: builtin)
}.value
guard generation == refreshGeneration else { return }
collection = result
var next = snapshot
next.version = result.version
next.sources = result.sources
next.issues = result.issues
next.isLoading = false
snapshot = applyingQuery(to: next)
}
func query(_ query: RulesQuery) {
var next = snapshot
next.query = query
snapshot = applyingQuery(to: next)
}
func select(_ ids: Set<RulesRow.SelectionID>) {
snapshot.selection = ids.intersection(Set(snapshot.rows.map(\.id)))
}
private func applyingQuery(to page: RulesPageSnapshot) -> RulesPageSnapshot {
guard let collection else { return page }
let query = page.query
let search = query.search.lowercased()
var rows = collection.rows.filter { row in
(query.action == nil || row.action == query.action)
&& (query.source.map { row.sources.contains($0) } ?? true)
&& (search.isEmpty || row.content.lowercased().contains(search))
}
if query.sort == .descending { rows.reverse() }
var next = page
next.rows = rows
next.selection.formIntersection(Set(rows.map(\.id)))
return next
}
}
@@ -6,6 +6,7 @@ enum WorkspaceDestination: String, CaseIterable, Hashable, Identifiable, Sendabl
case home
case servers
case subscriptions
case rules
case settings
case diagnostics
+38 -1
View File
@@ -2,7 +2,7 @@ import Foundation
/// Coverage is explanatory, never a persistence rejection or a routing priority.
struct RuleRelationship: Equatable, Sendable {
enum Kind: Sendable { case absorption, shadowing }
enum Kind: Hashable, Sendable { case absorption, shadowing }
enum Extent: Sendable { case full, partial }
let rule: RuleIdentity
let kind: Kind
@@ -51,3 +51,40 @@ struct RuleAnalysis: Equatable, Sendable {
}
}
}
/// Fixed policy includes a semantic simple-hostname condition, not an editable
/// regex identity. Coverage keeps that condition separate from ordinary rules.
struct FixedRuleCoverage: Equatable, Sendable {
let extent: RuleRelationship.Extent
let matches: [RuleMatch]
let includesSimpleHostname: Bool
}
extension RuleCoverage {
static let fixedLocalMatches: [RuleMatch] = {
FixedLocalProxyRanges.ipRanges.compactMap { value in
value.contains(":") ? try? RuleMatch(ipv6CIDR: value) : try? RuleMatch(ipv4CIDR: value)
} + [.domainSuffix("localhost"), .domainSuffix("local")]
}()
static func fixedLocalCoverage(of match: RuleMatch) -> FixedRuleCoverage? {
let matches = fixedLocalMatches.filter { intersects($0, match) }
let simple: Bool
let simpleIsFull: Bool
switch match {
case .domainExact(let value):
simple = !value.contains(".")
simpleIsFull = simple
case .domainSuffix(let value):
simple = !value.contains(".")
simpleIsFull = false
case .ipv4CIDR, .ipv6CIDR:
simple = false
simpleIsFull = false
}
guard simple || !matches.isEmpty else { return nil }
return FixedRuleCoverage(
extent: simpleIsFull || fullyCovered(match, by: matches) ? .full : .partial,
matches: matches, includesSimpleHostname: simple)
}
}
@@ -0,0 +1,53 @@
import Foundation
/// Session browsing state belongs to the workflow, so navigation never discards it.
struct RulesQuery: Equatable, Sendable {
enum Sort: String, CaseIterable, Sendable { case ascending, descending }
var search = ""
var action: RuleAction?
var source: RulesSource?
var sort: Sort = .ascending
}
enum RulesSource: String, CaseIterable, Identifiable, Sendable {
case custom, geolocationCN, chinaIPv4, gfwlist, fixed
var id: Self { self }
}
struct RulesSourceSnapshot: Equatable, Identifiable, Sendable {
let id: RulesSource
let count: Int
let metadata: RuleSnapshotMetadata?
let conversionReport: RuleConversionLossReport?
}
struct RulesRow: Equatable, Identifiable, Sendable {
enum SelectionID: Hashable, Sendable {
case rule(RuleIdentity)
case noDotHostname
}
let id: SelectionID
let identity: RuleIdentity?
let content: String
let sources: Set<RulesSource>
let customIDs: Set<UUID>
let relationships: [RuleRelationship]
let fixedCoverage: FixedRuleCoverage?
var isFixed: Bool { sources.contains(.fixed) }
var action: RuleAction { identity?.action ?? .direct }
}
struct RulesPageSnapshot: Equatable, Sendable {
enum Issue: Equatable, Hashable, Sendable {
case userDocument(String)
case builtin(RulesSource, String)
}
var version = ""
var isLoading = false
var issues: [Issue] = []
var sources: [RulesSourceSnapshot] = []
var rows: [RulesRow] = []
var selection: Set<RulesRow.SelectionID> = []
var query = RulesQuery()
var isComplete: Bool { !isLoading && issues.isEmpty && !version.isEmpty }
}
@@ -0,0 +1,160 @@
import Foundation
/// Browsing includes preserved expressible candidates without changing runtime sources.
struct RulesCollection: Sendable {
let version: String
let rows: [RulesRow]
let sources: [RulesSourceSnapshot]
let issues: [RulesPageSnapshot.Issue]
static func load(
custom loadCustom: () throws -> [CustomRule],
builtin loadBuiltin: (RulesSource) throws -> RuleSnapshot
) -> RulesCollection {
var input = RulesCollectionInput()
input.readCustom(loadCustom)
for source in [RulesSource.geolocationCN, .chinaIPv4, .gfwlist] {
input.readBuiltin(source, using: loadBuiltin)
}
return input.collection()
}
}
private struct RulesCandidate {
let rule: ProxyRule
let source: RulesSource
let customID: UUID?
}
private struct RulesCollectionInput {
var entries: [RulesCandidate] = []
var sources: [RulesSourceSnapshot] = []
var issues: [RulesPageSnapshot.Issue] = []
var metadataTokens: [String] = []
mutating func readCustom(_ loadCustom: () throws -> [CustomRule]) {
do {
let custom = try loadCustom()
let validation = CustomRuleValidator.validate(
custom: custom, defaultAction: .directWhenUnmatched)
guard validation.rejected.isEmpty else {
throw CustomRuleStoreError.corrupt(detail: "Invalid custom rule collection")
}
entries += custom.map { RulesCandidate(rule: $0.proxyRule, source: .custom, customID: $0.id) }
sources.append(
RulesSourceSnapshot(id: .custom, count: custom.count, metadata: nil, conversionReport: nil))
} catch {
issues.append(.userDocument(String(describing: error)))
}
}
mutating func readBuiltin(
_ source: RulesSource, using loadBuiltin: (RulesSource) throws -> RuleSnapshot
) {
do {
let snapshot = try loadBuiltin(source)
let expected: RuleSourceKind
switch source {
case .geolocationCN: expected = .geolocationCN
case .chinaIPv4: expected = .chinaIPv4
case .gfwlist: expected = .gfwlist
case .custom, .fixed: expected = .custom
}
guard snapshot.metadata.source.kind == expected,
(snapshot.rules + snapshot.absorbed).allSatisfy({ $0.source.kind == expected })
else { throw RuleSnapshotError.corrupt(detail: "Unexpected rule source") }
let candidates = snapshot.rules + snapshot.absorbed
entries += candidates.map { RulesCandidate(rule: $0, source: source, customID: nil) }
sources.append(
RulesSourceSnapshot(
id: source, count: candidates.count,
metadata: snapshot.metadata, conversionReport: snapshot.lossReport))
// Metadata changes invalidate details even when rule content is unchanged.
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
metadataTokens.append(
source.rawValue + ProxyACLDocument.digest(try encoder.encode(snapshot.metadata)))
metadataTokens.append(
ProxyACLDocument.digest(try encoder.encode(snapshot.lossReport)))
} catch {
issues.append(.builtin(source, String(describing: error)))
}
}
mutating func collection() -> RulesCollection {
let fixedSource = RuleSourceIdentity(kind: .custom, upstreamVersion: "fixed", label: "fixed")
let fixedMatches = RuleCoverage.fixedLocalMatches
entries += fixedMatches.map {
RulesCandidate(
rule: ProxyRule(action: .direct, match: $0, source: fixedSource), source: .fixed,
customID: nil)
}
sources.append(
RulesSourceSnapshot(
id: .fixed, count: fixedMatches.count + 1, metadata: nil, conversionReport: nil))
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
// cover or shadow another candidate in the effective collection.
let effective = rules.filter {
$0.action != .proxy || RuleCoverage.fixedLocalCoverage(of: $0.identity.match)?.extent != .full
}
let index = RulesOverlapIndex(rules: effective)
var rows = grouped.map { identity, entries in
row(identity: identity, entries: entries, index: index)
}
rows.append(
RulesRow(
id: .noDotHostname, identity: nil, content: "^[^.]+$", sources: [.fixed], customIDs: [],
relationships: [], fixedCoverage: nil))
rows.sort {
($0.content, $0.action.rawValue, $0.identity?.contentToken ?? "")
< ($1.content, $1.action.rawValue, $1.identity?.contentToken ?? "")
}
let tokens =
rows.map { row in
(row.identity?.contentToken ?? "fixed:no-dot") + "|"
+ row.sources.map(\.rawValue).sorted().joined(separator: ",")
+ "|" + row.customIDs.map(\.uuidString).sorted().joined(separator: ",")
} + metadataTokens.sorted() + issues.map { String(describing: $0) }
return RulesCollection(
version: String(ProxyACLDocument.digest(tokens.sorted().joined(separator: "\n")).prefix(16)),
rows: rows, sources: sources, issues: issues)
}
private func row(
identity: RuleIdentity, entries: [RulesCandidate], index: RulesOverlapIndex
) -> 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
let fixedCoverage =
memberships.contains(.fixed) ? nil : RuleCoverage.fixedLocalCoverage(of: identity.match)
if identity.action == .direct, let fixedCoverage {
relationships = relationships.compactMap { relationship in
guard relationship.kind == .shadowing else { return relationship }
guard fixedCoverage.extent == .partial else { return nil }
return RuleRelationship(
rule: identity, kind: .shadowing, extent: .partial,
covering: relationship.covering)
}
}
return RulesRow(
id: .rule(identity), identity: identity, content: identity.match.browsingContent,
sources: memberships, customIDs: Set(entries.compactMap { $0.customID }),
relationships: relationships, fixedCoverage: fixedCoverage)
}
}
extension RuleMatch {
var browsingContent: String {
switch self {
case .domainExact(let value), .domainSuffix(let value), .ipv4CIDR(let value),
.ipv6CIDR(let value):
value
}
}
}
@@ -0,0 +1,55 @@
import Foundation
/// Prefix trees restrict explanatory analysis to intersecting rules. Domain
/// labels and IP bits share the same tree mechanics; RuleCoverage remains the
/// authority for intersection, containment, and mapped-address semantics.
struct RulesOverlapIndex {
private final class Node {
var rules: [ProxyRule] = []
var children: [String: Node] = [:]
func descendants() -> [ProxyRule] { rules + children.values.flatMap { $0.descendants() } }
}
private let root = Node()
init(rules: [ProxyRule]) {
for rule in rules {
var node = root
for component in Self.path(rule.identity.match) {
if node.children[component] == nil { node.children[component] = Node() }
node = node.children[component]!
}
node.rules.append(rule)
}
}
func overlapping(_ match: RuleMatch) -> [ProxyRule] {
var node = root
var candidates = node.rules
for component in Self.path(match) {
guard let next = node.children[component] else {
return candidates.filter { RuleCoverage.intersects($0.identity.match, match) }
}
node = next
candidates += node.rules
}
candidates += node.children.values.flatMap { $0.descendants() }
return candidates.filter { RuleCoverage.intersects($0.identity.match, match) }
}
private static func path(_ match: RuleMatch) -> [String] {
switch match {
case .domainExact(let value), .domainSuffix(let value):
return ["domain"] + value.split(separator: ".").reversed().map(String.init)
case .ipv4CIDR, .ipv6CIDR:
guard let cidr = RuleCoverage.parseCIDR(match) else { return [] }
let bytes =
cidr.family == .ipv4
? Array(repeating: UInt8(0), count: 10) + [255, 255] + cidr.bytes : cidr.bytes
let prefix = cidr.prefixLength + (cidr.family == .ipv4 ? 96 : 0)
return ["ip"]
+ (0..<prefix).map { bit in
bytes[bit / 8] & (UInt8(1) << (7 - bit % 8)) == 0 ? "0" : "1"
}
}
}
}
@@ -0,0 +1,93 @@
import XCTest
@testable import ShadowsocksX_NG2
@MainActor
final class RulesWorkflowCoverageTests: XCTestCase {
func testCoverageUsesAllSourcesAndDistinguishesPartialFromFull() async throws {
let broad = CustomRule(action: .direct, match: try RuleMatch(domainSuffix: "example.com"))
let narrow = CustomRule(action: .proxy, match: try RuleMatch(domainExact: "www.example.com"))
let duplicate = CustomRule(
action: .direct, match: try RuleMatch(domainExact: "www.example.com"))
let workflow = RulesWorkflow(
loadCustom: { [broad, narrow, duplicate] }, loadBuiltin: { rulesFixture($0) })
await workflow.refresh()
workflow.query(RulesQuery(action: .direct, source: .custom))
let broadRow = try XCTUnwrap(workflow.snapshot.rows.first { $0.identity == broad.identity })
XCTAssertTrue(
broadRow.relationships.contains {
$0.kind == .shadowing && $0.extent == .partial && $0.covering == [narrow.identity]
})
let narrowRow = try XCTUnwrap(
workflow.snapshot.rows.first { $0.identity == duplicate.identity })
XCTAssertTrue(narrowRow.relationships.contains { $0.kind == .shadowing && $0.extent == .full })
XCTAssertTrue(narrowRow.relationships.contains { $0.kind == .absorption && $0.extent == .full })
XCTAssertTrue(workflow.snapshot.rows.filter(\.isFixed).allSatisfy { $0.relationships.isEmpty })
}
func testMappedIPAndCombinedNarrowRangesExplainCoverageThroughWorkflow() async throws {
let proxy = CustomRule(action: .proxy, match: try RuleMatch(ipv6CIDR: "::ffff:8.8.8.0/120"))
let lower = CustomRule(action: .direct, match: try RuleMatch(ipv4CIDR: "8.8.8.0/25"))
let upper = CustomRule(action: .direct, match: try RuleMatch(ipv4CIDR: "8.8.8.128/25"))
let workflow = RulesWorkflow(
loadCustom: { [proxy, lower, upper] }, loadBuiltin: { rulesFixture($0) })
await workflow.refresh()
workflow.query(RulesQuery(action: .proxy, source: .custom))
let row = try XCTUnwrap(workflow.snapshot.rows.first)
XCTAssertEqual(row.identity, proxy.identity)
XCTAssertEqual(row.relationships.count, 1)
XCTAssertEqual(row.relationships.first?.kind, .shadowing)
XCTAssertEqual(row.relationships.first?.extent, .full)
XCTAssertEqual(Set(row.relationships.first?.covering ?? []), [lower.identity, upper.identity])
}
func testFixedProtectionExplainsSimpleHostsAndPreventsFalseLocalShadowing() async throws {
let source = RuleSourceIdentity(kind: .gfwlist, upstreamVersion: "v1", label: "gfwlist")
let simple = ProxyRule(
action: .proxy, match: try RuleMatch(domainExact: "printer"), source: source)
let simpleSuffix = ProxyRule(action: .proxy, match: .domainSuffix("cn"), source: source)
let localProxy = ProxyRule(
action: .proxy, match: try RuleMatch(domainExact: "device.local"), source: source)
let localDirect = CustomRule(action: .direct, match: try RuleMatch(domainExact: "device.local"))
let nationalDirect = CustomRule(action: .direct, match: .domainSuffix("cn"))
let workflow = RulesWorkflow(
loadCustom: { [localDirect, nationalDirect] },
loadBuiltin: { requested in
rulesFixture(
requested, rules: requested == .gfwlist ? [simple, simpleSuffix, localProxy] : [])
})
await workflow.refresh()
workflow.query(RulesQuery(source: .gfwlist))
let simpleRow = try XCTUnwrap(workflow.snapshot.rows.first { $0.identity == simple.identity })
XCTAssertEqual(simpleRow.fixedCoverage?.extent, .full)
let suffixRow = try XCTUnwrap(
workflow.snapshot.rows.first { $0.identity == simpleSuffix.identity })
XCTAssertEqual(suffixRow.fixedCoverage?.extent, .partial)
workflow.query(RulesQuery(source: .custom))
let localRow = try XCTUnwrap(
workflow.snapshot.rows.first { $0.identity == localDirect.identity })
XCTAssertEqual(localRow.fixedCoverage?.extent, .full)
XCTAssertFalse(localRow.relationships.contains { $0.kind == .shadowing })
let nationalRow = try XCTUnwrap(
workflow.snapshot.rows.first { $0.identity == nationalDirect.identity })
XCTAssertEqual(nationalRow.fixedCoverage?.extent, .partial)
XCTAssertTrue(
nationalRow.relationships.contains { $0.kind == .shadowing && $0.extent == .partial })
}
func testFullyFixedProtectedProxyDoesNotShadowAnUnprotectedDirectSuffix() async throws {
let direct = CustomRule(action: .direct, match: .domainSuffix("cn"))
let source = RuleSourceIdentity(kind: .gfwlist, upstreamVersion: "v1", label: "gfwlist")
let proxy = ProxyRule(action: .proxy, match: try RuleMatch(domainExact: "cn"), source: source)
let workflow = RulesWorkflow(
loadCustom: { [direct] },
loadBuiltin: { requested in
rulesFixture(requested, rules: requested == .gfwlist ? [proxy] : [])
})
await workflow.refresh()
workflow.query(RulesQuery(source: .custom))
let row = try XCTUnwrap(workflow.snapshot.rows.first)
XCTAssertFalse(row.relationships.contains { $0.kind == .shadowing })
XCTAssertEqual(row.fixedCoverage?.extent, .partial)
}
}
@@ -0,0 +1,239 @@
import XCTest
@testable import ShadowsocksX_NG2
@MainActor
final class RulesWorkflowTests: XCTestCase {
func testEquivalentSourcesMergeAndAbsorbedCandidatesRemainBrowsable() async throws {
let match = try RuleMatch(domainExact: "Example.COM")
let custom = CustomRule(action: .direct, match: match)
let source = RuleSourceIdentity(kind: .geolocationCN, upstreamVersion: "fixture", label: "geo")
let snapshot = RuleSnapshot(
metadata: RuleSnapshotMetadata(
source: source, upstreamReference: "upstream", inputDigest: "digest",
fetchedAt: Date(timeIntervalSince1970: 0), license: "MIT", attribution: "author"),
rules: [], absorbed: [ProxyRule(action: .direct, match: match, source: source)])
let workflow = RulesWorkflow(
loadCustom: { [custom] },
loadBuiltin: { source in source == .geolocationCN ? snapshot : rulesFixture(source) })
await workflow.refresh()
workflow.query(RulesQuery(search: "EXAMPLE", action: .direct, source: .geolocationCN))
let row = try XCTUnwrap(workflow.snapshot.rows.first)
XCTAssertEqual(workflow.snapshot.rows.count, 1)
XCTAssertTrue(row.sources.contains(.custom))
XCTAssertTrue(row.sources.contains(.geolocationCN))
XCTAssertEqual(row.customIDs, [custom.id])
XCTAssertEqual(row.identity, custom.identity)
}
func testWrongBuiltinSourceIsAnIncompleteCollectionRatherThanMislabeledRules() async {
let workflow = RulesWorkflow(
loadCustom: { [] },
loadBuiltin: { _ in
rulesFixture(.geolocationCN)
})
await workflow.refresh()
XCTAssertFalse(workflow.snapshot.isComplete)
XCTAssertEqual(workflow.snapshot.issues.count, 2)
}
func testSearchActionAndSourceIntersectAndHiddenSelectionIsCleared() async throws {
let direct = CustomRule(action: .direct, match: try RuleMatch(domainSuffix: "example.com"))
let proxy = CustomRule(action: .proxy, match: try RuleMatch(domainExact: "outside.net"))
let workflow = RulesWorkflow(
loadCustom: { [direct, proxy] }, loadBuiltin: { rulesFixture($0) })
await workflow.refresh()
workflow.select([.rule(direct.identity), .rule(proxy.identity), .noDotHostname])
workflow.query(RulesQuery(search: "EXAMPLE", action: .direct, source: .custom))
XCTAssertEqual(workflow.snapshot.rows.map(\.identity), [direct.identity])
XCTAssertEqual(workflow.snapshot.selection, [.rule(direct.identity)])
workflow.query(RulesQuery(search: "EXAMPLE", action: .proxy, source: .custom))
XCTAssertTrue(workflow.snapshot.rows.isEmpty)
XCTAssertTrue(workflow.snapshot.selection.isEmpty)
XCTAssertTrue(workflow.snapshot.isComplete)
}
func testSortingAndNavigationPreserveVersionAndSessionQuery() async throws {
let entries = [
CustomRule(action: .proxy, match: try RuleMatch(domainExact: "z.net")),
CustomRule(action: .direct, match: try RuleMatch(domainExact: "a.net")),
]
let workflow = RulesWorkflow(loadCustom: { entries }, loadBuiltin: { rulesFixture($0) })
await workflow.refresh()
let version = workflow.snapshot.version
workflow.query(RulesQuery(source: .custom, sort: .descending))
workflow.select([.rule(entries[0].identity)])
let route = WorkspaceRoute()
route.navigate(to: .rules)
route.navigate(to: .home)
route.navigate(to: .rules)
XCTAssertEqual(workflow.snapshot.rows.map(\.content), ["z.net", "a.net"])
XCTAssertEqual(workflow.snapshot.query.source, .custom)
XCTAssertEqual(workflow.snapshot.selection, [.rule(entries[0].identity)])
await workflow.refresh()
XCTAssertEqual(workflow.snapshot.version, version)
XCTAssertEqual(workflow.snapshot.selection, [.rule(entries[0].identity)])
}
func testCorruptUserDocumentAndMissingBuiltinStayDistinctFromEmptySearch() async {
let workflow = RulesWorkflow(
loadCustom: { throw CustomRuleStoreError.corrupt(detail: "fixture") },
loadBuiltin: { source in
if source == .chinaIPv4 { throw RuleSnapshotError.missing }
return rulesFixture(source)
})
await workflow.refresh()
XCTAssertFalse(workflow.snapshot.isComplete)
XCTAssertEqual(
workflow.snapshot.issues,
[.userDocument("corrupt(detail: \"fixture\")"), .builtin(.chinaIPv4, "missing")])
XCTAssertTrue(workflow.snapshot.rows.contains { $0.isFixed })
workflow.query(RulesQuery(search: "no-result.example"))
XCTAssertTrue(workflow.snapshot.rows.isEmpty)
XCTAssertFalse(workflow.snapshot.isComplete)
XCTAssertEqual(workflow.snapshot.issues.count, 2)
}
func testPackagedCollectionLoadsMetadataAbsorbedRulesAndFixedSemanticPolicy() async throws {
let bundle = AppArtifact.bundle
let workflow = RulesWorkflow(
loadCustom: { [] },
loadBuiltin: { source in
switch source {
case .geolocationCN: try BuiltinRuleCatalog.loadGeolocationCN(from: bundle)
case .chinaIPv4: try BuiltinRuleCatalog.loadChinaIPv4(from: bundle)
case .gfwlist: try BuiltinRuleCatalog.loadGFWList(from: bundle)
case .custom, .fixed: throw RuleSnapshotError.missing
}
})
await workflow.refresh()
XCTAssertTrue(workflow.snapshot.isComplete, "\(workflow.snapshot.issues)")
XCTAssertEqual(workflow.snapshot.sources.count, 5)
XCTAssertTrue(workflow.snapshot.rows.contains { $0.id == .noDotHostname && $0.isFixed })
let geo = try XCTUnwrap(workflow.snapshot.sources.first { $0.id == .geolocationCN })
XCTAssertEqual(geo.count, 5379)
XCTAssertEqual(geo.metadata?.source.upstreamVersion, "20260925234224")
XCTAssertNotNil(geo.conversionReport)
workflow.query(RulesQuery(source: .geolocationCN))
XCTAssertTrue(workflow.snapshot.rows.contains { $0.content == "cn" })
XCTAssertGreaterThan(workflow.snapshot.rows.count, 4241)
}
func testRefreshPublishesVersionAndRowsTogetherAndInvalidatesRemovedSelection() 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 old = CustomRule(action: .direct, match: try RuleMatch(domainExact: "old.net"))
let new = CustomRule(action: .proxy, match: try RuleMatch(domainExact: "new.net"))
try store.save([old])
let workflow = RulesWorkflow(
loadCustom: { try store.load() }, loadBuiltin: { rulesFixture($0) })
await workflow.refresh()
workflow.query(RulesQuery(source: .custom))
workflow.select([.rule(old.identity)])
let oldVersion = workflow.snapshot.version
try store.save([new])
let observation = workflow.$snapshot.sink { snapshot in
if snapshot.version != oldVersion {
XCTAssertEqual(snapshot.rows.map(\.content), ["new.net"])
XCTAssertTrue(snapshot.selection.isEmpty)
}
}
await workflow.refresh()
observation.cancel()
XCTAssertNotEqual(workflow.snapshot.version, oldVersion)
XCTAssertEqual(workflow.snapshot.rows.map(\.identity), [new.identity])
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)
}
func testLateOldRefreshCannotReplaceTheNewCollection() async throws {
let firstRead = XCTestExpectation(description: "first read")
let barrier = RulesReadBarrier(firstRead: firstRead)
let workflow = RulesWorkflow(
loadCustom: { try barrier.read() }, loadBuiltin: { rulesFixture($0) })
let older = Task { await workflow.refresh() }
await fulfillment(of: [firstRead], timeout: 3)
defer { barrier.release.signal() }
XCTAssertTrue(workflow.snapshot.isLoading)
await workflow.refresh()
workflow.query(RulesQuery(source: .custom))
XCTAssertEqual(workflow.snapshot.rows.map(\.content), ["new.net"])
let version = workflow.snapshot.version
barrier.release.signal()
await older.value
XCTAssertEqual(workflow.snapshot.rows.map(\.content), ["new.net"])
XCTAssertEqual(workflow.snapshot.version, version)
XCTAssertFalse(workflow.snapshot.isLoading)
}
func testCandidatePermutationKeepsVersionRowsAndCoverageStable() async throws {
let broad = CustomRule(action: .direct, match: try RuleMatch(domainSuffix: "example.com"))
let narrow = CustomRule(action: .proxy, match: try RuleMatch(domainExact: "www.example.com"))
let forward = RulesWorkflow(loadCustom: { [broad, narrow] }, loadBuiltin: { rulesFixture($0) })
let reverse = RulesWorkflow(loadCustom: { [narrow, broad] }, loadBuiltin: { rulesFixture($0) })
await forward.refresh()
await reverse.refresh()
XCTAssertEqual(forward.snapshot.version, reverse.snapshot.version)
XCTAssertEqual(forward.snapshot.rows, reverse.snapshot.rows)
}
func testSameNamedExactAndSuffixHaveStableVersionsAcrossRefreshes() async throws {
let exact = CustomRule(action: .direct, match: try RuleMatch(domainExact: "example.com"))
let suffix = CustomRule(action: .direct, match: try RuleMatch(domainSuffix: "example.com"))
let workflow = RulesWorkflow(
loadCustom: { [suffix, exact] }, loadBuiltin: { rulesFixture($0) })
await workflow.refresh()
let version = workflow.snapshot.version
let identities = workflow.snapshot.rows.map(\.identity)
for _ in 0..<12 {
await workflow.refresh()
XCTAssertEqual(workflow.snapshot.version, version)
XCTAssertEqual(workflow.snapshot.rows.map(\.identity), identities)
}
}
}
func rulesFixture(
_ source: RulesSource, rules: [ProxyRule] = [], absorbed: [ProxyRule] = []
) -> RuleSnapshot {
let kind: RuleSourceKind
switch source {
case .geolocationCN: kind = .geolocationCN
case .chinaIPv4: kind = .chinaIPv4
case .gfwlist: kind = .gfwlist
case .custom, .fixed: kind = .custom
}
return RuleSnapshot(
metadata: RuleSnapshotMetadata(
source: RuleSourceIdentity(kind: kind, upstreamVersion: "v1", label: source.rawValue),
upstreamReference: "https://example.com/source", inputDigest: "fixture",
fetchedAt: Date(timeIntervalSince1970: 0),
license: "MIT", attribution: "fixture author"), rules: rules, absorbed: absorbed)
}
private final class RulesReadBarrier: @unchecked Sendable {
let release = DispatchSemaphore(value: 0)
private let lock = NSLock()
private var reads = 0
private let firstRead: XCTestExpectation
init(firstRead: XCTestExpectation) { self.firstRead = firstRead }
func read() throws -> [CustomRule] {
lock.lock()
reads += 1
let first = reads == 1
lock.unlock()
if first {
firstRead.fulfill()
guard release.wait(timeout: .now() + 5) == .success else { throw RuleSnapshotError.missing }
}
return [
CustomRule(action: .direct, match: try RuleMatch(domainExact: first ? "old.net" : "new.net"))
]
}
}
@@ -12,7 +12,7 @@ final class WorkspaceRouteTests: XCTestCase {
func testDestinationVocabularyIsFiniteAndStable() {
XCTAssertEqual(
WorkspaceDestination.allCases,
[.home, .servers, .subscriptions, .settings, .diagnostics])
[.home, .servers, .subscriptions, .rules, .settings, .diagnostics])
}
func testNavigateChangesDestinationOnly() {