diff --git a/punchnet/Shared/Networking/SDLAPIClient+Network.swift b/punchnet/Features/Network/API/SDLAPIClient+Network.swift similarity index 100% rename from punchnet/Shared/Networking/SDLAPIClient+Network.swift rename to punchnet/Features/Network/API/SDLAPIClient+Network.swift diff --git a/punchnet/Features/Network/ViewModels/NetworkModel.swift b/punchnet/Features/Network/ViewModels/NetworkModel.swift new file mode 100644 index 0000000..28d666d --- /dev/null +++ b/punchnet/Features/Network/ViewModels/NetworkModel.swift @@ -0,0 +1,417 @@ +// +// NetworkModel.swift +// punchnet +// +// Created by 安礼成 on 2026/4/17. +// +import Foundation +import Observation + +@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)" + } +} diff --git a/punchnet/Features/Network/Views/NetworkView.swift b/punchnet/Features/Network/Views/NetworkView.swift index 42eb8ba..3c16057 100644 --- a/punchnet/Features/Network/Views/NetworkView.swift +++ b/punchnet/Features/Network/Views/NetworkView.swift @@ -18,415 +18,7 @@ enum NetworkConnectionPhase { 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 {