diff --git a/Library/Network/CommandClient.swift b/Library/Network/CommandClient.swift index 7b1b4b5..4b9a6ac 100644 --- a/Library/Network/CommandClient.swift +++ b/Library/Network/CommandClient.swift @@ -1,6 +1,7 @@ import Foundation import Libbox import os +import Combine private let logger = Logger(category: "CommandClient") @@ -42,6 +43,22 @@ public enum LogLevel: Int, CaseIterable, Identifiable { } } +public struct TrafficSnapshot { + public var status: LibboxStatusMessage? + public var uplinkHistory: [CGFloat] + public var downlinkHistory: [CGFloat] + + public init( + status: LibboxStatusMessage? = nil, + uplinkHistory: [CGFloat] = Array(repeating: 0, count: 30), + downlinkHistory: [CGFloat] = Array(repeating: 0, count: 30) + ) { + self.status = status + self.uplinkHistory = uplinkHistory + self.downlinkHistory = downlinkHistory + } +} + public class CommandClient: ObservableObject { public enum ConnectionType { case status @@ -54,9 +71,18 @@ public class CommandClient: ObservableObject { private let connectionTypes: [ConnectionType] private let logMaxLines: Int private var commandClient: LibboxCommandClient? - private var connectTask: Task? + private var connectTask: Task? + private var activeConnectionToken: UInt64 = 0 + private var isConnecting = false @Published public var isConnected: Bool - @Published public var status: LibboxStatusMessage? + // Coalesce traffic updates so SwiftUI re-renders once per status tick. + @Published private var trafficSnapshot = TrafficSnapshot() + public var status: LibboxStatusMessage? { trafficSnapshot.status } + public var statusPublisher: AnyPublisher { + $trafficSnapshot + .map(\.status) + .eraseToAnyPublisher() + } @Published public var groups: [LibboxOutboundGroup]? @Published public var logList: [LogEntry] @Published public var defaultLogLevel = 0 @@ -70,8 +96,8 @@ public class CommandClient: ObservableObject { @Published public var hasAnyConnection: Bool = false private var connectionsStore: LibboxConnections? - @Published public var uplinkHistory: [CGFloat] = Array(repeating: 0, count: 30) - @Published public var downlinkHistory: [CGFloat] = Array(repeating: 0, count: 30) + public var uplinkHistory: [CGFloat] { trafficSnapshot.uplinkHistory } + public var downlinkHistory: [CGFloat] { trafficSnapshot.downlinkHistory } // Batch processing for logs private var pendingLogs: [LogEntry] = [] @@ -95,18 +121,26 @@ public class CommandClient: ObservableObject { isConnected = true clashModeList = ["rule", "global", "direct"] clashMode = "rule" - uplinkHistory = Array(repeating: CGFloat(1000), count: 30) - downlinkHistory = Array(repeating: CGFloat(5000), count: 30) + trafficSnapshot = TrafficSnapshot( + uplinkHistory: Array(repeating: CGFloat(1000), count: 30), + downlinkHistory: Array(repeating: CGFloat(5000), count: 30) + ) hasAnyConnection = true } public func connect() { - if isConnected { + if isConnected || isConnecting { return } - disconnect() - connectTask = Task { - await performConnection() + if let commandClient { + try? commandClient.disconnect() + self.commandClient = nil + } + isConnecting = true + activeConnectionToken &+= 1 + let token = activeConnectionToken + connectTask = Task { [weak self] in + await self?.performConnection(token: token) } } @@ -115,10 +149,15 @@ public class CommandClient: ObservableObject { connectTask.cancel() self.connectTask = nil } + isConnecting = false + activeConnectionToken &+= 1 if let commandClient { try? commandClient.disconnect() self.commandClient = nil } + if isConnected { + isConnected = false + } } private func flushPendingLogs() { @@ -180,7 +219,7 @@ public class CommandClient: ObservableObject { } } - private nonisolated func performConnection() async { + private nonisolated func performConnection(token: UInt64) async { if connectionTypes.contains(.connections) { await initializeConnectionFilterState() } @@ -201,26 +240,50 @@ public class CommandClient: ObservableObject { } } clientOptions.statusInterval = Int64(NSEC_PER_SEC) - let client = LibboxNewCommandClient(clientHandler(self), clientOptions)! + let client = LibboxNewCommandClient(clientHandler(self, connectionToken: token), clientOptions)! do { try client.connect() } catch { + await finishConnectionAttempt(token: token, client: nil) return } - await MainActor.run { - commandClient = client + await finishConnectionAttempt(token: token, client: client) + } + + private func finishConnectionAttempt(token: UInt64, client: LibboxCommandClient?) async { + await MainActor.run { [self] in + defer { + isConnecting = false + connectTask = nil + } + guard token == activeConnectionToken else { + if let client { + try? client.disconnect() + } + return + } + if let client { + commandClient = client + } } } private class clientHandler: NSObject, LibboxCommandClientHandlerProtocol { private let commandClient: CommandClient + private let connectionToken: UInt64 - init(_ commandClient: CommandClient) { + init(_ commandClient: CommandClient, connectionToken: UInt64) { self.commandClient = commandClient + self.connectionToken = connectionToken + } + + private func isActiveConnection() -> Bool { + commandClient.activeConnectionToken == connectionToken } func connected() { DispatchQueue.main.async { [self] in + guard isActiveConnection() else { return } if commandClient.connectionTypes.contains(.log) { commandClient.logList = [] } @@ -230,6 +293,7 @@ public class CommandClient: ObservableObject { func disconnected(_ message: String?) { DispatchQueue.main.async { [self] in + guard isActiveConnection() else { return } commandClient.isConnected = false } if let message { @@ -239,12 +303,14 @@ public class CommandClient: ObservableObject { func setDefaultLogLevel(_ level: Int32) { DispatchQueue.main.async { [self] in + guard isActiveConnection() else { return } commandClient.defaultLogLevel = Int(level) } } func clearLogs() { DispatchQueue.main.async { [self] in + guard isActiveConnection() else { return } commandClient.clearLogs() } } @@ -253,6 +319,7 @@ public class CommandClient: ObservableObject { guard let messageList else { return } + guard isActiveConnection() else { return } // Collect new logs var newLogs: [LogEntry] = [] @@ -264,6 +331,7 @@ public class CommandClient: ObservableObject { guard !newLogs.isEmpty else { return } DispatchQueue.main.async { [self] in + guard isActiveConnection() else { return } commandClient.pendingLogs.append(contentsOf: newLogs) if commandClient.logBatchTimer == nil { let workItem = DispatchWorkItem { [weak commandClient] in @@ -278,18 +346,17 @@ public class CommandClient: ObservableObject { func writeStatus(_ message: LibboxStatusMessage?) { DispatchQueue.main.async { [self] in - commandClient.status = message + guard isActiveConnection() else { return } + var snapshot = commandClient.trafficSnapshot + snapshot.status = message if let message, message.trafficAvailable { - var newUplink = commandClient.uplinkHistory - newUplink.removeFirst() - newUplink.append(CGFloat(message.uplink)) - commandClient.uplinkHistory = newUplink + snapshot.uplinkHistory.removeFirst() + snapshot.uplinkHistory.append(CGFloat(message.uplink)) - var newDownlink = commandClient.downlinkHistory - newDownlink.removeFirst() - newDownlink.append(CGFloat(message.downlink)) - commandClient.downlinkHistory = newDownlink + snapshot.downlinkHistory.removeFirst() + snapshot.downlinkHistory.append(CGFloat(message.downlink)) } + commandClient.trafficSnapshot = snapshot } } @@ -297,17 +364,20 @@ public class CommandClient: ObservableObject { guard let groups else { return } + guard isActiveConnection() else { return } var newGroups: [LibboxOutboundGroup] = [] while groups.hasNext() { newGroups.append(groups.next()!) } DispatchQueue.main.async { [self] in + guard isActiveConnection() else { return } commandClient.groups = newGroups } } func initializeClashMode(_ modeList: LibboxStringIteratorProtocol?, currentMode: String?) { DispatchQueue.main.async { [self] in + guard isActiveConnection() else { return } commandClient.clashModeList = modeList!.toArray() commandClient.clashMode = currentMode! } @@ -315,6 +385,7 @@ public class CommandClient: ObservableObject { func updateClashMode(_ newMode: String?) { DispatchQueue.main.async { [self] in + guard isActiveConnection() else { return } commandClient.clashMode = newMode! } } @@ -324,6 +395,7 @@ public class CommandClient: ObservableObject { return } DispatchQueue.main.async { [self] in + guard isActiveConnection() else { return } if commandClient.connectionsStore == nil { commandClient.connectionsStore = LibboxNewConnections() } diff --git a/MacLibrary/StatusBarController.swift b/MacLibrary/StatusBarController.swift index 9bced03..0cedb39 100644 --- a/MacLibrary/StatusBarController.swift +++ b/MacLibrary/StatusBarController.swift @@ -495,7 +495,7 @@ public class StatusBarController: NSObject, NSMenuDelegate { if shouldConnect { if commandClient == nil { commandClient = CommandClient(.status) - commandClient!.$status + commandClient!.statusPublisher .receive(on: DispatchQueue.main) .sink { [weak self] status in self?.updateSpeedDisplay(status: status)