This commit is contained in:
mrFq1
2023-04-25 14:51:23 +08:00
commit f3141fbffe
39 changed files with 3793 additions and 0 deletions

View File

@@ -0,0 +1,146 @@
//
// ConfigView.swift
// ClashX Dashboard
//
//
import SwiftUI
struct ConfigView: View {
@State var httpPort: Int = 0
@State var socks5Port: Int = 0
@State var mixedPort: Int = 0
@State var redirPort: Int = 0
@State var mode: String = "Rule"
@State var logLevel: String = "Debug"
@State var allowLAN: Bool = false
@State var sniffer: Bool = false
@State var enableTUNDevice: Bool = false
@State var tunIPStack: String = "System"
@State var deviceName: String = "utun9"
@State var interfaceName: String = "en0"
@State var disableAll = true
var body: some View {
ScrollView {
LazyVGrid(columns: [
GridItem(.flexible()),
GridItem(.flexible())
]) {
VStack(alignment: .leading) {
Text("Http Port")
TextField("0", value: $httpPort, formatter: NumberFormatter())
.disabled(disableAll)
}
VStack(alignment: .leading) {
Text("Socks5 Port")
TextField("0", value: $socks5Port, formatter: NumberFormatter())
.disabled(disableAll)
}
VStack(alignment: .leading) {
Text("Mixed Port")
TextField("0", value: $mixedPort, formatter: NumberFormatter())
.disabled(disableAll)
}
VStack(alignment: .leading) {
Text("Redir Port")
TextField("0", value: $redirPort, formatter: NumberFormatter())
.disabled(disableAll)
}
VStack(alignment: .leading) {
Text("Mode")
Picker("", selection: $mode) {
ForEach(["Direct", "Rule", "Script", "Global"], id: \.self) {
Text($0)
}
}
.pickerStyle(.menu)
.disabled(disableAll)
}
VStack(alignment: .leading) {
Text("Log Level")
Picker("", selection: $logLevel) {
ForEach(["Silent", "Error", "Warning", "Info", "Debug"], id: \.self) {
Text($0)
}
}
.pickerStyle(.menu)
.disabled(disableAll)
}
Toggle("Allow LAN", isOn: $allowLAN)
.disabled(disableAll)
Toggle("Sniffer", isOn: $sniffer)
.disabled(disableAll)
}
.padding()
Divider()
.padding()
LazyVGrid(columns: [
GridItem(.flexible()),
GridItem(.flexible())
]) {
Toggle("Enable TUN Device", isOn: $enableTUNDevice)
.disabled(disableAll)
VStack(alignment: .leading) {
Text("TUN IP Stack")
Picker("", selection: $tunIPStack) {
ForEach(["gVisor", "System", "LWIP"], id: \.self) {
Text($0)
}
}
.pickerStyle(.menu)
.disabled(disableAll)
}
VStack(alignment: .leading) {
Text("Device Name")
TextField("utun9", text: $deviceName)
.disabled(disableAll)
}
VStack(alignment: .leading) {
Text("Interface Name")
TextField("en0", text: $interfaceName)
.disabled(disableAll)
}
}
.padding()
}
.onAppear {
ApiRequest.requestConfig { config in
httpPort = config.port
socks5Port = config.socksPort
mixedPort = config.mixedPort
redirPort = config.redirPort
mode = config.mode.rawValue.capitalized
logLevel = config.logLevel.rawValue.capitalized
allowLAN = config.allowLan
sniffer = config.sniffing
enableTUNDevice = config.tun.enable
tunIPStack = config.tun.stack
deviceName = config.tun.device
interfaceName = config.interfaceName
}
}
}
}
//struct ConfigView_Previews: PreviewProvider {
// static var previews: some View {
// ConfigView()
// }
//}

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="21507" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="21507"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<customObject id="-2" userLabel="File's Owner"/>
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
<tableCellView identifier="CollectionTableCellView" id="E1E-Yw-tO2">
<rect key="frame" x="0.0" y="0.0" width="100" height="17"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" horizontalCompressionResistancePriority="250" translatesAutoresizingMaskIntoConstraints="NO" id="EkO-g0-3Rf">
<rect key="frame" x="0.0" y="1" width="100" height="16"/>
<textFieldCell key="cell" lineBreakMode="truncatingTail" sendsActionOnEndEditing="YES" title="Table View Cell" id="mZD-CG-RaE">
<font key="font" usesAppearanceFont="YES"/>
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
</subviews>
<constraints>
<constraint firstAttribute="trailing" secondItem="EkO-g0-3Rf" secondAttribute="trailing" constant="2" id="cZb-2s-5cP"/>
<constraint firstItem="EkO-g0-3Rf" firstAttribute="centerY" secondItem="E1E-Yw-tO2" secondAttribute="centerY" id="g8v-Gi-bjM"/>
<constraint firstItem="EkO-g0-3Rf" firstAttribute="leading" secondItem="E1E-Yw-tO2" secondAttribute="leading" constant="2" id="nDQ-TP-ijK"/>
</constraints>
<accessibility identifier="CollectionTableCellView"/>
<connections>
<outlet property="textField" destination="EkO-g0-3Rf" id="Kzu-HK-Onq"/>
</connections>
<point key="canvasLocation" x="-108" y="-102"/>
</tableCellView>
</objects>
</document>

