// // NetworkView.swift // punchnet import SwiftUI import Observation // 资源展示模式 enum NetworkShowMode: String, CaseIterable { case resource = "访问资源" case device = "成员设备" } // MARK: - 网络连接状态 enum NetworkConnectionPhase { case disconnected case connecting case connected case disconnecting } @MainActor @Observable final class NetworkModel { @ObservationIgnored private weak var appContext: AppContext? @ObservationIgnored private let vpnManager = VPNManager.shared @ObservationIgnored private var vpnStatusTask: Task? var showMode: NetworkShowMode = .resource var phase: NetworkConnectionPhase = .disconnected var networkSession: SDLAPIClient.NetworkSession? var networkContext: SDLAPIClient.NetworkContext? var selectedExitNodeIp: String? var selectedNodeId: Int? private(set) var nodeResourcesById: [Int: [SDLAPIClient.NetworkContext.Resource]] = [:] private(set) var loadingNodeIDs: Set = [] private(set) var isUpdatingExitNode: Bool = false private(set) var errorMessage: String? deinit { self.vpnStatusTask?.cancel() } var isBusy: Bool { switch self.phase { case .connecting, .disconnecting: return true case .connected, .disconnected: return self.isUpdatingExitNode } } var isTunnelEnabled: Bool { switch self.phase { case .connecting, .connected: return true case .disconnecting, .disconnected: return false } } var shouldShowModePicker: Bool { self.phase == .connected } var resourceList: [SDLAPIClient.NetworkContext.Resource] { self.networkContext?.resourceList ?? [] } var nodeList: [SDLAPIClient.NetworkContext.Node] { self.networkContext?.nodeList ?? [] } var selectedNode: SDLAPIClient.NetworkContext.Node? { self.networkContext?.getNode(id: self.selectedNodeId) } var canSelectExitNode: Bool { guard self.networkSession != nil else { return false } guard !self.isUpdatingExitNode else { return false } switch self.phase { case .connected: return self.networkContext != nil || self.selectedExitNodeIp != nil case .disconnected: return self.selectedExitNodeIp != nil case .connecting, .disconnecting: return false } } var exitNodeOptions: [ExitNodeOption] { guard let networkContext = self.networkContext else { return [] } return networkContext.exitNodeList.compactMap { exitNode in guard let node = networkContext.getNode(id: exitNode.nnid) else { return nil } return ExitNodeOption( id: exitNode.nnid, nodeName: exitNode.nodeName, ip: node.ip, system: node.system ) } } var selectedExitNode: ExitNodeOption? { guard let selectedExitNodeIp = self.selectedExitNodeIp else { return nil } return self.exitNodeOptions.first(where: { $0.ip == selectedExitNodeIp }) ?? ExitNodeOption( id: -1, nodeName: "已保存出口节点", ip: selectedExitNodeIp, system: nil ) } var exitNodeTitle: String { if self.isUpdatingExitNode { return "正在切换..." } if let selectedExitNode = self.selectedExitNode { return selectedExitNode.nodeName } return "未设置" } var exitNodeSubtitle: String { if let selectedExitNode = self.selectedExitNode { if let system = selectedExitNode.system, !system.isEmpty { return "\(selectedExitNode.ip) · \(system)" } return selectedExitNode.ip } if self.networkContext == nil { return "连接后可选择" } if self.exitNodeOptions.isEmpty { return "当前网络没有可用节点" } return "当前流量保持默认出口" } var exitNodeHelpText: String { if self.isUpdatingExitNode { return "正在更新出口节点" } if self.networkContext == nil { return "建立连接后可选择当前网络的出口节点" } return "切换当前网络流量的出口节点,也可以保持未设置" } func activate(appContext: AppContext) async { if self.appContext !== appContext { self.appContext = appContext } self.startObservingVPNStatusIfNeeded() await self.handleVPNStatusChange(self.vpnManager.vpnStatus) } func clearError() { self.errorMessage = nil } func setConnectionEnabled(_ enabled: Bool) async { if enabled { await self.connect() } else { await self.disconnect() } } func connect() async { guard let appContext = self.appContext else { return } guard !self.isBusy else { return } self.errorMessage = nil self.phase = .connecting do { if appContext.networkContext == nil { try await appContext.connectNetwork() } self.syncSharedStateFromAppContext() await self.applyNetworkContext(appContext.networkContext) try await appContext.startTun() if self.vpnManager.vpnStatus == .connected { self.phase = .connected } } catch let err as SDLAPIError { self.errorMessage = err.message self.handleDisconnectedState(syncAppContext: true) } catch let err as AppContextError { self.errorMessage = err.message self.handleDisconnectedState(syncAppContext: true) } catch { self.errorMessage = error.localizedDescription self.handleDisconnectedState(syncAppContext: true) } } func disconnect() async { guard let appContext = self.appContext else { return } guard !self.isBusy else { return } self.errorMessage = nil self.phase = .disconnecting do { try await appContext.stopTun() self.handleDisconnectedState(syncAppContext: false) } catch let err as AppContextError { self.errorMessage = err.message await self.handleVPNStatusChange(self.vpnManager.vpnStatus) } catch { self.errorMessage = error.localizedDescription await self.handleVPNStatusChange(self.vpnManager.vpnStatus) } } func selectNode(id: Int?) { self.selectedNodeId = id guard let id else { return } Task { @MainActor in await self.loadResourcesIfNeeded(for: id) } } func resources(for nodeId: Int) -> [SDLAPIClient.NetworkContext.Resource] { self.nodeResourcesById[nodeId] ?? [] } func isLoadingResources(for nodeId: Int) -> Bool { self.loadingNodeIDs.contains(nodeId) } func loadResourcesIfNeeded(for nodeId: Int) async { guard let session = self.networkSession else { return } guard self.nodeResourcesById[nodeId] == nil else { return } guard !self.loadingNodeIDs.contains(nodeId) else { return } let currentContextIdentity = self.contextIdentity(self.networkContext) self.loadingNodeIDs.insert(nodeId) defer { self.loadingNodeIDs.remove(nodeId) } let resources = await SDLAPIClient.loadNodeResources(accesToken: session.accessToken, id: nodeId) guard currentContextIdentity == self.contextIdentity(self.networkContext) else { return } self.nodeResourcesById[nodeId] = resources } func updateExitNodeSelection(_ ip: String?) async { guard let appContext = self.appContext else { return } guard !self.isUpdatingExitNode else { return } self.errorMessage = nil self.isUpdatingExitNode = true defer { self.isUpdatingExitNode = false } do { try await appContext.updateExitNodeIp(exitNodeIp: ip) self.syncSharedStateFromAppContext() } catch let err as AppContextError { self.errorMessage = err.message } catch { self.errorMessage = error.localizedDescription } } private func startObservingVPNStatusIfNeeded() { guard self.vpnStatusTask == nil else { return } let vpnStatusStream = self.vpnManager.vpnStatusStream self.vpnStatusTask = Task { [weak self, vpnStatusStream] in guard let self else { return } for await status in vpnStatusStream { if Task.isCancelled { return } await self.handleVPNStatusChange(status) } } } private func syncSharedStateFromAppContext() { self.networkSession = self.appContext?.networkSession self.selectedExitNodeIp = self.appContext?.selectedExitNodeIp } private func handleVPNStatusChange(_ status: VPNManager.VPNStatus) async { self.syncSharedStateFromAppContext() switch status { case .connecting: self.phase = .connecting await self.applyNetworkContext(self.appContext?.networkContext) case .connected: self.phase = .connected await self.applyNetworkContext(self.appContext?.networkContext) case .disconnecting: self.phase = .disconnecting case .disconnected: self.handleDisconnectedState(syncAppContext: true) } } private func handleDisconnectedState(syncAppContext: Bool) { if syncAppContext { self.appContext?.networkContext = nil } self.phase = .disconnected self.networkContext = nil self.selectedNodeId = nil self.nodeResourcesById.removeAll() self.loadingNodeIDs.removeAll() self.showMode = .resource self.syncSharedStateFromAppContext() } private func applyNetworkContext(_ newContext: SDLAPIClient.NetworkContext?) async { let contextChanged = self.contextIdentity(self.networkContext) != self.contextIdentity(newContext) self.networkContext = newContext if contextChanged { self.nodeResourcesById.removeAll() self.loadingNodeIDs.removeAll() self.selectedNodeId = nil } guard let newContext else { self.selectedNodeId = nil return } if let selectedNodeId = self.selectedNodeId, newContext.getNode(id: selectedNodeId) != nil { await self.loadResourcesIfNeeded(for: selectedNodeId) return } self.selectedNodeId = newContext.firstNodeId() if let selectedNodeId = self.selectedNodeId { await self.loadResourcesIfNeeded(for: selectedNodeId) } } private func contextIdentity(_ context: SDLAPIClient.NetworkContext?) -> String? { guard let context else { return nil } return "\(context.identityId)-\(context.ip)" } } // MARK: - 主网络视图 struct NetworkView: View { @Environment(AppContext.self) var appContext: AppContext @Environment(\.openWindow) var openWindow @State private var networkModel = NetworkModel() var body: some View { @Bindable var networkModel = self.networkModel VStack(spacing: 0) { // 1. 头部区域 (Header) HStack(spacing: 16) { NetworkStatusBar(model: self.networkModel) Spacer() if self.networkModel.shouldShowModePicker { Picker("", selection: $networkModel.showMode) { ForEach(NetworkShowMode.allCases, id: \.self) { Text($0.rawValue).tag($0) } } .pickerStyle(.segmented) .frame(width: 160) } Button { openWindow(id: "settings") } label: { Image(systemName: "slider.horizontal.3") .font(.system(size: 14)) .foregroundColor(.secondary) } .buttonStyle(.plain) .help("配置中心") } .padding(.horizontal, 20) .padding(.vertical, 14) .background(VisualEffectView(material: .headerView, blendingMode: .withinWindow)) Divider() // 2. 内容区域 (Content) Group { switch self.networkModel.phase { case .connecting, .disconnecting: NetworkWaitAuthView(phase: self.networkModel.phase) case .connected: NetworkConnectedView(model: self.networkModel) case .disconnected: NetworkDisconnectedView(model: self.networkModel) } } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(VisualEffectView(material: .windowBackground, blendingMode: .behindWindow)) } .frame(minWidth: 700, minHeight: 500) // 适当调大宽度以适应 SplitView .task { await self.networkModel.activate(appContext: self.appContext) } .alert("提示", isPresented: self.errorPresented) { Button("确定", role: .cancel) { self.networkModel.clearError() } } message: { Text(self.networkModel.errorMessage ?? "") } } private var errorPresented: Binding { Binding( get: { self.networkModel.errorMessage != nil }, set: { isPresented in if !isPresented { self.networkModel.clearError() } } ) } } struct NetworkStatusBar: View { var model: NetworkModel var body: some View { let isOnBinding = Binding( get: { self.model.isTunnelEnabled }, set: { newValue in Task { @MainActor in await self.model.setConnectionEnabled(newValue) } } ) HStack(spacing: 12) { // 左侧:状态指示器与文字 HStack(spacing: 20) { ZStack { Circle() .fill(self.model.isTunnelEnabled ? Color.green.opacity(0.15) : Color.primary.opacity(0.05)) .frame(width: 36, height: 36) Image(systemName: self.model.isTunnelEnabled ? "checkmark.shield.fill" : "shield.slash.fill") .symbolRenderingMode(.hierarchical) .foregroundStyle(self.model.isTunnelEnabled ? Color.green : Color.secondary) .font(.system(size: 16)) } VStack(alignment: .leading, spacing: 1) { if let networkSession = self.model.networkSession { Text(networkSession.networkName) .font(.system(size: 12, weight: .semibold)) Text("局域网IP: \(self.model.networkContext?.ip ?? "0.0.0.0")") .font(.system(size: 10, design: .monospaced)) .foregroundColor(.secondary) } else { Text("未登录网络") .font(.system(size: 12, weight: .semibold)) Text("登录后可建立连接") .font(.system(size: 10)) .foregroundColor(.secondary) } } } if self.model.networkSession != nil { exitNodeMenu } // 右侧:Switch 开关 // 注意:这里使用 Binding 手动接管连接/断开逻辑 Toggle("", isOn: isOnBinding) .toggleStyle(.switch) .controlSize(.small) // macOS 顶部栏或面板推荐使用 small 尺寸 .disabled(self.model.phase == .connecting || self.model.phase == .disconnecting || self.model.networkSession == nil) } .padding(.vertical, 5) } private var exitNodeMenu: some View { Menu { Button { Task { @MainActor in await self.model.updateExitNodeSelection(nil) } } label: { if self.model.selectedExitNode == nil { Label("不设置出口节点", systemImage: "checkmark") } else { Text("不设置出口节点") } } if !self.model.exitNodeOptions.isEmpty { Divider() ForEach(self.model.exitNodeOptions) { option in Button { Task { @MainActor in await self.model.updateExitNodeSelection(option.ip) } } label: { if self.model.selectedExitNode?.ip == option.ip { Label(option.nodeNameWithIp, systemImage: "checkmark") } else { Text(option.nodeNameWithIp) } } } } } label: { HStack(spacing: 10) { VStack(alignment: .leading, spacing: 3) { Text("出口节点") .font(.system(size: 10, weight: .medium)) .foregroundColor(.secondary) Text(self.model.exitNodeTitle) .font(.system(size: 12, weight: .semibold)) .foregroundColor(.primary) .lineLimit(1) Text(self.model.exitNodeSubtitle) .font(.system(size: 10, design: .monospaced)) .foregroundColor(.secondary) .lineLimit(1) } Spacer(minLength: 0) if self.model.isUpdatingExitNode { ProgressView() .controlSize(.small) } else { Image(systemName: "chevron.down") .font(.system(size: 10, weight: .semibold)) .foregroundColor(.secondary) } } .padding(.horizontal, 12) .padding(.vertical, 8) .frame(width: 220, alignment: .leading) .background( RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(Color.primary.opacity(0.04)) ) .overlay( RoundedRectangle(cornerRadius: 12, style: .continuous) .stroke(Color.primary.opacity(0.06), lineWidth: 1) ) } .buttonStyle(.plain) .disabled(!self.model.canSelectExitNode) .opacity(self.model.canSelectExitNode ? 1 : 0.7) .help(self.model.exitNodeHelpText) } } struct ExitNodeOption: Identifiable, Equatable { let id: Int let nodeName: String let ip: String let system: String? var nodeNameWithIp: String { "\(nodeName) (\(ip))" } } struct NetworkConnectedView: View { var model: NetworkModel var body: some View { if self.model.showMode == .resource { // 资源视图:网格布局 ScrollView { LazyVGrid(columns: [ GridItem(.flexible(), spacing: 8), GridItem(.flexible(), spacing: 8), GridItem(.flexible(), spacing: 8) ], spacing: 10) { ForEach(self.model.resourceList, id: \.uuid) { res in ResourceItemCard(resource: res) } } .padding(20) } .transition(.opacity) .frame(maxWidth: .infinity) } else { // 设备视图:双栏布局 NetworkDeviceGroupView(model: self.model) .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .opacity)) } } } struct NetworkDisconnectedView: View { var model: NetworkModel var body: some View { VStack(spacing: 20) { Spacer() Image(systemName: "antenna.radiowaves.left.and.right") .font(.system(size: 40, weight: .ultraLight)) .foregroundStyle(.tertiary) .symbolEffect(.pulse, options: .repeating) Text("尚未接入网络") .font(.headline) Button(action: { Task { @MainActor in await self.model.connect() } }) { Text("建立安全连接") .frame(width: 80) } .buttonStyle(.borderedProminent) .disabled(self.model.phase == .connecting || self.model.networkSession == nil) Spacer() } } } // MARK: - 设备组视图 (NavigationSplitView) struct NetworkDeviceGroupView: View { var model: NetworkModel // 侧边栏宽度 private let sidebarWidth: CGFloat = 240 var body: some View { let selectedIdBinding = Binding( get: { self.model.selectedNodeId }, set: { newValue in self.model.selectNode(id: newValue) } ) HStack(spacing: 0) { // --- 1. 自定义侧边栏 (Sidebar) --- VStack(alignment: .leading, spacing: 0) { // 顶部留白:避开 macOS 窗口左上角的红绿灯按钮 // 如果你的 WindowStyle 是 .hiddenTitleBar,这个 Padding 非常重要 Color.clear.frame(height: 28) List(self.model.nodeList, id: \.id, selection: selectedIdBinding) { node in NetworkNodeHeadView(node: node) // 技巧:在 HStack 方案中,tag 配合 List 的 selection 依然有效 .tag(node.id) .listRowSeparator(.hidden) } .listStyle(.inset) // 使用 inset 样式在自定义侧边栏中更美观 .scrollContentBackground(.hidden) // 隐藏默认白色背景,显示下方的磨砂材质 } .frame(width: sidebarWidth) Divider() // 分割线 // --- 2. 详情区域 (Detail) --- ZStack { if let selectedNode = self.model.selectedNode { NetworkNodeDetailView(model: self.model, node: selectedNode) .transition(.opacity.animation(.easeInOut(duration: 0.2))) } else { ContentUnavailableView( "选择成员设备", systemImage: "macbook.and.iphone", description: Text("查看详细网络信息和服务") ) } } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(nsColor: .windowBackgroundColor)) // 详情页使用标准窗口背景色 } .ignoresSafeArea() // 真正顶到最上方 } } // MARK: - 子组件 struct NetworkNodeHeadView: View { var node: SDLAPIClient.NetworkContext.Node var body: some View { HStack(spacing: 10) { Circle() .fill(node.connectionStatus == "在线" ? Color.green : Color.secondary.opacity(0.4)) .frame(width: 8, height: 8) VStack(alignment: .leading, spacing: 2) { Text(node.name) .font(.system(size: 13, weight: .medium)) Text(node.ip) .font(.system(size: 11, design: .monospaced)) .foregroundColor(.secondary) } } .padding(.vertical, 4) } } struct NetworkNodeDetailView: View { var model: NetworkModel var node: SDLAPIClient.NetworkContext.Node var body: some View { List { Section("节点信息") { LabeledContent("连接状态", value: node.connectionStatus) LabeledContent("虚拟IPv4", value: node.ip) LabeledContent("系统环境", value: node.system ?? "未知") } Section("提供的服务") { if self.model.isLoadingResources(for: node.id) { ProgressView() .controlSize(.small) } else if self.model.resources(for: node.id).isEmpty { Text("该节点暂未发布资源") .foregroundColor(.secondary) .font(.callout) } else { ForEach(self.model.resources(for: node.id), id: \.id) { res in VStack(alignment: .leading) { Text(res.name) .font(.body) Text(res.url) .font(.caption) .foregroundColor(.secondary) } } } } } .task(id: self.node.id) { await self.model.loadResourcesIfNeeded(for: self.node.id) } } } struct ResourceItemCard: View { let resource: SDLAPIClient.NetworkContext.Resource @State private var isHovered = false var body: some View { VStack(alignment: .leading, spacing: 8) { Image(systemName: "safari.fill") .foregroundColor(.accentColor) .font(.title3) Text(resource.name) .font(.headline) .lineLimit(1) .truncationMode(.tail) Text(resource.url) .font(.caption2) .foregroundColor(.secondary) .lineLimit(1) .truncationMode(.middle) } .padding() .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) .overlay( RoundedRectangle(cornerRadius: 10) .stroke(Color.gray, lineWidth: 1) ) .background( RoundedRectangle(cornerRadius: 10) .fill(Color(isHovered ? NSColor.selectedControlColor : NSColor.controlBackgroundColor)) ) .onHover { isHovered = $0 } } } struct NetworkWaitAuthView: View { var phase: NetworkConnectionPhase var body: some View { VStack(spacing: 16) { ProgressView() Text(self.phase == .disconnecting ? "正在断开网络..." : "正在建立安全连接...") .foregroundColor(.secondary) } } }