418 lines
12 KiB
Swift
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: NetworkSession?
|
|
var networkContext: NetworkContext?
|
|
var selectedExitNodeIp: String?
|
|
var selectedNodeId: Int?
|
|
|
|
private(set) var nodeResourcesById: [Int: [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: [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)"
|
|
}
|
|
}
|