View File

@@ -0,0 +1,271 @@
//
// CollectionsTableView.swift
// ClashX Dashboard
//
//
import SwiftUI
import AppKit
import DifferenceKit
struct CollectionsTableView<Item: Hashable>: NSViewRepresentable {
enum TableColumn: String, CaseIterable {
case host = "Host"
case sniffHost = "Sniff Host"
case process = "Process"
case dl = "DL"
case ul = "UL"
case chain = "Chain"
case rule = "Rule"
case time = "Time"
case source = "Source"
case destinationIP = "Destination IP"
case type = "Type"
}
var data: [Item]
var filterString: String
var startFormatter: RelativeDateTimeFormatter = {
let startFormatter = RelativeDateTimeFormatter()
startFormatter.unitsStyle = .short
return startFormatter
}()
var byteCountFormatter = ByteCountFormatter()
class NonRespondingScrollView: NSScrollView {
override var acceptsFirstResponder: Bool { false }
}
class NonRespondingTableView: NSTableView {
override var acceptsFirstResponder: Bool { false }
}
func makeNSView(context: Context) -> NSScrollView {
let scrollView = NonRespondingScrollView()
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalScroller = true
let tableView = NonRespondingTableView()
tableView.usesAlternatingRowBackgroundColors = true
tableView.delegate = context.coordinator
tableView.dataSource = context.coordinator
tableView.register(.init(nibNamed: .init("CollectionTableCellView"), bundle: .main), forIdentifier: .init(rawValue: "CollectionTableCellView"))
TableColumn.allCases.forEach {
let tableColumn = NSTableColumn(identifier: .init($0.rawValue))
tableColumn.title = $0.rawValue
tableColumn.isEditable = false
tableColumn.minWidth = 50
tableColumn.maxWidth = .infinity
tableView.addTableColumn(tableColumn)
var sort: NSSortDescriptor?
switch $0 {
case .host:
sort = .init(keyPath: \ClashConnectionObject.host, ascending: true)
case .sniffHost:
sort = .init(keyPath: \ClashConnectionObject.sniffHost, ascending: true)
case .process:
sort = .init(keyPath: \ClashConnectionObject.process, ascending: true)
case .dl:
sort = .init(keyPath: \ClashConnectionObject.download, ascending: true)
case .ul:
sort = .init(keyPath: \ClashConnectionObject.upload, ascending: true)
case .chain:
sort = .init(keyPath: \ClashConnectionObject.chainString, ascending: true)
case .rule:
sort = .init(keyPath: \ClashConnectionObject.ruleString, ascending: true)
case .time:
sort = .init(keyPath: \ClashConnectionObject.startDate, ascending: true)
case .source:
sort = .init(keyPath: \ClashConnectionObject.source, ascending: true)
case .destinationIP:
sort = .init(keyPath: \ClashConnectionObject.destinationIP, ascending: true)
case .type:
sort = .init(keyPath: \ClashConnectionObject.type, ascending: true)
default:
sort = nil
}
tableColumn.sortDescriptorPrototype = sort
}
if let sort = tableView.tableColumns.first?.sortDescriptorPrototype {
tableView.sortDescriptors = [sort]
}
scrollView.documentView = tableView
return scrollView
}
func updateNSView(_ nsView: NSScrollView, context: Context) {
context.coordinator.parent = self
guard let tableView = nsView.documentView as? NSTableView,
let data = data as? [ClashConnection] else {
return
}
let target = updateSorts(data.map(ClashConnectionObject.init), tableView: tableView)
let source = context.coordinator.conns
let changeset = StagedChangeset(source: source, target: target)
tableView.reload(using: changeset) { data in
context.coordinator.conns = data
}
}
func updateSorts(_ objects: [ClashConnectionObject],
tableView: NSTableView) -> [ClashConnectionObject] {
var re = objects
var sortDescriptors = tableView.sortDescriptors
sortDescriptors.append(.init(keyPath: \ClashConnectionObject.id, ascending: true))
re = re.sorted(descriptors: sortDescriptors)
let filterKeys = [
"host",
"process",
"chainString",
"ruleString",
"source",
"destinationIP",
"type",
]
re = re.filtered(filterString, for: filterKeys)
return re
}
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
class Coordinator: NSObject, NSTableViewDelegate, NSTableViewDataSource {
var parent: CollectionsTableView
var conns = [ClashConnectionObject]()
init(parent: CollectionsTableView) {
self.parent = parent
}
func numberOfRows(in tableView: NSTableView) -> Int {
conns.count
}
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
guard let cell = tableView.makeView(withIdentifier: .init(rawValue: "CollectionTableCellView"), owner: nil) as? NSTableCellView,
let s = tableColumn?.identifier.rawValue.split(separator: ".").last,
let tc = TableColumn(rawValue: String(s))
else { return nil }
let conn = conns[row]
cell.textField?.objectValue = {
switch tc {
case .host:
return conn.host
case .sniffHost:
return conn.sniffHost
case .process:
return conn.process
case .dl:
return conn.downloadString
case .ul:
return conn.uploadString
case .chain:
return conn.chainString
case .rule:
return conn.ruleString
case .time:
return conn.startString
case .source:
return conn.source
case .destinationIP:
return conn.destinationIP
case .type:
return conn.type
}
}()
return cell
}
func tableView(_ tableView: NSTableView, sortDescriptorsDidChange oldDescriptors: [NSSortDescriptor]) {
conns = parent.updateSorts(conns, tableView: tableView)
tableView.reloadData()
}
}
}
extension NSTableView {
/// Applies multiple animated updates in stages using `StagedChangeset`.
///
/// - Note: There are combination of changes that crash when applied simultaneously in `performBatchUpdates`.
/// Assumes that `StagedChangeset` has a minimum staged changesets to avoid it.
/// The data of the data-source needs to be updated synchronously before `performBatchUpdates` in every stages.
///
/// - Parameters:
/// - stagedChangeset: A staged set of changes.
/// - interrupt: A closure that takes an changeset as its argument and returns `true` if the animated
/// updates should be stopped and performed reloadData. Default is nil.
/// - setData: A closure that takes the collection as a parameter.
/// The collection should be set to data-source of NSTableView.
func reload<C>(
using stagedChangeset: StagedChangeset<C>,
interrupt: ((Changeset<C>) -> Bool)? = nil,
setData: (C) -> Void
) {
if case .none = window, let data = stagedChangeset.last?.data {
setData(data)
return reloadData()
}
for changeset in stagedChangeset {
if let interrupt = interrupt, interrupt(changeset), let data = stagedChangeset.last?.data {
setData(data)
return reloadData()
}
beginUpdates()
setData(changeset.data)
if !changeset.elementDeleted.isEmpty {
removeRows(at: IndexSet(changeset.elementDeleted.map { $0.element }))
}
if !changeset.elementUpdated.isEmpty {
reloadData(forRowIndexes: IndexSet(changeset.elementUpdated.map { $0.element }), columnIndexes: IndexSet(0..<tableColumns.count))
}
if !changeset.elementInserted.isEmpty {
insertRows(at: IndexSet(changeset.elementInserted.map { $0.element }))
}
endUpdates()
}
}
}

