This commit is contained in:
mrFq1
2023-06-05 23:39:23 +08:00
parent e5860ead2c
commit f488f41f8d
72 changed files with 247 additions and 45 deletions

View 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) {}
}

View File

@@ -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
}
}

View File

@@ -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) ?? ""
}
}

View File

@@ -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")
}
}
}

View 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 ?? ""
}
}

View 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
}
}

View File

@@ -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]
}
}

View File

@@ -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"
}
}

View 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
}()
}

View 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()
}
}

View File

@@ -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?
}

View File

@@ -0,0 +1,13 @@
import SwiftUI
@available(macOS 12.0, *)
public struct DashboardView: View {
public init() {
}
public var body: some View {
ContentView()
}
}

View 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
]
}
}

View 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))"
}
}

View 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
}
}

View 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
}
}
}

View 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")
}

View File

@@ -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()
// }
//}

View File

@@ -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()
}
}

View File

@@ -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
}
}

View File

@@ -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()
// }
//}

View 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]
}
}

View 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]()
}

View File

@@ -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")
}
}
}

View File

@@ -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()
// }
//}

View File

@@ -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
}
}

View File

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

View File

@@ -0,0 +1,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()
}
}

View File

@@ -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
}
}
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View File

@@ -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()
// }
//}

View File

@@ -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()
// }
//}

View File

@@ -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()
// }
//}

View File

@@ -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()
// }
//}

View File

@@ -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()
// }
//}

View File

@@ -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()
// }
//}

View File

@@ -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()
// }
//}

View File

@@ -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()
// }
//}

View File

@@ -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()
// }
//}

View File

@@ -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()
// }
//}

View File

@@ -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))
}
}

View File

@@ -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()
// }
//}
//

View File

@@ -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()
// }
//}

View File

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

View File

@@ -0,0 +1,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()
// }
//}

View 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()
}
}

View 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"
}

View File

@@ -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()
// }
//}

View 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()
// }
//}

View 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))
}
}