mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-13 17:43:45 +00:00
App/ 根目录原有 73 个散落源文件,已无法从目录读出任何分组信息。 按「目录即分层」重组为 Composition / Application / Presentation / UI / PlatformEffects / Resources 六个目录,UI 层再按子视图分目录(Workspace、 Home、Servers(含 Plugin)、Subscriptions、Rules、Settings、Diagnostics、 StatusMenu)。纯移动:类型名、模块名与 import 均未改动,App/ 根层不再有 散落文件。 配套改动: - project.yml:Core 测试镜像的 excludes 由裸文件名改为 `**/` 递归形式。 XcodeGen 的 exclude 不跨 `/`,裸名只匹配 App 根层;MainApp.swift 下移后 旧写法会静默失效,把 @main 组合根编进测试镜像。info.path 与 entitlements.path 指向 App/Resources/;并排除 `**/*.md`——App/ 下任何 非源码文件都会被 XcodeGen 收进 Resources 阶段并封进签名后的 bundle。 - SkeletonTests 的架构守卫由「默认扫描全部 App/ + 7 个豁免文件 + 1 个豁免 前缀 + 4 个豁免目录」改为正面圈定 App/UI/,豁免登记全部删除;新增运行时 适配器放进 Application/ 即自动落在扫描范围外,无需再登记。 - README 工程结构说明同步;根 .gitignore 的生成物路径随 Resources/ 归位。 验证:clean build 0 error;task test 1022 用例全通过(含两个架构守卫); task format 无改动(证明纯移动);task lint 与移动前同为 21 条内容型 warning;packaging-gate.sh ALL CHECKS PASSED,确认 plist/entitlements 归位 后签名与 entitlements 语义不变。 注意:迁移时须先删旧的生成物再 xcodegen generate,否则遗留的 App/Info.plist 会被当作普通资源扫进 Resources 阶段。
154 lines
5.8 KiB
Swift
154 lines
5.8 KiB
Swift
import Foundation
|
|
|
|
// MARK: - 自定义规则更新结果
|
|
|
|
/// The result of accepting and saving a rule document, independent of runtime.
|
|
/// `busy`/`superseded` are transaction-gate rejections: no save was attempted.
|
|
enum CustomRuleUpdateOutcome: Equatable, Sendable {
|
|
/// 规则已保存;运行规则模式时应用。
|
|
case saved
|
|
/// 校验拒绝:整批不落地,旧规则保持不变;附可解释原因。
|
|
case rejected([RejectedCustomRule])
|
|
/// 持久化失败:旧规则保持不变。
|
|
case persistenceFailed
|
|
case busy
|
|
/// 事务门拒绝:意图捕获后事实已前进(集合版本/完整性变化),本次更新未尝试保存。
|
|
case superseded
|
|
case invalidDocument(detail: String)
|
|
|
|
var isSuccess: Bool {
|
|
switch self {
|
|
case .saved: true
|
|
default: false
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Final persisted facts, independent of whether runtime application succeeded.
|
|
struct RuleDocumentCommit: Sendable {
|
|
let outcome: CustomRuleUpdateOutcome
|
|
let document: CustomRuleDocument?
|
|
}
|
|
|
|
// MARK: - 结果呈现文案
|
|
|
|
// outcome→文案映射与 outcome 类型同住,供状态行与编辑器两个视图共享。
|
|
|
|
extension RulesCommitFeedback {
|
|
var summary: String {
|
|
guard outcome.isSuccess else { return outcome.rulesMessage }
|
|
let count: String
|
|
switch operation {
|
|
case .enablement(let enabled):
|
|
count = String.localizedStringWithFormat(
|
|
RulesCopy.text(enabled ? "已启用 %lld 条规则" : "已禁用 %lld 条规则"), Int64(changedCount))
|
|
case .add: count = RulesCopy.text("已新增规则")
|
|
case .edit: count = RulesCopy.text("已编辑规则")
|
|
case .delete:
|
|
count = String.localizedStringWithFormat(
|
|
RulesCopy.text("已删除 %lld 条自定义规则"), Int64(changedCount))
|
|
}
|
|
return count + " · " + RulesCopy.text("已保存")
|
|
}
|
|
}
|
|
|
|
extension CustomRuleUpdateOutcome {
|
|
var rulesMessage: String {
|
|
switch self {
|
|
case .saved: RulesCopy.text("已保存")
|
|
case .persistenceFailed: RulesCopy.text("保存失败,规则未更改")
|
|
case .busy: RulesCopy.text("正在更新规则…")
|
|
case .superseded: RulesCopy.text("规则集合已变化,操作未执行。")
|
|
case .invalidDocument, .rejected: RulesCopy.text("无法更新规则")
|
|
}
|
|
}
|
|
|
|
var failureDetail: String? {
|
|
switch self {
|
|
case .invalidDocument(let detail): detail
|
|
case .rejected(let rejected): rejected.map(\.explanation).joined(separator: "\n")
|
|
default: nil
|
|
}
|
|
}
|
|
|
|
var nextStep: String? {
|
|
switch self {
|
|
case .persistenceFailed, .busy, .superseded: "请重试规则操作。"
|
|
case .invalidDocument, .rejected: "请检查规则数据后再操作。"
|
|
default: nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - 自定义规则命令面
|
|
|
|
extension ProxyRuntimeController {
|
|
/// Validate and atomically save intent; runtime application is a separate worker.
|
|
/// Deployment failure never restores an older saved rule document.
|
|
/// 全局和直连模式不加载自定义规则,但持久化仍然进行(切换到规则模式后生效)。
|
|
func commitRuleDocument(_ document: CustomRuleDocument) async -> RuleDocumentCommit {
|
|
let previous = try? ruleDocuments.load()
|
|
let outcome = await updateRuleDocument(document)
|
|
return RuleDocumentCommit(outcome: outcome, document: ruleDocuments.current ?? previous)
|
|
}
|
|
|
|
func updateRuleDocument(_ document: CustomRuleDocument) async -> CustomRuleUpdateOutcome {
|
|
guard !isUpdatingRules else { return .busy }
|
|
isUpdatingRules = true
|
|
defer { isUpdatingRules = false }
|
|
let validation = validateCustomRulesForPersistence(document.rules)
|
|
guard validation.rejected.isEmpty else { return .rejected(validation.rejected) }
|
|
guard
|
|
document.disabledIdentities.isDisjoint(
|
|
with:
|
|
Set(RuleCoverage.fixedLocalMatches.map { RuleIdentity(action: .direct, match: $0) }))
|
|
else { return .invalidDocument(detail: "Fixed local policy cannot be disabled") }
|
|
do {
|
|
_ = try ruleDocuments.load()
|
|
try ruleDocuments.save(document)
|
|
} catch {
|
|
RuntimeLog.emit(.runtimePersistFailed(detail: String(describing: error)))
|
|
return .persistenceFailed
|
|
}
|
|
scheduleRuleApplication()
|
|
return .saved
|
|
}
|
|
|
|
/// One worker owns rule deployments. Saves never cancel an in-flight restart;
|
|
/// another saved revision is coalesced and applied after that restart finishes.
|
|
func scheduleRuleApplication() {
|
|
ruleApplicationGeneration += 1
|
|
guard ruleApplicationTask == nil else { return }
|
|
ruleApplicationTask = Task { [weak self] in
|
|
guard let self else { return }
|
|
defer { self.ruleApplicationTask = nil }
|
|
while !Task.isCancelled {
|
|
let generation = self.ruleApplicationGeneration
|
|
do { try await self.ruleApplicationDelay() } catch { return }
|
|
guard generation == self.ruleApplicationGeneration else { continue }
|
|
await self.convergeRuntime(.savedRules)
|
|
if generation == self.ruleApplicationGeneration { return }
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 保存前校验(issue #66 AC2):固定本地冲突与重复整批拒绝并返回可解释原因。
|
|
/// 同行动覆盖和相反行动遮蔽不阻止保存;编译时按当前骨架解释完整集合。
|
|
func validateCustomRulesForPersistence(_ rules: [CustomRule]) -> (
|
|
acceptedRules: [CustomRule], rejected: [RejectedCustomRule]
|
|
) {
|
|
let base = CustomRuleValidator.hardValidation(custom: rules)
|
|
let hardRejected = base.rejected.filter {
|
|
$0.reason == .conflictsWithFixedLocalScope || $0.reason == .duplicate
|
|
}
|
|
let hardRejectedTokens = Set(hardRejected.map(\.rule.contentToken))
|
|
let accepted = rules.filter { !hardRejectedTokens.contains($0.contentToken) }
|
|
return (accepted, hardRejected)
|
|
}
|
|
|
|
/// 自定义规则安全摘要(issue #66 AC5):数量 + 内容版本,不含原始域名。
|
|
func readCustomRuleSummary() -> CustomRuleSummary? {
|
|
try? customRuleStore.summary()
|
|
}
|
|
}
|