Files
ShadowsocksX-NG/ShadowsocksX-NG2/App/UI/Workspace/StatusCardModel.swift
T
Qiu Yuzhou fb428f12bc refactor(app): 按分层重组 App/ 源码目录
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 阶段。
2026-10-05 19:56:18 +08:00

109 lines
4.0 KiB
Swift

import Foundation
/// 侧栏底部代理状态卡的呈现模型(issue #60,架构评审候选④):从代理控制
/// 工作流的整体 snapshot 派生卡片数据——行结构(哪些行、条件 detail、顺序)、
/// 活动目标回退文案与语义色调政策全部在此决定;文本复用 StatusMenuModel 的
/// 同一映射(配色与文本同源)。不持有 SwiftUI/AppKit 类型,纯逻辑可测;视觉
/// 细节(tone→语义色、排版)在 StatusCardView 呈现,不建自动测试。
enum StatusCardModel {
/// 语义色调:呈现层映射为系统语义色(positive=green、attention=orange、
/// failure=red、neutral=secondary)。
enum Tone: Equatable {
case positive
case attention
case failure
case neutral
}
struct Row: Equatable {
let label: String
let text: String
let tone: Tone
}
struct DetailLine: Equatable {
let text: String
let tone: Tone
}
/// 活动目标行:正文与悬停/辅助说明(未激活时按模式区分回退文案)。
struct TargetPresentation: Equatable {
let text: String
let help: String
}
struct Card: Equatable {
let runtimeRow: Row
/// agent 侧点名原因(运行失败或激活拒绝);无则 nil。
let runtimeDetail: DetailLine?
let systemProxyRow: Row
/// 系统代理写入/清理失败点名;非失败态为 nil。
let systemProxyDetail: DetailLine?
let target: TargetPresentation
/// 模式行文本(规则模式带子选项,与状态菜单同口径)。
let modeText: String
}
static func card(from snapshot: ProxyControlSnapshot) -> Card {
let summary = StatusMenuModel.summary(from: snapshot)
let runtimeTone = runtimeTone(for: snapshot.runtime.status)
return Card(
runtimeRow: Row(label: "后台代理", text: summary.status, tone: runtimeTone),
runtimeDetail: summary.detail.map {
DetailLine(text: $0, tone: runtimeDetailTone(snapshot: snapshot, runtimeTone: runtimeTone))
},
systemProxyRow: Row(
label: "系统代理设置",
text: summary.systemProxyStateLabel,
tone: systemProxyTone(for: snapshot.systemProxyApplication)),
systemProxyDetail: summary.systemProxyDetail.map {
DetailLine(text: $0, tone: .failure)
},
target: targetPresentation(summary: summary, proxyMode: snapshot.proxyMode),
modeText: summary.modeLabel)
}
private static func runtimeTone(for status: ProxyRuntimeStatus) -> Tone {
switch status {
case .running: .positive
case .starting, .off: .neutral
case .firewallBlocked, .requiresApproval: .attention
case .launchFailed, .serviceFailed: .failure
}
}
/// agent 点名原因色调:运行失败跟随运行状态色调;激活点名失败独立显红;
/// 其余中性。
private static func runtimeDetailTone(snapshot: ProxyControlSnapshot, runtimeTone: Tone) -> Tone {
if snapshot.runtime.failure != nil {
return runtimeTone
}
return snapshot.activationFailure == nil ? .neutral : .failure
}
private static func systemProxyTone(for application: SystemProxyApplicationFacts) -> Tone {
switch application {
case .idle: .neutral
case .pending, .changed, .applying, .repairing, .paused: .attention
case .applied: .positive
case .failed, .repairFailed, .clearFailed, .unreadable: .failure
}
}
/// 活动目标回退:有目标用路径;未激活时直连模式无需目标,其余模式提示
/// 未激活。
private static func targetPresentation(
summary: StatusMenuModel.Summary, proxyMode: ProxyMode
) -> TargetPresentation {
if let targetPath = summary.targetPath {
return TargetPresentation(text: targetPath, help: "活动目标:\(targetPath)")
}
if proxyMode == .direct {
return TargetPresentation(
text: "直连模式(无需服务器)", help: "直连模式无需选择活动目标")
}
return TargetPresentation(
text: "未激活", help: "未设置活动目标;在首页或服务器目录中激活")
}
}