ClashX.Meta/ProxyConfigHelper/MetaTask.swift

330 lines
9.9 KiB
Swift

//
// MetaTask.swift
// com.metacubex.ClashX.ProxyConfigHelper
import Cocoa
class MetaTask: NSObject {
struct MetaCurl: Decodable {
let hello: String
}
let proc = Process()
var timer: DispatchSourceTimer?
let timerQueue = DispatchQueue(label: Bundle.main.bundleIdentifier! + ".timer")
@objc func start(_ path: String,
confPath: String,
confFilePath: String,
confJSON: String,
result: @escaping (String?) -> Void) {
var resultReturned = false
func returnResult(_ re: String) {
guard !resultReturned else { return }
timer?.cancel()
timer = nil
resultReturned = true
result(re)
}
proc.executableURL = .init(fileURLWithPath: path)
var args = [
"-d",
confPath
]
if confFilePath != "" {
args.append(contentsOf: [
"-f",
confFilePath
])
}
killOldProc()
do {
guard let confData = confJSON.data(using: .utf8),
var serverResult = try? JSONDecoder().decode(MetaServer.self, from: confData) else {
returnResult("Can't decode config file.")
return
}
self.proc.arguments = args
self.proc.qualityOfService = .userInitiated
let pipe = Pipe()
var logs = [String]()
let errorPipe = Pipe()
var errorLogs = [String]()
pipe.fileHandleForReading.readabilityHandler = { pipe in
guard let output = String(data: pipe.availableData, encoding: .utf8),
!resultReturned else {
return
}
output.split(separator: "\n").map {
self.formatMsg(String($0))
}.forEach {
logs.append($0)
if $0.contains("External controller listen error:") || $0.contains("External controller serve error:") {
returnResult($0)
}
/*
if let range = $0.range(of: "RESTful API listening at: ") {
let addr = String($0[range.upperBound..<$0.endIndex])
guard addr.split(separator: ":").count == 2,
let port = Int(addr.split(separator: ":")[1]) else {
returnResult("Not found RESTful API port.")
return
}
let testLP = self.testListenPort(port)
if testLP.pid != 0,
testLP.pid == self.proc.processIdentifier,
testLP.addr == addr {
serverResult.log = logs.joined(separator: "\n")
returnResult(serverResult.jsonString())
} else {
returnResult("Check RESTful API pid failed.")
}
}
*/
if $0.contains("RESTful API listening at:") {
if self.testExternalController(serverResult) {
serverResult.log = logs.joined(separator: "\n")
returnResult(serverResult.jsonString())
} else {
returnResult("Check RESTful API failed.")
}
}
}
}
errorPipe.fileHandleForReading.readabilityHandler = { pipe in
guard let output = String(data: pipe.availableData, encoding: .utf8) else {
return
}
output.split(separator: "\n").forEach {
errorLogs.append(String($0))
}
}
self.proc.standardError = errorPipe
self.proc.standardOutput = pipe
self.proc.terminationHandler = { proc in
guard !resultReturned else {
guard errorLogs.count > 0 else { return }
errorLogs.append("terminationStatus: \(proc.terminationStatus)")
errorLogs.append("terminationReason: \(proc.terminationReason)")
let data = errorLogs.joined(separator: "\n").data(using: .utf8)
let url = URL(fileURLWithPath: confPath)
.appendingPathComponent("logs")
let fm = FileManager.default
try? fm.createDirectory(atPath: url.path, withIntermediateDirectories: true)
let fileName = {
let dateformat = DateFormatter()
dateformat.dateFormat = "yyyy-MM-dd_HH-mm-ss"
let s = dateformat.string(from: Date())
return "meta_core_crash_\(s).log"
}()
fm.createFile(atPath: url.appendingPathComponent(fileName).path, contents: data)
return
}
let data = pipe.fileHandleForReading.readDataToEndOfFile()
guard let string = String(data: data, encoding: String.Encoding.utf8) else {
returnResult("Meta process terminated, no found output.")
return
}
let results = string.split(separator: "\n").map(String.init).map(self.formatMsg(_:))
returnResult(results.joined(separator: "\n"))
}
self.timer = DispatchSource.makeTimerSource(queue: self.timerQueue)
self.timer?.schedule(deadline: .now(), repeating: .milliseconds(500))
self.timer?.setEventHandler {
guard self.testExternalController(serverResult) else {
return
}
serverResult.log = logs.joined(separator: "\n")
returnResult(serverResult.jsonString())
}
DispatchQueue.global().asyncAfter(deadline: .now() + 30) {
serverResult.log = logs.joined(separator: "\n")
returnResult(serverResult.jsonString())
}
try self.proc.run()
self.timer?.resume()
} catch let error {
returnResult("Start meta error, \(error.localizedDescription).")
}
}
@objc func stop() {
DispatchQueue.main.async {
guard self.proc.isRunning else { return }
let proc = Process()
proc.executableURL = .init(fileURLWithPath: "/bin/kill")
proc.arguments = ["-9", "\(self.proc.processIdentifier)"]
try? proc.run()
proc.waitUntilExit()
}
}
func killOldProc() {
let proc = Process()
proc.executableURL = .init(fileURLWithPath: "/usr/bin/killall")
proc.arguments = ["com.metacubex.ClashX.ProxyConfigHelper.meta"]
try? proc.run()
proc.waitUntilExit()
}
@objc func getUsedPorts(_ result: @escaping (String?) -> Void) {
let proc = Process()
let pipe = Pipe()
proc.standardOutput = pipe
proc.executableURL = .init(fileURLWithPath: "/bin/bash")
proc.arguments = ["-c", "lsof -nP -iTCP -sTCP:LISTEN | grep LISTEN"]
try? proc.run()
proc.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
guard let str = String(data: data, encoding: .utf8) else {
result("")
return
}
let usedPorts = str.split(separator: "\n").compactMap { str -> Int? in
let line = str.split(separator: " ").map(String.init)
guard line.count == 10,
let port = line[8].components(separatedBy: ":").last else { return nil }
return Int(port)
}.map(String.init).joined(separator: ",")
result(usedPorts)
}
func testListenPort(_ port: Int) -> (pid: Int32, addr: String) {
let proc = Process()
let pipe = Pipe()
proc.standardOutput = pipe
proc.executableURL = .init(fileURLWithPath: "/bin/bash")
proc.arguments = ["-c", "lsof -nP -iTCP:\(port) -sTCP:LISTEN | grep LISTEN"]
try? proc.run()
proc.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
guard let str = String(data: data, encoding: .utf8),
str.split(separator: " ").map(String.init).count == 10 else {
return (0, "")
}
let re = str.split(separator: " ").map(String.init)
let pid = re[1]
let addr = re[8]
return (Int32(pid) ?? 0, addr)
}
func testExternalController(_ server: MetaServer) -> Bool {
let proc = Process()
let pipe = Pipe()
proc.standardOutput = pipe
proc.executableURL = .init(fileURLWithPath: "/usr/bin/curl")
var args = [server.externalController]
if server.secret != "" {
args.append(contentsOf: [
"--header",
"Authorization: Bearer \(server.secret)"
])
}
proc.arguments = args
try? proc.run()
proc.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
guard let str = try? JSONDecoder().decode(MetaCurl.self, from: data),
(str.hello == "clash.meta" || str.hello == "mihomo") else {
return false
}
return true
}
func formatMsg(_ msg: String) -> String {
let msgs = msg.split(separator: " ", maxSplits: 2).map(String.init)
guard msgs.count == 3,
msgs[1].starts(with: "level"),
msgs[2].starts(with: "msg") else {
return msg
}
let level = msgs[1].replacingOccurrences(of: "level=", with: "")
var re = msgs[2].replacingOccurrences(of: "msg=\"", with: "")
while re.last == "\"" || re.last == "\n" {
re.removeLast()
}
if re.contains("time=") {
print(re)
}
return "[\(level)] \(re)"
}
func parseConfFile(_ confPath: String, confFilePath: String) -> MetaServer? {
let fileURL = confFilePath == "" ? URL(fileURLWithPath: confPath).appendingPathComponent("config.yaml", isDirectory: false) : URL(fileURLWithPath: confFilePath)
guard let data = FileManager.default.contents(atPath: fileURL.path),
let content = String(data: data, encoding: .utf8) else {
return nil
}
let lines = content.split(separator: "\n").map(String.init)
func find(_ key: String) -> String {
var re = lines.first(where: { $0.starts(with: "\(key): ") })?.dropFirst("\(key): ".count) ?? ""
if re.hasPrefix("\"") && re.hasSuffix("\"")
|| re.hasPrefix("'") && re.hasSuffix("'") {
re.removeLast()
re.removeFirst()
}
return String(re)
}
return MetaServer(externalController: find("external-controller"),
secret: find("secret"))
}
}