mirror of
https://github.com/yJason/ClashX-Dashboard.git
synced 2026-03-01 00:35:19 +08:00
init
This commit is contained in:
146
ClashX Dashboard/Views/ContentTabs/Config/ConfigView.swift
Normal file
146
ClashX Dashboard/Views/ContentTabs/Config/ConfigView.swift
Normal 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()
|
||||
// }
|
||||
//}
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
56
ClashX Dashboard/Views/ContentTabs/Logs/LogsView.swift
Normal file
56
ClashX Dashboard/Views/ContentTabs/Logs/LogsView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
// }
|
||||
//}
|
||||
@@ -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()
|
||||
// }
|
||||
//}
|
||||
90
ClashX Dashboard/Views/ContentTabs/Proxies/ProxiesView.swift
Normal file
90
ClashX Dashboard/Views/ContentTabs/Proxies/ProxiesView.swift
Normal 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()
|
||||
// }
|
||||
//}
|
||||
143
ClashX Dashboard/Views/ContentTabs/Proxies/ProxyGroupView.swift
Normal file
143
ClashX Dashboard/Views/ContentTabs/Proxies/ProxyGroupView.swift
Normal 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()
|
||||
// }
|
||||
//}
|
||||
//
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
// }
|
||||
//}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
// }
|
||||
//}
|
||||
69
ClashX Dashboard/Views/ContentTabs/Rules/RuleItemView.swift
Normal file
69
ClashX Dashboard/Views/ContentTabs/Rules/RuleItemView.swift
Normal 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
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
// }
|
||||
//}
|
||||
67
ClashX Dashboard/Views/ContentTabs/Rules/RulesView.swift
Normal file
67
ClashX Dashboard/Views/ContentTabs/Rules/RulesView.swift
Normal 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()
|
||||
// }
|
||||
//}
|
||||
Reference in New Issue
Block a user