View File

@@ -0,0 +1,37 @@
//
// Connections.swift
// ClashX Dashboard
//
//
import Cocoa
class Connections: ObservableObject, Identifiable {
let id = UUID()
@Published var items: [ConnectionItem]
init(_ items: [ConnectionItem]) {
self.items = items
}
}
class ConnectionItem: ObservableObject, Decodable {
let id: String
let host: String
let sniffHost: String
let process: String
let dl: String
let ul: String
let dlSpeed: String
let ulSpeed: String
let chains: String
let rule: String
let time: String
let source: String
let destinationIP: String
let type: String
}

View File

@@ -0,0 +1,32 @@
//
// ConnectionsView.swift
// ClashX Dashboard
//
//
import SwiftUI
struct ConnectionsView: View {
@EnvironmentObject var data: ClashConnsStorage
@State private var searchString: String = ""
var body: some View {
CollectionsTableView(data: data.conns,
filterString: searchString)
.background(.white)
.searchable(text: $searchString)
}
}
struct ConnectionsView_Previews: PreviewProvider {
static var previews: some View {
ConnectionsView()
}
}

View File

@@ -0,0 +1,56 @@
//
// LogsView.swift
// ClashX Dashboard
//
//
import SwiftUI
struct LogsView: View {
@EnvironmentObject var logStorage: ClashLogStorage
@State var searchString: String = ""
var logs: [ClashLogStorage.ClashLog] {
let logs: [ClashLogStorage.ClashLog] = logStorage.logs.reversed()
if searchString.isEmpty {
return logs
} else {
return logs.filtered(searchString, for: ["log", "levelString"])
}
}
var body: some View {
Table(logs) {
TableColumn("Date") {
Text($0.date.formatted(
Date.FormatStyle()
.year(.twoDigits)
.month(.twoDigits)
.day(.twoDigits)
.hour(.twoDigits(amPM: .omitted))
.minute(.twoDigits)
.second(.twoDigits)
))
.foregroundColor(.orange)
.truncationMode(.head)
}
.width(min: 60, max: 130)
TableColumn("Level") {
Text("[\($0.level.rawValue)]")
.foregroundColor($0.levelColor)
}
.width(min: 40, max: 65)
TableColumn("", value: \.log)
}
.background(.white)
.searchable(text: $searchString)
}
}
struct LogsView_Previews: PreviewProvider {
static var previews: some View {
LogsView()
}
}

