diff --git a/punchnet/Core/VPNManager.swift b/punchnet/Core/VPNManager.swift index ccd9ff6..6dbe654 100644 --- a/punchnet/Core/VPNManager.swift +++ b/punchnet/Core/VPNManager.swift @@ -29,7 +29,9 @@ class VPNManager { private var vpnStatusCont: AsyncStream.Continuation enum VPNStatus { + case connecting case connected + case disconnecting case disconnected } @@ -93,11 +95,19 @@ class VPNManager { self.statusObserver = NotificationCenter.default.addObserver(forName: .NEVPNStatusDidChange, object: manager.connection, queue: .main) {[weak self] _ in NSLog("status channge: \(manager.connection.status)") switch manager.connection.status { - case .invalid, .disconnected, .disconnecting: + case .invalid, .disconnected: self?.vpnStatusCont.yield(.disconnected) self?.vpnStatus = .disconnected self?.isConnected = false - case .connecting, .connected, .reasserting: + case .disconnecting: + self?.vpnStatusCont.yield(.disconnecting) + self?.vpnStatus = .disconnecting + self?.isConnected = false + case .connecting, .reasserting: + self?.vpnStatusCont.yield(.connecting) + self?.vpnStatus = .connecting + self?.isConnected = true + case .connected: self?.vpnStatusCont.yield(.connected) self?.vpnStatus = .connected self?.isConnected = true diff --git a/punchnet/Views/AppContext.swift b/punchnet/Views/AppContext.swift index 08c6551..b863add 100644 --- a/punchnet/Views/AppContext.swift +++ b/punchnet/Views/AppContext.swift @@ -146,6 +146,7 @@ class AppContext { // 断开网络连接 func stopTun() async throws { try await self.vpnManager.disableVpn() + self.networkContext = nil } // 退出登陆 diff --git a/punchnet/Views/MenuBar/MainMenuBar.swift b/punchnet/Views/MenuBar/MainMenuBar.swift index 5f21500..d424975 100644 --- a/punchnet/Views/MenuBar/MainMenuBar.swift +++ b/punchnet/Views/MenuBar/MainMenuBar.swift @@ -18,11 +18,23 @@ struct MainMenuBar: View { case .connected: Button(action: { Task { @MainActor in - try await vpnManager.disableVpn() + try await appContext.stopTun() } }, label: { Text("停止") }) + case .connecting: + Button(action: { + }, label: { + Text("连接中...") + }) + .disabled(true) + case .disconnecting: + Button(action: { + }, label: { + Text("断开中...") + }) + .disabled(true) case .disconnected: Button(action: { Task { @MainActor in @@ -55,8 +67,13 @@ struct MainMenuBar: View { } private func startVPN() async { - if let options = appContext.vpnOptions { - try? await vpnManager.enableVpn(options: options) + do { + if appContext.networkContext == nil { + try await appContext.connectNetwork() + } + try await appContext.startTun() + } catch { + NSLog("menu start vpn failed: \(error)") } } diff --git a/punchnet/Views/Network/NetworkView.swift b/punchnet/Views/Network/NetworkView.swift index b1ddb02..42eb8ba 100644 --- a/punchnet/Views/Network/NetworkView.swift +++ b/punchnet/Views/Network/NetworkView.swift @@ -4,39 +4,449 @@ import SwiftUI import Observation -// MARK: - 基础模型协议 -enum ConnectState { - case waitAuth - case connected - case disconnected -} - // 资源展示模式 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 showMode: NetworkShowMode = .resource - @State private var connectState: ConnectState = .disconnected - private var vpnManager = VPNManager.shared + @State private var networkModel = NetworkModel() var body: some View { + @Bindable var networkModel = self.networkModel + VStack(spacing: 0) { // 1. 头部区域 (Header) HStack(spacing: 16) { - NetworkStatusBar() + NetworkStatusBar(model: self.networkModel) Spacer() - if connectState == .connected { - Picker("", selection: $showMode) { + if self.networkModel.shouldShowModePicker { + Picker("", selection: $networkModel.showMode) { ForEach(NetworkShowMode.allCases, id: \.self) { Text($0.rawValue).tag($0) } @@ -63,66 +473,52 @@ struct NetworkView: View { // 2. 内容区域 (Content) Group { - switch connectState { - case .waitAuth: - NetworkWaitAuthView() + switch self.networkModel.phase { + case .connecting, .disconnecting: + NetworkWaitAuthView(phase: self.networkModel.phase) case .connected: - NetworkConnectedView(showMode: $showMode) + NetworkConnectedView(model: self.networkModel) case .disconnected: - NetworkDisconnectedView() + NetworkDisconnectedView(model: self.networkModel) } } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(VisualEffectView(material: .windowBackground, blendingMode: .behindWindow)) } .frame(minWidth: 700, minHeight: 500) // 适当调大宽度以适应 SplitView - .onAppear { - syncState(vpnManager.vpnStatus) + .task { + await self.networkModel.activate(appContext: self.appContext) } - .onChange(of: vpnManager.vpnStatus) { _, newStatus in - withAnimation(.snappy) { - syncState(newStatus) + .alert("提示", isPresented: self.errorPresented) { + Button("确定", role: .cancel) { + self.networkModel.clearError() } + } message: { + Text(self.networkModel.errorMessage ?? "") } } - // 通过VPN的连接状态同步当前页面的显示状态 - private func syncState(_ status: VPNManager.VPNStatus) { - switch status { - case .connected: - connectState = .connected - case .disconnected: - connectState = .disconnected - @unknown default: - connectState = .disconnected - } + private var errorPresented: Binding { + Binding( + get: { self.networkModel.errorMessage != nil }, + set: { isPresented in + if !isPresented { + self.networkModel.clearError() + } + } + ) } - } struct NetworkStatusBar: View { - @Environment(AppContext.self) private var appContext - @State private var vpnManger = VPNManager.shared - - @State private var isUpdatingExitNode: Bool = false - @State private var showExitNodeError: Bool = false - @State private var exitNodeErrorMessage: String = "" + var model: NetworkModel var body: some View { let isOnBinding = Binding( - get: { vpnManger.isConnected }, + get: { self.model.isTunnelEnabled }, set: { newValue in - if newValue { - Task { - if self.appContext.networkContext == nil { - try? await self.appContext.connectNetwork() - } - try? await self.appContext.startTun() - } - } else { - Task { - try? await self.appContext.stopTun() - } + Task { @MainActor in + await self.model.setConnectionEnabled(newValue) } } ) @@ -132,21 +528,21 @@ struct NetworkStatusBar: View { HStack(spacing: 20) { ZStack { Circle() - .fill(vpnManger.isConnected ? Color.green.opacity(0.15) : Color.primary.opacity(0.05)) + .fill(self.model.isTunnelEnabled ? Color.green.opacity(0.15) : Color.primary.opacity(0.05)) .frame(width: 36, height: 36) - Image(systemName: vpnManger.isConnected ? "checkmark.shield.fill" : "shield.slash.fill") + Image(systemName: self.model.isTunnelEnabled ? "checkmark.shield.fill" : "shield.slash.fill") .symbolRenderingMode(.hierarchical) - .foregroundStyle(vpnManger.isConnected ? Color.green : Color.secondary) + .foregroundStyle(self.model.isTunnelEnabled ? Color.green : Color.secondary) .font(.system(size: 16)) } VStack(alignment: .leading, spacing: 1) { - if let networkSession = appContext.networkSession { + if let networkSession = self.model.networkSession { Text(networkSession.networkName) .font(.system(size: 12, weight: .semibold)) - Text("局域网IP: \(appContext.networkContext?.ip ?? "0.0.0.0")") + Text("局域网IP: \(self.model.networkContext?.ip ?? "0.0.0.0")") .font(.system(size: 10, design: .monospaced)) .foregroundColor(.secondary) } else { @@ -160,7 +556,7 @@ struct NetworkStatusBar: View { } } - if appContext.networkSession != nil { + if self.model.networkSession != nil { exitNodeMenu } @@ -169,50 +565,35 @@ struct NetworkStatusBar: View { Toggle("", isOn: isOnBinding) .toggleStyle(.switch) .controlSize(.small) // macOS 顶部栏或面板推荐使用 small 尺寸 - - -// TextField("出口节点:", text: $exitNodeIp) -// -// Button { -// Task { -// let result = try await self.appContext.changeExitNodeIp(exitNodeIp: self.exitNodeIp) -// let reply = try TunnelResponse(serializedBytes: result) -// NSLog("change exit node ip: \(reply)") -// } -// } label: { -// Text("启动出口节点") -// } - + .disabled(self.model.phase == .connecting || self.model.phase == .disconnecting || self.model.networkSession == nil) } .padding(.vertical, 5) - .alert("出口节点切换失败", isPresented: $showExitNodeError) { - Button("确定", role: .cancel) { - } - } message: { - Text(exitNodeErrorMessage) - } } private var exitNodeMenu: some View { Menu { Button { - applyExitNodeSelection(nil) + Task { @MainActor in + await self.model.updateExitNodeSelection(nil) + } } label: { - if selectedExitNode == nil { + if self.model.selectedExitNode == nil { Label("不设置出口节点", systemImage: "checkmark") } else { Text("不设置出口节点") } } - if !exitNodeOptions.isEmpty { + if !self.model.exitNodeOptions.isEmpty { Divider() - ForEach(exitNodeOptions) { option in + ForEach(self.model.exitNodeOptions) { option in Button { - applyExitNodeSelection(option.ip) + Task { @MainActor in + await self.model.updateExitNodeSelection(option.ip) + } } label: { - if selectedExitNode?.ip == option.ip { + if self.model.selectedExitNode?.ip == option.ip { Label(option.nodeNameWithIp, systemImage: "checkmark") } else { Text(option.nodeNameWithIp) @@ -227,12 +608,12 @@ struct NetworkStatusBar: View { .font(.system(size: 10, weight: .medium)) .foregroundColor(.secondary) - Text(exitNodeTitle) + Text(self.model.exitNodeTitle) .font(.system(size: 12, weight: .semibold)) .foregroundColor(.primary) .lineLimit(1) - Text(exitNodeSubtitle) + Text(self.model.exitNodeSubtitle) .font(.system(size: 10, design: .monospaced)) .foregroundColor(.secondary) .lineLimit(1) @@ -240,7 +621,7 @@ struct NetworkStatusBar: View { Spacer(minLength: 0) - if isUpdatingExitNode { + if self.model.isUpdatingExitNode { ProgressView() .controlSize(.small) } else { @@ -262,118 +643,13 @@ struct NetworkStatusBar: View { ) } .buttonStyle(.plain) - .disabled(isUpdatingExitNode || !canUpdateExitNode) - .opacity(canUpdateExitNode ? 1 : 0.7) - .help(exitNodeHelpText) + .disabled(!self.model.canSelectExitNode) + .opacity(self.model.canSelectExitNode ? 1 : 0.7) + .help(self.model.exitNodeHelpText) } - - private var exitNodeOptions: [ExitNodeOption] { - guard let networkContext = appContext.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 - ) - } - } - - private var selectedExitNode: ExitNodeOption? { - guard let selectedExitNodeIp = appContext.selectedExitNodeIp else { - return nil - } - - return exitNodeOptions.first(where: { $0.ip == selectedExitNodeIp }) - ?? ExitNodeOption( - id: -1, - nodeName: "已保存出口节点", - ip: selectedExitNodeIp, - system: nil - ) - } - - private var exitNodeTitle: String { - if isUpdatingExitNode { - return "正在切换..." - } - - if let selectedExitNode { - return selectedExitNode.nodeName - } - - return "未设置" - } - - private var exitNodeSubtitle: String { - if let selectedExitNode { - if let system = selectedExitNode.system, !system.isEmpty { - return "\(selectedExitNode.ip) · \(system)" - } - - return selectedExitNode.ip - } - - if appContext.networkContext == nil { - return "连接后可选择" - } - - if exitNodeOptions.isEmpty { - return "当前网络没有可用节点" - } - - return "当前流量保持默认出口" - } - - private var canUpdateExitNode: Bool { - appContext.networkContext != nil || appContext.selectedExitNodeIp != nil - } - - private var exitNodeHelpText: String { - if isUpdatingExitNode { - return "正在更新出口节点" - } - - if appContext.networkContext == nil { - return "建立连接后可选择当前网络的出口节点" - } - - return "切换当前网络流量的出口节点,也可以保持未设置" - } - - private func applyExitNodeSelection(_ ip: String?) { - guard !isUpdatingExitNode else { - return - } - - Task { @MainActor in - self.isUpdatingExitNode = true - defer { - self.isUpdatingExitNode = false - } - - do { - try await self.appContext.updateExitNodeIp(exitNodeIp: ip) - } catch let err as AppContextError { - self.exitNodeErrorMessage = err.message - self.showExitNodeError = true - } catch { - self.exitNodeErrorMessage = error.localizedDescription - self.showExitNodeError = true - } - } - } - } -private struct ExitNodeOption: Identifiable, Equatable { +struct ExitNodeOption: Identifiable, Equatable { let id: Int let nodeName: String let ip: String @@ -385,11 +661,10 @@ private struct ExitNodeOption: Identifiable, Equatable { } struct NetworkConnectedView: View { - @Environment(AppContext.self) private var appContext: AppContext - @Binding var showMode: NetworkShowMode + var model: NetworkModel var body: some View { - if showMode == .resource { + if self.model.showMode == .resource { // 资源视图:网格布局 ScrollView { LazyVGrid(columns: [ @@ -397,7 +672,7 @@ struct NetworkConnectedView: View { GridItem(.flexible(), spacing: 8), GridItem(.flexible(), spacing: 8) ], spacing: 10) { - ForEach(appContext.networkContext?.resourceList ?? [], id: \.uuid) { res in + ForEach(self.model.resourceList, id: \.uuid) { res in ResourceItemCard(resource: res) } } @@ -407,19 +682,14 @@ struct NetworkConnectedView: View { .frame(maxWidth: .infinity) } else { // 设备视图:双栏布局 - NetworkDeviceGroupView() + NetworkDeviceGroupView(model: self.model) .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .opacity)) } } - } struct NetworkDisconnectedView: View { - @Environment(AppContext.self) private var appContext: AppContext - @State private var isConnecting: Bool = false - - @State private var showAlert: Bool = false - @State private var errorMessage: String = "" + var model: NetworkModel var body: some View { VStack(spacing: 20) { @@ -434,59 +704,34 @@ struct NetworkDisconnectedView: View { Button(action: { Task { @MainActor in - await startConnection() + await self.model.connect() } }) { - if isConnecting { - ProgressView() - .controlSize(.small) - .frame(width: 80) - } else { - Text("建立安全连接") - .frame(width: 80) - } + Text("建立安全连接") + .frame(width: 80) } .buttonStyle(.borderedProminent) - .disabled(isConnecting) + .disabled(self.model.phase == .connecting || self.model.networkSession == nil) Spacer() } - .alert(isPresented: $showAlert) { - Alert(title: Text("提示"), message: Text(errorMessage)) - } } - - private func startConnection() async { - self.isConnecting = true - defer { - self.isConnecting = false - } - - do { - try await self.appContext.connectNetwork() - try await self.appContext.startTun() - } catch let err as SDLAPIError { - self.showAlert = true - self.errorMessage = err.message - } catch let err as AppContextError { - self.showAlert = true - self.errorMessage = err.message - } catch let err { - self.showAlert = true - self.errorMessage = err.localizedDescription - } - } - } // MARK: - 设备组视图 (NavigationSplitView) struct NetworkDeviceGroupView: View { - @Environment(AppContext.self) private var appContext: AppContext - @State private var selectedId: Int? + 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) { @@ -494,7 +739,7 @@ struct NetworkDeviceGroupView: View { // 如果你的 WindowStyle 是 .hiddenTitleBar,这个 Padding 非常重要 Color.clear.frame(height: 28) - List(appContext.networkContext?.nodeList ?? [], id: \.id, selection: $selectedId) { node in + List(self.model.nodeList, id: \.id, selection: selectedIdBinding) { node in NetworkNodeHeadView(node: node) // 技巧:在 HStack 方案中,tag 配合 List 的 selection 依然有效 .tag(node.id) @@ -509,8 +754,8 @@ struct NetworkDeviceGroupView: View { // --- 2. 详情区域 (Detail) --- ZStack { - if let selectedNode = appContext.networkContext?.getNode(id: selectedId) { - NetworkNodeDetailView(node: selectedNode) + if let selectedNode = self.model.selectedNode { + NetworkNodeDetailView(model: self.model, node: selectedNode) .transition(.opacity.animation(.easeInOut(duration: 0.2))) } else { ContentUnavailableView( @@ -524,17 +769,13 @@ struct NetworkDeviceGroupView: View { .background(Color(nsColor: .windowBackgroundColor)) // 详情页使用标准窗口背景色 } .ignoresSafeArea() // 真正顶到最上方 - .onAppear { - if selectedId == nil { - selectedId = appContext.networkContext?.firstNodeId() - } - } } } // MARK: - 子组件 struct NetworkNodeHeadView: View { var node: SDLAPIClient.NetworkContext.Node + var body: some View { HStack(spacing: 10) { Circle() @@ -555,11 +796,8 @@ struct NetworkNodeHeadView: View { } struct NetworkNodeDetailView: View { - @Environment(AppContext.self) private var appContext: AppContext - + var model: NetworkModel var node: SDLAPIClient.NetworkContext.Node - @State private var resources: [SDLAPIClient.NetworkContext.Resource] = [] - @State private var isLoading = false var body: some View { List { @@ -570,15 +808,15 @@ struct NetworkNodeDetailView: View { } Section("提供的服务") { - if isLoading { + if self.model.isLoadingResources(for: node.id) { ProgressView() .controlSize(.small) - } else if resources.isEmpty { + } else if self.model.resources(for: node.id).isEmpty { Text("该节点暂未发布资源") .foregroundColor(.secondary) .font(.callout) } else { - ForEach(resources, id: \.id) { res in + ForEach(self.model.resources(for: node.id), id: \.id) { res in VStack(alignment: .leading) { Text(res.name) .font(.body) @@ -591,24 +829,10 @@ struct NetworkNodeDetailView: View { } } } - .task { - await loadNodeResources(id: node.id) + .task(id: self.node.id) { + await self.model.loadResourcesIfNeeded(for: self.node.id) } } - - // 请求对应的资源信息 - private func loadNodeResources(id: Int) async { - guard let session = appContext.networkSession else { - return - } - - self.isLoading = true - defer { - self.isLoading = false - } - self.resources = await SDLAPIClient.loadNodeResources(accesToken: session.accessToken, id: id) - } - } struct ResourceItemCard: View { @@ -649,11 +873,13 @@ struct ResourceItemCard: View { } struct NetworkWaitAuthView: View { + var phase: NetworkConnectionPhase + var body: some View { VStack(spacing: 16) { ProgressView() - Text("等待认证确认中...") + Text(self.phase == .disconnecting ? "正在断开网络..." : "正在建立安全连接...") .foregroundColor(.secondary) } }