330 lines
9.9 KiB
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"))
|
|
}
|
|
}
|