View File

@@ -0,0 +1,38 @@
//
// OverviewTopItemView.swift
// ClashX Dashboard
//
//
import SwiftUI
struct OverviewTopItemView: View {
@State var name: String
@Binding var value: String
var body: some View {
VStack(alignment: .leading) {
HStack {
Text(name)
.font(.subheadline)
.foregroundColor(.secondary)
Spacer()
}
Spacer()
Text(value)
.font(.system(size: 16))
}
.frame(width: 130, height: 45)
.padding(EdgeInsets(top: 12, leading: 14, bottom: 12, trailing: 14))
.background(.white)
.cornerRadius(12)
}
}
struct OverviewTopItemView_Previews: PreviewProvider {
@State static var value: String = "Value"
static var previews: some View {
OverviewTopItemView(name: "Name", value: $value)
}
}

View File

@@ -0,0 +1,44 @@
//
// OverviewView.swift
// ClashX Dashboard
//
//
import SwiftUI
import DSFSparkline
struct OverviewView: View {
@EnvironmentObject var data: ClashOverviewData
var body: some View {
VStack(spacing: 25) {
HStack() {
OverviewTopItemView(name: "Upload", value: $data.uploadString)
OverviewTopItemView(name: "Download", value: $data.downloadString)
OverviewTopItemView(name: "Upload Total", value: $data.uploadTotal)
OverviewTopItemView(name: "Download Total", value: $data.downloadTotal)
OverviewTopItemView(name: "Active Connections", value: $data.activeConns)
}
TrafficGraphView(values: $data.downloadHistories,
graphColor: .systemBlue)
TrafficGraphView(values: $data.uploadHistories,
graphColor: .systemGreen)
}.padding()
}
}
//struct OverviewView_Previews: PreviewProvider {
// static var previews: some View {
// OverviewView()
// }
//}

View File

@@ -0,0 +1,125 @@
//
// TrafficGraphView.swift
// ClashX Dashboard
//
//
import SwiftUI
import DSFSparkline
fileprivate let labelsCount = 4
struct TrafficGraphView: View {
@Binding var values: [CGFloat]
@State var graphColor: DSFColor
init(values: Binding<[CGFloat]>,
graphColor: DSFColor) {
self._values = values
self.graphColor = graphColor
updateChart(values.wrappedValue)
}
@State private var labels = [String]()
@State private var dataSource = DSFSparkline.DataSource()
var body: some View {
HStack {
VStack {
ForEach(Array(labels.enumerated()), id: \.offset) {
Text($0.element)
.font(.system(size: 11, weight: .light))
Spacer()
}
}
graphView
}
.onChange(of: values) { newValue in
updateChart(newValue)
}
}
var graphView: some View {
DSFSparklineLineGraphView.SwiftUI(
dataSource: dataSource,
graphColor: graphColor,
interpolated: false,
showZeroLine: false
)
}
func updateChart(_ values: [CGFloat]) {
let max = values.max() ?? CGFloat(labelsCount) * 1000
let byte = Int64(max)
let kb = byte / 1000
var v1: Double = 0
var v2 = ""
var v3: Double = 1
switch kb {
case 0..<Int64(labelsCount):
v1 = Double(labelsCount)
v2 = "KB/s"
case Int64(labelsCount)..<100:
// 0 - 99 KB/s
v1 = Double(kb)
v2 = "KB/s"
case 100..<100_000:
// 0.1 - 99MB/s
v1 = Double(kb) / 1_000
v2 = "MB/s"
v3 = 1_000
default:
// case 10_000..<100_000:
// 0.1 - 10GB/s
v1 = Double(kb) / 1_000_000
v2 = "GB/s"
v3 = 1_000_000
}
v1 = (v1 * 10).rounded() / 10
let vv = v1.truncatingRemainder(dividingBy: 1) == 0 ? Double(labelsCount) : Double(labelsCount) / 10
if v1.truncatingRemainder(dividingBy: vv) != 0 {
v1 = ((v1 / vv).rounded() + 1) * vv
}
var re = [String]()
for i in 0...labelsCount {
let s = String(format: "%.1f%@", v1 * Double(i) / Double(labelsCount), v2)
re.append(s)
}
re = re.reversed()
let _ = re.removeLast()
self.dataSource.set(values: values)
let upperBound = CGFloat(v1*v3)
if upperBound != 0,
let old = self.dataSource.range?.upperBound,
old != upperBound {
self.dataSource.setRange(lowerBound: 0, upperBound: upperBound)
self.dataSource.resetRange()
}
self.labels = re
}
}
//struct TrafficGraphView_Previews: PreviewProvider {
// static var previews: some View {
// TrafficGraphView()
// }
//}

