From 9dadf5f6486a9b08d7a3db7750d98bcc76d26f80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 6 Jun 2024 22:41:56 +0800 Subject: [PATCH] Add connections dashboard --- .../Views/Connections/Connection.swift | 74 ++++++++ .../Connections/ConnectionDetailsView.swift | 56 +++++++ .../Connections/ConnectionListPage.swift | 31 ++++ .../Connections/ConnectionListView.swift | 158 ++++++++++++++++++ .../Views/Connections/ConnectionView.swift | 121 ++++++++++++++ .../Views/Dashboard/ActiveDashboardView.swift | 66 ++++++-- .../Views/Dashboard/DashboardPage.swift | 26 ++- ApplicationLibrary/Views/Log/LogView.swift | 2 +- ApplicationLibrary/Views/NavigationPage.swift | 9 +- .../Views/Profile/EditProfileView.swift | 2 +- .../Views/Profile/ProfileView.swift | 2 +- Library/Database/Profile+Date.swift | 6 +- Library/Database/SharedPreferences.swift | 5 + Library/Network/CommandClient.swift | 104 +++++++++++- Library/Network/Extension+Iterator.swift | 2 +- MacLibrary/SidebarView.swift | 1 + sing-box.xcodeproj/project.pbxproj | 30 +++- 17 files changed, 670 insertions(+), 25 deletions(-) create mode 100644 ApplicationLibrary/Views/Connections/Connection.swift create mode 100644 ApplicationLibrary/Views/Connections/ConnectionDetailsView.swift create mode 100644 ApplicationLibrary/Views/Connections/ConnectionListPage.swift create mode 100644 ApplicationLibrary/Views/Connections/ConnectionListView.swift create mode 100644 ApplicationLibrary/Views/Connections/ConnectionView.swift diff --git a/ApplicationLibrary/Views/Connections/Connection.swift b/ApplicationLibrary/Views/Connections/Connection.swift new file mode 100644 index 0000000..2047597 --- /dev/null +++ b/ApplicationLibrary/Views/Connections/Connection.swift @@ -0,0 +1,74 @@ +import Foundation + +public struct Connection: Codable { + public let id: String + public let inbound: String + public let inboundType: String + public let ipVersion: Int32 + public let network: String + public let source: String + public let destination: String + public let domain: String + public let displayDestination: String + public let protocolName: String + public let user: String + public let fromOutbound: String + public let createdAt: Date + public let closedAt: Date? + public var upload: Int64 + public var download: Int64 + public var uploadTotal: Int64 + public var downloadTotal: Int64 + public let rule: String + public let outbound: String + public let outboundType: String + public let chain: [String] + + var hashValue: Int { + var value = id.hashValue + (value, _) = value.addingReportingOverflow(upload.hashValue) + (value, _) = value.addingReportingOverflow(download.hashValue) + (value, _) = value.addingReportingOverflow(uploadTotal.hashValue) + (value, _) = value.addingReportingOverflow(downloadTotal.hashValue) + return value + } + + func performSearch(_ content: String) -> Bool { + for item in content.components(separatedBy: " ") { + let itemSep = item.components(separatedBy: ":") + if itemSep.count == 2 { + if !performSearchType(type: itemSep[0], value: itemSep[1]) { + return false + } + continue + } + if !performSearchPlain(item) { + return false + } + } + return true + } + + private func performSearchPlain(_ content: String) -> Bool { + destination.contains(content) || + domain.contains(content) + } + + private func performSearchType(type: String, value: String) -> Bool { + switch type { + // TODO: impl more + case "network": + return network == value + case "inbound": + return inbound.contains(value) + case "inbound.type": + return inboundType == value + case "source": + return source.contains(value) + case "destination": + return destination.contains(value) + default: + return false + } + } +} diff --git a/ApplicationLibrary/Views/Connections/ConnectionDetailsView.swift b/ApplicationLibrary/Views/Connections/ConnectionDetailsView.swift new file mode 100644 index 0000000..6a68aae --- /dev/null +++ b/ApplicationLibrary/Views/Connections/ConnectionDetailsView.swift @@ -0,0 +1,56 @@ +import Foundation +import Libbox +import SwiftUI + +public struct ConnectionDetailsView: View { + private let connection: Connection + public init(_ connection: Connection) { + self.connection = connection + } + + public var body: some View { + FormView { + if connection.closedAt != nil { + FormTextItem("State", "Closed") + FormTextItem("Created At", connection.createdAt.myFormat) + } else { + FormTextItem("State", "Active") + FormTextItem("Created At", connection.createdAt.myFormat) + } + if let closedAt = connection.closedAt { + FormTextItem("Closed At", closedAt.myFormat) + } + FormTextItem("Upload", LibboxFormatBytes(connection.uploadTotal)) + FormTextItem("Download", LibboxFormatBytes(connection.downloadTotal)) + Section("Metadata") { + FormTextItem("Inbound", connection.inbound) + FormTextItem("Inbound Type", connection.inboundType) + FormTextItem("IP Version", "\(connection.ipVersion)") + FormTextItem("Network", connection.network.uppercased()) + FormTextItem("Source", connection.source) + FormTextItem("Destination", connection.destination) + if !connection.domain.isEmpty { + FormTextItem("Domain", connection.domain) + } + if !connection.protocolName.isEmpty { + FormTextItem("Protocol", connection.protocolName) + } + if !connection.user.isEmpty { + FormTextItem("User", connection.user) + } + if !connection.fromOutbound.isEmpty { + FormTextItem("From Outbound", connection.fromOutbound) + } + if !connection.rule.isEmpty { + FormTextItem("Match Rule", connection.rule) + } + FormTextItem("Outbound", connection.outbound) + FormTextItem("Outbound Type", connection.outboundType) + if connection.chain.count > 1 { + FormTextItem("Chain", connection.chain.reversed().joined(separator: "/")) + } + } + } + .navigationTitle("Connection") + } +} diff --git a/ApplicationLibrary/Views/Connections/ConnectionListPage.swift b/ApplicationLibrary/Views/Connections/ConnectionListPage.swift new file mode 100644 index 0000000..01c26c4 --- /dev/null +++ b/ApplicationLibrary/Views/Connections/ConnectionListPage.swift @@ -0,0 +1,31 @@ +import Foundation +import SwiftUI + +public enum ConnectionListPage: Int, CaseIterable, Identifiable { + public var id: Self { + self + } + + case active + case closed +} + +public extension ConnectionListPage { + var title: String { + switch self { + case .active: + return NSLocalizedString("Active", comment: "") + case .closed: + return NSLocalizedString("Closed", comment: "") + } + } + + var label: some View { + switch self { + case .active: + return Label(title, systemImage: "play.fill") + case .closed: + return Label(title, systemImage: "stop.fill") + } + } +} diff --git a/ApplicationLibrary/Views/Connections/ConnectionListView.swift b/ApplicationLibrary/Views/Connections/ConnectionListView.swift new file mode 100644 index 0000000..661b12b --- /dev/null +++ b/ApplicationLibrary/Views/Connections/ConnectionListView.swift @@ -0,0 +1,158 @@ +import Libbox +import Library +import SwiftUI + +@MainActor +public struct ConnectionListView: View { + @Environment(\.scenePhase) private var scenePhase + @State private var isLoading = true + @StateObject private var commandClient = CommandClient(.connections) + @State private var connections: [Connection] = [] + @State private var selection: ConnectionListPage = .active + @State private var searchText = "" + @State private var alert: Alert? + + public init() {} + public var body: some View { + VStack { + if isLoading { + Text("Loading...") + } else { + if connections.isEmpty { + Text("Empty connections") + } else { + ScrollView { + LazyVGrid(columns: [GridItem(.flexible())], alignment: .leading) { + ForEach(connections.filter { it in + searchText == "" || it.performSearch(searchText) + }, id: \.hashValue) { it in + ConnectionView(it) + } + } + } + .padding() + } + } + } + .toolbar { + ToolbarItem { + Menu { + Picker("State", selection: $commandClient.connectionStateFilter) { + ForEach(ConnectionStateFilter.allCases) { state in + Text(state.name) + } + } + + Picker("Sort By", selection: $commandClient.connectionSort) { + ForEach(ConnectionSort.allCases, id: \.self) { sortBy in + Text(sortBy.name) + } + } + + Button("Close All Connections", role: .destructive) { + do { + try LibboxNewStandaloneCommandClient()!.closeConnections() + } catch { + alert = Alert(error) + } + } + } label: { + Label("Filter", systemImage: "line.3.horizontal.circle") + } + } + } + .searchable(text: $searchText) + .alertBinding($alert) + .onAppear { + connect() + } + .onDisappear { + commandClient.disconnect() + } + .onChangeCompat(of: scenePhase) { newValue in + if newValue == .active { + commandClient.connect() + } else { + commandClient.disconnect() + } + } + .onChangeCompat(of: commandClient.connectionStateFilter) { it in + commandClient.filterConnectionsNow() + Task { + await SharedPreferences.connectionStateFilter.set(it.rawValue) + } + } + .onChangeCompat(of: commandClient.connectionSort) { it in + commandClient.filterConnectionsNow() + Task { + await SharedPreferences.connectionSort.set(it.rawValue) + } + } + .onReceive(commandClient.$connections, perform: { connections in + if let connections { + self.connections = convertConnections(connections) + isLoading = false + } + }) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + #if os(iOS) + .background(Color(uiColor: .systemGroupedBackground)) + #endif + } + + private var backgroundColor: Color { + #if os(iOS) + return Color(uiColor: .secondarySystemGroupedBackground) + #elseif os(macOS) + return Color(nsColor: .textBackgroundColor) + #elseif os(tvOS) + return Color(uiColor: .black) + #endif + } + + private func connect() { + if ApplicationLibrary.inPreview { + isLoading = false + } else { + commandClient.connect() + } + } + + private func convertConnections(_ goConnections: [LibboxConnection]) -> [Connection] { + var connections = [Connection]() + for goConnection in goConnections { + if goConnection.outboundType == "dns" { + continue + } + var closedAt: Date? + if goConnection.closedAt > 0 { + closedAt = Date(timeIntervalSince1970: Double(goConnection.closedAt) / 1000) + } + connections.append(Connection( + id: goConnection.id_, + inbound: goConnection.inbound, + inboundType: goConnection.inboundType, + ipVersion: goConnection.ipVersion, + network: goConnection.network, + source: goConnection.source, + destination: goConnection.destination, + domain: goConnection.domain, + displayDestination: goConnection.displayDestination(), + protocolName: goConnection.protocol, + user: goConnection.user, + fromOutbound: goConnection.fromOutbound, + createdAt: Date(timeIntervalSince1970: Double(goConnection.createdAt) / 1000), + closedAt: closedAt, + upload: goConnection.uplink, + download: goConnection.downlink, + uploadTotal: goConnection.uplinkTotal, + downloadTotal: goConnection.downlinkTotal, + rule: goConnection.rule, + outbound: goConnection.outbound, + outboundType: goConnection.outboundType, + chain: goConnection.chain()!.toArray() + )) + } + return connections + } +} diff --git a/ApplicationLibrary/Views/Connections/ConnectionView.swift b/ApplicationLibrary/Views/Connections/ConnectionView.swift new file mode 100644 index 0000000..137c289 --- /dev/null +++ b/ApplicationLibrary/Views/Connections/ConnectionView.swift @@ -0,0 +1,121 @@ +import Libbox +import SwiftUI + +@MainActor +public struct ConnectionView: View { + private let connection: Connection + public init(_ connection: Connection) { + self.connection = connection + } + + private func format(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss" + return formatter.string(from: date) + } + + public func formatInterval(_ createdAt: Date, _ closedAt: Date) -> String { + LibboxFormatDuration(Int64((closedAt.timeIntervalSince1970 - createdAt.timeIntervalSince1970) * 1000)) + } + + @State private var alert: Alert? + + public var body: some View { + FormNavigationLink { + ConnectionDetailsView(connection) + } label: { + VStack { + HStack { + VStack(alignment: .leading) { + HStack(alignment: .center) { + Text("\(connection.network.uppercased()) \(connection.displayDestination)") + Spacer() + if connection.closedAt == nil { + Text("Active").foregroundStyle(.green) + } else { + Text("Closed").foregroundStyle(.red) + } + } + .font(.caption2.monospaced().bold()) + .padding([.bottom], 4) + HStack { + if let closedAt = connection.closedAt { + VStack(alignment: .leading) { + Text("↑ \(LibboxFormatBytes(connection.uploadTotal))") + Text("↓ \(LibboxFormatBytes(connection.downloadTotal))") + } + .font(.caption2) + VStack(alignment: .leading) { + Text(format(connection.createdAt)) + Text(formatInterval(connection.createdAt, closedAt)) + } + Spacer() + VStack(alignment: .trailing) { + Text(connection.inboundType + "/" + connection.inbound) + Text(connection.chain.reversed().joined(separator: "/")) + } + } else { + VStack(alignment: .leading) { + Text("↑ \(LibboxFormatBytes(connection.upload))/s") + Text("↓ \(LibboxFormatBytes(connection.download))/s") + } + .font(.caption2) + VStack(alignment: .leading) { + Text(LibboxFormatBytes(connection.uploadTotal)) + Text(LibboxFormatBytes(connection.downloadTotal)) + } + Spacer() + VStack(alignment: .trailing) { + Text(connection.inboundType + "/" + connection.inbound) + Text(connection.chain.reversed().joined(separator: "/")) + } + } + } + .font(.caption2.monospaced()) + } + } + .foregroundColor(.textColor) + #if !os(tvOS) + .padding(EdgeInsets(top: 10, leading: 13, bottom: 10, trailing: 13)) + .background(backgroundColor) + .cornerRadius(10) + #endif + } + .background(.clear) + } + #if !os(tvOS) + .buttonStyle(.borderless) + #endif + .alertBinding($alert) + .contextMenu { + if connection.closedAt == nil { + Button("Close", role: .destructive) { + Task { + await closeConnection() + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + + private var backgroundColor: Color { + #if os(iOS) + return Color(uiColor: .secondarySystemGroupedBackground) + #elseif os(macOS) + return Color(nsColor: .textBackgroundColor) + #elseif os(tvOS) + return Color.black + #endif + } + + private nonisolated func closeConnection() async { + do { + try LibboxNewStandaloneCommandClient()!.closeConnection(connection.id) + } catch { + await MainActor.run { + alert = Alert(error) + } + } + } +} diff --git a/ApplicationLibrary/Views/Dashboard/ActiveDashboardView.swift b/ApplicationLibrary/Views/Dashboard/ActiveDashboardView.swift index daff14d..0c73fef 100644 --- a/ApplicationLibrary/Views/Dashboard/ActiveDashboardView.swift +++ b/ApplicationLibrary/Views/Dashboard/ActiveDashboardView.swift @@ -50,23 +50,24 @@ public struct ActiveDashboardView: View { VStack { #if os(iOS) || os(tvOS) if ApplicationLibrary.inPreview || profile.status.isConnectedStrict { - Picker("Page", selection: $selection) { - ForEach(DashboardPage.allCases) { page in - page.label - } + viewBuilder { + #if os(iOS) + if #available(iOS 16.0, *) { + content1 + } else { + content0 + } + #else + content0 + #endif } - .pickerStyle(.segmented) #if os(iOS) - .padding([.leading, .trailing]) - .navigationBarTitleDisplayMode(.inline) + .navigationBarTitleDisplayMode(.inline) #endif - TabView(selection: $selection) { - ForEach(DashboardPage.allCases) { page in - page.contentView($profileList, $selectedProfileID, $systemProxyAvailable, $systemProxyEnabled) - .tag(page) - } + .onAppear { + UIScrollView.appearance().isScrollEnabled = false } - .tabViewStyle(.page(indexDisplayMode: .always)) + .tabViewStyle(.page(indexDisplayMode: .never)) } else { OverviewView($profileList, $selectedProfileID, $systemProxyAvailable, $systemProxyEnabled) } @@ -90,6 +91,45 @@ public struct ActiveDashboardView: View { .alertBinding($alert) } + @ViewBuilder + private var content0: some View { + Picker("Page", selection: $selection) { + ForEach(DashboardPage.allCases) { page in + page.label + } + } + .pickerStyle(.segmented) + #if os(iOS) + .padding([.leading, .trailing]) + .navigationBarTitleDisplayMode(.inline) + #endif + TabView(selection: $selection) { + ForEach(DashboardPage.enabledCases) { page in + page.contentView($profileList, $selectedProfileID, $systemProxyAvailable, $systemProxyEnabled) + .tag(page) + } + } + } + + @ViewBuilder + private var content1: some View { + TabView(selection: $selection) { + ForEach(DashboardPage.enabledCases) { page in + page.contentView($profileList, $selectedProfileID, $systemProxyAvailable, $systemProxyEnabled) + .tag(page) + } + } + .toolbar { + ToolbarTitleMenu { + Picker("Page", selection: $selection) { + ForEach(DashboardPage.allCases) { page in + page.label + } + } + } + } + } + private func doReload() async { defer { isLoading = false diff --git a/ApplicationLibrary/Views/Dashboard/DashboardPage.swift b/ApplicationLibrary/Views/Dashboard/DashboardPage.swift index d0a2245..fc0728c 100644 --- a/ApplicationLibrary/Views/Dashboard/DashboardPage.swift +++ b/ApplicationLibrary/Views/Dashboard/DashboardPage.swift @@ -9,6 +9,22 @@ public enum DashboardPage: Int, CaseIterable, Identifiable { case overview case groups + case connections +} + +public extension DashboardPage { + #if !os(tvOS) + static var enabledCases: [DashboardPage] = [ + .overview, + .groups, + .connections, + ] + #else + static var enabledCases: [DashboardPage] = [ + .overview, + .groups, + ] + #endif } public extension DashboardPage { @@ -18,15 +34,19 @@ public extension DashboardPage { return NSLocalizedString("Overview", comment: "") case .groups: return NSLocalizedString("Groups", comment: "") + case .connections: + return NSLocalizedString("Connections", comment: "") } } var label: some View { switch self { case .overview: - return Label("Overview", systemImage: "text.and.command.macwindow") + return Label(title, systemImage: "text.and.command.macwindow") case .groups: - return Label("Groups", systemImage: "rectangle.3.group.fill") + return Label(title, systemImage: "rectangle.3.group.fill") + case .connections: + return Label(title, systemImage: "list.bullet.rectangle.portrait.fill") } } @@ -38,6 +58,8 @@ public extension DashboardPage { OverviewView(profileList, selectedProfileID, systemProxyAvailable, systemProxyEnabled) case .groups: GroupListView() + case .connections: + ConnectionListView() } } } diff --git a/ApplicationLibrary/Views/Log/LogView.swift b/ApplicationLibrary/Views/Log/LogView.swift index 5b6963f..adcd7aa 100644 --- a/ApplicationLibrary/Views/Log/LogView.swift +++ b/ApplicationLibrary/Views/Log/LogView.swift @@ -57,7 +57,7 @@ public struct LogView: View { } else { ScrollViewReader { reader in ScrollView { - VStack(alignment: .leading, spacing: 0) { + LazyVGrid(columns: [GridItem(.flexible())], alignment: .leading, spacing: 0) { ForEach(Array(logClient.logList.enumerated()), id: \.offset) { it in Text(it.element) .font(logFont) diff --git a/ApplicationLibrary/Views/NavigationPage.swift b/ApplicationLibrary/Views/NavigationPage.swift index a0cec65..346db43 100644 --- a/ApplicationLibrary/Views/NavigationPage.swift +++ b/ApplicationLibrary/Views/NavigationPage.swift @@ -10,6 +10,7 @@ public enum NavigationPage: Int, CaseIterable, Identifiable { case dashboard #if os(macOS) case groups + case connections #endif case logs case profiles @@ -35,6 +36,8 @@ public extension NavigationPage { #if os(macOS) case .groups: return NSLocalizedString("Groups", comment: "") + case .connections: + return NSLocalizedString("Connections", comment: "") #endif case .logs: return NSLocalizedString("Logs", comment: "") @@ -52,6 +55,8 @@ public extension NavigationPage { #if os(macOS) case .groups: return "rectangle.3.group.fill" + case .connections: + return "list.bullet.rectangle.portrait.fill" #endif case .logs: return "doc.text.fill" @@ -71,6 +76,8 @@ public extension NavigationPage { #if os(macOS) case .groups: GroupListView() + case .connections: + ConnectionListView() #endif case .logs: LogView() @@ -89,7 +96,7 @@ public extension NavigationPage { #if os(macOS) func visible(_ profile: ExtensionProfile?) -> Bool { switch self { - case .groups: + case .groups, .connections: return profile?.status.isConnectedStrict == true default: return true diff --git a/ApplicationLibrary/Views/Profile/EditProfileView.swift b/ApplicationLibrary/Views/Profile/EditProfileView.swift index 2f4f605..fc79d2e 100644 --- a/ApplicationLibrary/Views/Profile/EditProfileView.swift +++ b/ApplicationLibrary/Views/Profile/EditProfileView.swift @@ -51,7 +51,7 @@ public struct EditProfileView: View { } if profile.type == .remote { Section("Status") { - FormTextItem("Last Updated", profile.lastUpdatedString) + FormTextItem("Last Updated", profile.lastUpdated!.myFormat) } } Section("Action") { diff --git a/ApplicationLibrary/Views/Profile/ProfileView.swift b/ApplicationLibrary/Views/Profile/ProfileView.swift index dd32c2d..4786be8 100644 --- a/ApplicationLibrary/Views/Profile/ProfileView.swift +++ b/ApplicationLibrary/Views/Profile/ProfileView.swift @@ -334,7 +334,7 @@ public struct ProfileView: View { Text(profile.name) if profile.type == .remote { Spacer(minLength: 4) - Text("Last Updated: \(profile.origin.lastUpdatedString)").font(.caption) + Text("Last Updated: \(profile.origin.lastUpdated!.myFormat)").font(.caption) } } HStack { diff --git a/Library/Database/Profile+Date.swift b/Library/Database/Profile+Date.swift index c6f4b71..0486a7c 100644 --- a/Library/Database/Profile+Date.swift +++ b/Library/Database/Profile+Date.swift @@ -1,9 +1,9 @@ import Foundation -public extension Profile { - var lastUpdatedString: String { +public extension Date { + var myFormat: String { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" - return dateFormatter.string(from: lastUpdated!) + return dateFormatter.string(from: self) } } diff --git a/Library/Database/SharedPreferences.swift b/Library/Database/SharedPreferences.swift index a0c2f56..7b93f4e 100644 --- a/Library/Database/SharedPreferences.swift +++ b/Library/Database/SharedPreferences.swift @@ -68,6 +68,11 @@ public enum SharedPreferences { await excludeAPNsRoute.set(nil) } + // Connections Filter + + public static let connectionStateFilter = Preference("connection_state_filter", defaultValue: 0) + public static let connectionSort = Preference("connection_sort", defaultValue: 0) + // On Demand Rules public static let alwaysOn = Preference("always_on", defaultValue: false) diff --git a/Library/Network/CommandClient.swift b/Library/Network/CommandClient.swift index 5f24080..484f6b2 100644 --- a/Library/Network/CommandClient.swift +++ b/Library/Network/CommandClient.swift @@ -7,6 +7,7 @@ public class CommandClient: ObservableObject { case groups case log case clashMode + case connections } private let connectionType: ConnectionType @@ -21,6 +22,11 @@ public class CommandClient: ObservableObject { @Published public var clashModeList: [String] @Published public var clashMode: String + @Published public var connectionStateFilter = ConnectionStateFilter.all + @Published public var connectionSort = ConnectionSort.byDate + @Published public var connections: [LibboxConnection]? + public var rawConnections: LibboxConnections? + public init(_ connectionType: ConnectionType, logMaxLines: Int = 300) { self.connectionType = connectionType self.logMaxLines = logMaxLines @@ -53,7 +59,41 @@ public class CommandClient: ObservableObject { } } + public func filterConnectionsNow() { + guard let message = rawConnections else { + return + } + connections = filterConnections(message) + } + + private func filterConnections(_ message: LibboxConnections) -> [LibboxConnection] { + message.filterState(Int32(connectionStateFilter.rawValue)) + switch connectionSort { + case .byDate: + message.sortByDate() + case .byTraffic: + message.sortByTraffic() + case .byTrafficTotal: + message.sortByTrafficTotal() + } + let connectionIterator = message.iterator()! + var connections: [LibboxConnection] = [] + while connectionIterator.hasNext() { + connections.append(connectionIterator.next()!) + } + return connections + } + + private func initializeConnectionFilterState() async { + connectionStateFilter = await .init(rawValue: SharedPreferences.connectionStateFilter.get()) ?? .all + connectionSort = await .init(rawValue: SharedPreferences.connectionSort.get()) ?? .byDate + } + private nonisolated func connect0() async { + if connectionType == .connections { + await initializeConnectionFilterState() + } + let clientOptions = LibboxCommandClientOptions() switch connectionType { case .status: @@ -64,6 +104,8 @@ public class CommandClient: ObservableObject { clientOptions.command = LibboxCommandLog case .clashMode: clientOptions.command = LibboxCommandClashMode + case .connections: + clientOptions.command = LibboxCommandConnections } clientOptions.statusInterval = Int64(2 * NSEC_PER_SEC) let client = LibboxNewCommandClient(clientHandler(self), clientOptions)! @@ -101,10 +143,13 @@ public class CommandClient: ObservableObject { } } - func disconnected(_: String?) { + func disconnected(_ message: String?) { DispatchQueue.main.async { [self] in commandClient.isConnected = false } + if let message { + NSLog("client disconnected: \(message)") + } } func clearLog() { @@ -156,5 +201,62 @@ public class CommandClient: ObservableObject { commandClient.clashMode = newMode! } } + + func write(_ message: LibboxConnections?) { + guard let message else { + return + } + let connections = commandClient.filterConnections(message) + DispatchQueue.main.async { [self] in + commandClient.rawConnections = message + commandClient.connections = connections + } + } + } +} + +public enum ConnectionStateFilter: Int, CaseIterable, Identifiable { + public var id: Self { + self + } + + case all + case active + case closed +} + +public extension ConnectionStateFilter { + var name: String { + switch self { + case .all: + return NSLocalizedString("All", comment: "") + case .active: + return NSLocalizedString("Active", comment: "") + case .closed: + return NSLocalizedString("Closed", comment: "") + } + } +} + +public enum ConnectionSort: Int, CaseIterable, Identifiable { + public var id: Self { + self + } + + case byDate + case byTraffic + case byTrafficTotal +} + +public extension ConnectionSort { + var name: String { + switch self { + case .byDate: + return NSLocalizedString("Date", comment: "") + case .byTraffic: + return NSLocalizedString("Traffic", comment: "") + case .byTrafficTotal: + return NSLocalizedString("Traffic Total", comment: "") + } } } diff --git a/Library/Network/Extension+Iterator.swift b/Library/Network/Extension+Iterator.swift index b7d57bb..00832d0 100644 --- a/Library/Network/Extension+Iterator.swift +++ b/Library/Network/Extension+Iterator.swift @@ -1,7 +1,7 @@ import Foundation import Libbox -extension LibboxStringIteratorProtocol { +public extension LibboxStringIteratorProtocol { func toArray() -> [String] { var array: [String] = [] while hasNext() { diff --git a/MacLibrary/SidebarView.swift b/MacLibrary/SidebarView.swift index 7c4b33e..9a54d6a 100644 --- a/MacLibrary/SidebarView.swift +++ b/MacLibrary/SidebarView.swift @@ -35,6 +35,7 @@ public struct SidebarView: View { .tint(.textColor) .tag(NavigationPage.dashboard) NavigationPage.groups.label.tag(NavigationPage.groups) + NavigationPage.connections.label.tag(NavigationPage.connections) } Divider() ForEach(NavigationPage.macosDefaultPages, id: \.self) { it in diff --git a/sing-box.xcodeproj/project.pbxproj b/sing-box.xcodeproj/project.pbxproj index c74407c..b0be7f0 100644 --- a/sing-box.xcodeproj/project.pbxproj +++ b/sing-box.xcodeproj/project.pbxproj @@ -25,6 +25,7 @@ 3A27D9022A89C6870031EBCC /* ExtensionEnvironments.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A27D9012A89C6870031EBCC /* ExtensionEnvironments.swift */; }; 3A2EAEED2A6F4CBB00D00DE3 /* IndependentApplicationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A2EAEEC2A6F4CBB00D00DE3 /* IndependentApplicationDelegate.swift */; }; 3A2F29EB2C998A5D007E024C /* Export.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3A2F29EA2C998A5D007E024C /* Export.plist */; }; + 3A334ED02C0F621E00E9C577 /* ConnectionDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A334ECF2C0F621E00E9C577 /* ConnectionDetailsView.swift */; }; 3A3AA7FC2A4EFDAE002F78AB /* Library.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEC211D2A459B4700A63465 /* Library.framework */; }; 3A3AA7FF2A4EFDB3002F78AB /* Library.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEC211D2A459B4700A63465 /* Library.framework */; }; 3A3AB2A72B70C146001815AE /* CoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A3AB2A62B70C146001815AE /* CoreView.swift */; }; @@ -68,6 +69,9 @@ 3A60CC272B70880100D2D682 /* PacketTunnelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A60CC262B70880100D2D682 /* PacketTunnelView.swift */; }; 3A60CC292B70A7C400D2D682 /* Color+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A60CC282B70A7C400D2D682 /* Color+Extension.swift */; }; 3A60CC2B2B70AD6700D2D682 /* SettingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A60CC2A2B70AD6700D2D682 /* SettingView.swift */; }; + 3A63269E2C0DE12D0076E274 /* ConnectionListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A63269D2C0DE12D0076E274 /* ConnectionListView.swift */; }; + 3A6326A02C0DE15C0076E274 /* Connection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A63269F2C0DE15C0076E274 /* Connection.swift */; }; + 3A6326A22C0DE64F0076E274 /* ConnectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A6326A12C0DE64F0076E274 /* ConnectionView.swift */; }; 3A648D2D2A4EEAA600D95A12 /* Library.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A648D2C2A4EEAA600D95A12 /* Library.swift */; }; 3A648D542A4EF4C700D95A12 /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AF342B12A4AA520002B34AC /* NetworkExtension.framework */; }; 3A6CA4542BC19FDE0012B238 /* OnDemandRulesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A6CA4532BC19FDE0012B238 /* OnDemandRulesView.swift */; }; @@ -89,6 +93,7 @@ 3A99B42A2A7526990010D4B0 /* NavigationStackCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A99B4292A7526990010D4B0 /* NavigationStackCompat.swift */; }; 3A99B42C2A75288C0010D4B0 /* ViewCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A99B42B2A75288C0010D4B0 /* ViewCompat.swift */; }; 3A99B42E2A752ABB0010D4B0 /* NavigationDestinationCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A99B42D2A752ABB0010D4B0 /* NavigationDestinationCompat.swift */; }; + 3A9E6EBF2C0F20B0005061F3 /* ConnectionListPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A9E6EBE2C0F20B0005061F3 /* ConnectionListPage.swift */; }; 3AABFD432A9CC5A7005A24A4 /* Upload.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3AABFD422A9CC5A7005A24A4 /* Upload.plist */; }; 3AABFD472A9CCC58005A24A4 /* Upload.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3AABFD462A9CCC58005A24A4 /* Upload.plist */; }; 3AB1220B2A70FD500087CD55 /* Alert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AB1220A2A70FD500087CD55 /* Alert.swift */; }; @@ -456,6 +461,7 @@ 3A27D8FF2A89BE230031EBCC /* CommandClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandClient.swift; sourceTree = ""; }; 3A27D9012A89C6870031EBCC /* ExtensionEnvironments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionEnvironments.swift; sourceTree = ""; }; 3A2EAEEC2A6F4CBB00D00DE3 /* IndependentApplicationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndependentApplicationDelegate.swift; sourceTree = ""; }; + 3A334ECF2C0F621E00E9C577 /* ConnectionDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionDetailsView.swift; sourceTree = ""; }; 3A2F29EA2C998A5D007E024C /* Export.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Export.plist; sourceTree = ""; }; 3A334ECF2C0F621E00E9C577 /* ConnectionDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionDetailsView.swift; sourceTree = ""; }; 3A3AB2A62B70C146001815AE /* CoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreView.swift; sourceTree = ""; }; @@ -482,6 +488,9 @@ 3A60CC262B70880100D2D682 /* PacketTunnelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelView.swift; sourceTree = ""; }; 3A60CC282B70A7C400D2D682 /* Color+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extension.swift"; sourceTree = ""; }; 3A60CC2A2B70AD6700D2D682 /* SettingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingView.swift; sourceTree = ""; }; + 3A63269D2C0DE12D0076E274 /* ConnectionListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionListView.swift; sourceTree = ""; }; + 3A63269F2C0DE15C0076E274 /* Connection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Connection.swift; sourceTree = ""; }; + 3A6326A12C0DE64F0076E274 /* ConnectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionView.swift; sourceTree = ""; }; 3A648D2C2A4EEAA600D95A12 /* Library.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Library.swift; sourceTree = ""; }; 3A6CA4532BC19FDE0012B238 /* OnDemandRulesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnDemandRulesView.swift; sourceTree = ""; }; 3A6CA5A52A713AA10027933B /* AppIcon.icns */ = {isa = PBXFileReference; lastKnownFileType = image.icns; path = AppIcon.icns; sourceTree = ""; }; @@ -497,6 +506,7 @@ 3A99B4292A7526990010D4B0 /* NavigationStackCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationStackCompat.swift; sourceTree = ""; }; 3A99B42B2A75288C0010D4B0 /* ViewCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewCompat.swift; sourceTree = ""; }; 3A99B42D2A752ABB0010D4B0 /* NavigationDestinationCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationDestinationCompat.swift; sourceTree = ""; }; + 3A9E6EBE2C0F20B0005061F3 /* ConnectionListPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionListPage.swift; sourceTree = ""; }; 3AA1ABB92A4C4054000FD4BA /* LogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogView.swift; sourceTree = ""; }; 3AAB5E732A4BF90B009757F1 /* ServiceLogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceLogView.swift; sourceTree = ""; }; 3AAB5E752A4BFB0B009757F1 /* EditProfileContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditProfileContentView.swift; sourceTree = ""; }; @@ -724,6 +734,18 @@ path = Service; sourceTree = ""; }; + 3A63269C2C0DE10B0076E274 /* Connections */ = { + isa = PBXGroup; + children = ( + 3A63269D2C0DE12D0076E274 /* ConnectionListView.swift */, + 3A63269F2C0DE15C0076E274 /* Connection.swift */, + 3A6326A12C0DE64F0076E274 /* ConnectionView.swift */, + 3A9E6EBE2C0F20B0005061F3 /* ConnectionListPage.swift */, + 3A334ECF2C0F621E00E9C577 /* ConnectionDetailsView.swift */, + ); + path = Connections; + sourceTree = ""; + }; 3A6CA5A42A713A6C0027933B /* Icons */ = { isa = PBXGroup; children = ( @@ -946,6 +968,7 @@ 3AEC21732A45B0AC00A63465 /* Views */ = { isa = PBXGroup; children = ( + 3A63269C2C0DE10B0076E274 /* Connections */, 3AF342D22A4AADA5002B34AC /* Abstract */, 3AF342A12A4A9B8D002B34AC /* Dashboard */, 3A1CF2EE2A50E5D5000A8289 /* Groups */, @@ -1481,6 +1504,7 @@ 3A4EAD352A4FEB9C005435B3 /* UIProfileUpdateTask.swift in Sources */, 3A60CC272B70880100D2D682 /* PacketTunnelView.swift in Sources */, 3A4EAD222A4FEB54005435B3 /* NavigationPage.swift in Sources */, + 3A9E6EBF2C0F20B0005061F3 /* ConnectionListPage.swift in Sources */, 3A411CEC2B734959000D9501 /* MacAppView.swift in Sources */, 3AC8CF9B2A736C750002AF3C /* ImportProfileView.swift in Sources */, 3A4EAD292A4FEB6D005435B3 /* FormItem.swift in Sources */, @@ -1490,6 +1514,7 @@ 3A172D2B2B88E9DB00D98050 /* BackButton.swift in Sources */, 3A4EAD242A4FEB65005435B3 /* InstallProfileButton.swift in Sources */, 3AE4D0C12A6E4852009FEA9E /* InstallSystemExtensionButton.swift in Sources */, + 3A6326A22C0DE64F0076E274 /* ConnectionView.swift in Sources */, 3A4EAD232A4FEB5A005435B3 /* EnvironmentValues.swift in Sources */, 3A4F68B02A97602C003D66D3 /* ClashModeView.swift in Sources */, 3A4EAD282A4FEB65005435B3 /* ActiveDashboardView.swift in Sources */, @@ -1514,8 +1539,11 @@ 3ACA8B332B7E037800B7238F /* DeleteButton.swift in Sources */, 3ACE6DE32ACADF55009D9A8A /* Binding+Setter.swift in Sources */, 3A4EAD262A4FEB65005435B3 /* ExtensionStatusView.swift in Sources */, + 3A63269E2C0DE12D0076E274 /* ConnectionListView.swift in Sources */, 3A4EAD252A4FEB65005435B3 /* StartStopButton.swift in Sources */, + 3A6326A02C0DE15C0076E274 /* Connection.swift in Sources */, 3A4EAD2B2A4FEB6D005435B3 /* ViewBuilder.swift in Sources */, + 3A334ED02C0F621E00E9C577 /* ConnectionDetailsView.swift in Sources */, 3AC5EC082A6417470077AF34 /* DeviceCensorship.swift in Sources */, 3A0C6D3E2A79D6A600A4DF2B /* DashboardPage.swift in Sources */, 3A3AB2A92B70C5F1001815AE /* RequestReviewButton.swift in Sources */, @@ -2885,7 +2913,7 @@ repositoryURL = "https://github.com/orchetect/MacControlCenterUI"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 2.0.1; + minimumVersion = 2.0.7; }; }; 3A7E90362A46778E00D53052 /* XCRemoteSwiftPackageReference "BinaryCodable" */ = {