mirror of
https://github.com/yJason/ClashX-Dashboard.git
synced 2026-02-04 10:02:26 +08:00
fix: spm
This commit is contained in:
596
Sources/ClashX Dashboard/ClashX Links/ApiRequest.swift
Normal file
596
Sources/ClashX Dashboard/ClashX Links/ApiRequest.swift
Normal file
@@ -0,0 +1,596 @@
|
||||
//
|
||||
// ApiRequest.swift
|
||||
// ClashX
|
||||
//
|
||||
// Created by CYC on 2018/7/30.
|
||||
// Copyright © 2018年 yichengchen. All rights reserved.
|
||||
//
|
||||
|
||||
import Alamofire
|
||||
import Cocoa
|
||||
import Starscream
|
||||
import SwiftyJSON
|
||||
import SwiftUI
|
||||
|
||||
protocol ApiRequestStreamDelegate: AnyObject {
|
||||
func didUpdateTraffic(up: Int, down: Int)
|
||||
func didGetLog(log: String, level: String)
|
||||
func didUpdateMemory(memory: Int64)
|
||||
func streamStatusChanged()
|
||||
}
|
||||
|
||||
typealias ErrorString = String
|
||||
|
||||
struct ClashVersion: Decodable {
|
||||
let version: String
|
||||
let meta: Bool?
|
||||
}
|
||||
|
||||
class ApiRequest {
|
||||
static let shared = ApiRequest()
|
||||
|
||||
private var proxyRespCache: ClashProxyResp?
|
||||
|
||||
private lazy var logQueue = DispatchQueue(label: "com.ClashX.core.log")
|
||||
|
||||
static let clashRequestQueue = DispatchQueue(label: "com.clashx.clashRequestQueue")
|
||||
|
||||
@objc enum ProviderType: Int {
|
||||
case rule, proxy
|
||||
|
||||
func apiString() -> String {
|
||||
self == .proxy ? "proxies" : "rules"
|
||||
}
|
||||
|
||||
func logString() -> String {
|
||||
self == .proxy ? "Proxy" : "Rule"
|
||||
}
|
||||
}
|
||||
|
||||
private init() {
|
||||
let configuration = URLSessionConfiguration.default
|
||||
configuration.timeoutIntervalForRequest = 604800
|
||||
configuration.timeoutIntervalForResource = 604800
|
||||
configuration.httpMaximumConnectionsPerHost = 100
|
||||
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
alamoFireManager = Session(configuration: configuration)
|
||||
}
|
||||
|
||||
private static func authHeader() -> HTTPHeaders {
|
||||
let secret = ConfigManager.shared.overrideSecret ?? ConfigManager.shared.apiSecret
|
||||
return (secret.count > 0) ? ["Authorization": "Bearer \(secret)"] : [:]
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private static func req(
|
||||
_ url: String,
|
||||
method: HTTPMethod = .get,
|
||||
parameters: Parameters? = nil,
|
||||
encoding: ParameterEncoding = URLEncoding.default
|
||||
)
|
||||
-> DataRequest {
|
||||
guard ConfigManager.shared.isRunning else {
|
||||
return AF.request("")
|
||||
}
|
||||
|
||||
return shared.alamoFireManager
|
||||
.request(ConfigManager.apiUrl + url,
|
||||
method: method,
|
||||
parameters: parameters,
|
||||
encoding: encoding,
|
||||
headers: authHeader())
|
||||
}
|
||||
|
||||
weak var delegate: ApiRequestStreamDelegate?
|
||||
|
||||
private var trafficWebSocket: WebSocket?
|
||||
private var loggingWebSocket: WebSocket?
|
||||
private var memoryWebSocket: WebSocket?
|
||||
|
||||
private var trafficWebSocketRetryDelay: TimeInterval = 1
|
||||
private var loggingWebSocketRetryDelay: TimeInterval = 1
|
||||
private var memoryWebSocketRetryDelay: TimeInterval = 1
|
||||
|
||||
private var trafficWebSocketRetryTimer: Timer?
|
||||
private var loggingWebSocketRetryTimer: Timer?
|
||||
private var memoryWebSocketRetryTimer: Timer?
|
||||
|
||||
private var alamoFireManager: Session
|
||||
|
||||
static func requestVersion(completeHandler: @escaping ((ClashVersion?) -> Void)) {
|
||||
shared.alamoFireManager
|
||||
.request(ConfigManager.apiUrl + "/version",
|
||||
method: .get,
|
||||
headers: authHeader())
|
||||
.responseDecodable(of: ClashVersion.self) {
|
||||
resp in
|
||||
switch resp.result {
|
||||
case let .success(ver):
|
||||
completeHandler(ver)
|
||||
case let .failure(err):
|
||||
completeHandler(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func requestConfig(completeHandler: @escaping ((ClashConfig) -> Void)) {
|
||||
req("/configs").responseDecodable(of: ClashConfig.self) {
|
||||
resp in
|
||||
switch resp.result {
|
||||
case let .success(config):
|
||||
completeHandler(config)
|
||||
case let .failure(err):
|
||||
Logger.log(err.localizedDescription)
|
||||
// NSUserNotificationCenter.default.post(title: "Error", info: err.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func updateOutBoundMode(mode: ClashProxyMode, callback: ((Bool) -> Void)? = nil) {
|
||||
req("/configs", method: .patch, parameters: ["mode": mode.rawValue], encoding: JSONEncoding.default)
|
||||
.responseData { response in
|
||||
switch response.result {
|
||||
case .success:
|
||||
callback?(true)
|
||||
case .failure:
|
||||
callback?(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func updateLogLevel(level: ClashLogLevel, callback: ((Bool) -> Void)? = nil) {
|
||||
req("/configs", method: .patch, parameters: ["log-level": level.rawValue], encoding: JSONEncoding.default).responseData(completionHandler: { response in
|
||||
switch response.result {
|
||||
case .success:
|
||||
callback?(true)
|
||||
case .failure:
|
||||
callback?(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
static func requestProxyGroupList(completeHandler: ((ClashProxyResp) -> Void)? = nil) {
|
||||
req("/proxies").responseData {
|
||||
res in
|
||||
let proxies = ClashProxyResp(try? res.result.get())
|
||||
ApiRequest.shared.proxyRespCache = proxies
|
||||
completeHandler?(proxies)
|
||||
}
|
||||
}
|
||||
|
||||
static func requestProxyProviderList(completeHandler: ((ClashProviderResp) -> Void)? = nil) {
|
||||
req("/providers/proxies")
|
||||
.responseDecodable(of: ClashProviderResp.self, decoder: ClashProviderResp.decoder) { resp in
|
||||
switch resp.result {
|
||||
case let .success(providerResp):
|
||||
completeHandler?(providerResp)
|
||||
case let .failure(err):
|
||||
Logger.log("requestProxyProviderList error \(err.localizedDescription)")
|
||||
completeHandler?(ClashProviderResp())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func updateAllowLan(allow: Bool, completeHandler: (() -> Void)? = nil) {
|
||||
Logger.log("update allow lan:\(allow)", level: .debug)
|
||||
req("/configs",
|
||||
method: .patch,
|
||||
parameters: ["allow-lan": allow],
|
||||
encoding: JSONEncoding.default).response {
|
||||
_ in
|
||||
completeHandler?()
|
||||
}
|
||||
}
|
||||
|
||||
static func updateProxyGroup(group: String, selectProxy: String, callback: @escaping ((Bool) -> Void)) {
|
||||
req("/proxies/\(group.encoded)",
|
||||
method: .put,
|
||||
parameters: ["name": selectProxy],
|
||||
encoding: JSONEncoding.default)
|
||||
.responseData { response in
|
||||
callback(response.response?.statusCode == 204)
|
||||
}
|
||||
}
|
||||
|
||||
static func getAllProxyList(callback: @escaping (([ClashProxyName]) -> Void)) {
|
||||
requestProxyGroupList {
|
||||
proxyInfo in
|
||||
let lists: [ClashProxyName] = proxyInfo.proxiesMap["GLOBAL"]?.all ?? []
|
||||
callback(lists)
|
||||
}
|
||||
}
|
||||
|
||||
static func getMergedProxyData(complete: ((ClashProxyResp?) -> Void)? = nil) {
|
||||
let group = DispatchGroup()
|
||||
group.enter()
|
||||
group.enter()
|
||||
|
||||
var provider: ClashProviderResp?
|
||||
var proxyInfo: ClashProxyResp?
|
||||
|
||||
group.notify(queue: .main) {
|
||||
guard let proxyInfo = proxyInfo, let proxyprovider = provider else {
|
||||
assertionFailure()
|
||||
complete?(nil)
|
||||
return
|
||||
}
|
||||
proxyInfo.updateProvider(proxyprovider)
|
||||
complete?(proxyInfo)
|
||||
}
|
||||
|
||||
ApiRequest.requestProxyProviderList {
|
||||
proxyprovider in
|
||||
provider = proxyprovider
|
||||
group.leave()
|
||||
}
|
||||
|
||||
ApiRequest.requestProxyGroupList {
|
||||
proxy in
|
||||
proxyInfo = proxy
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
|
||||
static func getProxyDelay(proxyName: String, callback: @escaping ((Int) -> Void)) {
|
||||
req("/proxies/\(proxyName.encoded)/delay",
|
||||
method: .get,
|
||||
parameters: ["timeout": 2500, "url": ConfigManager.shared.benchMarkUrl])
|
||||
.responseData { res in
|
||||
switch res.result {
|
||||
case let .success(value):
|
||||
let json = JSON(value)
|
||||
callback(json["delay"].intValue)
|
||||
case .failure:
|
||||
callback(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func getGroupDelay(groupName: String, callback: @escaping (([String: Int]) -> Void)) {
|
||||
req("/group/\(groupName.encoded)/delay",
|
||||
method: .get,
|
||||
parameters: ["timeout": 2500, "url": ConfigManager.shared.benchMarkUrl])
|
||||
.responseData { res in
|
||||
switch res.result {
|
||||
case let .success(value):
|
||||
let dic = try? JSONDecoder().decode([String: Int].self, from: value)
|
||||
callback(dic ?? [:])
|
||||
case .failure:
|
||||
callback([:])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func getRules(completeHandler: @escaping ([ClashRule]) -> Void) {
|
||||
req("/rules").responseData { res in
|
||||
guard let data = try? res.result.get() else { return }
|
||||
|
||||
ClashRuleProviderResp.init()
|
||||
|
||||
let rule = ClashRuleResponse.fromData(data)
|
||||
completeHandler(rule.rules ?? [])
|
||||
}
|
||||
}
|
||||
|
||||
static func healthCheck(proxy: ClashProviderName, completeHandler: (() -> Void)? = nil) {
|
||||
Logger.log("HeathCheck for \(proxy) started")
|
||||
req("/providers/proxies/\(proxy.encoded)/healthcheck").response { res in
|
||||
if res.response?.statusCode == 204 {
|
||||
Logger.log("HeathCheck for \(proxy) finished")
|
||||
} else {
|
||||
Logger.log("HeathCheck for \(proxy) failed:\(res.response?.statusCode ?? -1)")
|
||||
}
|
||||
completeHandler?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Connections
|
||||
|
||||
extension ApiRequest {
|
||||
static func getConnections(completeHandler: @escaping (DBConnectionSnapShot) -> Void) {
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .formatted(DateFormatter.js)
|
||||
|
||||
req("/connections").responseDecodable(of: DBConnectionSnapShot.self, decoder: decoder) { resp in
|
||||
switch resp.result {
|
||||
case let .success(snapshot):
|
||||
completeHandler(snapshot)
|
||||
case .failure:
|
||||
return
|
||||
// assertionFailure()
|
||||
// completeHandler(DBConnectionSnapShot())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func closeConnection(_ conn: ClashConnectionSnapShot.Connection) {
|
||||
req("/connections/".appending(conn.id), method: .delete).response { _ in }
|
||||
}
|
||||
|
||||
static func closeAllConnection() {
|
||||
req("/connections", method: .delete).response { _ in }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Meta
|
||||
|
||||
extension ApiRequest {
|
||||
static func updateAllProviders(for type: ProviderType, completeHandler: ((Int) -> Void)? = nil) {
|
||||
var failuresCount = 0
|
||||
|
||||
let group = DispatchGroup()
|
||||
group.enter()
|
||||
|
||||
if type == .proxy {
|
||||
requestProxyProviderList { resp in
|
||||
resp.allProviders.filter {
|
||||
$0.value.vehicleType == .HTTP
|
||||
}.forEach {
|
||||
group.enter()
|
||||
updateProvider(for: .proxy, name: $0.key) {
|
||||
if !$0 {
|
||||
failuresCount += 1
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
} else {
|
||||
requestRuleProviderList { resp in
|
||||
resp.allProviders.forEach {
|
||||
group.enter()
|
||||
updateProvider(for: .rule, name: $0.key) {
|
||||
if !$0 {
|
||||
failuresCount += 1
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
|
||||
group.notify(queue: .main) {
|
||||
completeHandler?(failuresCount)
|
||||
}
|
||||
}
|
||||
|
||||
static func updateProvider(for type: ProviderType, name: String, completeHandler: ((Bool) -> Void)? = nil) {
|
||||
let s = "Update \(type.logString()) Provider"
|
||||
|
||||
Logger.log("\(s) \(name)")
|
||||
req("/providers/\(type.apiString())/\(name)", method: .put).response {
|
||||
let re = $0.response?.statusCode == 204
|
||||
Logger.log("\(s) \(name) \(re ? "success" : "failed")")
|
||||
completeHandler?(re)
|
||||
}
|
||||
}
|
||||
|
||||
static func requestRuleProviderList(completeHandler: @escaping (ClashRuleProviderResp) -> Void) {
|
||||
req("/providers/rules")
|
||||
.responseDecodable(of: ClashRuleProviderResp.self, decoder: ClashProviderResp.decoder) { resp in
|
||||
switch resp.result {
|
||||
case let .success(providerResp):
|
||||
completeHandler(providerResp)
|
||||
case let .failure(err):
|
||||
Logger.log("Get Rule providers error \(err.errorDescription ?? "unknown")" )
|
||||
completeHandler(ClashRuleProviderResp())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func updateGEO(completeHandler: ((Bool) -> Void)? = nil) {
|
||||
Logger.log("UpdateGEO")
|
||||
req("/configs/geo", method: .post).response {
|
||||
let re = $0.response?.statusCode == 204
|
||||
|
||||
completeHandler?(re)
|
||||
// Logger.log("UpdateGEO \(re ? "success" : "failed")")
|
||||
Logger.log("Updating GEO Databases...")
|
||||
}
|
||||
}
|
||||
|
||||
static func updateTun(enable: Bool, completeHandler: (() -> Void)? = nil) {
|
||||
Logger.log("update tun:\(enable)", level: .debug)
|
||||
req("/configs",
|
||||
method: .patch,
|
||||
parameters: ["tun": ["enable": enable]],
|
||||
encoding: JSONEncoding.default).response {
|
||||
_ in
|
||||
completeHandler?()
|
||||
}
|
||||
}
|
||||
|
||||
static func updateSniffing(enable: Bool, completeHandler: (() -> Void)? = nil) {
|
||||
Logger.log("update sniffing:\(enable)", level: .debug)
|
||||
req("/configs",
|
||||
method: .patch,
|
||||
parameters: ["sniffing": enable],
|
||||
encoding: JSONEncoding.default).response {
|
||||
_ in
|
||||
completeHandler?()
|
||||
}
|
||||
}
|
||||
|
||||
static func flushFakeipCache(completeHandler: ((Bool) -> Void)? = nil) {
|
||||
Logger.log("FlushFakeipCache")
|
||||
req("/cache/fakeip/flush",
|
||||
method: .post).response {
|
||||
let re = $0.response?.statusCode == 204
|
||||
completeHandler?(re)
|
||||
Logger.log("FlushFakeipCache \(re ? "success" : "failed")")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stream Apis
|
||||
|
||||
extension ApiRequest {
|
||||
func resetStreamApis() {
|
||||
resetLogStreamApi()
|
||||
resetTrafficStreamApi()
|
||||
resetMemoryStreamApi()
|
||||
}
|
||||
|
||||
func resetLogStreamApi() {
|
||||
loggingWebSocketRetryTimer?.invalidate()
|
||||
loggingWebSocketRetryTimer = nil
|
||||
loggingWebSocketRetryDelay = 1
|
||||
requestLog()
|
||||
}
|
||||
|
||||
func resetTrafficStreamApi() {
|
||||
trafficWebSocketRetryTimer?.invalidate()
|
||||
trafficWebSocketRetryTimer = nil
|
||||
trafficWebSocketRetryDelay = 1
|
||||
requestTrafficInfo()
|
||||
}
|
||||
|
||||
func resetMemoryStreamApi() {
|
||||
memoryWebSocketRetryTimer?.invalidate()
|
||||
memoryWebSocketRetryTimer = nil
|
||||
memoryWebSocketRetryDelay = 1
|
||||
requestMemoryInfo()
|
||||
}
|
||||
|
||||
private func requestTrafficInfo() {
|
||||
trafficWebSocketRetryTimer?.invalidate()
|
||||
trafficWebSocketRetryTimer = nil
|
||||
trafficWebSocket?.disconnect(forceTimeout: 0.5)
|
||||
|
||||
let socket = WebSocket(url: URL(string: ConfigManager.apiUrl.appending("/traffic"))!)
|
||||
|
||||
for header in ApiRequest.authHeader() {
|
||||
socket.request.setValue(header.value, forHTTPHeaderField: header.name)
|
||||
}
|
||||
socket.delegate = self
|
||||
socket.connect()
|
||||
trafficWebSocket = socket
|
||||
}
|
||||
|
||||
private func requestLog() {
|
||||
loggingWebSocketRetryTimer?.invalidate()
|
||||
loggingWebSocketRetryTimer = nil
|
||||
loggingWebSocket?.disconnect(forceTimeout: 1)
|
||||
|
||||
let uriString = "/logs?level=".appending(ConfigManager.selectLoggingApiLevel.rawValue)
|
||||
let socket = WebSocket(url: URL(string: ConfigManager.apiUrl.appending(uriString))!)
|
||||
for header in ApiRequest.authHeader() {
|
||||
socket.request.setValue(header.value, forHTTPHeaderField: header.name)
|
||||
}
|
||||
socket.delegate = self
|
||||
socket.callbackQueue = logQueue
|
||||
socket.connect()
|
||||
loggingWebSocket = socket
|
||||
}
|
||||
|
||||
private func requestMemoryInfo() {
|
||||
memoryWebSocketRetryTimer?.invalidate()
|
||||
memoryWebSocketRetryTimer = nil
|
||||
memoryWebSocket?.disconnect(forceTimeout: 1)
|
||||
|
||||
let socket = WebSocket(url: URL(string: ConfigManager.apiUrl.appending("/memory"))!)
|
||||
for header in ApiRequest.authHeader() {
|
||||
socket.request.setValue(header.value, forHTTPHeaderField: header.name)
|
||||
}
|
||||
socket.delegate = self
|
||||
socket.connect()
|
||||
memoryWebSocket = socket
|
||||
}
|
||||
}
|
||||
|
||||
extension ApiRequest: WebSocketDelegate {
|
||||
func websocketDidConnect(socket: WebSocketClient) {
|
||||
guard let webSocket = socket as? WebSocket else { return }
|
||||
switch webSocket {
|
||||
case trafficWebSocket:
|
||||
trafficWebSocketRetryDelay = 1
|
||||
Logger.log("trafficWebSocket did Connect", level: .debug)
|
||||
|
||||
ConfigManager.shared.isRunning = true
|
||||
delegate?.streamStatusChanged()
|
||||
case loggingWebSocket:
|
||||
loggingWebSocketRetryDelay = 1
|
||||
Logger.log("loggingWebSocket did Connect", level: .debug)
|
||||
case memoryWebSocket:
|
||||
memoryWebSocketRetryDelay = 1
|
||||
Logger.log("memoryWebSocket did Connect", level: .debug)
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func websocketDidDisconnect(socket: WebSocketClient, error: Error?) {
|
||||
|
||||
if (socket as? WebSocket) == trafficWebSocket {
|
||||
ConfigManager.shared.isRunning = false
|
||||
delegate?.streamStatusChanged()
|
||||
}
|
||||
|
||||
guard let err = error else {
|
||||
return
|
||||
}
|
||||
|
||||
Logger.log(err.localizedDescription, level: .error)
|
||||
|
||||
guard let webSocket = socket as? WebSocket else { return }
|
||||
|
||||
switch webSocket {
|
||||
case trafficWebSocket:
|
||||
Logger.log("trafficWebSocket did disconnect", level: .debug)
|
||||
|
||||
trafficWebSocketRetryTimer?.invalidate()
|
||||
trafficWebSocketRetryTimer =
|
||||
Timer.scheduledTimer(withTimeInterval: trafficWebSocketRetryDelay, repeats: false, block: {
|
||||
[weak self] _ in
|
||||
if self?.trafficWebSocket?.isConnected == true { return }
|
||||
self?.requestTrafficInfo()
|
||||
})
|
||||
trafficWebSocketRetryDelay *= 2
|
||||
case loggingWebSocket:
|
||||
Logger.log("loggingWebSocket did disconnect", level: .debug)
|
||||
loggingWebSocketRetryTimer =
|
||||
Timer.scheduledTimer(withTimeInterval: loggingWebSocketRetryDelay, repeats: false, block: {
|
||||
[weak self] _ in
|
||||
if self?.loggingWebSocket?.isConnected == true { return }
|
||||
self?.requestLog()
|
||||
})
|
||||
loggingWebSocketRetryDelay *= 2
|
||||
case memoryWebSocket:
|
||||
Logger.log("memoryWebSocket did disconnect", level: .debug)
|
||||
|
||||
memoryWebSocketRetryTimer =
|
||||
Timer.scheduledTimer(withTimeInterval: memoryWebSocketRetryDelay, repeats: false, block: {
|
||||
[weak self] _ in
|
||||
if self?.memoryWebSocket?.isConnected == true { return }
|
||||
self?.requestMemoryInfo()
|
||||
})
|
||||
|
||||
memoryWebSocketRetryDelay *= 2
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func websocketDidReceiveMessage(socket: WebSocketClient, text: String) {
|
||||
guard let webSocket = socket as? WebSocket else { return }
|
||||
let json = JSON(parseJSON: text)
|
||||
|
||||
|
||||
switch webSocket {
|
||||
case trafficWebSocket:
|
||||
delegate?.didUpdateTraffic(up: json["up"].intValue, down: json["down"].intValue)
|
||||
case loggingWebSocket:
|
||||
delegate?.didGetLog(log: json["payload"].stringValue, level: json["type"].string ?? "info")
|
||||
case memoryWebSocket:
|
||||
delegate?.didUpdateMemory(memory: json["inuse"].int64Value)
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func websocketDidReceiveData(socket: WebSocketClient, data: Data) {}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//
|
||||
// DateFormatter+.swift
|
||||
// ClashX
|
||||
//
|
||||
// Created by yicheng on 2019/12/14.
|
||||
// Copyright © 2019 west2online. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
|
||||
extension DateFormatter {
|
||||
static var js: DateFormatter {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.locale = Locale(identifier: NSCalendar.Identifier.ISO8601.rawValue)
|
||||
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ"
|
||||
return dateFormatter
|
||||
}
|
||||
|
||||
static var provider: DateFormatter {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSSZZ"
|
||||
return f
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// String+Encode.swift
|
||||
// ClashX
|
||||
//
|
||||
// Created by yicheng on 2019/12/11.
|
||||
// Copyright © 2019 west2online. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
|
||||
extension String {
|
||||
var encoded: String {
|
||||
return addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//
|
||||
// ConfigManager.swift
|
||||
// ClashX
|
||||
//
|
||||
// Created by CYC on 2018/6/12.
|
||||
// Copyright © 2018年 yichengchen. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
import Foundation
|
||||
|
||||
class ConfigManager {
|
||||
static let shared = ConfigManager()
|
||||
var apiPort = "9090"
|
||||
var apiSecret: String = ""
|
||||
var overrideApiURL: URL?
|
||||
var overrideSecret: String?
|
||||
|
||||
|
||||
var isRunning: Bool = false {
|
||||
didSet {
|
||||
NotificationCenter.default.post(.init(name: .init("ClashRunningStateChanged")))
|
||||
}
|
||||
}
|
||||
|
||||
var benchMarkUrl: String = UserDefaults.standard.string(forKey: "benchMarkUrl") ?? "http://cp.cloudflare.com/generate_204" {
|
||||
didSet {
|
||||
UserDefaults.standard.set(benchMarkUrl, forKey: "benchMarkUrl")
|
||||
}
|
||||
}
|
||||
|
||||
static var apiUrl: String {
|
||||
if let override = shared.overrideApiURL {
|
||||
return override.absoluteString
|
||||
}
|
||||
return "http://127.0.0.1:\(shared.apiPort)"
|
||||
}
|
||||
|
||||
static var webSocketUrl: String {
|
||||
if let override = shared.overrideApiURL, var comp = URLComponents(url: override, resolvingAgainstBaseURL: true) {
|
||||
if comp.scheme == "https" {
|
||||
comp.scheme = "wss"
|
||||
} else {
|
||||
comp.scheme = "ws"
|
||||
}
|
||||
return comp.url?.absoluteString ?? ""
|
||||
}
|
||||
return "ws://127.0.0.1:\(shared.apiPort)"
|
||||
}
|
||||
|
||||
static var selectLoggingApiLevel: ClashLogLevel {
|
||||
get {
|
||||
return ClashLogLevel(rawValue: UserDefaults.standard.string(forKey: "selectLoggingApiLevel") ?? "") ?? .info
|
||||
}
|
||||
set {
|
||||
UserDefaults.standard.set(newValue.rawValue, forKey: "selectLoggingApiLevel")
|
||||
}
|
||||
}
|
||||
}
|
||||
52
Sources/ClashX Dashboard/ClashX Links/Logger.swift
Normal file
52
Sources/ClashX Dashboard/ClashX Links/Logger.swift
Normal file
@@ -0,0 +1,52 @@
|
||||
//
|
||||
// Logger.swift
|
||||
// ClashX
|
||||
//
|
||||
// Created by CYC on 2018/8/7.
|
||||
// Copyright © 2018年 yichengchen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CocoaLumberjackSwift
|
||||
|
||||
class Logger {
|
||||
static let shared = Logger()
|
||||
var fileLogger: DDFileLogger = DDFileLogger()
|
||||
|
||||
private init() {
|
||||
#if DEBUG
|
||||
DDLog.add(DDOSLogger.sharedInstance)
|
||||
#endif
|
||||
// default time zone is "UTC"
|
||||
let dataFormatter = DateFormatter()
|
||||
dataFormatter.setLocalizedDateFormatFromTemplate("YYYY/MM/dd HH:mm:ss:SSS")
|
||||
fileLogger.logFormatter = DDLogFileFormatterDefault.init(dateFormatter: dataFormatter)
|
||||
fileLogger.rollingFrequency = TimeInterval(60 * 60 * 24) // 24 hours
|
||||
fileLogger.logFileManager.maximumNumberOfLogFiles = 3
|
||||
DDLog.add(fileLogger)
|
||||
dynamicLogLevel = ConfigManager.selectLoggingApiLevel.toDDLogLevel()
|
||||
}
|
||||
|
||||
private func logToFile(msg: String, level: ClashLogLevel) {
|
||||
switch level {
|
||||
case .debug, .silent:
|
||||
DDLogDebug(msg)
|
||||
case .error:
|
||||
DDLogError(msg)
|
||||
case .info:
|
||||
DDLogInfo(msg)
|
||||
case .warning:
|
||||
DDLogWarn(msg)
|
||||
case .unknow:
|
||||
DDLogWarn(msg)
|
||||
}
|
||||
}
|
||||
|
||||
static func log(_ msg: String, level: ClashLogLevel = .info) {
|
||||
shared.logToFile(msg: "[\(level.rawValue)] \(msg)", level: level)
|
||||
}
|
||||
|
||||
func logFilePath() -> String {
|
||||
return fileLogger.logFileManager.sortedLogFilePaths.first ?? ""
|
||||
}
|
||||
}
|
||||
110
Sources/ClashX Dashboard/ClashX Links/Models/ClashConfig.swift
Normal file
110
Sources/ClashX Dashboard/ClashX Links/Models/ClashConfig.swift
Normal file
@@ -0,0 +1,110 @@
|
||||
//
|
||||
// ClashConfig.swift
|
||||
// ClashX
|
||||
//
|
||||
// Created by CYC on 2018/7/30.
|
||||
// Copyright © 2018年 yichengchen. All rights reserved.
|
||||
//
|
||||
import Foundation
|
||||
import CocoaLumberjackSwift
|
||||
|
||||
enum ClashProxyMode: String, Codable {
|
||||
case rule
|
||||
case global
|
||||
case direct
|
||||
}
|
||||
|
||||
extension ClashProxyMode {
|
||||
var name: String {
|
||||
switch self {
|
||||
case .rule: return NSLocalizedString("Rule", comment: "")
|
||||
case .global: return NSLocalizedString("Global", comment: "")
|
||||
case .direct: return NSLocalizedString("Direct", comment: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ClashLogLevel: String, Codable {
|
||||
case info
|
||||
case warning
|
||||
case error
|
||||
case debug
|
||||
case silent
|
||||
case unknow = "unknown"
|
||||
|
||||
func toDDLogLevel() -> DDLogLevel {
|
||||
switch self {
|
||||
case .info:
|
||||
return .info
|
||||
case .warning:
|
||||
return .warning
|
||||
case .error:
|
||||
return .error
|
||||
case .debug:
|
||||
return .debug
|
||||
case .silent:
|
||||
return .off
|
||||
case .unknow:
|
||||
return .error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ClashConfig: Codable {
|
||||
var port: Int
|
||||
var socksPort: Int
|
||||
var redirPort: Int
|
||||
var allowLan: Bool
|
||||
var mixedPort: Int
|
||||
var mode: ClashProxyMode
|
||||
var logLevel: ClashLogLevel
|
||||
|
||||
var sniffing: Bool
|
||||
var ipv6: Bool
|
||||
|
||||
var tun: Tun
|
||||
var interfaceName: String
|
||||
|
||||
struct Tun: Codable {
|
||||
let enable: Bool
|
||||
let device: String
|
||||
let stack: String
|
||||
// let dns-hijack: [String]
|
||||
// let auto-route: Bool
|
||||
// let auto-detect-interface: Bool
|
||||
}
|
||||
|
||||
var usedHttpPort: Int {
|
||||
if mixedPort > 0 {
|
||||
return mixedPort
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
var usedSocksPort: Int {
|
||||
if mixedPort > 0 {
|
||||
return mixedPort
|
||||
}
|
||||
return socksPort
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case port, socksPort = "socks-port", redirPort = "redir-port", mixedPort = "mixed-port", allowLan = "allow-lan", mode, logLevel = "log-level", sniffing, tun, interfaceName = "interface-name", ipv6
|
||||
}
|
||||
|
||||
static func fromData(_ data: Data) -> ClashConfig? {
|
||||
let decoder = JSONDecoder()
|
||||
do {
|
||||
return try decoder.decode(ClashConfig.self, from: data)
|
||||
} catch let err {
|
||||
Logger.log((err as NSError).description, level: .error)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func copy() -> ClashConfig? {
|
||||
guard let data = try? JSONEncoder().encode(self) else { return nil }
|
||||
let copy = try? JSONDecoder().decode(ClashConfig.self, from: data)
|
||||
return copy
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//
|
||||
// ClashConnection.swift
|
||||
// ClashX
|
||||
//
|
||||
// Created by yicheng on 2019/10/28.
|
||||
// Copyright © 2019 west2online. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
|
||||
struct ClashConnectionSnapShot: Codable {
|
||||
let connections: [Connection]
|
||||
}
|
||||
|
||||
extension ClashConnectionSnapShot {
|
||||
struct Connection: Codable {
|
||||
let id: String
|
||||
let chains: [String]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//
|
||||
// ClashProvider.swift
|
||||
// ClashX
|
||||
//
|
||||
// Created by yichengchen on 2019/12/14.
|
||||
// Copyright © 2019 west2online. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
|
||||
class ClashProviderResp: Codable {
|
||||
let allProviders: [ClashProxyName: ClashProvider]
|
||||
lazy var providers: [ClashProxyName: ClashProvider] = {
|
||||
return allProviders.filter({ $0.value.vehicleType != .Compatible })
|
||||
}()
|
||||
|
||||
init() {
|
||||
allProviders = [:]
|
||||
}
|
||||
|
||||
static var decoder: JSONDecoder {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .formatted(DateFormatter.js)
|
||||
return decoder
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case allProviders = "providers"
|
||||
}
|
||||
}
|
||||
|
||||
class ClashProvider: Codable {
|
||||
enum ProviderType: String, Codable {
|
||||
case Proxy
|
||||
case String
|
||||
}
|
||||
|
||||
enum ProviderVehicleType: String, Codable {
|
||||
case HTTP
|
||||
case File
|
||||
case Compatible
|
||||
case Unknown
|
||||
}
|
||||
|
||||
let name: ClashProviderName
|
||||
let proxies: [ClashProxy]
|
||||
let type: ProviderType
|
||||
let vehicleType: ProviderVehicleType
|
||||
let updatedAt: Date?
|
||||
|
||||
let subscriptionInfo: ClashProviderSubInfo?
|
||||
}
|
||||
|
||||
class ClashProviderSubInfo: Codable {
|
||||
let upload: Int64
|
||||
let download: Int64
|
||||
let total: Int64
|
||||
let expire: Int
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case upload = "Upload",
|
||||
download = "Download",
|
||||
total = "Total",
|
||||
expire = "Expire"
|
||||
}
|
||||
}
|
||||
248
Sources/ClashX Dashboard/ClashX Links/Models/ClashProxy.swift
Normal file
248
Sources/ClashX Dashboard/ClashX Links/Models/ClashProxy.swift
Normal file
@@ -0,0 +1,248 @@
|
||||
//
|
||||
// ClashProxy.swift
|
||||
// ClashX
|
||||
//
|
||||
// Created by CYC on 2019/3/17.
|
||||
// Copyright © 2019 west2online. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
import SwiftyJSON
|
||||
|
||||
enum ClashProxyType: String, Codable {
|
||||
case urltest = "URLTest"
|
||||
case fallback = "Fallback"
|
||||
case loadBalance = "LoadBalance"
|
||||
case select = "Selector"
|
||||
case direct = "Direct"
|
||||
case reject = "Reject"
|
||||
case shadowsocks = "Shadowsocks"
|
||||
case shadowsocksR = "ShadowsocksR"
|
||||
case socks5 = "Socks5"
|
||||
case http = "Http"
|
||||
case vmess = "Vmess"
|
||||
case snell = "Snell"
|
||||
case trojan = "Trojan"
|
||||
case relay = "Relay"
|
||||
|
||||
case vless = "Vless"
|
||||
case hysteria = "Hysteria"
|
||||
case wireguardMeta = "WireGuard"
|
||||
case wireguard = "Wireguard"
|
||||
case tuic = "Tuic"
|
||||
|
||||
case pass = "Pass"
|
||||
|
||||
case unknown = "Unknown"
|
||||
|
||||
static let proxyGroups: [ClashProxyType] = [.select, .urltest, .fallback, .loadBalance]
|
||||
|
||||
var isAutoGroup: Bool {
|
||||
switch self {
|
||||
case .urltest, .fallback, .loadBalance:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
static func isProxyGroup(_ proxy: ClashProxy) -> Bool {
|
||||
switch proxy.type {
|
||||
case .select, .urltest, .fallback, .loadBalance, .relay: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
static func isBuiltInProxy(_ proxy: ClashProxy) -> Bool {
|
||||
switch proxy.name {
|
||||
case "DIRECT", "REJECT", "PASS": return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
typealias ClashProxyName = String
|
||||
typealias ClashProviderName = String
|
||||
|
||||
class ClashProxySpeedHistory: Codable {
|
||||
let time: Date
|
||||
let delay: Int
|
||||
let meanDelay: Int?
|
||||
|
||||
class HisDateFormaterInstance {
|
||||
static let shared = HisDateFormaterInstance()
|
||||
lazy var formater: DateFormatter = {
|
||||
var f = DateFormatter()
|
||||
f.dateFormat = "HH:mm"
|
||||
return f
|
||||
}()
|
||||
}
|
||||
|
||||
lazy var delayInt: Int = {
|
||||
meanDelay ?? delay
|
||||
}()
|
||||
|
||||
lazy var delayDisplay: String = {
|
||||
switch delayInt {
|
||||
case 0: return NSLocalizedString("fail", comment: "")
|
||||
default: return "\(delayInt) ms"
|
||||
}
|
||||
}()
|
||||
|
||||
lazy var dateDisplay: String = {
|
||||
return HisDateFormaterInstance.shared.formater.string(from: time)
|
||||
}()
|
||||
|
||||
lazy var displayString: String = "\(dateDisplay) \(delayDisplay)"
|
||||
}
|
||||
|
||||
class ClashProxy: Codable {
|
||||
let id: String?
|
||||
let name: ClashProxyName
|
||||
let type: ClashProxyType
|
||||
let all: [ClashProxyName]?
|
||||
let history: [ClashProxySpeedHistory]
|
||||
let now: ClashProxyName?
|
||||
weak var enclosingResp: ClashProxyResp?
|
||||
weak var enclosingProvider: ClashProvider?
|
||||
|
||||
let udp: Bool
|
||||
let xudp: Bool
|
||||
let tfo: Bool
|
||||
|
||||
enum SpeedtestAbleItem {
|
||||
case proxy(name: ClashProxyName)
|
||||
case provider(name: ClashProxyName, provider: ClashProviderName)
|
||||
case group(name: ClashProxyName)
|
||||
}
|
||||
|
||||
private static var nameLengthCachedMap = [ClashProxyName: CGFloat]()
|
||||
static func cleanCache() {
|
||||
nameLengthCachedMap.removeAll()
|
||||
}
|
||||
|
||||
lazy var speedtestAble: [SpeedtestAbleItem] = {
|
||||
guard let resp = enclosingResp, let allProxys = all else { return [] }
|
||||
var proxys = [SpeedtestAbleItem]()
|
||||
for proxy in allProxys {
|
||||
if let p = resp.proxiesMap[proxy] {
|
||||
if !ClashProxyType.isProxyGroup(p) {
|
||||
if let provider = p.enclosingProvider {
|
||||
proxys.append(.provider(name: p.name, provider: provider.name))
|
||||
} else {
|
||||
proxys.append(.proxy(name: p.name))
|
||||
}
|
||||
} else {
|
||||
proxys.append(.group(name: p.name))
|
||||
}
|
||||
}
|
||||
}
|
||||
return proxys
|
||||
}()
|
||||
|
||||
lazy var isSpeedTestable: Bool = {
|
||||
return speedtestAble.count > 0
|
||||
}()
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case type, all, history, now, name, udp, xudp, tfo, id
|
||||
}
|
||||
|
||||
lazy var maxProxyNameLength: CGFloat = {
|
||||
let rect = CGSize(width: CGFloat.greatestFiniteMagnitude, height: 20)
|
||||
|
||||
let lengths = all?.compactMap({ name -> CGFloat in
|
||||
if let length = ClashProxy.nameLengthCachedMap[name] {
|
||||
return length
|
||||
}
|
||||
|
||||
let rects = CGSize(width: CGFloat.greatestFiniteMagnitude, height: 20)
|
||||
let attr = [NSAttributedString.Key.font: NSFont.menuBarFont(ofSize: 14)]
|
||||
let length = (name as NSString)
|
||||
.boundingRect(with: rect,
|
||||
options: .usesLineFragmentOrigin,
|
||||
attributes: attr).width
|
||||
ClashProxy.nameLengthCachedMap[name] = length
|
||||
return length
|
||||
})
|
||||
return lengths?.max() ?? 0
|
||||
}()
|
||||
}
|
||||
|
||||
class ClashProxyResp {
|
||||
var proxies: [ClashProxy]
|
||||
|
||||
var proxiesMap: [ClashProxyName: ClashProxy]
|
||||
|
||||
var enclosingProviderResp: ClashProviderResp?
|
||||
|
||||
init(_ data: Data?) {
|
||||
guard let data
|
||||
else {
|
||||
self.proxiesMap = [:]
|
||||
self.proxies = []
|
||||
return
|
||||
}
|
||||
let proxies = JSON(data)["proxies"]
|
||||
var proxiesModel = [ClashProxy]()
|
||||
|
||||
var proxiesMap = [ClashProxyName: ClashProxy]()
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .formatted(DateFormatter.js)
|
||||
for value in proxies.dictionaryValue.values {
|
||||
guard let data = try? value.rawData() else {
|
||||
continue
|
||||
}
|
||||
guard let proxy = try? decoder.decode(ClashProxy.self, from: data) else {
|
||||
continue
|
||||
}
|
||||
proxiesModel.append(proxy)
|
||||
proxiesMap[proxy.name] = proxy
|
||||
}
|
||||
self.proxiesMap = proxiesMap
|
||||
self.proxies = proxiesModel
|
||||
|
||||
for proxy in self.proxies {
|
||||
proxy.enclosingResp = self
|
||||
}
|
||||
}
|
||||
|
||||
func updateProvider(_ providerResp: ClashProviderResp) {
|
||||
enclosingProviderResp = providerResp
|
||||
for provider in providerResp.providers.values {
|
||||
for proxy in provider.proxies {
|
||||
proxy.enclosingProvider = provider
|
||||
proxiesMap[proxy.name] = proxy
|
||||
proxies.append(proxy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lazy var proxiesSortMap: [ClashProxyName: Int] = {
|
||||
var map = [ClashProxyName: Int]()
|
||||
for (idx, proxy) in (self.proxiesMap["GLOBAL"]?.all ?? []).enumerated() {
|
||||
map[proxy] = idx
|
||||
}
|
||||
return map
|
||||
}()
|
||||
|
||||
lazy var proxyGroups: [ClashProxy] = {
|
||||
return proxies.filter {
|
||||
ClashProxyType.isProxyGroup($0)
|
||||
}.sorted(by: { proxiesSortMap[$0.name] ?? -1 < proxiesSortMap[$1.name] ?? -1 })
|
||||
}()
|
||||
|
||||
lazy var longestProxyGroupName = {
|
||||
return proxyGroups.max { $1.name.count > $0.name.count }?.name ?? ""
|
||||
}()
|
||||
|
||||
lazy var maxProxyNameLength: CGFloat = {
|
||||
let rect = CGSize(width: CGFloat.greatestFiniteMagnitude, height: 20)
|
||||
let attr = [NSAttributedString.Key.font: NSFont.menuBarFont(ofSize: 0)]
|
||||
return (self.longestProxyGroupName as NSString)
|
||||
.boundingRect(with: rect,
|
||||
options: .usesLineFragmentOrigin,
|
||||
attributes: attr).width
|
||||
}()
|
||||
}
|
||||
36
Sources/ClashX Dashboard/ClashX Links/Models/ClashRule.swift
Normal file
36
Sources/ClashX Dashboard/ClashX Links/Models/ClashRule.swift
Normal file
@@ -0,0 +1,36 @@
|
||||
//
|
||||
// ClashRule.swift
|
||||
// ClashX
|
||||
//
|
||||
// Created by CYC on 2018/10/27.
|
||||
// Copyright © 2018 west2online. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Cocoa
|
||||
|
||||
class ClashRule: NSObject, Codable, Identifiable {
|
||||
@objc let type: String
|
||||
@objc let payload: String?
|
||||
@objc let proxy: String?
|
||||
|
||||
init(type: String, payload: String?, proxy: String?) {
|
||||
self.type = type
|
||||
self.payload = payload
|
||||
self.proxy = proxy
|
||||
}
|
||||
}
|
||||
|
||||
class ClashRuleResponse: Codable {
|
||||
var rules: [ClashRule]?
|
||||
|
||||
static func empty() -> ClashRuleResponse {
|
||||
return ClashRuleResponse()
|
||||
}
|
||||
|
||||
static func fromData(_ data: Data) -> ClashRuleResponse {
|
||||
let decoder = JSONDecoder()
|
||||
let model = try? decoder.decode(ClashRuleResponse.self, from: data)
|
||||
return model ?? ClashRuleResponse.empty()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//
|
||||
// ClashRuleProvider.swift
|
||||
// ClashX Meta
|
||||
|
||||
import Foundation
|
||||
|
||||
class ClashRuleProviderResp: Codable {
|
||||
let allProviders: [ClashProxyName: ClashRuleProvider]
|
||||
|
||||
init() {
|
||||
allProviders = [:]
|
||||
}
|
||||
|
||||
static var decoder: JSONDecoder {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .formatted(DateFormatter.js)
|
||||
return decoder
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case allProviders = "providers"
|
||||
}
|
||||
}
|
||||
|
||||
class ClashRuleProvider: NSObject, Codable {
|
||||
@objc let name: ClashProviderName
|
||||
let ruleCount: Int
|
||||
@objc let behavior: String
|
||||
@objc let type: String
|
||||
let updatedAt: Date?
|
||||
}
|
||||
13
Sources/ClashX Dashboard/ClashX_Dashboard.swift
Normal file
13
Sources/ClashX Dashboard/ClashX_Dashboard.swift
Normal file
@@ -0,0 +1,13 @@
|
||||
import SwiftUI
|
||||
|
||||
@available(macOS 12.0, *)
|
||||
public struct DashboardView: View {
|
||||
|
||||
public init() {
|
||||
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
ContentView()
|
||||
}
|
||||
}
|
||||
283
Sources/ClashX Dashboard/DashboardViewContoller.swift
Normal file
283
Sources/ClashX Dashboard/DashboardViewContoller.swift
Normal file
@@ -0,0 +1,283 @@
|
||||
//
|
||||
// DashboardViewContoller.swift
|
||||
// ClashX
|
||||
//
|
||||
// Created by yicheng on 2018/8/28.
|
||||
// Copyright © 2018年 west2online. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
import SwiftUI
|
||||
|
||||
public class DashboardWindowController: NSWindowController {
|
||||
public var onWindowClose: (() -> Void)?
|
||||
|
||||
public static func create() -> DashboardWindowController {
|
||||
let win = NSWindow()
|
||||
win.center()
|
||||
let wc = DashboardWindowController(window: win)
|
||||
wc.contentViewController = DashboardViewContoller()
|
||||
return wc
|
||||
}
|
||||
|
||||
public override func showWindow(_ sender: Any?) {
|
||||
super.showWindow(sender)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
window?.makeKeyAndOrderFront(self)
|
||||
window?.delegate = self
|
||||
}
|
||||
|
||||
public func set(_ apiURL: String, secret: String? = nil) {
|
||||
ConfigManager.shared.isRunning = true
|
||||
ConfigManager.shared.overrideApiURL = .init(string: apiURL)
|
||||
ConfigManager.shared.overrideSecret = secret
|
||||
}
|
||||
|
||||
public func reload() {
|
||||
NotificationCenter.default.post(name: .reloadDashboard, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
extension DashboardWindowController: NSWindowDelegate {
|
||||
public func windowWillClose(_ notification: Notification) {
|
||||
NSApp.setActivationPolicy(.accessory)
|
||||
onWindowClose?()
|
||||
if let contentVC = contentViewController as? DashboardViewContoller, let win = window {
|
||||
if !win.styleMask.contains(.fullScreen) {
|
||||
contentVC.lastSize = win.frame.size
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DashboardViewContoller: NSViewController {
|
||||
let contentView = NSHostingView(rootView: DashboardView())
|
||||
let minSize = NSSize(width: 920, height: 580)
|
||||
var lastSize: CGSize? {
|
||||
set {
|
||||
if let size = newValue {
|
||||
UserDefaults.standard.set(NSStringFromSize(size), forKey: "ClashWebViewContoller.lastSize")
|
||||
}
|
||||
}
|
||||
get {
|
||||
if let str = UserDefaults.standard.value(forKey: "ClashWebViewContoller.lastSize") as? String {
|
||||
return NSSizeFromString(str) as CGSize
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
let effectView = NSVisualEffectView()
|
||||
|
||||
private let levels = [
|
||||
ClashLogLevel.silent,
|
||||
.error,
|
||||
.warning,
|
||||
.info,
|
||||
.debug
|
||||
]
|
||||
|
||||
private var sidebarItemObserver: NSObjectProtocol?
|
||||
|
||||
func createWindowController() -> NSWindowController {
|
||||
let sb = NSStoryboard(name: "Main", bundle: Bundle.main)
|
||||
let vc = sb.instantiateController(withIdentifier: "DashboardViewContoller") as! DashboardViewContoller
|
||||
let wc = NSWindowController(window: NSWindow())
|
||||
wc.contentViewController = vc
|
||||
return wc
|
||||
}
|
||||
|
||||
override func loadView() {
|
||||
view = contentView
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
sidebarItemObserver = NotificationCenter.default.addObserver(forName: .sidebarItemChanged, object: nil, queue: .main) {
|
||||
guard let item = $0.userInfo?["item"] as? SidebarItem else { return }
|
||||
|
||||
var items = [NSToolbarItem.Identifier]()
|
||||
items.append(.toggleSidebar)
|
||||
|
||||
switch item {
|
||||
case .overview, .config:
|
||||
break
|
||||
case .proxies, .providers:
|
||||
items.append(.hideNamesItem)
|
||||
items.append(.searchItem)
|
||||
case .rules:
|
||||
items.append(.searchItem)
|
||||
case .conns:
|
||||
items.append(.stopConnsItem)
|
||||
items.append(.searchItem)
|
||||
case .logs:
|
||||
items.append(.logLevelItem)
|
||||
items.append(.searchItem)
|
||||
}
|
||||
self.reinitToolbar(items)
|
||||
}
|
||||
}
|
||||
|
||||
public override func viewWillAppear() {
|
||||
super.viewWillAppear()
|
||||
view.window?.styleMask.insert(.fullSizeContentView)
|
||||
|
||||
view.window?.isOpaque = false
|
||||
view.window?.styleMask.insert(.closable)
|
||||
view.window?.styleMask.insert(.resizable)
|
||||
view.window?.styleMask.insert(.miniaturizable)
|
||||
|
||||
let toolbar = NSToolbar(identifier: .init("DashboardToolbar"))
|
||||
toolbar.displayMode = .iconOnly
|
||||
toolbar.delegate = self
|
||||
|
||||
view.window?.toolbar = toolbar
|
||||
view.window?.title = "Dashboard"
|
||||
reinitToolbar([])
|
||||
|
||||
view.window?.minSize = minSize
|
||||
if let lastSize = lastSize, lastSize != .zero {
|
||||
view.window?.setContentSize(lastSize)
|
||||
}
|
||||
view.window?.center()
|
||||
if NSApp.activationPolicy() == .accessory {
|
||||
NSApp.setActivationPolicy(.regular)
|
||||
}
|
||||
}
|
||||
|
||||
func reinitToolbar(_ items: [NSToolbarItem.Identifier]) {
|
||||
guard let toolbar = view.window?.toolbar else { return }
|
||||
|
||||
toolbar.items.enumerated().reversed().forEach {
|
||||
toolbar.removeItem(at: $0.offset)
|
||||
}
|
||||
|
||||
items.reversed().forEach {
|
||||
toolbar.insertItem(withItemIdentifier: $0, at: 0)
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let sidebarItemObserver {
|
||||
NotificationCenter.default.removeObserver(sidebarItemObserver)
|
||||
}
|
||||
NSApp.setActivationPolicy(.accessory)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
extension NSToolbarItem.Identifier {
|
||||
static let hideNamesItem = NSToolbarItem.Identifier("HideNamesItem")
|
||||
static let stopConnsItem = NSToolbarItem.Identifier("StopConnsItem")
|
||||
static let logLevelItem = NSToolbarItem.Identifier("LogLevelItem")
|
||||
static let searchItem = NSToolbarItem.Identifier("SearchItem")
|
||||
}
|
||||
|
||||
extension DashboardViewContoller: NSSearchFieldDelegate {
|
||||
|
||||
func controlTextDidChange(_ obj: Notification) {
|
||||
guard let obj = obj.object as? NSSearchField else { return }
|
||||
NotificationCenter.default.post(name: .toolbarSearchString, object: nil, userInfo: ["String": obj.stringValue])
|
||||
}
|
||||
|
||||
@IBAction func stopConns(_ sender: NSToolbarItem) {
|
||||
NotificationCenter.default.post(name: .stopConns, object: nil)
|
||||
}
|
||||
|
||||
@IBAction func hideNames(_ sender: NSToolbarItem) {
|
||||
switch sender.tag {
|
||||
case 0:
|
||||
sender.tag = 1
|
||||
sender.image = NSImage(systemSymbolName: "eyeglasses", accessibilityDescription: nil)
|
||||
case 1:
|
||||
sender.tag = 0
|
||||
sender.image = NSImage(systemSymbolName: "wand.and.stars", accessibilityDescription: nil)
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
NotificationCenter.default.post(name: .hideNames, object: nil, userInfo: ["hide": sender.tag == 1])
|
||||
}
|
||||
|
||||
@objc func setLogLevel(_ sender: NSToolbarItemGroup) {
|
||||
guard sender.selectedIndex < levels.count, sender.selectedIndex >= 0 else { return }
|
||||
let level = levels[sender.selectedIndex]
|
||||
|
||||
NotificationCenter.default.post(name: .logLevelChanged, object: nil, userInfo: ["level": level])
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension DashboardViewContoller: NSToolbarDelegate, NSToolbarItemValidation {
|
||||
|
||||
func validateToolbarItem(_ item: NSToolbarItem) -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? {
|
||||
|
||||
switch itemIdentifier {
|
||||
case .searchItem:
|
||||
let item = NSSearchToolbarItem(itemIdentifier: .searchItem)
|
||||
item.resignsFirstResponderWithCancel = true
|
||||
item.searchField.delegate = self
|
||||
item.toolTip = "Search"
|
||||
return item
|
||||
case .toggleSidebar:
|
||||
return NSTrackingSeparatorToolbarItem(itemIdentifier: .toggleSidebar)
|
||||
case .logLevelItem:
|
||||
|
||||
let titles = levels.map {
|
||||
$0.rawValue.capitalized
|
||||
}
|
||||
|
||||
let group = NSToolbarItemGroup(itemIdentifier: .logLevelItem, titles: titles, selectionMode: .selectOne, labels: titles, target: nil, action: #selector(setLogLevel(_:)))
|
||||
group.selectionMode = .selectOne
|
||||
group.controlRepresentation = .collapsed
|
||||
group.selectedIndex = levels.firstIndex(of: ConfigManager.selectLoggingApiLevel) ?? 0
|
||||
|
||||
return group
|
||||
case .hideNamesItem:
|
||||
let item = NSToolbarItem(itemIdentifier: .hideNamesItem)
|
||||
item.target = self
|
||||
item.action = #selector(hideNames(_:))
|
||||
item.isBordered = true
|
||||
item.tag = 0
|
||||
item.image = NSImage(systemSymbolName: "wand.and.stars", accessibilityDescription: nil)
|
||||
return item
|
||||
case .stopConnsItem:
|
||||
let item = NSToolbarItem(itemIdentifier: .stopConnsItem)
|
||||
item.target = self
|
||||
item.action = #selector(stopConns(_:))
|
||||
item.isBordered = true
|
||||
item.image = NSImage(systemSymbolName: "stop.circle.fill", accessibilityDescription: nil)
|
||||
return item
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
|
||||
[
|
||||
.toggleSidebar,
|
||||
.stopConnsItem,
|
||||
.hideNamesItem,
|
||||
.logLevelItem,
|
||||
.searchItem
|
||||
]
|
||||
}
|
||||
|
||||
func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
|
||||
[
|
||||
.toggleSidebar,
|
||||
.stopConnsItem,
|
||||
.hideNamesItem,
|
||||
.logLevelItem,
|
||||
.searchItem
|
||||
]
|
||||
}
|
||||
}
|
||||
108
Sources/ClashX Dashboard/Models/DBConnectionSnapShot.swift
Normal file
108
Sources/ClashX Dashboard/Models/DBConnectionSnapShot.swift
Normal file
@@ -0,0 +1,108 @@
|
||||
//
|
||||
// DBConnectionSnapShot.swift
|
||||
// ClashX Dashboard
|
||||
//
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
import DifferenceKit
|
||||
|
||||
struct DBConnectionSnapShot: Codable {
|
||||
let downloadTotal: Int
|
||||
let uploadTotal: Int
|
||||
let connections: [DBConnection]
|
||||
}
|
||||
|
||||
struct DBConnection: Codable, Hashable {
|
||||
let id: String
|
||||
let chains: [String]
|
||||
let upload: Int64
|
||||
let download: Int64
|
||||
let start: Date
|
||||
let rule: String
|
||||
let rulePayload: String
|
||||
|
||||
let metadata: DBMetaConnectionData
|
||||
}
|
||||
|
||||
struct DBMetaConnectionData: Codable, Hashable {
|
||||
let uid: Int
|
||||
|
||||
let network: String
|
||||
let type: String
|
||||
let sourceIP: String
|
||||
let destinationIP: String
|
||||
let sourcePort: String
|
||||
let destinationPort: String
|
||||
let inboundIP: String
|
||||
let inboundPort: String
|
||||
let inboundName: String
|
||||
let host: String
|
||||
let dnsMode: String
|
||||
let process: String
|
||||
let processPath: String
|
||||
let specialProxy: String
|
||||
let specialRules: String
|
||||
let remoteDestination: String
|
||||
let sniffHost: String
|
||||
|
||||
}
|
||||
|
||||
|
||||
class DBConnectionObject: NSObject, Differentiable {
|
||||
@objc let id: String
|
||||
@objc let host: String
|
||||
@objc let sniffHost: String
|
||||
@objc let process: String
|
||||
@objc let download: Int64
|
||||
@objc let upload: Int64
|
||||
let downloadString: String
|
||||
let uploadString: String
|
||||
let chains: [String]
|
||||
@objc let chainString: String
|
||||
@objc let ruleString: String
|
||||
@objc let startDate: Date
|
||||
let startString: String
|
||||
@objc let source: String
|
||||
@objc let destinationIP: String?
|
||||
@objc let type: String
|
||||
|
||||
var differenceIdentifier: String {
|
||||
return id
|
||||
}
|
||||
|
||||
func isContentEqual(to source: DBConnectionObject) -> Bool {
|
||||
download == source.download &&
|
||||
upload == source.upload &&
|
||||
startString == source.startString
|
||||
}
|
||||
|
||||
init(_ conn: DBConnection) {
|
||||
let byteCountFormatter = ByteCountFormatter()
|
||||
let startFormatter = RelativeDateTimeFormatter()
|
||||
startFormatter.unitsStyle = .short
|
||||
|
||||
let metadata = conn.metadata
|
||||
|
||||
id = conn.id
|
||||
host = "\(metadata.host == "" ? metadata.destinationIP : metadata.host):\(metadata.destinationPort)"
|
||||
sniffHost = metadata.sniffHost == "" ? "-" : metadata.sniffHost
|
||||
process = metadata.process
|
||||
download = conn.download
|
||||
downloadString = byteCountFormatter.string(fromByteCount: conn.download)
|
||||
upload = conn.upload
|
||||
uploadString = byteCountFormatter.string(fromByteCount: conn.upload)
|
||||
chains = conn.chains
|
||||
chainString = conn.chains.reversed().joined(separator: "/")
|
||||
ruleString = conn.rulePayload == "" ? conn.rule : "\(conn.rule) :: \(conn.rulePayload)"
|
||||
startDate = conn.start
|
||||
startString = startFormatter.localizedString(for: conn.start, relativeTo: Date())
|
||||
source = "\(metadata.sourceIP):\(metadata.sourcePort)"
|
||||
destinationIP = [metadata.remoteDestination,
|
||||
metadata.destinationIP,
|
||||
metadata.host].first(where: { $0 != "" })
|
||||
|
||||
type = "\(metadata.type)(\(metadata.network))"
|
||||
}
|
||||
|
||||
}
|
||||
98
Sources/ClashX Dashboard/Models/DBProviderStorage.swift
Normal file
98
Sources/ClashX Dashboard/Models/DBProviderStorage.swift
Normal file
@@ -0,0 +1,98 @@
|
||||
//
|
||||
// DBProviderStorage.swift
|
||||
// ClashX Dashboard
|
||||
//
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
import SwiftUI
|
||||
|
||||
class DBProviderStorage: ObservableObject {
|
||||
@Published var proxyProviders = [DBProxyProvider]()
|
||||
@Published var ruleProviders = [DBRuleProvider]()
|
||||
|
||||
init() {}
|
||||
|
||||
}
|
||||
|
||||
class DBProxyProvider: ObservableObject, Identifiable {
|
||||
let id = UUID().uuidString
|
||||
|
||||
@Published var name: ClashProviderName
|
||||
@Published var proxies: [DBProxy]
|
||||
@Published var type: ClashProvider.ProviderType
|
||||
@Published var vehicleType: ClashProvider.ProviderVehicleType
|
||||
|
||||
@Published var trafficInfo: String
|
||||
@Published var trafficPercentage: String
|
||||
@Published var expireDate: String
|
||||
@Published var updatedAt: String
|
||||
|
||||
init(provider: ClashProvider) {
|
||||
name = provider.name
|
||||
proxies = provider.proxies.map(DBProxy.init)
|
||||
type = provider.type
|
||||
vehicleType = provider.vehicleType
|
||||
|
||||
if let info = provider.subscriptionInfo {
|
||||
let used = info.download + info.upload
|
||||
let total = info.total
|
||||
|
||||
let trafficRate = "\(String(format: "%.2f", Double(used)/Double(total/100)))%"
|
||||
|
||||
let formatter = ByteCountFormatter()
|
||||
|
||||
trafficInfo = formatter.string(fromByteCount: used)
|
||||
+ " / "
|
||||
+ formatter.string(fromByteCount: total)
|
||||
+ " ( \(trafficRate) )"
|
||||
|
||||
let expire = info.expire
|
||||
|
||||
expireDate = "Expire: "
|
||||
+ Date(timeIntervalSince1970: TimeInterval(expire))
|
||||
.formatted()
|
||||
self.trafficPercentage = trafficRate
|
||||
} else {
|
||||
trafficInfo = ""
|
||||
expireDate = ""
|
||||
trafficPercentage = "0.0%"
|
||||
}
|
||||
|
||||
if let updatedAt = provider.updatedAt {
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .abbreviated
|
||||
self.updatedAt = formatter.localizedString(for: updatedAt, relativeTo: .now)
|
||||
} else {
|
||||
self.updatedAt = ""
|
||||
}
|
||||
}
|
||||
|
||||
func updateInfo(_ new: DBProxyProvider) {
|
||||
proxies = new.proxies
|
||||
updatedAt = new.updatedAt
|
||||
expireDate = new.expireDate
|
||||
trafficInfo = new.trafficInfo
|
||||
trafficPercentage = new.trafficPercentage
|
||||
}
|
||||
}
|
||||
|
||||
class DBRuleProvider: ObservableObject, Identifiable {
|
||||
let id: String
|
||||
|
||||
@Published var name: ClashProviderName
|
||||
@Published var ruleCount: Int
|
||||
@Published var behavior: String
|
||||
@Published var type: String
|
||||
@Published var updatedAt: Date?
|
||||
|
||||
init(provider: ClashRuleProvider) {
|
||||
id = UUID().uuidString
|
||||
|
||||
name = provider.name
|
||||
ruleCount = provider.ruleCount
|
||||
behavior = provider.behavior
|
||||
type = provider.type
|
||||
updatedAt = provider.updatedAt
|
||||
}
|
||||
}
|
||||
120
Sources/ClashX Dashboard/Models/DBProxyStorage.swift
Normal file
120
Sources/ClashX Dashboard/Models/DBProxyStorage.swift
Normal file
@@ -0,0 +1,120 @@
|
||||
//
|
||||
// DBProxyStorage.swift
|
||||
// ClashX Dashboard
|
||||
//
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
import SwiftUI
|
||||
|
||||
class DBProxyStorage: ObservableObject {
|
||||
@Published var groups = [DBProxyGroup]()
|
||||
|
||||
init() {
|
||||
|
||||
}
|
||||
|
||||
init(_ resp: ClashProxyResp) {
|
||||
groups = resp.proxyGroups.map {
|
||||
DBProxyGroup($0, resp: resp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DBProxyGroup: ObservableObject, Identifiable {
|
||||
let id = UUID().uuidString
|
||||
@Published var name: ClashProxyName
|
||||
@Published var type: ClashProxyType
|
||||
@Published var now: ClashProxyName? {
|
||||
didSet {
|
||||
currentProxy = proxies.first {
|
||||
$0.name == now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Published var proxies: [DBProxy]
|
||||
|
||||
@Published var currentProxy: DBProxy?
|
||||
|
||||
init(_ group: ClashProxy, resp: ClashProxyResp) {
|
||||
name = group.name
|
||||
type = group.type
|
||||
now = group.now
|
||||
|
||||
proxies = group.all?.compactMap { name in
|
||||
resp.proxiesMap[name]
|
||||
}.map(DBProxy.init) ?? []
|
||||
|
||||
currentProxy = proxies.first {
|
||||
$0.name == now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DBProxy: ObservableObject {
|
||||
let id: String
|
||||
@Published var name: ClashProxyName
|
||||
@Published var type: ClashProxyType
|
||||
@Published var udpString: String
|
||||
@Published var tfo: Bool
|
||||
|
||||
var delay: Int {
|
||||
didSet {
|
||||
delayString = DBProxy.delayString(delay)
|
||||
delayColor = DBProxy.delayColor(delay)
|
||||
}
|
||||
}
|
||||
|
||||
@Published var delayString: String
|
||||
@Published var delayColor: Color
|
||||
|
||||
init(_ proxy: ClashProxy) {
|
||||
id = proxy.id ?? UUID().uuidString
|
||||
name = proxy.name
|
||||
type = proxy.type
|
||||
tfo = proxy.tfo
|
||||
delay = proxy.history.last?.delayInt ?? 0
|
||||
|
||||
udpString = {
|
||||
if proxy.udp {
|
||||
return "UDP"
|
||||
} else if proxy.xudp {
|
||||
return "XUDP"
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}()
|
||||
delayString = DBProxy.delayString(delay)
|
||||
delayColor = DBProxy.delayColor(delay)
|
||||
}
|
||||
|
||||
static func delayString(_ delay: Int) -> String {
|
||||
switch delay {
|
||||
case 0:
|
||||
return NSLocalizedString("fail", comment: "")
|
||||
default:
|
||||
return "\(delay) ms"
|
||||
}
|
||||
}
|
||||
|
||||
static func delayColor(_ delay: Int) -> Color {
|
||||
let httpsTest = true
|
||||
|
||||
switch delay {
|
||||
case 0:
|
||||
return .gray
|
||||
case ..<200 where !httpsTest:
|
||||
return .green
|
||||
case ..<800 where httpsTest:
|
||||
return .green
|
||||
case 200..<500 where !httpsTest:
|
||||
return .yellow
|
||||
case 800..<1500 where httpsTest:
|
||||
return .yellow
|
||||
default:
|
||||
return .orange
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
17
Sources/ClashX Dashboard/NotificationNames.swift
Normal file
17
Sources/ClashX Dashboard/NotificationNames.swift
Normal file
@@ -0,0 +1,17 @@
|
||||
//
|
||||
// NotificationNames.swift
|
||||
//
|
||||
//
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension NSNotification.Name {
|
||||
static let reloadDashboard = NSNotification.Name("ReloadDashboard")
|
||||
static let sidebarItemChanged = NSNotification.Name("SidebarItemChanged")
|
||||
|
||||
static let toolbarSearchString = NSNotification.Name("ToolbarSearchString")
|
||||
static let stopConns = NSNotification.Name("StopConns")
|
||||
static let hideNames = NSNotification.Name("HideNames")
|
||||
static let logLevelChanged = NSNotification.Name("LogLevelChanged")
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//
|
||||
// APIServerItem.swift
|
||||
// ClashX Dashboard
|
||||
//
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct APIServerItem: View {
|
||||
@State var server: String
|
||||
|
||||
var action: () -> Void
|
||||
var onDelete: () -> Void
|
||||
|
||||
@State private var mouseOver = false
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Button("X") {
|
||||
onDelete()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button() {
|
||||
action()
|
||||
} label: {
|
||||
Text(server)
|
||||
.font(.title2)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.frame(height: 21)
|
||||
.padding(EdgeInsets(top: 12, leading: 20, bottom: 12, trailing: 20))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(.secondary, lineWidth: 1)
|
||||
.padding(1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
//struct APIServerItem_Previews: PreviewProvider {
|
||||
// static var previews: some View {
|
||||
// APIServerItem()
|
||||
// }
|
||||
//}
|
||||
@@ -0,0 +1,81 @@
|
||||
//
|
||||
// APISettingView.swift
|
||||
// ClashX Dashboard
|
||||
//
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Introspect
|
||||
|
||||
struct APISettingView: View {
|
||||
@State var baseURL: String = ""
|
||||
@State var secret: String = ""
|
||||
|
||||
@State var connectInfo: String = ""
|
||||
|
||||
@AppStorage("savedServers") var savedServers = SavedServersAppStorage()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .center) {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text("API Base URL")
|
||||
TextField("http://127.0.0.1:9090", text: $baseURL)
|
||||
}
|
||||
.frame(width: 250)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text("Secret(optional)")
|
||||
TextField("", text: $secret)
|
||||
}
|
||||
.frame(width: 120)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text(connectInfo)
|
||||
Spacer()
|
||||
Button("Add") {
|
||||
savedServers.append(.init(apiURL: baseURL, secret: secret))
|
||||
|
||||
print(savedServers)
|
||||
}
|
||||
}
|
||||
|
||||
List(savedServers, id: \.id) { server in
|
||||
APIServerItem(server: server.apiURL) {
|
||||
|
||||
ConfigManager.shared.overrideApiURL = .init(string: server.apiURL)
|
||||
ConfigManager.shared.overrideSecret = server.secret
|
||||
|
||||
ApiRequest.requestVersion { version in
|
||||
if let version {
|
||||
connectInfo = ""
|
||||
print(version)
|
||||
ConfigManager.shared.isRunning = true
|
||||
} else {
|
||||
connectInfo = "Failed to connect"
|
||||
}
|
||||
}
|
||||
} onDelete: {
|
||||
savedServers.removeAll {
|
||||
$0.id == server.id
|
||||
}
|
||||
}
|
||||
}
|
||||
.introspectTableView {
|
||||
$0.backgroundColor = NSColor.clear
|
||||
$0.enclosingScrollView?.drawsBackground = false
|
||||
}
|
||||
}
|
||||
.padding(.top)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct APISettingView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
APISettingView()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//
|
||||
// ClashServerAppStorage.swift
|
||||
// ClashX Dashboard
|
||||
//
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
typealias SavedServersAppStorage = [ClashServerAppStorage]
|
||||
|
||||
struct ClashServerAppStorage: Codable, Identifiable {
|
||||
var id = UUID().uuidString
|
||||
let apiURL: String
|
||||
let secret: String
|
||||
}
|
||||
|
||||
extension SavedServersAppStorage: RawRepresentable {
|
||||
public init?(rawValue: String) {
|
||||
guard let data = rawValue.data(using: .utf8),
|
||||
let result = try? JSONDecoder().decode(SavedServersAppStorage.self, from: data)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
self = result
|
||||
}
|
||||
|
||||
public var rawValue: String {
|
||||
guard let data = try? JSONEncoder().encode(self),
|
||||
let result = String(data: data, encoding: .utf8)
|
||||
else {
|
||||
return "[]"
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//
|
||||
// ProgressButton.swift
|
||||
// ClashX Dashboard
|
||||
//
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
struct ProgressButton: View {
|
||||
|
||||
@State var title: String
|
||||
@State var title2: String
|
||||
@State var iconName: String
|
||||
@Binding var inProgress: Bool
|
||||
|
||||
@State var autoWidth = true
|
||||
|
||||
@State var action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button() {
|
||||
action()
|
||||
} label: {
|
||||
HStack {
|
||||
VStack {
|
||||
if inProgress {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Image(systemName: iconName)
|
||||
}
|
||||
}
|
||||
.frame(width: 12)
|
||||
|
||||
if title != "" {
|
||||
Spacer()
|
||||
|
||||
Text(inProgress ? title2 : title)
|
||||
.font(.system(size: 13))
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.animation(.default, value: inProgress)
|
||||
.foregroundColor(inProgress ? .gray : .blue)
|
||||
}
|
||||
.disabled(inProgress)
|
||||
.frame(width: autoWidth ? ProgressButton.width([title, title2]) : nil)
|
||||
}
|
||||
|
||||
static func width(_ titles: [String]) -> CGFloat {
|
||||
let str = titles.max {
|
||||
$0.count < $1.count
|
||||
} ?? ""
|
||||
|
||||
if str == "" {
|
||||
return 12 + 8
|
||||
}
|
||||
|
||||
let w = str.size(withAttributes: [.font: NSFont.systemFont(ofSize: 13)]).width
|
||||
return w + 12 + 45
|
||||
}
|
||||
}
|
||||
|
||||
//struct ProgressButton_Previews: PreviewProvider {
|
||||
// static var previews: some View {
|
||||
// ProgressButton()
|
||||
// }
|
||||
//}
|
||||
39
Sources/ClashX Dashboard/Views/ArrayExtensions.swift
Normal file
39
Sources/ClashX Dashboard/Views/ArrayExtensions.swift
Normal file
@@ -0,0 +1,39 @@
|
||||
//
|
||||
// ArrayExtensions.swift
|
||||
// ClashX Dashboard
|
||||
//
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
|
||||
extension Array where Element: NSObject {
|
||||
func sorted(descriptors: [NSSortDescriptor]) -> [Element] {
|
||||
return (self as NSArray).sortedArray(using: descriptors) as! [Element]
|
||||
}
|
||||
|
||||
func filtered(_ str: String, for keys: [String]) -> [Element] {
|
||||
|
||||
guard str != "", keys.count > 0 else { return self }
|
||||
|
||||
let format = keys.map {
|
||||
$0 + " CONTAINS[c] %@"
|
||||
}.joined(separator: " OR ")
|
||||
|
||||
let arg = str as CVarArg
|
||||
|
||||
let args: [CVarArg] = {
|
||||
let args = NSMutableArray()
|
||||
for _ in 0..<keys.count {
|
||||
args.add(arg)
|
||||
}
|
||||
return args as! [CVarArg]
|
||||
}()
|
||||
|
||||
let predicate = NSPredicate(format: format, args)
|
||||
let re = NSMutableArray(array: self)
|
||||
|
||||
re.filter(using: predicate)
|
||||
return re as! [Element]
|
||||
}
|
||||
}
|
||||
167
Sources/ClashX Dashboard/Views/ClashApiDatasStorage.swift
Normal file
167
Sources/ClashX Dashboard/Views/ClashApiDatasStorage.swift
Normal file
@@ -0,0 +1,167 @@
|
||||
//
|
||||
// ClashApiDatasStorage.swift
|
||||
// ClashX Dashboard
|
||||
//
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
import SwiftUI
|
||||
import CocoaLumberjackSwift
|
||||
import DifferenceKit
|
||||
|
||||
class ClashApiDatasStorage: NSObject, ObservableObject {
|
||||
|
||||
@Published var overviewData = ClashOverviewData()
|
||||
|
||||
@Published var logStorage = ClashLogStorage()
|
||||
@Published var connsStorage = ClashConnsStorage()
|
||||
|
||||
func resetStreamApi() {
|
||||
ApiRequest.shared.delegate = self
|
||||
ApiRequest.shared.resetStreamApis()
|
||||
}
|
||||
}
|
||||
|
||||
extension ClashApiDatasStorage: ApiRequestStreamDelegate {
|
||||
func streamStatusChanged() {
|
||||
print("streamStatusChanged", ConfigManager.shared.isRunning)
|
||||
|
||||
}
|
||||
|
||||
func didUpdateTraffic(up: Int, down: Int) {
|
||||
overviewData.down = down
|
||||
overviewData.up = up
|
||||
}
|
||||
|
||||
func didGetLog(log: String, level: String) {
|
||||
DispatchQueue.main.async {
|
||||
self.logStorage.logs.append(.init(level: level, log: log))
|
||||
|
||||
if self.logStorage.logs.count > 1000 {
|
||||
self.logStorage.logs.removeFirst(100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func didUpdateMemory(memory: Int64) {
|
||||
let v = ByteCountFormatter().string(fromByteCount: memory)
|
||||
|
||||
if overviewData.memory != v {
|
||||
overviewData.memory = v
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fileprivate let TrafficHistoryLimit = 120
|
||||
|
||||
class ClashOverviewData: ObservableObject, Identifiable {
|
||||
let id = UUID().uuidString
|
||||
|
||||
@Published var uploadString = "N/A"
|
||||
@Published var downloadString = "N/A"
|
||||
|
||||
@Published var downloadTotal = "N/A"
|
||||
@Published var uploadTotal = "N/A"
|
||||
|
||||
@Published var activeConns = "0"
|
||||
|
||||
@Published var memory = "0 MB"
|
||||
|
||||
@Published var downloadHistories = [CGFloat](repeating: 0, count: TrafficHistoryLimit)
|
||||
@Published var uploadHistories = [CGFloat](repeating: 0, count: TrafficHistoryLimit)
|
||||
|
||||
var down: Int = 0 {
|
||||
didSet {
|
||||
downloadString = getSpeedString(for: down)
|
||||
downloadHistories.append(CGFloat(down))
|
||||
|
||||
if downloadHistories.count > 120 {
|
||||
downloadHistories.removeFirst()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var up: Int = 0 {
|
||||
didSet {
|
||||
uploadString = getSpeedString(for: up)
|
||||
uploadHistories.append(CGFloat(up))
|
||||
|
||||
if uploadHistories.count > 120 {
|
||||
uploadHistories.removeFirst()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var downTotal: Int = 0 {
|
||||
didSet {
|
||||
downloadTotal = getSpeedString(for: downTotal).replacingOccurrences(of: "/s", with: "")
|
||||
}
|
||||
}
|
||||
|
||||
var upTotal: Int = 0 {
|
||||
didSet {
|
||||
uploadTotal = getSpeedString(for: upTotal).replacingOccurrences(of: "/s", with: "")
|
||||
}
|
||||
}
|
||||
|
||||
func getSpeedString(for byte: Int) -> String {
|
||||
let kb = byte / 1000
|
||||
if kb < 1000 {
|
||||
return "\(kb)KB/s"
|
||||
} else {
|
||||
let mb = Double(kb) / 1000
|
||||
if mb >= 100 {
|
||||
if mb >= 1000 {
|
||||
return String(format: "%.1fGB/s", mb/1000)
|
||||
}
|
||||
return String(format: "%.1fMB/s", mb)
|
||||
} else {
|
||||
return String(format: "%.2fMB/s", mb)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ClashLogStorage: ObservableObject {
|
||||
@Published var logs = [ClashLog]()
|
||||
|
||||
class ClashLog: NSObject, ObservableObject, Identifiable, Differentiable {
|
||||
let id: String
|
||||
var differenceIdentifier: String {
|
||||
return id
|
||||
}
|
||||
|
||||
let date: Date
|
||||
let level: ClashLogLevel
|
||||
@objc let log: String
|
||||
|
||||
let levelColor: NSColor
|
||||
@objc let levelString: String
|
||||
|
||||
init(level: String, log: String) {
|
||||
self.date = Date()
|
||||
self.level = .init(rawValue: level) ?? .unknow
|
||||
self.log = log
|
||||
|
||||
id = "\(date)" + log
|
||||
self.levelString = level
|
||||
switch self.level {
|
||||
case .info:
|
||||
levelColor = .systemBlue
|
||||
case .warning:
|
||||
levelColor = .systemYellow
|
||||
case .error:
|
||||
levelColor = .systemRed
|
||||
case .debug:
|
||||
levelColor = .systemGreen
|
||||
default:
|
||||
levelColor = .white
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ClashConnsStorage: ObservableObject {
|
||||
@Published var conns = [DBConnection]()
|
||||
}
|
||||
@@ -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()
|
||||
// }
|
||||
//}
|
||||
53
Sources/ClashX Dashboard/Views/ContentView.swift
Normal file
53
Sources/ClashX Dashboard/Views/ContentView.swift
Normal file
@@ -0,0 +1,53 @@
|
||||
//
|
||||
// ContentView.swift
|
||||
// ClashX Dashboard
|
||||
//
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
class HideProxyNames: ObservableObject, Identifiable {
|
||||
let id = UUID().uuidString
|
||||
@Published var hide = false
|
||||
}
|
||||
|
||||
struct ContentView: View {
|
||||
|
||||
private let runningState = NotificationCenter.default.publisher(for: .init("ClashRunningStateChanged"))
|
||||
@State private var isRunning = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if !isRunning {
|
||||
APISettingView()
|
||||
|
||||
// .presentedWindowToolbarStyle(.expanded)
|
||||
} else {
|
||||
NavigationView {
|
||||
SidebarView()
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigation) {
|
||||
Button {
|
||||
NSApp.keyWindow?.firstResponder?.tryToPerform(#selector(NSSplitViewController.toggleSidebar(_:)), with: nil)
|
||||
} label: {
|
||||
Image(systemName: "sidebar.left")
|
||||
}
|
||||
.help("Toggle Sidebar")
|
||||
}
|
||||
}
|
||||
.onReceive(runningState) { _ in
|
||||
isRunning = ConfigManager.shared.isRunning
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
struct ContentView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ContentView()
|
||||
}
|
||||
}
|
||||
18
Sources/ClashX Dashboard/Views/SidebarView/SidebarItem.swift
Normal file
18
Sources/ClashX Dashboard/Views/SidebarView/SidebarItem.swift
Normal file
@@ -0,0 +1,18 @@
|
||||
//
|
||||
// SidebarItem.swift
|
||||
// ClashX Dashboard
|
||||
//
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
import SwiftUI
|
||||
|
||||
enum SidebarItem: String {
|
||||
case overview = "Overview"
|
||||
case proxies = "Proxies"
|
||||
case providers = "Providers"
|
||||
case rules = "Rules"
|
||||
case conns = "Conns"
|
||||
case config = "Config"
|
||||
case logs = "Logs"
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//
|
||||
// SidebarListView.swift
|
||||
// ClashX Dashboard
|
||||
//
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Introspect
|
||||
|
||||
struct SidebarListView: View {
|
||||
|
||||
@Binding var selectionName: SidebarItem?
|
||||
|
||||
@State private var reloadID = UUID().uuidString
|
||||
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
NavigationLink(destination: OverviewView(),
|
||||
tag: SidebarItem.overview,
|
||||
selection: $selectionName) {
|
||||
Label(SidebarItem.overview.rawValue, systemImage: "chart.bar.xaxis")
|
||||
}
|
||||
|
||||
NavigationLink(destination: ProxiesView(),
|
||||
tag: SidebarItem.proxies,
|
||||
selection: $selectionName) {
|
||||
Label(SidebarItem.proxies.rawValue, systemImage: "globe.asia.australia")
|
||||
}
|
||||
|
||||
NavigationLink(destination: ProvidersView(),
|
||||
tag: SidebarItem.providers,
|
||||
selection: $selectionName) {
|
||||
Label(SidebarItem.providers.rawValue, systemImage: "link.icloud")
|
||||
}
|
||||
|
||||
NavigationLink(destination: RulesView(),
|
||||
tag: SidebarItem.rules,
|
||||
selection: $selectionName) {
|
||||
Label(SidebarItem.rules.rawValue, systemImage: "waveform.and.magnifyingglass")
|
||||
}
|
||||
|
||||
NavigationLink(destination: ConnectionsView(),
|
||||
tag: SidebarItem.conns,
|
||||
selection: $selectionName) {
|
||||
Label(SidebarItem.conns.rawValue, systemImage: "app.connected.to.app.below.fill")
|
||||
}
|
||||
|
||||
NavigationLink(destination: ConfigView(),
|
||||
tag: SidebarItem.config,
|
||||
selection: $selectionName) {
|
||||
Label(SidebarItem.config.rawValue, systemImage: "slider.horizontal.3")
|
||||
}
|
||||
|
||||
NavigationLink(destination: LogsView(),
|
||||
tag: SidebarItem.logs,
|
||||
selection: $selectionName) {
|
||||
Label(SidebarItem.logs.rawValue, systemImage: "wand.and.stars.inverse")
|
||||
}
|
||||
|
||||
}
|
||||
.introspectTableView {
|
||||
if selectionName == nil {
|
||||
selectionName = SidebarItem.overview
|
||||
$0.allowsEmptySelection = false
|
||||
if $0.selectedRow == -1 {
|
||||
$0.selectRowIndexes(.init(integer: 0), byExtendingSelection: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
.id(reloadID)
|
||||
.onReceive(NotificationCenter.default.publisher(for: .reloadDashboard)) { _ in
|
||||
reloadID = UUID().uuidString
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//struct SidebarListView_Previews: PreviewProvider {
|
||||
// static var previews: some View {
|
||||
// SidebarListView()
|
||||
// }
|
||||
//}
|
||||
69
Sources/ClashX Dashboard/Views/SidebarView/SidebarView.swift
Normal file
69
Sources/ClashX Dashboard/Views/SidebarView/SidebarView.swift
Normal file
@@ -0,0 +1,69 @@
|
||||
//
|
||||
// SidebarView.swift
|
||||
// ClashX Dashboard
|
||||
//
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct SidebarView: View {
|
||||
|
||||
@StateObject var clashApiDatasStorage = ClashApiDatasStorage()
|
||||
|
||||
private let connsQueue = DispatchQueue(label: "thread-safe-connsQueue", attributes: .concurrent)
|
||||
private let timer = Timer.publish(every: 1, on: .main, in: .default).autoconnect()
|
||||
|
||||
@State private var sidebarSelectionName: SidebarItem?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
SidebarListView(selectionName: $sidebarSelectionName)
|
||||
}
|
||||
.environmentObject(clashApiDatasStorage.overviewData)
|
||||
.environmentObject(clashApiDatasStorage.logStorage)
|
||||
.environmentObject(clashApiDatasStorage.connsStorage)
|
||||
.onAppear {
|
||||
if ConfigManager.selectLoggingApiLevel == .unknow {
|
||||
ConfigManager.selectLoggingApiLevel = .info
|
||||
}
|
||||
|
||||
clashApiDatasStorage.resetStreamApi()
|
||||
connsQueue.sync {
|
||||
clashApiDatasStorage.connsStorage.conns
|
||||
.removeAll()
|
||||
}
|
||||
|
||||
updateConnections()
|
||||
}
|
||||
.onChange(of: sidebarSelectionName) { newValue in
|
||||
sidebarItemChanged(newValue)
|
||||
}
|
||||
.onReceive(timer, perform: { _ in
|
||||
updateConnections()
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func updateConnections() {
|
||||
ApiRequest.getConnections { snap in
|
||||
connsQueue.sync {
|
||||
clashApiDatasStorage.overviewData.upTotal = snap.uploadTotal
|
||||
clashApiDatasStorage.overviewData.downTotal = snap.downloadTotal
|
||||
clashApiDatasStorage.overviewData.activeConns = "\(snap.connections.count)"
|
||||
clashApiDatasStorage.connsStorage.conns = snap.connections
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sidebarItemChanged(_ item: SidebarItem?) {
|
||||
guard let item else { return }
|
||||
|
||||
NotificationCenter.default.post(name: .sidebarItemChanged, object: nil, userInfo: ["item": item])
|
||||
}
|
||||
}
|
||||
|
||||
//struct SidebarView_Previews: PreviewProvider {
|
||||
// static var previews: some View {
|
||||
// SidebarView()
|
||||
// }
|
||||
//}
|
||||
27
Sources/ClashX Dashboard/Views/SwiftUIViewExtensions.swift
Normal file
27
Sources/ClashX Dashboard/Views/SwiftUIViewExtensions.swift
Normal file
@@ -0,0 +1,27 @@
|
||||
//
|
||||
// SwiftUIViewExtensions.swift
|
||||
// ClashX Dashboard
|
||||
//
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct Show: ViewModifier {
|
||||
let isVisible: Bool
|
||||
|
||||
@ViewBuilder
|
||||
func body(content: Content) -> some View {
|
||||
if isVisible {
|
||||
content
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func show(isVisible: Bool) -> some View {
|
||||
ModifiedContent(content: self, modifier: Show(isVisible: isVisible))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user