View File

@@ -0,0 +1,90 @@
//
// ProxiesView.swift
// ClashX Dashboard
//
//
import SwiftUI
class ProxiesSearchString: ObservableObject, Identifiable {
let id = UUID().uuidString
@Published var string: String = ""
}
struct ProxiesView: View {
@State var proxyInfo: ClashProxyResp?
@State var proxyGroups = [ClashProxy]()
@State var providerInfo: ClashProviderResp?
@State var providers = [ClashProvider]()
// @State var proxyProviderList
@State private var searchString = ProxiesSearchString()
@State private var isGlobalMode = false
@State private var proxyListColumnCount = 3
var body: some View {
List() {
Text("Proxies")
.font(.title)
ForEach(proxyGroups, id: \.id) { group in
ProxyGroupView(columnCount: $proxyListColumnCount, proxyGroup: group, proxyInfo: proxyInfo!)
}
Text("Proxy Provider")
.font(.title)
.padding(.top)
ForEach($providers, id: \.id) { provider in
ProxyProviderGroupView(columnCount: $proxyListColumnCount, providerInfo: provider)
}
}
.background {
GeometryReader { geometry in
Rectangle()
.fill(.clear)
.frame(height: 1)
.onChange(of: geometry.size.width) { newValue in
updateColumnCount(newValue)
}
.onAppear {
updateColumnCount(geometry.size.width)
}
}.padding()
}
.searchable(text: $searchString.string)
.environmentObject(searchString)
.onAppear {
// self.isGlobalMode = ConfigManager.shared.currentConfig?.mode == .global
ApiRequest.getMergedProxyData {
proxyInfo = $0
proxyGroups = ($0?.proxyGroups ?? []).filter {
isGlobalMode ? true : $0.name != "GLOBAL"
}
providerInfo = proxyInfo?.enclosingProviderResp
providers = providerInfo?.providers.map {
$0.value
} ?? []
}
}
}
func updateColumnCount(_ width: Double) {
let v = Int(Int(width) / 200)
let new = v == 0 ? 1 : v
if new != proxyListColumnCount {
proxyListColumnCount = new
}
}
}
//struct ProxiesView_Previews: PreviewProvider {
// static var previews: some View {
// ProxiesView()
// }
//}

View File

