Files
ShadowsocksX-NG/ShadowsocksX-NG2/App/UI/StatusMenu/ProxyStatusMenu.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

153 lines
5.9 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import SwiftUI
/// 状态菜单(spec #21 D11 白名单收敛为六项,issue #31/#41/#47/#60):
/// ①打开主窗口(置顶单独区域)②头部状态摘要(agent 运行状态、当前模式、
/// 活动目标、系统代理实际应用)③两个开关(后台代理 agent 与系统代理,互不
/// 代替)④模式选择(勾选态)⑤活动目标级联选择器(组树子菜单、只读)
/// ⑥退出(明示代理仍在后台运行)。白名单外操作一律不进菜单栏;编辑类操作
/// 只在主 workspace。运行时事实、开关与模式全部来自代理控制工作流的整体
/// snapshot(issue #47/#60),菜单不直接读控制器字段;目录树与激活动作仍走
/// 目录工作流。
struct ProxyStatusMenu: View {
/// 代理控制唯一 seam(issue #47):状态摘要与代理命令的唯一来源。
@ObservedObject var control: ProxyControlWorkflow
@ObservedObject var catalogWorkflow: CatalogWorkflow
/// 主窗口是 SwiftUI `Window` scene(见 MainApp):启动呈现由场景的
/// defaultLaunchBehavior 负责,状态菜单是关窗后的重开入口(ADR 0016);
/// scene id 经 WorkspaceRoute 单点引用。
@Environment(\.openWindow) private var openWindow
var body: some View {
let snapshot = control.snapshot
let summary = StatusMenuModel.summary(from: snapshot)
let targetTree = catalogWorkflow.tree.roots
// ① 重开主窗口(置顶单独区域):启动已由 defaultLaunchBehavior(.presented)
// 呈现,本项服务关窗后的重开。accessory 形态下 SwiftUI 开窗不会自行抢
// 焦点,先激活本 app 让窗口压过当前前台应用。
Button("打开主窗口…") {
NSApp.activate(ignoringOtherApps: true)
openWindow(id: WorkspaceRoute.workspaceSceneID)
}
Divider()
// ② 头部状态摘要(agent 运行状态与系统代理实际应用分开呈现,issue #60)
Text(summary.status)
Text("模式:\(summary.modeLabel)")
Text(summary.targetPath.map { "目标:\($0)" } ?? "目标:未激活")
if let detail = summary.detail {
Text(detail)
.font(.caption)
.foregroundStyle(.secondary)
}
Text(summary.systemProxyStatus)
if let systemProxyDetail = summary.systemProxyDetail {
Text(systemProxyDetail)
.font(.caption)
.foregroundStyle(.secondary)
}
if snapshot.skippedInvalidServerCount > 0 {
Text("已跳过 \(snapshot.skippedInvalidServerCount) 个无效服务器")
.font(.caption)
.foregroundStyle(.orange)
.help("激活时跳过了存在已知本地阻塞问题的服务器")
}
Divider()
// ③ 两个开关(issue #60):agent 与系统代理意图互不代替,各绑定持久化
// 意图;agent 关闭会保留选择,系统代理关闭会清理匹配 NG2 端点的设置。
Toggle("后台代理", isOn: agentToggleBinding)
Toggle("设置系统代理", isOn: systemProxyToggleBinding)
// ④ 模式选择(勾选态):可选性与顺序来自 snapshot 的 Domain 单点策略。
Picker("模式", selection: modeBinding) {
ForEach(snapshot.availableModes, id: \.self) { mode in
Text(mode.label).tag(mode)
}
}
.pickerStyle(.inline)
// 规则模式子选项(issue #63):仅规则模式呈现。
if snapshot.proxyMode == .rule {
Picker("未匹配规则时", selection: ruleDefaultActionBinding) {
ForEach(RuleDefaultAction.allCases, id: \.self) { action in
Text(action.label).tag(action)
}
}
.pickerStyle(.inline)
}
// ⑤ 活动目标级联(只读)
Menu("活动目标") {
if targetTree.isEmpty {
Text("目录为空")
} else {
TargetCascade(nodes: targetTree, activeTargetID: snapshot.activeTarget?.id)
}
}
Divider()
// ⑥ 退出:仅退 GUI;agent 由 launchd 持有,代理不受影响(构造上成立)。
Button("退出 ShadowsocksX-NG2(代理仍在后台运行)") {
NSApp.terminate(nil)
}
}
/// 模式选择走同一控制 seam:切换语义与菜单勾选态由同一 snapshot 事实来源
/// 保证一致(issue #47)。
private var modeBinding: Binding<ProxyMode> {
Binding(
get: { control.snapshot.proxyMode },
set: { mode in Task { await control.setProxyMode(mode) } })
}
/// 规则模式子选项(issue #63):与首页共用同一控制 seam。
private var ruleDefaultActionBinding: Binding<RuleDefaultAction> {
Binding(
get: { control.snapshot.ruleDefaultAction },
set: { action in Task { await control.setRuleDefaultAction(action) } })
}
/// 两个开关绑定持久化意图(issue #60);命令完成由 workflow 整体重发布。
private var agentToggleBinding: Binding<Bool> {
Binding(
get: { control.snapshot.agentIntentEnabled },
set: { enabled in Task { await control.setAgentEnabled(enabled) } })
}
private var systemProxyToggleBinding: Binding<Bool> {
Binding(
get: { control.snapshot.systemProxyIntentEnabled },
set: { enabled in Task { await control.setSystemProxyEnabled(enabled) } })
}
/// 只读级联树:分组展开为子菜单,活动目标以勾选呈现;无编辑入口。
/// 递归经由具名 View 类型展开(opaque 自引用无法编译)。
private struct TargetCascade: View {
let nodes: [CatalogTreeNode]
let activeTargetID: NodeID?
var body: some View {
ForEach(nodes, id: \.id) { node in
if node.isGroup {
Menu(node.name) {
if node.childNodes.isEmpty {
Text("空分组")
} else {
TargetCascade(nodes: node.childNodes, activeTargetID: activeTargetID)
}
}
} else if node.id == activeTargetID {
Text("✓ \(node.name)")
} else {
Text(node.name)
}
}
}
}
}