2026-04-17 14:58:36 +08:00

418 lines
12 KiB
Swift

//
// 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<Void, Never>?
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<Int> = []
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)"
}
}