// // 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: NetworkSession? var networkContext: NetworkContext? var selectedExitNodeIp: String? var selectedNodeId: Int? private(set) var nodeResourcesById: [Int: [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: [NetworkContext.Resource] { self.networkContext?.resourceList ?? [] } var nodeList: [NetworkContext.Node] { self.networkContext?.nodeList ?? [] } var selectedNode: 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) -> [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 NetworkService.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: 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: NetworkContext?) -> String? { guard let context else { return nil } return "\(context.identityId)-\(context.ip)" } } extension NetworkModel { struct ExitNodeOption: Identifiable, Equatable { let id: Int let nodeName: String let ip: String let system: String? var nodeNameWithIp: String { "\(nodeName) (\(ip))" } } }