mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-14 10:13:54 +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 空监 听回环证据);替身测试不表述为真实系统写入验证。
352 lines
11 KiB
Swift
352 lines
11 KiB
Swift
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
|
||
}
|
||
}
|
||
|
||
}
|