This commit is contained in:
mrFq1
2023-06-05 23:39:23 +08:00
parent e5860ead2c
commit f488f41f8d
72 changed files with 247 additions and 45 deletions
@@ -0,0 +1,36 @@
//
// ConfigItemView.swift
// ClashX Dashboard
//
//
import SwiftUI
struct ConfigItemView<Content: View>: View {
@State var name: String
var content: () -> Content
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(name)
.font(.subheadline)
.foregroundColor(.secondary)
Spacer()
}
HStack(content: content)
}
.padding(EdgeInsets(top: 10, leading: 13, bottom: 10, trailing: 13))
.background(Color(nsColor: .textBackgroundColor))
.cornerRadius(10)
}
}
struct ConfigItemView_Previews: PreviewProvider {
static var previews: some View {
ConfigItemView(name: "test") {
Text("label")
}
}
}
@@ -0,0 +1,257 @@
//
// 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: ClashProxyMode = .direct
@State var logLevel: ClashLogLevel = .unknow
@State var allowLAN: Bool = false
@State var sniffer: Bool = false
@State var ipv6: Bool = false
@State var enableTUNDevice: Bool = false
@State var tunIPStack: String = "System"
@State var deviceName: String = "utun9"
@State var interfaceName: String = "en0"
@State private var configInited = false
private let toggleStyle = SwitchToggleStyle()
var body: some View {
ScrollView {
modeView
content1
.padding()
Divider()
.padding()
tunView
.padding()
Divider()
.padding()
content2
.padding()
}
.disabled(!configInited)
.onAppear {
configInited = false
ApiRequest.requestConfig { config in
httpPort = config.port
socks5Port = config.socksPort
mixedPort = config.mixedPort
redirPort = config.redirPort
mode = config.mode
logLevel = config.logLevel
allowLAN = config.allowLan
sniffer = config.sniffing
ipv6 = config.ipv6
enableTUNDevice = config.tun.enable
tunIPStack = config.tun.stack
deviceName = config.tun.device
interfaceName = config.interfaceName
configInited = true
}
}
.onDisappear {
configInited = false
}
}
var modeView: some View {
Picker("", selection: $mode) {
ForEach([
ClashProxyMode.direct,
.rule,
.global
], id: \.self) {
Text($0.name).tag($0)
}
}
.onChange(of: mode) { newValue in
guard configInited else { return }
ApiRequest.updateOutBoundMode(mode: newValue)
}
.padding()
.controlSize(.large)
.labelsHidden()
.pickerStyle(.segmented)
}
var content1: some View {
LazyVGrid(columns: [
GridItem(.flexible()),
GridItem(.flexible())
], alignment: .leading) {
ConfigItemView(name: "Http Port") {
Text(String(httpPort))
.font(.system(size: 17))
}
ConfigItemView(name: "Socks5 Port") {
Text(String(socks5Port))
.font(.system(size: 17))
}
ConfigItemView(name: "Mixed Port") {
Text(String(mixedPort))
.font(.system(size: 17))
}
ConfigItemView(name: "Redir Port") {
Text(String(redirPort))
.font(.system(size: 17))
}
ConfigItemView(name: "Log Level") {
Text(logLevel.rawValue.capitalized)
.font(.system(size: 17))
// Picker("", selection: $logLevel) {
// ForEach([
// ClashLogLevel.silent,
// .error,
// .warning,
// .info,
// .debug,
// .unknow
// ], id: \.self) {
// Text($0.rawValue.capitalized).tag($0)
// }
// }
// .disabled(true)
// .pickerStyle(.menu)
}
ConfigItemView(name: "ipv6") {
Toggle("", isOn: $ipv6)
.toggleStyle(toggleStyle)
.disabled(true)
}
}
}
var tunView: some View {
LazyVGrid(columns: [
GridItem(.flexible()),
GridItem(.flexible())
], alignment: .leading) {
ConfigItemView(name: "Enable TUN Device") {
Toggle("", isOn: $enableTUNDevice)
.toggleStyle(toggleStyle)
}
ConfigItemView(name: "TUN IP Stack") {
// Picker("", selection: $tunIPStack) {
// ForEach(["gVisor", "System", "LWIP"], id: \.self) {
// Text($0)
// }
// }
// .pickerStyle(.menu)
Text(tunIPStack)
.font(.system(size: 17))
}
ConfigItemView(name: "Device Name") {
Text(deviceName)
.font(.system(size: 17))
}
ConfigItemView(name: "Interface Name") {
Text(interfaceName)
.font(.system(size: 17))
}
}
}
var content2: some View {
LazyVGrid(columns: [
GridItem(.flexible()),
GridItem(.flexible())
], alignment: .leading) {
ConfigItemView(name: "Allow LAN") {
Toggle("", isOn: $allowLAN)
.toggleStyle(toggleStyle)
.onChange(of: allowLAN) { newValue in
guard configInited else { return }
ApiRequest.updateAllowLan(allow: newValue) {
ApiRequest.requestConfig { config in
allowLAN = config.allowLan
}
}
}
}
ConfigItemView(name: "Sniffer") {
Toggle("", isOn: $sniffer)
.toggleStyle(toggleStyle)
.onChange(of: sniffer) { newValue in
guard configInited else { return }
ApiRequest.updateSniffing(enable: newValue) {
ApiRequest.requestConfig { config in
sniffer = config.sniffing
}
}
}
}
/*
ConfigItemView(name: "Reload") {
Button {
AppDelegate.shared.updateConfig()
} label: {
Text("Reload config file")
}
}
*/
ConfigItemView(name: "GEO Databases") {
Button {
ApiRequest.updateGEO()
} label: {
Text("Update GEO Databases")
}
}
ConfigItemView(name: "FakeIP") {
Button {
ApiRequest.flushFakeipCache()
} label: {
Text("Flush fake-iP data")
}
}
}
}
}
//struct ConfigView_Previews: PreviewProvider {
// static var previews: some View {
// ConfigView()
// }
//}
@@ -0,0 +1,320 @@
//
// 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
scrollView.autohidesScrollers = true
let tableView = NonRespondingTableView()
tableView.usesAlternatingRowBackgroundColors = true
tableView.delegate = context.coordinator
tableView.dataSource = context.coordinator
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: \DBConnectionObject.host, ascending: true)
case .sniffHost:
sort = .init(keyPath: \DBConnectionObject.sniffHost, ascending: true)
case .process:
sort = .init(keyPath: \DBConnectionObject.process, ascending: true)
case .dl:
sort = .init(keyPath: \DBConnectionObject.download, ascending: true)
case .ul:
sort = .init(keyPath: \DBConnectionObject.upload, ascending: true)
case .chain:
sort = .init(keyPath: \DBConnectionObject.chainString, ascending: true)
case .rule:
sort = .init(keyPath: \DBConnectionObject.ruleString, ascending: true)
case .time:
sort = .init(keyPath: \DBConnectionObject.startDate, ascending: true)
case .source:
sort = .init(keyPath: \DBConnectionObject.source, ascending: true)
case .destinationIP:
sort = .init(keyPath: \DBConnectionObject.destinationIP, ascending: true)
case .type:
sort = .init(keyPath: \DBConnectionObject.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? [DBConnection] else {
return
}
let target = updateSorts(data.map(DBConnectionObject.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: [DBConnectionObject],
tableView: NSTableView) -> [DBConnectionObject] {
var re = objects
var sortDescriptors = tableView.sortDescriptors
sortDescriptors.append(.init(keyPath: \DBConnectionObject.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 = [DBConnectionObject]()
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 cellView = tableView.createCellView(with: "ConnsTableCellView"),
let s = tableColumn?.identifier.rawValue.split(separator: ".").last,
let tc = TableColumn(rawValue: String(s))
else { return nil }
let conn = conns[row]
cellView.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 cellView
}
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()
}
}
func createCellView(with identifier: String) -> NSTableCellView? {
// https://stackoverflow.com/a/27624927
var cellView: NSTableCellView?
if let spareView = makeView(withIdentifier: .init(identifier),
owner: self) as? NSTableCellView {
// We can use an old cell - no need to do anything.
cellView = spareView
} else {
// Create a text field for the cell
let textField = NSTextField()
textField.backgroundColor = NSColor.clear
textField.translatesAutoresizingMaskIntoConstraints = false
textField.isBordered = false
textField.font = .systemFont(ofSize: 13)
textField.lineBreakMode = .byTruncatingTail
// Create a cell
let newCell = NSTableCellView()
newCell.identifier = .init(identifier)
newCell.addSubview(textField)
newCell.textField = textField
// Constrain the text field within the cell
newCell.addConstraints(
NSLayoutConstraint.constraints(withVisualFormat: "H:|[textField]|",
options: [],
metrics: nil,
views: ["textField" : textField]))
newCell.addConstraint(.init(item: textField, attribute: .centerY, relatedBy: .equal, toItem: newCell, attribute: .centerY, multiplier: 1, constant: 0))
textField.bind(NSBindingName.value,
to: newCell,
withKeyPath: "objectValue",
options: nil)
cellView = newCell
}
return cellView
}
}
@@ -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,48 @@
//
// 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(Color(nsColor: .textBackgroundColor))
.searchable(text: $searchString)
.onReceive(NotificationCenter.default.publisher(for: .toolbarSearchString)) {
guard let string = $0.userInfo?["String"] as? String else { return }
searchString = string
}
.onReceive(NotificationCenter.default.publisher(for: .stopConns)) { _ in
stopConns()
}
.toolbar {
ToolbarItem {
Button {
stopConns()
} label: {
Image(systemName: "stop.circle.fill")
}
}
}
}
func stopConns() {
ApiRequest.closeAllConnection()
}
}
struct ConnectionsView_Previews: PreviewProvider {
static var previews: some View {
ConnectionsView()
}
}
@@ -0,0 +1,158 @@
//
// LogsTableView.swift
//
//
//
import Cocoa
import SwiftUI
import DifferenceKit
struct LogsTableView<Item: Hashable>: NSViewRepresentable {
enum TableColumn: String, CaseIterable {
case date = "Date"
case level = "Level"
case log = "Log"
}
var data: [Item]
var filterString: String
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 = false
scrollView.autohidesScrollers = true
let tableView = NonRespondingTableView()
tableView.usesAlternatingRowBackgroundColors = true
tableView.delegate = context.coordinator
tableView.dataSource = context.coordinator
TableColumn.allCases.forEach {
let tableColumn = NSTableColumn(identifier: .init($0.rawValue))
tableColumn.title = $0.rawValue
tableColumn.isEditable = false
switch $0 {
case .date:
tableColumn.minWidth = 60
tableColumn.maxWidth = 140
tableColumn.width = 135
case .level:
tableColumn.minWidth = 40
tableColumn.maxWidth = 65
default:
tableColumn.minWidth = 120
tableColumn.maxWidth = .infinity
}
tableView.addTableColumn(tableColumn)
}
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? [ClashLogStorage.ClashLog] else {
return
}
let target = updateSorts(data, tableView: tableView)
let source = context.coordinator.logs
let changeset = StagedChangeset(source: source, target: target)
tableView.reload(using: changeset) { data in
context.coordinator.logs = data
}
}
func updateSorts(_ objects: [ClashLogStorage.ClashLog],
tableView: NSTableView) -> [ClashLogStorage.ClashLog] {
var re = objects
let filterKeys = [
"levelString",
"log",
]
re = re.filtered(filterString, for: filterKeys)
return re
}
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
class Coordinator: NSObject, NSTableViewDelegate, NSTableViewDataSource {
var parent: LogsTableView
var logs = [ClashLogStorage.ClashLog]()
let dateFormatter = {
let df = DateFormatter()
df.dateFormat = "MM/dd HH:mm:ss.SSS"
return df
}()
init(parent: LogsTableView) {
self.parent = parent
}
func numberOfRows(in tableView: NSTableView) -> Int {
logs.count
}
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
guard let cellView = tableView.createCellView(with: "LogsTableCellView"),
let s = tableColumn?.identifier.rawValue.split(separator: ".").last,
let tc = TableColumn(rawValue: String(s))
else { return nil }
let log = logs[row]
let tf = cellView.textField
switch tc {
case .date:
tf?.lineBreakMode = .byTruncatingHead
tf?.textColor = .orange
tf?.stringValue = dateFormatter.string(from: log.date)
case .level:
tf?.lineBreakMode = .byTruncatingTail
tf?.textColor = log.levelColor
tf?.stringValue = log.levelString
case .log:
tf?.lineBreakMode = .byTruncatingTail
tf?.textColor = .labelColor
tf?.stringValue = log.log
}
return cellView
}
}
}
@@ -0,0 +1,62 @@
//
// LogsView.swift
// ClashX Dashboard
//
//
import SwiftUI
struct LogsView: View {
@EnvironmentObject var logStorage: ClashLogStorage
@State var searchString: String = ""
@State var logLevel = ConfigManager.selectLoggingApiLevel
var body: some View {
Group {
LogsTableView(data: logStorage.logs.reversed(), filterString: searchString)
}
.searchable(text: $searchString)
.onReceive(NotificationCenter.default.publisher(for: .toolbarSearchString)) {
guard let string = $0.userInfo?["String"] as? String else { return }
searchString = string
}
.onReceive(NotificationCenter.default.publisher(for: .logLevelChanged)) {
guard let level = $0.userInfo?["level"] as? ClashLogLevel else { return }
logLevelChanged(level)
}
.toolbar {
ToolbarItem {
Picker("", selection: $logLevel) {
ForEach([
ClashLogLevel.silent,
.error,
.warning,
.info,
.debug
], id: \.self) {
Text($0.rawValue.capitalized).tag($0)
}
}
.pickerStyle(.menu)
.onChange(of: logLevel) { newValue in
guard newValue != ConfigManager.selectLoggingApiLevel else { return }
logLevelChanged(newValue)
}
}
}
}
func logLevelChanged(_ level: ClashLogLevel) {
logStorage.logs.removeAll()
ConfigManager.selectLoggingApiLevel = level
ApiRequest.shared.resetLogStreamApi()
}
}
struct LogsView_Previews: PreviewProvider {
static var previews: some View {
LogsView()
}
}
@@ -0,0 +1,37 @@
//
// OverviewTopItemView.swift
// ClashX Dashboard
//
//
import SwiftUI
struct OverviewTopItemView: View {
@State var name: String
@Binding var value: String
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(name)
.font(.subheadline)
.foregroundColor(.secondary)
Spacer()
}
Text(value)
.font(.system(size: 16))
}
.frame(width: 125)
.padding(EdgeInsets(top: 10, leading: 13, bottom: 10, trailing: 13))
.background(Color(nsColor: .textBackgroundColor))
.cornerRadius(10)
}
}
struct OverviewTopItemView_Previews: PreviewProvider {
@State static var value: String = "Value"
static var previews: some View {
OverviewTopItemView(name: "Name", value: $value)
}
}
@@ -0,0 +1,85 @@
//
// OverviewView.swift
// ClashX Dashboard
//
//
import SwiftUI
import DSFSparkline
struct OverviewView: View {
@EnvironmentObject var data: ClashOverviewData
@State private var columnCount: Int = 4
var body: some View {
VStack(spacing: 25) {
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: columnCount)) {
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)
OverviewTopItemView(name: "Memory Usage", value: $data.memory)
}
.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()
}
HStack {
RoundedRectangle(cornerRadius: 2)
.fill(Color(nsColor: .systemBlue))
.frame(width: 20, height: 13)
Text("Down")
RoundedRectangle(cornerRadius: 2)
.fill(Color(nsColor: .systemGreen))
.frame(width: 20, height: 13)
Text("Up")
}
TrafficGraphView(values: $data.downloadHistories,
graphColor: .systemBlue)
TrafficGraphView(values: $data.uploadHistories,
graphColor: .systemGreen)
}.padding()
}
func updateColumnCount(_ width: Double) {
let v = Int(Int(width) / 155)
let new = v == 0 ? 1 : v
if new != columnCount {
columnCount = new
}
}
}
//struct OverviewView_Previews: PreviewProvider {
// static var previews: some View {
// OverviewView()
// }
//}
@@ -0,0 +1,148 @@
//
// 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
}
@State private var labels = [String]()
@State private var dataSource = DSFSparkline.DataSource()
@State private var currentMaxValue: CGFloat = 0
var body: some View {
HStack {
VStack {
ForEach(labels, id: \.self) {
Text($0)
.font(.system(size: 11, weight: .light))
Spacer()
}
}
graphView
}
.onAppear {
updateChart(values)
}
.onChange(of: values) { newValue in
updateChart(newValue)
}
}
var graphView: some View {
ZStack {
DSFSparklineLineGraphView.SwiftUI(
dataSource: dataSource,
graphColor: graphColor,
interpolated: false,
showZeroLine: false
)
DSFSparklineSurface.SwiftUI([
gridOverlay
])
}
}
let gridOverlay: DSFSparklineOverlay = {
let grid = DSFSparklineOverlay.GridLines()
grid.dataSource = .init(values: [1], range: 0...1)
var floatValues = [CGFloat]()
for i in 0...labelsCount {
floatValues.append(CGFloat(i) / CGFloat(labelsCount))
}
let _ = floatValues.removeFirst()
grid.floatValues = floatValues.reversed()
grid.strokeColor = DSFColor.systemGray.withAlphaComponent(0.3).cgColor
grid.strokeWidth = 0.5
grid.dashStyle = [2, 2]
return grid
}()
func updateChart(_ values: [CGFloat]) {
let max = values.max() ?? CGFloat(labelsCount) * 1000
if currentMaxValue != 0 && currentMaxValue == max {
self.dataSource.set(values: values)
return
} else {
currentMaxValue = max
}
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
}
let vv = Double(labelsCount) / 10
if v1.truncatingRemainder(dividingBy: vv) != 0 {
v1 = Double((Int(v1 / vv) + 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()
let upperBound = CGFloat(v1*v3*1000)
self.dataSource.set(values: values)
self.dataSource.setRange(lowerBound: 0, upperBound: upperBound)
self.labels = re
}
}
//struct TrafficGraphView_Previews: PreviewProvider {
// static var previews: some View {
// TrafficGraphView()
// }
//}
@@ -0,0 +1,140 @@
//
// ProviderProxiesView.swift
// ClashX Dashboard
//
//
import SwiftUI
struct ProviderProxiesView: View {
@ObservedObject var provider: DBProxyProvider
@EnvironmentObject var hideProxyNames: HideProxyNames
@EnvironmentObject var searchString: ProxiesSearchString
@State private var columnCount: Int = 3
@State private var isTesting = false
@State private var isUpdating = false
var proxies: [DBProxy] {
if searchString.string.isEmpty {
return provider.proxies
} else {
return provider.proxies.filter {
$0.name.lowercased().contains(searchString.string.lowercased())
}
}
}
var body: some View {
ScrollView {
Section {
proxyListView
} header: {
HStack {
ProxyProviderInfoView(provider: provider)
buttonsView
}
}
.padding()
}
.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()
}
}
func updateColumnCount(_ width: Double) {
let v = Int(Int(width) / 180)
let new = v == 0 ? 1 : v
if new != columnCount {
columnCount = new
}
}
var proxyListView: some View {
LazyVGrid(columns: Array(repeating: GridItem(.flexible()),
count: columnCount)) {
ForEach(proxies, id: \.id) { proxy in
ProxyItemView(
proxy: proxy,
selectable: false
)
.background(Color(nsColor: .textBackgroundColor))
.cornerRadius(8)
}
}
}
var buttonsView: some View {
VStack {
ProgressButton(
title: "Health Check",
title2: "Testing",
iconName: "bolt.fill",
inProgress: $isTesting,
autoWidth: false) {
startHealthCheck()
}
ProgressButton(
title: "Update",
title2: "Updating",
iconName: "arrow.clockwise",
inProgress: $isUpdating,
autoWidth: false) {
startUpdate()
}
}
.frame(width: ProgressButton.width(
[
"Health Check",
"Testing",
"Update",
"Updating"]
))
}
func startHealthCheck() {
isTesting = true
ApiRequest.healthCheck(proxy: provider.name) {
updateProvider {
isTesting = false
}
}
}
func startUpdate() {
isUpdating = true
ApiRequest.updateProvider(for: .proxy, name: provider.name) { _ in
updateProvider {
isUpdating = false
}
}
}
func updateProvider(_ completeHandler: (() -> Void)? = nil) {
ApiRequest.requestProxyProviderList { resp in
if let p = resp.allProviders[provider.name] {
provider.updateInfo(DBProxyProvider(provider: p))
}
completeHandler?()
}
}
}
//struct ProviderProxiesView_Previews: PreviewProvider {
// static var previews: some View {
// ProviderProxiesView()
// }
//}
@@ -0,0 +1,51 @@
//
// ProviderRowView.swift
// ClashX Dashboard
//
//
import SwiftUI
struct ProviderRowView: View {
@ObservedObject var proxyProvider: DBProxyProvider
@EnvironmentObject var hideProxyNames: HideProxyNames
var body: some View {
NavigationLink {
ProviderProxiesView(provider: proxyProvider)
} label: {
labelView
}
}
var labelView: some View {
VStack(spacing: 2) {
HStack(alignment: .center) {
Text(hideProxyNames.hide
? String(proxyProvider.id.prefix(8))
: proxyProvider.name)
.font(.system(size: 15))
Spacer()
Text(proxyProvider.trafficPercentage)
.font(.system(size: 12))
.foregroundColor(.secondary)
}
HStack {
Text(proxyProvider.vehicleType.rawValue)
Spacer()
Text(proxyProvider.updatedAt)
}
.font(.system(size: 11))
.foregroundColor(.secondary)
}
.padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4))
}
}
//struct ProviderRowView_Previews: PreviewProvider {
// static var previews: some View {
// ProviderRowView()
// }
//}
@@ -0,0 +1,104 @@
//
// ProvidersView.swift
// ClashX Dashboard
//
//
import SwiftUI
struct ProvidersView: View {
@ObservedObject var providerStorage = DBProviderStorage()
@State private var searchString = ProxiesSearchString()
@StateObject private var hideProxyNames = HideProxyNames()
var body: some View {
NavigationView {
listView
EmptyView()
}
.searchable(text: $searchString.string)
.onReceive(NotificationCenter.default.publisher(for: .toolbarSearchString)) {
guard let string = $0.userInfo?["String"] as? String else { return }
searchString.string = string
}
.onReceive(NotificationCenter.default.publisher(for: .hideNames)) {
guard let hide = $0.userInfo?["hide"] as? Bool else { return }
hideProxyNames.hide = hide
}
.environmentObject(searchString)
.onAppear {
loadProviders()
}
.environmentObject(hideProxyNames)
.toolbar {
ToolbarItem {
Button {
hideProxyNames.hide = !hideProxyNames.hide
} label: {
Image(systemName: hideProxyNames.hide ? "eyeglasses" : "wand.and.stars")
}
}
}
}
var listView: some View {
List {
if providerStorage.proxyProviders.isEmpty,
providerStorage.ruleProviders.isEmpty {
Text("Empty")
.padding()
} else {
Section("Providers") {
if !providerStorage.proxyProviders.isEmpty {
ProxyProvidersRowView(providerStorage: providerStorage)
}
if !providerStorage.ruleProviders.isEmpty {
RuleProvidersRowView(providerStorage: providerStorage)
}
}
}
if !providerStorage.proxyProviders.isEmpty {
Text("")
Section("Proxy Provider") {
ForEach(providerStorage.proxyProviders,id: \.id) {
ProviderRowView(proxyProvider: $0)
}
}
}
}
.introspectTableView {
$0.refusesFirstResponder = true
$0.doubleAction = nil
}
.listStyle(.plain)
}
func loadProviders() {
ApiRequest.requestProxyProviderList { resp in
providerStorage.proxyProviders = resp.allProviders.values.filter {
$0.vehicleType == .HTTP
}.sorted {
$0.name < $1.name
}
.map(DBProxyProvider.init)
}
ApiRequest.requestRuleProviderList { resp in
providerStorage.ruleProviders = resp.allProviders.values.sorted {
$0.name < $1.name
}
.map(DBRuleProvider.init)
}
}
}
//struct ProvidersView_Previews: PreviewProvider {
// static var previews: some View {
// ProvidersView()
// }
//}
@@ -0,0 +1,89 @@
//
// ProxyProviderInfoView.swift
// ClashX Dashboard
//
//
import SwiftUI
struct ProxyProviderInfoView: View {
@ObservedObject var provider: DBProxyProvider
@EnvironmentObject var hideProxyNames: HideProxyNames
@State var withUpdateButton = false
@State var isUpdating = false
var body: some View {
HStack {
VStack {
header
content
}
if withUpdateButton {
ProgressButton(
title: "",
title2: "",
iconName: "arrow.clockwise",
inProgress: $isUpdating) {
update()
}
}
}
}
var header: some View {
HStack() {
Text(hideProxyNames.hide
? String(provider.id.prefix(8))
: provider.name)
.font(.system(size: 17))
Text(provider.vehicleType.rawValue)
.font(.system(size: 13))
.foregroundColor(.secondary)
Text("\(provider.proxies.count)")
.font(.system(size: 11))
.padding(EdgeInsets(top: 2, leading: 4, bottom: 2, trailing: 4))
.background(Color.gray.opacity(0.5))
.cornerRadius(4)
Spacer()
}
}
var content: some View {
VStack {
HStack(spacing: 20) {
Text(provider.trafficInfo)
Text(provider.expireDate)
Spacer()
}
HStack {
Text("Updated \(provider.updatedAt)")
Spacer()
}
}
.font(.system(size: 12))
.foregroundColor(.secondary)
}
func update() {
isUpdating = true
let name = provider.name
ApiRequest.updateProvider(for: .proxy, name: name) { _ in
ApiRequest.requestProxyProviderList() { resp in
if let p = resp.allProviders[provider.name] {
provider.updateInfo(DBProxyProvider(provider: p))
}
isUpdating = false
}
}
}
}
//struct ProxyProviderInfoView_Previews: PreviewProvider {
// static var previews: some View {
// ProxyProviderInfoView()
// }
//}
@@ -0,0 +1,81 @@
//
// ProxyProvidersRowView.swift
// ClashX Dashboard
//
//
import SwiftUI
struct ProxyProvidersRowView: View {
@ObservedObject var providerStorage: DBProviderStorage
@EnvironmentObject var searchString: ProxiesSearchString
@State private var isUpdating = false
var providers: [DBProxyProvider] {
if searchString.string.isEmpty {
return providerStorage.proxyProviders
} else {
return providerStorage.proxyProviders.filter {
$0.name.lowercased().contains(searchString.string.lowercased())
}
}
}
var body: some View {
NavigationLink {
contentView
} label: {
Text("Proxy")
.font(.system(size: 15))
.padding(EdgeInsets(top: 2, leading: 4, bottom: 2, trailing: 4))
}
}
var contentView: some View {
ScrollView {
Section {
VStack(spacing: 16) {
listView
}
} header: {
ProgressButton(
title: "Update All",
title2: "Updating",
iconName: "arrow.clockwise", inProgress: $isUpdating) {
updateAll()
}
}
.padding()
}
}
var listView: some View {
ForEach(providers, id: \.id) { provider in
ProxyProviderInfoView(provider: provider, withUpdateButton: true)
}
}
func updateAll() {
isUpdating = true
ApiRequest.updateAllProviders(for: .proxy) { _ in
ApiRequest.requestProxyProviderList { resp in
providerStorage.proxyProviders = resp.allProviders.values.filter {
$0.vehicleType == .HTTP
}.sorted {
$0.name < $1.name
}
.map(DBProxyProvider.init)
isUpdating = false
}
}
}
}
//struct AllProvidersRowView_Previews: PreviewProvider {
// static var previews: some View {
// ProxyProvidersRowView()
// }
//}
@@ -0,0 +1,42 @@
//
// RuleProviderView.swift
// ClashX Dashboard
//
//
import SwiftUI
struct RuleProviderView: View {
@State var provider: DBRuleProvider
var body: some View {
VStack(alignment: .leading) {
HStack {
Text(provider.name)
.font(.title)
.fontWeight(.medium)
Text(provider.type)
Text(provider.behavior)
Spacer()
}
HStack {
Text("\(provider.ruleCount) rules")
if let date = provider.updatedAt {
Text("Updated \(RelativeDateTimeFormatter().localizedString(for: date, relativeTo: .now))")
}
Spacer()
}
.font(.system(size: 12))
.foregroundColor(.secondary)
}
}
}
//struct RuleProviderView_Previews: PreviewProvider {
// static var previews: some View {
// RuleProviderView()
// }
//}
@@ -0,0 +1,75 @@
//
// RuleProvidersRowView.swift
// ClashX Dashboard
//
//
import SwiftUI
struct RuleProvidersRowView: View {
@ObservedObject var providerStorage: DBProviderStorage
@EnvironmentObject var searchString: ProxiesSearchString
@State private var isUpdating = false
var providers: [DBRuleProvider] {
if searchString.string.isEmpty {
return providerStorage.ruleProviders
} else {
return providerStorage.ruleProviders.filter {
$0.name.lowercased().contains(searchString.string.lowercased())
}
}
}
var body: some View {
NavigationLink {
contentView
} label: {
Text("Rule")
.font(.system(size: 15))
.padding(EdgeInsets(top: 2, leading: 4, bottom: 2, trailing: 4))
}
}
var contentView: some View {
ScrollView {
Section {
VStack(spacing: 12) {
ForEach(providers, id: \.id) {
RuleProviderView(provider: $0)
}
}
} header: {
ProgressButton(
title: "Update All",
title2: "Updating",
iconName: "arrow.clockwise",
inProgress: $isUpdating) {
updateAll()
}
}
.padding()
}
}
func updateAll() {
isUpdating = true
ApiRequest.updateAllProviders(for: .rule) { _ in
ApiRequest.requestRuleProviderList { resp in
providerStorage.ruleProviders = resp.allProviders.values.sorted {
$0.name < $1.name
}
.map(DBRuleProvider.init)
isUpdating = false
}
}
}
}
//struct ProxyProvidersRowView_Previews: PreviewProvider {
// static var previews: some View {
// RuleProvidersRowView()
// }
//}
@@ -0,0 +1,77 @@
//
// ProxiesView.swift
// ClashX Dashboard
//
//
import SwiftUI
import Introspect
class ProxiesSearchString: ObservableObject, Identifiable {
let id = UUID().uuidString
@Published var string: String = ""
}
struct ProxiesView: View {
@ObservedObject var proxyStorage = DBProxyStorage()
@State private var searchString = ProxiesSearchString()
@State private var isGlobalMode = false
@StateObject private var hideProxyNames = HideProxyNames()
var body: some View {
NavigationView {
List(proxyStorage.groups, id: \.id) { group in
ProxyGroupRowView(proxyGroup: group)
}
.introspectTableView {
$0.refusesFirstResponder = true
$0.doubleAction = nil
}
.listStyle(.plain)
EmptyView()
}
.searchable(text: $searchString.string)
.onReceive(NotificationCenter.default.publisher(for: .toolbarSearchString)) {
guard let string = $0.userInfo?["String"] as? String else { return }
searchString.string = string
}
.onReceive(NotificationCenter.default.publisher(for: .hideNames)) {
guard let hide = $0.userInfo?["hide"] as? Bool else { return }
hideProxyNames.hide = hide
}
.environmentObject(searchString)
.onAppear {
loadProxies()
}
.environmentObject(hideProxyNames)
.toolbar {
ToolbarItem {
Button {
hideProxyNames.hide = !hideProxyNames.hide
} label: {
Image(systemName: hideProxyNames.hide ? "eyeglasses" : "wand.and.stars")
}
}
}
}
func loadProxies() {
// self.isGlobalMode = ConfigManager.shared.currentConfig?.mode == .global
ApiRequest.getMergedProxyData {
guard let resp = $0 else { return }
proxyStorage.groups = DBProxyStorage(resp).groups.filter {
isGlobalMode ? true : $0.name != "GLOBAL"
}
}
}
}
//struct ProxiesView_Previews: PreviewProvider {
// static var previews: some View {
// ProxiesView()
// }
//}
@@ -0,0 +1,53 @@
//
// ProxyGroupInfoView.swift
// ClashX Dashboard
//
//
import SwiftUI
struct ProxyGroupRowView: View {
@ObservedObject var proxyGroup: DBProxyGroup
@EnvironmentObject var hideProxyNames: HideProxyNames
var body: some View {
NavigationLink {
ProxyGroupView(proxyGroup: proxyGroup)
} label: {
labelView
}
}
var labelView: some View {
VStack(spacing: 2) {
HStack(alignment: .center) {
Text(hideProxyNames.hide
? String(proxyGroup.id.prefix(8))
: proxyGroup.name)
.font(.system(size: 15))
Spacer()
if let proxy = proxyGroup.currentProxy {
Text(proxy.delayString)
.foregroundColor(proxy.delayColor)
.font(.system(size: 12))
}
}
HStack {
Text(proxyGroup.type.rawValue)
Spacer()
if let proxy = proxyGroup.currentProxy {
Text(hideProxyNames.hide
? String(proxy.id.prefix(8))
: proxy.name)
}
}
.font(.system(size: 11))
.foregroundColor(.secondary)
}
.padding(EdgeInsets(top: 3, leading: 4, bottom: 3, trailing: 4))
}
}
@@ -0,0 +1,151 @@
//
// ProxyView.swift
// ClashX Dashboard
//
//
import SwiftUI
struct ProxyGroupView: View {
@ObservedObject var proxyGroup: DBProxyGroup
@EnvironmentObject var searchString: ProxiesSearchString
@EnvironmentObject var hideProxyNames: HideProxyNames
@State private var columnCount: Int = 3
@State private var isUpdatingSelect = false
@State private var selectable = false
@State private var isTesting = false
var proxies: [DBProxy] {
if searchString.string.isEmpty {
return proxyGroup.proxies
} else {
return proxyGroup.proxies.filter {
$0.name.lowercased().contains(searchString.string.lowercased())
}
}
}
var body: some View {
ScrollView {
Section {
proxyListView
} header: {
proxyInfoView
}
.padding()
}
.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()
}
.onAppear {
self.selectable = [.select, .fallback].contains(proxyGroup.type)
}
}
func updateColumnCount(_ width: Double) {
let v = Int(Int(width) / 180)
let new = v == 0 ? 1 : v
if new != columnCount {
columnCount = new
}
}
var proxyInfoView: some View {
HStack() {
Text(hideProxyNames.hide
? String(proxyGroup.id.prefix(8))
: proxyGroup.name)
.font(.system(size: 17))
Text(proxyGroup.type.rawValue)
.font(.system(size: 13))
.foregroundColor(.secondary)
Text("\(proxyGroup.proxies.count)")
.font(.system(size: 11))
.padding(EdgeInsets(top: 2, leading: 4, bottom: 2, trailing: 4))
.background(Color.gray.opacity(0.5))
.cornerRadius(4)
Spacer()
ProgressButton(
title: proxyGroup.type == .urltest ? "Retest" : "Benchmark",
title2: "Testing",
iconName: "bolt.fill",
inProgress: $isTesting) {
startBenchmark()
}
}
}
var proxyListView: some View {
LazyVGrid(columns: Array(repeating: GridItem(.flexible()),
count: columnCount)) {
ForEach(proxies, id: \.id) { proxy in
ProxyItemView(
proxy: proxy,
selectable: selectable
)
.background(proxyGroup.now == proxy.name ? Color.pink.opacity(0.3) : Color(nsColor: .textBackgroundColor))
.cornerRadius(8)
.onTapGesture {
let item = proxy
updateSelect(item.name)
}
}
}
}
func startBenchmark() {
isTesting = true
ApiRequest.getGroupDelay(groupName: proxyGroup.name) { delays in
proxyGroup.proxies.enumerated().forEach {
var delay = 0
if let d = delays[$0.element.name], d != 0 {
delay = d
}
guard $0.offset < proxyGroup.proxies.count,
proxyGroup.proxies[$0.offset].name == $0.element.name
else { return }
proxyGroup.proxies[$0.offset].delay = delay
if proxyGroup.currentProxy?.name == $0.element.name {
proxyGroup.currentProxy = proxyGroup.proxies[$0.offset]
}
}
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 }
proxyGroup.now = name
}
}
}
//struct ProxyView_Previews: PreviewProvider {
// static var previews: some View {
// ProxyGroupView()
// }
//}
//
@@ -0,0 +1,78 @@
//
// ProxyItemView.swift
// ClashX Dashboard
//
//
import SwiftUI
struct ProxyItemView: View {
@ObservedObject var proxy: DBProxy
@State var selectable: Bool
@EnvironmentObject var hideProxyNames: HideProxyNames
init(proxy: DBProxy, selectable: Bool) {
self.proxy = proxy
self.selectable = selectable
self.isBuiltInProxy = [.pass, .direct, .reject].contains(proxy.type)
}
@State private var isBuiltInProxy: Bool
@State private var mouseOver = false
var body: some View {
VStack {
HStack(alignment: .center) {
Text(hideProxyNames.hide
? String(proxy.id.prefix(8))
: 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,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,49 @@
//
// RulesView.swift
// ClashX Dashboard
//
//
import SwiftUI
struct RulesView: View {
@State var ruleItems = [ClashRule]()
@State private var searchString: String = ""
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(rules, id: \.element.id) {
RuleItemView(index: $0.offset, rule: $0.element)
}
}
.searchable(text: $searchString)
.onReceive(NotificationCenter.default.publisher(for: .toolbarSearchString)) {
guard let string = $0.userInfo?["String"] as? String else { return }
searchString = string
}
.onAppear {
ruleItems.removeAll()
ApiRequest.getRules {
ruleItems = $0
}
}
}
}
//struct RulesView_Previews: PreviewProvider {
// static var previews: some View {
// RulesView()
// }
//}