@@ -0,0 +1,143 @@
//
// ProxyView.swift
// ClashX Dashboard
//
//
import SwiftUI
struct ProxyGroupView: View {
@Binding var columnCount: Int
@State var proxyGroup: ClashProxy
@State var proxyInfo: ClashProxyResp
@State private var proxyItems: [ProxyItemData]
@State private var currentProxy: ClashProxyName
@State private var isUpdatingSelect = false
@State private var selectable = false
@State private var isListExpanded = false
@State private var isTesting = false
@EnvironmentObject var searchString: ProxiesSearchString
init(columnCount: Binding<Int>,
proxyGroup: ClashProxy,
proxyInfo: ClashProxyResp) {
self._columnCount = columnCount
self.proxyGroup = proxyGroup
self.proxyInfo = proxyInfo
self.currentProxy = proxyGroup.now ?? ""
self.selectable = [.select, .fallback].contains(proxyGroup.type)
self.proxyItems = proxyGroup.all?.compactMap { name in
proxyInfo.proxiesMap[name]
}.map(ProxyItemData.init) ?? []
}
var body: some View {
Section {
proxyListView
.background {
Rectangle()
.frame(width: 2, height: listHeight(columnCount))
.foregroundColor(.clear)
}
.show(isVisible: !isListExpanded)
} header: {
proxyInfoView
}
}
var proxyInfoView: some View {
HStack() {
Text(proxyGroup.name)
.font(.title)
.fontWeight(.medium)
Text(proxyGroup.type.rawValue)
Text("\(proxyGroup.all?.count ?? 0)")
Button() {
isListExpanded = !isListExpanded
} label: {
Image(systemName: isListExpanded ? "chevron.up" : "chevron.down")
}
Button() {
startBenchmark()
} label: {
Image(systemName: "bolt.fill")
}
.disabled(isTesting)
}
}
var proxyListView: some View {
LazyVGrid(columns: Array(repeating: GridItem(.flexible()),
count: columnCount)) {
ForEach($proxyItems, id: \.id) { item in
ProxyItemView(
proxy: item,
selectable: selectable
)
.background(currentProxy == item.wrappedValue.name ? Color.teal : Color.white)
.cornerRadius(8)
.onTapGesture {
let item = item.wrappedValue
updateSelect(item.name)
}
.show(isVisible: {
if searchString.string.isEmpty {
return true
} else {
return item.wrappedValue.name.lowercased().contains(searchString.string.lowercased())
}
}())
}
}
}
func listHeight(_ columnCount: Int) -> Double {
let lineCount = ceil(Double(proxyItems.count) / Double(columnCount))
return lineCount * 60 + (lineCount - 1) * 8
}
func startBenchmark() {
isTesting = true
ApiRequest.getGroupDelay(groupName: proxyGroup.name) { delays in
proxyGroup.all?.forEach { proxyName in
var delay = 0
if let d = delays[proxyName], d != 0 {
delay = d
}
proxyItems.first {
$0.name == proxyName
}?.delay = delay
}
isTesting = false
}
}
func updateSelect(_ name: String) {
guard selectable, !isUpdatingSelect else { return }
isUpdatingSelect = true
ApiRequest.updateProxyGroup(group: proxyGroup.name, selectProxy: name) { success in
isUpdatingSelect = false
guard success else { return }
currentProxy = name
}
}
}
//struct ProxyView_Previews: PreviewProvider {
// static var previews: some View {
// ProxyGroupView()
// }
//}
//

View File

@@ -0,0 +1,75 @@
//
// ProxyItemData.swift
// ClashX Dashboard
//
//
import Cocoa
import SwiftUI
class ProxyItemData: NSObject, ObservableObject {
let id: String
@objc let name: ClashProxyName
let type: ClashProxyType
let udpString: String
let tfo: Bool
let all: [ClashProxyName]
var delay: Int {
didSet {
switch delay {
case 0:
delayString = NSLocalizedString("fail", comment: "")
default:
delayString = "\(delay) ms"
}
let httpsTest = true
switch delay {
case 0:
delayColor = .gray
case ..<200 where !httpsTest:
delayColor = .green
case ..<800 where httpsTest:
delayColor = .green
case 200..<500 where !httpsTest:
delayColor = .yellow
case 800..<1500 where httpsTest:
delayColor = .yellow
default:
delayColor = .orange
}
}
}
@Published var delayString = ""
@Published var delayColor = Color.clear
init(clashProxy: ClashProxy) {
id = clashProxy.id
name = clashProxy.name
type = clashProxy.type
tfo = clashProxy.tfo
all = clashProxy.all ?? []
udpString = {
if clashProxy.udp {
return "UDP"
} else if clashProxy.xudp {
return "XUDP"
} else {
return ""
}
}()
delay = 0
super.init()
defer {
delay = clashProxy.history.last?.meanDelay ?? clashProxy.history.last?.delay ?? 0
}
}
}

View File

@@ -0,0 +1,74 @@
//
// ProxyItemView.swift
// ClashX Dashboard
//
//
import SwiftUI
struct ProxyItemView: View {
@Binding var proxy: ProxyItemData
@State var selectable: Bool
init(proxy: Binding<ProxyItemData>, selectable: Bool) {
self._proxy = proxy
self.selectable = selectable
self.isBuiltInProxy = [.pass, .direct, .reject].contains(proxy.wrappedValue.type)
}
@State private var isBuiltInProxy: Bool
@State private var mouseOver = false
var body: some View {
VStack {
HStack(alignment: .center) {
Text(proxy.name)
.truncationMode(.tail)
.lineLimit(1)
Spacer(minLength: 6)
Text(proxy.udpString)
.foregroundColor(.secondary)
.font(.system(size: 11))
.show(isVisible: !isBuiltInProxy)
}
Spacer(minLength: 6)
.show(isVisible: !isBuiltInProxy)
HStack(alignment: .center) {
Text(proxy.type.rawValue)
.foregroundColor(.secondary)
.font(.system(size: 12))
Text("[TFO]")
.font(.system(size: 9))
.show(isVisible: proxy.tfo)
Spacer(minLength: 6)
Text(proxy.delayString)
.foregroundColor(proxy.delayColor)
.font(.system(size: 11))
}
.show(isVisible: !isBuiltInProxy)
}
.onHover {
guard selectable else { return }
mouseOver = $0
}
.frame(height: 36)
.padding(12)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(mouseOver ? .secondary : Color.clear, lineWidth: 2)
.padding(1)
)
}
}
//struct ProxyItemView_Previews: PreviewProvider {
// static var previews: some View {
// ProxyItemView()
// }
//}

