punchnet-macos/punchnet/App/AppContext.swift
2026-05-27 18:22:22 +08:00

379 lines
12 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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,
exposedService: context.exposedService,
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<String>()
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
}
}