mirror of
https://github.com/yJason/ClashX-Dashboard.git
synced 2026-03-01 00:35:19 +08:00
fix: spm
This commit is contained in:
@@ -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()
|
||||
// }
|
||||
//}
|
||||
Reference in New Issue
Block a user