Files
ShadowsocksX-NG/ShadowsocksX-NG2/App/MainWindowView.swift
T
Qiu Yuzhou 398e8d84b6 feat(ng2): split agent and system proxy switches
实现 #60(ACL 1/8):agent 意图与系统代理意图拆为两个独立开关,
首次运行默认 agent 开、系统代理关;显式关闭 agent 持久化并在 GUI
重启后生效;关闭系统代理只恢复 NG2 持有的系统设置,本地 SOCKS/HTTP
继续监听;关闭 agent 先恢复系统设置再停止监听。无活动目标时 agent
以空服务器列表契约提供本地监听(isWellFormed 放行空 servers,上游
sslocal v1.25.0 接受);需要代理出口的模式在活动目标缺失或端点不健
康时系统代理保持待应用,条件恢复后随下次收敛自动应用。首页、状态菜
单与侧栏状态卡经同一代理控制快照展示/操作 agent 意图/运行状态与系
统代理意图/实际应用及 typed 错误。测试覆盖启动顺序、持久化、健康门
禁、ownership 冲突、无效活动目标与 GUI 重同步(含真实 sslocal 空监
听回环证据);替身测试不表述为真实系统写入验证。
2026-09-26 00:38:28 +08:00

352 lines
11 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 SwiftUI
/// 主窗口外壳(地图 #52,票 #53):NavigationSplitView 侧栏承载五项导航与
/// 底部常驻代理状态卡;详情区按 route destination 承载各分区视图。分区大
/// 标题由窗口标题承担(组合根把 destination label 推给开窗器,见
/// WorkspaceWindowOpeningAdapter.bindTitle);订阅/诊断的页级动作经 .toolbar
/// 桥接进窗口工具栏右端(票 #56/#58 的槽位仅呈现位置变化),设置的表单级
/// 提交动作在其视图内容顶部(见 SettingsView)。路由状态仍由
/// WorkspaceRoute 持有;代理状态卡的开关、模式与摘要只来自代理控制工作流
/// 的整体 snapshot(issue #47),与状态菜单同一口径。
struct MainWindowView: View {
@ObservedObject var route: WorkspaceRoute
@ObservedObject var workflow: CatalogWorkflow
@ObservedObject var control: ProxyControlWorkflow
@ObservedObject var proxyController: ProxyRuntimeController
@ObservedObject var diagnostics: DiagnosticsWorkflow
@ObservedObject var settingsWorkflow: SettingsWorkflow
@ObservedObject var loginController: LaunchAtLoginController
let clipboard: any TextClipboard
let diagnosticReportExporter: any DiagnosticReportExporter
@State private var selection: NodeID?
/// 分区动作的呈现状态(票 #56/#58):添加订阅 sheet 与诊断导出由壳持有。
@State private var showAddSubscription = false
@State private var exportedDiagnosticsPath: String?
@StateObject private var shellActionErrors = ErrorAlertPresenter()
var body: some View {
NavigationSplitView {
sidebar
.navigationSplitViewColumnWidth(min: 200, ideal: 236, max: 300)
} detail: {
destinationView
}
.frame(minWidth: 920, minHeight: 580)
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
destinationActions
}
}
.sheet(isPresented: $showAddSubscription) {
AddSubscriptionSheet(workflow: workflow, errors: shellActionErrors)
}
.alert(
"操作失败",
isPresented: Binding(
get: { shellActionErrors.isPresented },
set: { if !$0 { shellActionErrors.dismiss() } })
) {
Button("好", role: .cancel) {}
} message: {
Text(shellActionErrors.message ?? "")
}
.alert(
"诊断已导出",
isPresented: Binding(
get: { exportedDiagnosticsPath != nil },
set: { if !$0 { exportedDiagnosticsPath = nil } })
) {
Button("好", role: .cancel) {}
} message: {
Text(exportedDiagnosticsPath ?? "")
}
}
// MARK: - 分区动作槽位
/// 分区动作(票 #53/#56/#57):页级动作随 destination 切换,经工具栏桥接
/// 出现在窗口工具栏右端;首页/服务器无页级动作。例外:设置的保存/恢复
/// 默认是表单级提交动作,由 SettingsView 内容顶部行承载(动作与表单同置,
/// 工具栏呈现效果差)。sheet/alert 呈现状态仍由壳持有,本处只负责动作的
/// 呈现。
@ViewBuilder
private var destinationActions: some View {
switch route.destination {
case .subscriptions:
HStack(spacing: 8) {
if !workflow.refreshingSubscriptionIDs.isEmpty {
ProgressView()
.controlSize(.small)
}
Button("添加订阅", systemImage: "plus") {
showAddSubscription = true
}
Button("更新全部", systemImage: "arrow.triangle.2.circlepath") {
Task { await workflow.refreshAllSubscriptions() }
}
.disabled(workflow.subscriptions.isEmpty)
}
case .diagnostics:
Button("导出诊断…", systemImage: "square.and.arrow.up") {
exportDiagnostics()
}
case .home, .servers, .settings:
EmptyView()
}
}
/// 诊断导出(票 #58):沿用 DiagnosticReportExportAction 既有入口;文件写
/// 入成功后才登记导出完成事件。
private func exportDiagnostics() {
switch DiagnosticReportExportAction(
diagnostics: diagnostics, exporter: diagnosticReportExporter
).perform()
{
case .preparationFailed(let failure):
shellActionErrors.present(failure)
case .cancelled:
break
case .saved(let url):
diagnostics.noteExportCompleted()
exportedDiagnosticsPath = url.path
case .exportFailed(let failure):
shellActionErrors.present(failure)
}
}
// MARK: - 分区承载
@ViewBuilder
private var destinationView: some View {
switch route.destination {
case .home:
WorkspaceHomeView(
workflow: workflow,
control: control,
clipboard: clipboard,
onManageServers: { route.navigate(to: .servers) })
case .servers:
ServersView(
workflow: workflow,
proxyController: proxyController,
selection: $selection,
clipboard: clipboard)
case .subscriptions:
WorkspaceSubscriptionsView(
workflow: workflow,
onNodesRemoved: clearSelectionIfInvalidated)
case .settings:
SettingsView(workflow: settingsWorkflow, loginController: loginController)
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .diagnostics:
WorkspaceDiagnosticsView(
diagnostics: diagnostics,
clipboard: clipboard)
}
}
private func clearSelectionIfInvalidated(_ removed: Set<NodeID>) {
if let selection, removed.contains(selection) {
self.selection = nil
}
}
}
extension WorkspaceDestination {
var label: String {
switch self {
case .home: "首页"
case .servers: "服务器"
case .subscriptions: "订阅"
case .settings: "设置"
case .diagnostics: "诊断"
}
}
var systemImage: String {
switch self {
case .home: "house"
case .servers: "server.rack"
case .subscriptions: "arrow.triangle.2.circlepath"
case .settings: "gearshape"
case .diagnostics: "chart.bar"
}
}
}
// MARK: - 侧栏与底部代理状态卡(同文件扩展,保持 private 访问)
extension MainWindowView {
// MARK: - 侧栏
private var sidebar: some View {
List(selection: navigationBinding) {
Section {
ForEach(WorkspaceDestination.allCases) { destination in
sidebarRow(destination)
.tag(destination)
}
}
}
.listStyle(.sidebar)
.safeAreaInset(edge: .top, spacing: 0) { identityHeader }
.safeAreaInset(edge: .bottom, spacing: 0) { statusCard }
}
private var identityHeader: some View {
HStack(spacing: 10) {
ZStack {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Color.primary)
Image(systemName: "network")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Color(nsColor: .windowBackgroundColor))
}
.frame(width: 30, height: 30)
Text("ShadowsocksX-NG")
.font(.headline)
Spacer(minLength: 0)
}
.padding(.leading, 16)
.padding(.trailing, 12)
.padding(.top, 12)
.padding(.bottom, 4)
}
private func sidebarRow(_ destination: WorkspaceDestination) -> some View {
HStack(spacing: 8) {
Label(destination.label, systemImage: destination.systemImage)
Spacer(minLength: 0)
if let count = badgeCount(for: destination) {
Text("\(count)")
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 1)
.background(Capsule().fill(.quaternary))
}
}
}
private var navigationBinding: Binding<WorkspaceDestination?> {
Binding(
get: { route.destination },
set: { destination in
guard let destination else { return }
route.navigate(to: destination)
})
}
/// 行尾数量角标:服务器 = 目录树内服务器叶子数;订阅 = 订阅数。
private func badgeCount(for destination: WorkspaceDestination) -> Int? {
switch destination {
case .servers:
serverLeafCount > 0 ? serverLeafCount : nil
case .subscriptions:
workflow.subscriptions.isEmpty ? nil : workflow.subscriptions.count
default:
nil
}
}
private var serverLeafCount: Int {
func leaves(_ nodes: [CatalogTreeNode]) -> Int {
nodes.reduce(0) { $0 + ($1.isGroup ? leaves($1.childNodes) : 1) }
}
return leaves(workflow.tree.roots)
}
// MARK: - 底部代理状态卡
/// 底部状态卡(issue #60):agent 与系统代理两个开关并排呈现,各自绑定
/// 持久化意图;运行状态、系统代理实际应用与点名原因来自同一 snapshot。
private var statusCard: some View {
let summary = StatusMenuModel.summary(from: control.snapshot)
return VStack(alignment: .leading, spacing: 7) {
HStack(spacing: 8) {
Toggle("后台代理", isOn: agentToggleBinding)
.toggleStyle(.switch)
.controlSize(.mini)
.labelsHidden()
.help("控制后台代理 agent:提供本地 SOCKS/HTTP 监听,关闭选择会保留")
Toggle("系统代理", isOn: systemProxyToggleBinding)
.toggleStyle(.switch)
.controlSize(.mini)
.labelsHidden()
.help("让 macOS 系统代理指向本地入口;关闭只恢复 NG2 持有的系统设置")
Text(summary.status)
.font(.footnote.weight(.semibold))
.foregroundStyle(statusColor(summary))
.lineLimit(1)
}
Text(summary.targetPath ?? "未激活")
.font(.footnote.weight(.medium))
.lineLimit(1)
.truncationMode(.middle)
.help(
summary.targetPath.map { "活动目标:\($0)" }
?? "未设置活动目标;在首页或服务器目录中激活")
Text("模式:\(summary.modeLabel) · \(summary.systemProxyStatus)")
.font(.caption)
.foregroundStyle(.secondary)
if let detail = summary.detail {
Text(detail)
.font(.caption)
.foregroundStyle(.orange)
.lineLimit(2)
.help(detail)
}
if let systemProxyDetail = summary.systemProxyDetail {
Text(systemProxyDetail)
.font(.caption)
.foregroundStyle(.orange)
.lineLimit(2)
.help(systemProxyDetail)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.background.secondary, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(.quaternary)
)
.padding(.horizontal, 12)
.padding(.top, 8)
.padding(.bottom, 12)
}
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) }
})
}
private func statusColor(_ summary: StatusMenuModel.Summary) -> Color {
switch control.snapshot.runtime.status {
case .running:
.green
case .starting:
.secondary
case .off:
.secondary
case .firewallBlocked, .requiresApproval:
.orange
case .launchFailed, .serviceFailed:
.red
}
}
}