mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-02 16:53:45 +00:00
feat(rules): add read-only rule browsing (#76)
This commit is contained in:
+2
-1
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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: ", ")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user