View File

@@ -0,0 +1,19 @@
//
// ProxyListView.swift
// ClashX Dashboard
//
//
import SwiftUI
struct ProxyListView: View {
var body: some View {
Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
}
}
struct ProxyListView_Previews: PreviewProvider {
static var previews: some View {
ProxyListView()
}
}

View File

@@ -0,0 +1,207 @@
//
// ProxyProviderGroupView.swift
// ClashX Dashboard
//
//
import SwiftUI
struct ProxyProviderGroupView: View {
@Binding var columnCount: Int
@Binding var providerInfo: ClashProvider
@State private var proxyItems: [ProxyItemData]
@State private var trafficInfo: String
@State private var expireDate: String
@State private var updateAt: String
@State private var isListExpanded = false
@State private var isTesting = false
@State private var isUpdating = false
@EnvironmentObject var searchString: ProxiesSearchString
init(columnCount: Binding<Int>,
providerInfo: Binding<ClashProvider>) {
self._columnCount = columnCount
self._providerInfo = providerInfo
let info = providerInfo.wrappedValue
self.proxyItems = info.proxies.map(ProxyItemData.init)
if let info = info.subscriptionInfo {
let used = info.download + info.upload
let total = info.total
let formatter = ByteCountFormatter()
trafficInfo = formatter.string(fromByteCount: used)
+ " / "
+ formatter.string(fromByteCount: total)
+ " ( \(String(format: "%.2f", Double(used)/Double(total/100)))% )"
let expire = info.expire
expireDate = "Expire: "
+ Date(timeIntervalSince1970: TimeInterval(expire))
.formatted()
} else {
trafficInfo = ""
expireDate = ""
}
if let updatedAt = info.updatedAt {
let formatter = RelativeDateTimeFormatter()
self.updateAt = formatter.localizedString(for: updatedAt, relativeTo: .now)
} else {
self.updateAt = ""
}
}
var body: some View {
Section {
providerListView
.background {
Rectangle()
.frame(width: 2, height: listHeight(columnCount))
.foregroundColor(.clear)
}
.show(isVisible: !isListExpanded)
} header: {
providerInfoView
} footer: {
HStack {
Button {
update()
} label: {
Label("Update", systemImage: "arrow.clockwise")
}
.disabled(isUpdating)
Button {
startBenchmark()
} label: {
Label("Benchmark", systemImage: "bolt.fill")
}
.disabled(isTesting)
}
}
}
var providerInfoView: some View {
VStack(alignment: .leading) {
HStack {
Text(providerInfo.name)
.font(.title)
.fontWeight(.medium)
Text(providerInfo.vehicleType.rawValue)
.fontWeight(.regular)
Text("\(providerInfo.proxies.count)")
Button() {
isListExpanded = !isListExpanded
} label: {
Image(systemName: isListExpanded ? "chevron.up" : "chevron.down")
}
Button() {
update()
} label: {
Image(systemName: "arrow.clockwise")
}
.disabled(isUpdating)
Button() {
startBenchmark()
} label: {
Image(systemName: "bolt.fill")
}
.disabled(isTesting)
}
HStack {
if trafficInfo != "" {
Text(trafficInfo)
.fontWeight(.regular)
}
if expireDate != "" {
Text(expireDate)
.fontWeight(.regular)
}
}
if updateAt != "" {
Text("Updated \(updateAt)")
.fontWeight(.regular)
}
}
}
var providerListView: some View {
LazyVGrid(columns: Array(repeating: GridItem(.flexible()),
count: columnCount)) {
ForEach($proxyItems, id: \.id) { item in
ProxyItemView(
proxy: item,
selectable: false
)
.background(.white)
.cornerRadius(8)
// .onTapGesture {
// let item = item.wrappedValue
// updateSelect(item.name)
// }
.show(isVisible: {
if searchString.string.isEmpty {
return true
} else {
return item.wrappedValue.name.lowercased().contains(searchString.string.lowercased())
}
}())
}
}
}
func listHeight(_ columnCount: Int) -> Double {
let lineCount = ceil(Double(providerInfo.proxies.count) / Double(columnCount))
return lineCount * 60 + (lineCount - 1) * 8
}
func startBenchmark() {
isTesting = true
let name = providerInfo.name
ApiRequest.healthCheck(proxy: name) {
ApiRequest.requestProxyProviderList {
isTesting = false
guard let provider = $0.allProviders[name] else { return }
self.proxyItems = provider.proxies.map(ProxyItemData.init)
}
}
}
func update() {
isUpdating = true
let name = providerInfo.name
ApiRequest.updateProvider(for: .proxy, name: name) { _ in
ApiRequest.requestProxyProviderList {
isUpdating = false
guard let provider = $0.allProviders[name] else { return }
self.providerInfo = provider
}
}
}
}
//struct ProviderGroupView_Previews: PreviewProvider {
// static var previews: some View {
// ProviderGroupView()
// }
//}

