diff --git a/CONTEXT.md b/CONTEXT.md index fafea1c7..ee59cd5e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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. diff --git a/ShadowsocksX-NG2/App/MainWindowView.swift b/ShadowsocksX-NG2/App/MainWindowView.swift index 2e62f27e..c288bd87 100644 --- a/ShadowsocksX-NG2/App/MainWindowView.swift +++ b/ShadowsocksX-NG2/App/MainWindowView.swift @@ -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" } diff --git a/ShadowsocksX-NG2/App/Rules.xcstrings b/ShadowsocksX-NG2/App/Rules.xcstrings new file mode 100644 index 00000000..2c2cf820 --- /dev/null +++ b/ShadowsocksX-NG2/App/Rules.xcstrings @@ -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" +} diff --git a/ShadowsocksX-NG2/App/RulesSourceDetailsView.swift b/ShadowsocksX-NG2/App/RulesSourceDetailsView.swift new file mode 100644 index 00000000..802b7f45 --- /dev/null +++ b/ShadowsocksX-NG2/App/RulesSourceDetailsView.swift @@ -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: ", ") + } +} diff --git a/ShadowsocksX-NG2/App/RulesView.swift b/ShadowsocksX-NG2/App/RulesView.swift new file mode 100644 index 00000000..951d7263 --- /dev/null +++ b/ShadowsocksX-NG2/App/RulesView.swift @@ -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.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 { + 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 + } +} diff --git a/ShadowsocksX-NG2/App/RulesWorkflow.swift b/ShadowsocksX-NG2/App/RulesWorkflow.swift new file mode 100644 index 00000000..38f01194 --- /dev/null +++ b/ShadowsocksX-NG2/App/RulesWorkflow.swift @@ -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) { + 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 + } +} diff --git a/ShadowsocksX-NG2/App/WorkspaceRoute.swift b/ShadowsocksX-NG2/App/WorkspaceRoute.swift index 8948b640..3ab4d5c0 100644 --- a/ShadowsocksX-NG2/App/WorkspaceRoute.swift +++ b/ShadowsocksX-NG2/App/WorkspaceRoute.swift @@ -6,6 +6,7 @@ enum WorkspaceDestination: String, CaseIterable, Hashable, Identifiable, Sendabl case home case servers case subscriptions + case rules case settings case diagnostics diff --git a/ShadowsocksX-NG2/Domain/RuleAnalysis.swift b/ShadowsocksX-NG2/Domain/RuleAnalysis.swift index 0b09f8c1..021a2e14 100644 --- a/ShadowsocksX-NG2/Domain/RuleAnalysis.swift +++ b/ShadowsocksX-NG2/Domain/RuleAnalysis.swift @@ -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) + } +} diff --git a/ShadowsocksX-NG2/Domain/RulesBrowsing.swift b/ShadowsocksX-NG2/Domain/RulesBrowsing.swift new file mode 100644 index 00000000..7f4cc2d4 --- /dev/null +++ b/ShadowsocksX-NG2/Domain/RulesBrowsing.swift @@ -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 + let customIDs: Set + 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 = [] + var query = RulesQuery() + var isComplete: Bool { !isLoading && issues.isEmpty && !version.isEmpty } +} diff --git a/ShadowsocksX-NG2/Domain/RulesCollection.swift b/ShadowsocksX-NG2/Domain/RulesCollection.swift new file mode 100644 index 00000000..67dd6399 --- /dev/null +++ b/ShadowsocksX-NG2/Domain/RulesCollection.swift @@ -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 + } + } +} diff --git a/ShadowsocksX-NG2/Domain/RulesOverlapIndex.swift b/ShadowsocksX-NG2/Domain/RulesOverlapIndex.swift new file mode 100644 index 00000000..82cd8753 --- /dev/null +++ b/ShadowsocksX-NG2/Domain/RulesOverlapIndex.swift @@ -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.. 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")) + ] + } +} diff --git a/ShadowsocksX-NG2/Tests/WorkspaceRouteTests.swift b/ShadowsocksX-NG2/Tests/WorkspaceRouteTests.swift index f989b33a..33ac372a 100644 --- a/ShadowsocksX-NG2/Tests/WorkspaceRouteTests.swift +++ b/ShadowsocksX-NG2/Tests/WorkspaceRouteTests.swift @@ -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() {