mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-08 21:04:50 +00:00
153 lines
5.4 KiB
Swift
153 lines
5.4 KiB
Swift
import Foundation
|
|
|
|
/// 内置规则目录:从 bundle 资源加载已固定的 geolocation-cn、
|
|
/// china-ipv4 与 gfwlist 快照。普通构建只读本地快照,运行时不抓取或转换;
|
|
/// 快照缺失/损坏/版本不匹配时加载失败,不生成空规则 ACL。
|
|
struct BuiltinRuleCatalog {
|
|
/// 生产加载缝:bundle 内固定快照(`rules/<source>/snapshot.json`)。
|
|
static func loadGeolocationCN(from bundle: Bundle = .main) throws -> RuleSnapshot {
|
|
try loadSnapshot(named: "geolocation-cn", from: bundle)
|
|
}
|
|
|
|
/// china-operator-ip 中国 IPv4 CIDR 直连候选。
|
|
static func loadChinaIPv4(from bundle: Bundle = .main) throws -> RuleSnapshot {
|
|
try loadSnapshot(named: "china-ipv4", from: bundle)
|
|
}
|
|
|
|
/// GFWList 代理候选:仅含可准确表达且未被更宽代理规则遮蔽的规则。
|
|
static func loadGFWList(from bundle: Bundle = .main) throws -> RuleSnapshot {
|
|
try loadSnapshot(named: "gfwlist", from: bundle)
|
|
}
|
|
|
|
fileprivate static func loadSnapshot(named name: String, from bundle: Bundle) throws
|
|
-> RuleSnapshot
|
|
{
|
|
// folder reference 保留 `rules/<source>/snapshot.json` 目录结构。
|
|
guard
|
|
let url = bundle.url(
|
|
forResource: "snapshot", withExtension: "json", subdirectory: "rules/\(name)")
|
|
else {
|
|
throw RuleSnapshotError.missing
|
|
}
|
|
return try RuleSnapshotStore(fileURL: url).load()
|
|
}
|
|
|
|
/// 供 ACL 编译的中国直连候选(`.cn` 后缀 + geolocation-cn + china-ipv4)。
|
|
static func chinaDirectRules(from snapshot: RuleSnapshot) -> [ProxyRule] {
|
|
snapshot.rules.filter { $0.action == .direct }
|
|
}
|
|
|
|
/// 合并多份快照的直连候选;保持输入顺序(域名在前,CIDR 在后)。
|
|
static func chinaDirectRules(from snapshots: [RuleSnapshot]) -> [ProxyRule] {
|
|
snapshots.flatMap { chinaDirectRules(from: $0) }
|
|
}
|
|
|
|
/// 供 ACL 编译的 GFWList 候选:可准确表达且未被遮蔽的规则
|
|
/// (代理候选 + 未遮蔽例外),两种动作都参与编译。
|
|
static func gfwlistRules(from snapshot: RuleSnapshot) -> [ProxyRule] {
|
|
snapshot.rules
|
|
}
|
|
}
|
|
|
|
/// App-process source facts. Concurrent consumers share one attempt per source;
|
|
/// successful bundled snapshots never expire, failed attempts require explicit retry.
|
|
actor BuiltinRuleSnapshots {
|
|
typealias Loader = @Sendable (RulesSource) async throws -> RuleSnapshot
|
|
private struct Attempt {
|
|
let id: UUID
|
|
let task: Task<RuleSnapshot, Error>
|
|
}
|
|
private let loader: Loader
|
|
private var attempts: [RulesSource: Attempt] = [:]
|
|
private var failures: Set<RulesSource> = []
|
|
|
|
init(bundle: Bundle = .main, loader: Loader? = nil) {
|
|
self.loader =
|
|
loader ?? { source in
|
|
guard let kind = source.sourceKind, kind != .custom else {
|
|
throw RuleSnapshotError.missing
|
|
}
|
|
return try BuiltinRuleCatalog.loadSnapshot(named: kind.rawValue, from: bundle)
|
|
}
|
|
}
|
|
|
|
func load(_ source: RulesSource, retryFailure: Bool = false) async throws -> RuleSnapshot {
|
|
if retryFailure, failures.remove(source) != nil { attempts[source] = nil }
|
|
let attempt: Attempt
|
|
if let existing = attempts[source] {
|
|
attempt = existing
|
|
} else {
|
|
let loader = loader
|
|
attempt = Attempt(
|
|
id: UUID(),
|
|
task: Task.detached(priority: .userInitiated) {
|
|
let snapshot = try await loader(source)
|
|
guard let kind = source.sourceKind, kind != .custom else {
|
|
throw RuleSnapshotError.missing
|
|
}
|
|
guard snapshot.metadata.source.kind == kind
|
|
else { throw RuleSnapshotError.corrupt(detail: "Unexpected rule source") }
|
|
return snapshot
|
|
})
|
|
attempts[source] = attempt
|
|
}
|
|
do {
|
|
return try await attempt.task.value
|
|
} catch {
|
|
if attempts[source]?.id == attempt.id { failures.insert(source) }
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func browsingSources(
|
|
sources: [RulesSource] = [.geolocationCN, .chinaIPv4, .gfwlist],
|
|
retryFailures: Bool = false
|
|
) async -> [RulesSource: Result<
|
|
RuleSnapshot, Error
|
|
>] {
|
|
var results: [RulesSource: Result<RuleSnapshot, Error>] = [:]
|
|
for source in sources {
|
|
do { results[source] = .success(try await load(source, retryFailure: retryFailures)) } catch {
|
|
results[source] = .failure(error)
|
|
}
|
|
}
|
|
return results
|
|
}
|
|
}
|
|
|
|
/// The same policy supplies runtime candidates and the management collection.
|
|
enum RulePreset: String, Codable, CaseIterable, Hashable, Sendable {
|
|
case gfwlist = "gfwlist"
|
|
case chinaDirect = "china-direct"
|
|
case chinaProxy = "china-proxy"
|
|
|
|
init(legacy action: RuleDefaultAction) {
|
|
self = action == .proxyWhenUnmatched ? .chinaDirect : .gfwlist
|
|
}
|
|
|
|
var defaultAction: RuleDefaultAction {
|
|
self == .chinaDirect ? .proxyWhenUnmatched : .directWhenUnmatched
|
|
}
|
|
|
|
var matchedAction: RuleAction { self == .chinaDirect ? .direct : .proxy }
|
|
|
|
var sources: [RulesSource] {
|
|
self == .gfwlist ? [.gfwlist] : [.geolocationCN, .chinaIPv4]
|
|
}
|
|
|
|
var label: String {
|
|
switch self {
|
|
case .chinaDirect: String(localized: "中国大陆目标直连")
|
|
case .gfwlist: String(localized: "仅GFW列表内目标代理")
|
|
case .chinaProxy: String(localized: "中国大陆目标代理")
|
|
}
|
|
}
|
|
|
|
func rules(from snapshot: RuleSnapshot) -> [ProxyRule] {
|
|
if self == .gfwlist { return snapshot.rules.filter { $0.action == matchedAction } }
|
|
return BuiltinRuleCatalog.chinaDirectRules(from: snapshot).map {
|
|
ProxyRule(action: matchedAction, match: $0.match)
|
|
}
|
|
}
|
|
}
|