// // LoginState.swift // punchnet // // Created by 安礼成 on 2026/1/16. // import Foundation import Observation struct AppContextError: Error { let message: String } struct TokenHistoryItem: Codable, Hashable { let token: String let networkName: String? } @Observable class AppContext { private let cachedTokenAccount = "token" private let cachedTokenHistoryAccount = "tokenHistory" private let maxCachedTokenCount = 10 private var vpnManager = VPNManager.shared // 调用 "/connect" 之后的网络信息 var networkContext: NetworkContext? = nil // 当前选择的出口节点 IP,为 nil 表示不设置出口节点 var selectedExitNodeIp: String? = nil // 在menu里面需要使用 var vpnOptions: [String: NSObject]? = nil // 当前app所处的场景 var appScene: AppScene = .login(username: nil) // 当前的场景 enum AppScene: Equatable { case login(username: String?) case logined case register case resetPassword } // 登陆凭证 var loginCredit: Credit? // 判断用户是否登陆 var isLogined: Bool { return loginCredit != nil } enum Credit { case token(token: String, session: NetworkSession) case accountAndPasword(account: String, password: String, session: NetworkSession) } @ObservationIgnored var networkSession: NetworkSession? { guard let loginCredit = self.loginCredit else { return nil } switch loginCredit { case .token(_, let session): return session case .accountAndPasword(_, _, let session): return session } } var tunnelEvent: SDLTunnelAppEventStore.Event? = nil @ObservationIgnored private var lastTunnelEventID: String? init() { self.observeTunnelEvent() self.consumeLatestTunnelEvent() } func loginWith(token: String) async throws { let networkSession = try await AuthService.loginWithToken(token: token) self.loginCredit = .token(token: token, session: networkSession) self.selectedExitNodeIp = self.loadExitNodeIp(networkId: networkSession.networkId) try self.saveCacheToken(token, networkName: networkSession.networkName) } func loginWith(username: String, password: String) async throws { let networkSession = try await AuthService.loginWithAccountAndPassword(username: username, password: password) self.loginCredit = .accountAndPasword(account: username, password: password, session: networkSession) self.selectedExitNodeIp = self.loadExitNodeIp(networkId: networkSession.networkId) // 将数据缓存到keychain if let data = "\(username):\(password)".data(using: .utf8) { try KeychainStore.shared.save(data, account: "accountAndPasword") } } // 连接到对应的网络 func connectNetwork() async throws { guard let session = self.networkSession else { throw AppContextError(message: "未登陆") } // 避免重复连接 guard !vpnManager.isConnected else { throw AppContextError(message: "网络已经连接") } self.networkContext = try await NetworkService.connectNetwork(accesToken: session.accessToken) } func changeExitNodeIp(exitNodeIp: String) async throws -> Data { // 避免重复连接 guard vpnManager.isConnected else { throw AppContextError(message: "网络未连接") } var changeExitNode = AppRequest.ChangeExitNodeRequest() changeExitNode.ip = exitNodeIp var appRequest = AppRequest() appRequest.command = .changeExitNode(changeExitNode) let message = try appRequest.serializedData() return try await self.vpnManager.sendMessage(message) } // 启动tun func startTun() async throws { guard let session = self.networkSession, let context = self.networkContext else { return } let options = SystemConfig.getOptions( networkId: UInt32(session.networkId), networkDomain: session.networkDomain, ip: context.ip, maskLen: context.maskLen, accessToken: session.accessToken, identityId: context.identityId, hostname: context.hostname, exitNodeIp: self.selectedExitNodeIp ) try await self.vpnManager.enableVpn(options: options) } // 断开网络连接 func stopTun() async throws { try await self.vpnManager.disableVpn() self.networkContext = nil } // 退出登陆 func logout() async throws { try await self.vpnManager.disableVpn() self.networkContext = nil self.selectedExitNodeIp = nil self.loginCredit = nil } func loadCacheToken() -> String? { if let item = self.loadCacheTokenHistory().first { return item.token } return nil } func loadCacheTokenHistory() -> [TokenHistoryItem] { if let data = try? KeychainStore.shared.load(account: cachedTokenHistoryAccount), let items = try? JSONDecoder().decode([TokenHistoryItem].self, from: data) { let normalizedItems = self.normalizedTokenHistory(items) if !normalizedItems.isEmpty { return normalizedItems } } if let data = try? KeychainStore.shared.load(account: cachedTokenHistoryAccount), let tokens = try? JSONDecoder().decode([String].self, from: data) { let normalizedItems = self.normalizedTokenHistory(tokens.map { TokenHistoryItem(token: $0, networkName: nil) }) if !normalizedItems.isEmpty { return normalizedItems } } if let data = try? KeychainStore.shared.load(account: cachedTokenAccount), let token = String(data: data, encoding: .utf8) { return self.normalizedTokenHistory([ TokenHistoryItem(token: token, networkName: nil) ]) } return [] } func deleteCacheToken(_ token: String) throws { let normalizedToken = token.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedToken.isEmpty else { return } var items = self.loadCacheTokenHistory() items.removeAll { $0.token == normalizedToken } items = Array(items.prefix(maxCachedTokenCount)) let historyData = try JSONEncoder().encode(items) try KeychainStore.shared.save(historyData, account: cachedTokenHistoryAccount) if let firstToken = items.first?.token, let data = firstToken.data(using: .utf8) { try KeychainStore.shared.save(data, account: cachedTokenAccount) } else { try KeychainStore.shared.delete(account: cachedTokenAccount) } } private func saveCacheToken(_ token: String, networkName: String) throws { let normalizedToken = token.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedToken.isEmpty else { return } let normalizedNetworkName = networkName.trimmingCharacters(in: .whitespacesAndNewlines) var items = self.loadCacheTokenHistory() items.removeAll { $0.token == normalizedToken } items.insert( TokenHistoryItem( token: normalizedToken, networkName: normalizedNetworkName.isEmpty ? nil : normalizedNetworkName ), at: 0 ) items = Array(items.prefix(maxCachedTokenCount)) let historyData = try JSONEncoder().encode(items) try KeychainStore.shared.save(historyData, account: cachedTokenHistoryAccount) if let data = normalizedToken.data(using: .utf8) { try KeychainStore.shared.save(data, account: cachedTokenAccount) } } private func normalizedTokenHistory(_ items: [TokenHistoryItem]) -> [TokenHistoryItem] { var seen = Set() return items.compactMap { item in let normalizedToken = item.token.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedToken.isEmpty, !seen.contains(normalizedToken) else { return nil } seen.insert(normalizedToken) let normalizedNetworkName = item.networkName?.trimmingCharacters(in: .whitespacesAndNewlines) return TokenHistoryItem( token: normalizedToken, networkName: normalizedNetworkName?.isEmpty == true ? nil : normalizedNetworkName ) } } func loadCacheUsernameAndPassword() -> (String, String)? { if let data = try? KeychainStore.shared.load(account: "accountAndPasword"), let str = String(data: data, encoding: .utf8) { let parts = str.split(separator: ":") if parts.count == 2 { return (String(parts[0]), String(parts[1])) } } return nil } // MARK: TunEvent func dismissTunnelEvent() { self.tunnelEvent = nil } private func observeTunnelEvent() { SDLNotificationCenter.shared.addObserver(for: .tunnelEventChanged) { [weak self] _ in self?.consumeLatestTunnelEvent() } } private func consumeLatestTunnelEvent() { guard let event = SDLTunnelAppEventStore.loadLatestEvent(), self.lastTunnelEventID != event.id else { return } self.lastTunnelEventID = event.id self.tunnelEvent = event SDLTunnelAppEventStore.clearLatestEvent() } deinit { SDLNotificationCenter.shared.removeObserver(for: .tunnelEventChanged) } } // 处理网络出口数据 extension AppContext { private func exitNodeStorageAccount(networkId: Int?) -> String { if let networkId { return "exitNodeIp_\(networkId)" } return "exitNodeIp" } func loadExitNodeIp(networkId: Int? = nil) -> String? { let account = self.exitNodeStorageAccount(networkId: networkId) if let data = try? KeychainStore.shared.load(account: account) { return String(data: data, encoding: .utf8) } if networkId != nil, let data = try? KeychainStore.shared.load(account: self.exitNodeStorageAccount(networkId: nil)) { return String(data: data, encoding: .utf8) } return nil } func saveExitNodeIp(exitNodeIp: String, networkId: Int? = nil) async throws { let account = self.exitNodeStorageAccount(networkId: networkId) // 将数据缓存到keychain if let data = exitNodeIp.data(using: .utf8) { try KeychainStore.shared.save(data, account: account) } } func clearExitNodeIp(networkId: Int? = nil) throws { try KeychainStore.shared.delete(account: self.exitNodeStorageAccount(networkId: networkId)) } func updateExitNodeIp(exitNodeIp: String?) async throws { let normalizedExitNodeIp = exitNodeIp?.trimmingCharacters(in: .whitespacesAndNewlines) let finalExitNodeIp = normalizedExitNodeIp?.isEmpty == true ? nil : normalizedExitNodeIp let networkId = self.networkSession?.networkId if vpnManager.isConnected { let result = try await self.changeExitNodeIp(exitNodeIp: finalExitNodeIp ?? "0.0.0.0") let reply = try TunnelResponse(serializedBytes: result) guard reply.code == 0 else { throw AppContextError(message: reply.message) } } if let finalExitNodeIp { try await self.saveExitNodeIp(exitNodeIp: finalExitNodeIp, networkId: networkId) } else { try self.clearExitNodeIp(networkId: networkId) } if networkId != nil { try? self.clearExitNodeIp(networkId: nil) } self.selectedExitNodeIp = finalExitNodeIp } }