mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-15 18:59:52 +00:00
实现 #60(ACL 1/8):agent 意图与系统代理意图拆为两个独立开关, 首次运行默认 agent 开、系统代理关;显式关闭 agent 持久化并在 GUI 重启后生效;关闭系统代理只恢复 NG2 持有的系统设置,本地 SOCKS/HTTP 继续监听;关闭 agent 先恢复系统设置再停止监听。无活动目标时 agent 以空服务器列表契约提供本地监听(isWellFormed 放行空 servers,上游 sslocal v1.25.0 接受);需要代理出口的模式在活动目标缺失或端点不健 康时系统代理保持待应用,条件恢复后随下次收敛自动应用。首页、状态菜 单与侧栏状态卡经同一代理控制快照展示/操作 agent 意图/运行状态与系 统代理意图/实际应用及 typed 错误。测试覆盖启动顺序、持久化、健康门 禁、ownership 冲突、无效活动目标与 GUI 重同步(含真实 sslocal 空监 听回环证据);替身测试不表述为真实系统写入验证。
172 lines
6.0 KiB
Swift
172 lines
6.0 KiB
Swift
import AppKit
|
||
import SwiftUI
|
||
|
||
/// 状态菜单(spec #21 D11 八项白名单,issue #31/#41/#47/#60):①头部状态
|
||
/// 摘要(agent 运行状态、当前模式、活动目标、系统代理实际应用)②两个开关
|
||
/// (后台代理 agent 与系统代理,互不代替)③模式选择(勾选态)④活动目标
|
||
/// 级联选择器(组树子菜单、只读)⑤立即更新全部订阅 ⑥复制 HTTP 导出行
|
||
/// ⑦打开主窗口 ⑧退出(明示代理仍在后台运行)。白名单外操作一律不进菜单
|
||
/// 栏;编辑类操作只在主 workspace。运行时事实、开关、模式与导出能力全部
|
||
/// 来自代理控制工作流的整体 snapshot(issue #47/#60),菜单不直接读控制器
|
||
/// 字段;目录树、激活与订阅动作仍走目录工作流,剪贴板写入等 AppKit 副作用
|
||
/// 留在呈现边界。
|
||
struct ProxyStatusMenu: View {
|
||
/// 代理控制唯一 seam(issue #47):状态摘要与代理命令的唯一来源。
|
||
@ObservedObject var control: ProxyControlWorkflow
|
||
@ObservedObject var catalogWorkflow: CatalogWorkflow
|
||
@ObservedObject var route: WorkspaceRoute
|
||
let clipboard: any TextClipboard
|
||
/// 开窗 seam 由组合根注入(菜单 extra 上下文的 SwiftUI openWindow 实测不可用)。
|
||
let windowOpening: any WorkspaceWindowOpening
|
||
@StateObject private var errors = ErrorAlertPresenter()
|
||
|
||
var body: some View {
|
||
menuContent
|
||
.alert(
|
||
"操作失败",
|
||
isPresented: Binding(
|
||
get: { errors.isPresented },
|
||
set: { if !$0 { errors.dismiss() } })
|
||
) {
|
||
Button("好", role: .cancel) {}
|
||
} message: {
|
||
Text(errors.message ?? "")
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var menuContent: some View {
|
||
let snapshot = control.snapshot
|
||
let summary = StatusMenuModel.summary(from: snapshot)
|
||
let targetTree = catalogWorkflow.tree.roots
|
||
let exportLine = snapshot.httpExport.copyableLine
|
||
|
||
// ① 头部状态摘要(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)
|
||
|
||
// ④ 活动目标级联(只读)
|
||
Menu("活动目标") {
|
||
if targetTree.isEmpty {
|
||
Text("目录为空")
|
||
} else {
|
||
TargetCascade(nodes: targetTree, activeTargetID: snapshot.activeTarget?.id)
|
||
}
|
||
}
|
||
|
||
Divider()
|
||
|
||
// ⑤ 立即更新全部订阅(实际刷新语义 #35)
|
||
Button("立即更新全部订阅") {
|
||
Task { await catalogWorkflow.refreshAllSubscriptions() }
|
||
}
|
||
.disabled(catalogWorkflow.subscriptions.isEmpty)
|
||
|
||
// ⑥ 复制 HTTP 导出行:workflow 只提供安全能力,复制是 UI 副作用。
|
||
Button("复制 HTTP 导出行") {
|
||
copyHTTPExportLine(exportLine)
|
||
}
|
||
|
||
Divider()
|
||
|
||
// ⑦ 打开主窗口
|
||
Button("打开主窗口…") {
|
||
route.handle(.reopen, using: windowOpening)
|
||
}
|
||
|
||
Divider()
|
||
|
||
// ⑧ 退出:仅退 GUI;agent 由 launchd 持有,代理不受影响(构造上成立)。
|
||
Button("退出 ShadowsocksX-NG 2.0(代理仍在后台运行)") {
|
||
NSApp.terminate(nil)
|
||
}
|
||
}
|
||
|
||
private func copyHTTPExportLine(_ line: String) {
|
||
do {
|
||
try clipboard.write(line)
|
||
} catch {
|
||
errors.present(error)
|
||
}
|
||
}
|
||
|
||
/// 模式选择走同一控制 seam:切换语义与菜单勾选态由同一 snapshot 事实来源
|
||
/// 保证一致(issue #47)。
|
||
private var modeBinding: Binding<ProxyMode> {
|
||
Binding(
|
||
get: { control.snapshot.proxyMode },
|
||
set: { mode in Task { await control.setProxyMode(mode) } })
|
||
}
|
||
|
||
/// 两个开关绑定持久化意图(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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|