View File

@@ -0,0 +1,69 @@
//
// RuleItemView.swift
// ClashX Dashboard
//
//
import SwiftUI
struct RuleItemView: View {
@State var index: Int
@State var rule: ClashRule
var body: some View {
HStack(alignment: .center, spacing: 12) {
Text("\(index)")
.font(.system(size: 16))
.foregroundColor(.secondary)
.frame(width: 30)
VStack(alignment: .leading) {
if let payload = rule.payload,
payload != "" {
Text(rule.payload!)
.font(.system(size: 14))
}
HStack() {
Text(rule.type)
.foregroundColor(.secondary)
.frame(width: 120, alignment: .leading)
Text(rule.proxy ?? "")
.foregroundColor({
switch rule.proxy {
case "DIRECT":
return .orange
case "REJECT":
return .red
default:
return .blue
}
}())
}
}
}
}
}
struct RulesRowView_Previews: PreviewProvider {
static var previews: some View {
RuleItemView(index: 114, rule: .init(type: "DIRECT", payload: "cn", proxy: "GeoSite"))
}
}
extension HorizontalAlignment {
private struct RuleItemOBAlignment: AlignmentID {
static func defaultValue(in context: ViewDimensions) -> CGFloat {
context[.leading]
}
}
static let ruleItemOBAlignmentGuide = HorizontalAlignment(
RuleItemOBAlignment.self
)
}

View File

@@ -0,0 +1,38 @@
//
// RuleProviderView.swift
// ClashX Dashboard
//
//
import SwiftUI
struct RuleProviderView: View {
@State var ruleProvider: ClashRuleProvider
var body: some View {
VStack(alignment: .leading) {
HStack {
Text(ruleProvider.name)
.font(.title)
.fontWeight(.medium)
Text(ruleProvider.type)
Text(ruleProvider.behavior)
}
HStack {
Text("\(ruleProvider.ruleCount) rules")
if let date = ruleProvider.updatedAt {
Text("Updated \(RelativeDateTimeFormatter().localizedString(for: date, relativeTo: .now))")
}
}
}
}
}
//struct RuleProviderView_Previews: PreviewProvider {
// static var previews: some View {
// RuleProviderView()
// }
//}

View File

@@ -0,0 +1,67 @@
//
// RulesView.swift
// ClashX Dashboard
//
//
import SwiftUI
struct RulesView: View {
@State var ruleProviders = [ClashRuleProvider]()
@State var ruleItems = [ClashRule]()
@State private var searchString: String = ""
var providers: [ClashRuleProvider] {
if searchString.isEmpty {
return ruleProviders
} else {
return ruleProviders.filtered(searchString, for: ["name", "behavior", "type"])
}
}
var rules: [EnumeratedSequence<[ClashRule]>.Element] {
if searchString.isEmpty {
return Array(ruleItems.enumerated())
} else {
return Array(ruleItems.filtered(searchString, for: ["type", "payload", "proxy"]).enumerated())
}
}
var body: some View {
List {
ForEach(providers, id: \.self) {
RuleProviderView(ruleProvider: $0)
}
ForEach(rules, id: \.element.id) {
RuleItemView(index: $0.offset, rule: $0.element)
}
}
.searchable(text: $searchString)
.onAppear {
ruleItems.removeAll()
ApiRequest.getRules {
ruleItems = $0
}
ApiRequest.requestRuleProviderList {
ruleProviders = $0.allProviders.map {
$0.value
}.sorted {
$0.name < $1.name
}
}
}
}
}
//struct RulesView_Previews: PreviewProvider {
// static var previews: some View {
// RulesView()
// }
//}