montana/Montana-Protocol/Code/desktop/MontanaMonitor/Sources/MontanaMonitor/NodeRunner.swift
2026-05-26 21:14:51 +03:00

232 lines
8.3 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Foundation
import SwiftUI
enum NodePhase: String, Equatable, Codable {
case unknown = "Unknown"
case bootstrap = "Bootstrap"
case candidateVdf = "CandidateVdf"
case registered = "Registered"
case active = "Active"
var color: Color {
switch self {
case .unknown: return .gray
case .bootstrap: return .orange
case .candidateVdf: return .yellow
case .registered: return .blue
case .active: return .green
}
}
}
struct NodeStatus: Equatable {
var phase: NodePhase = .unknown
var accountIdHex: String = ""
var nodeIdHex: String = ""
var currentWindow: UInt64 = 0
var chainLength: UInt64 = 0
var tau2: UInt64 = 20160
var supplyNJ: UInt64 = 0
var balanceNJ: UInt64 = 0
var isActive: Bool = false
var lastUpdate: Date? = nil
}
@MainActor
final class NodeRunner: ObservableObject {
@Published private(set) var status = NodeStatus()
@Published private(set) var isRunning = false
@Published private(set) var lastError: String? = nil
@Published private(set) var lastLogLines: [String] = []
private var process: Process?
private var statusTimer: Timer?
private var stdoutPipe: Pipe?
private var stderrPipe: Pipe?
var dataDir: URL {
let base = (try? FileManager.default.url(
for: .applicationSupportDirectory, in: .userDomainMask,
appropriateFor: nil, create: true
)) ?? URL(fileURLWithPath: NSHomeDirectory() + "/Library/Application Support")
let dir = base.appendingPathComponent("Montana/node", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}
var hasIdentity: Bool {
FileManager.default.fileExists(atPath: dataDir.appendingPathComponent("identity.bin").path)
}
private static func binaryPath() -> String? {
if let p = Bundle.main.url(forResource: "montana-node", withExtension: nil)?.path,
FileManager.default.isExecutableFile(atPath: p) { return p }
for c in ["/opt/homebrew/bin/montana-node", "/usr/local/bin/montana-node"] {
if FileManager.default.isExecutableFile(atPath: c) { return c }
}
return nil
}
// MARK: - Identity install via init
/// Создать identity (новую mnemonic либо восстановить переданную).
/// Если node-data уже содержит identity.bin используется `--force`.
func installIdentity(mnemonic: String) throws {
guard let bin = Self.binaryPath() else {
throw NSError(domain: "NodeRunner", code: 1, userInfo: [NSLocalizedDescriptionKey: "montana-node binary не найден"])
}
let p = Process()
p.executableURL = URL(fileURLWithPath: bin)
p.arguments = ["init",
"--data-dir", dataDir.path,
"--mnemonic-stdin",
"--force"]
let stdin = Pipe()
p.standardInput = stdin
let stderr = Pipe()
p.standardError = stderr
try p.run()
let line = mnemonic.trimmingCharacters(in: .whitespacesAndNewlines) + "\n"
try stdin.fileHandleForWriting.write(contentsOf: Data(line.utf8))
try stdin.fileHandleForWriting.close()
p.waitUntilExit()
if p.terminationStatus != 0 {
let err = String(data: stderr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
throw NSError(domain: "NodeRunner", code: Int(p.terminationStatus),
userInfo: [NSLocalizedDescriptionKey: "init failed: \(err.trimmingCharacters(in: .whitespacesAndNewlines))"])
}
}
// MARK: - Start / stop node
func start() {
guard let bin = Self.binaryPath() else {
lastError = "montana-node не найден в Resources"
return
}
guard hasIdentity else {
lastError = "сначала создайте кошелёк"
return
}
stop()
let p = Process()
p.executableURL = URL(fileURLWithPath: bin)
p.arguments = ["start", "--data-dir", dataDir.path]
let out = Pipe(); let err = Pipe()
p.standardOutput = out
p.standardError = err
p.terminationHandler = { [weak self] _ in
Task { @MainActor in
self?.isRunning = false
}
}
do {
try p.run()
self.process = p
self.stdoutPipe = out
self.stderrPipe = err
self.isRunning = true
self.lastError = nil
startLogPump(pipe: out)
startLogPump(pipe: err)
startStatusPolling()
} catch {
self.lastError = "не удалось запустить узел: \(error)"
}
}
func stop() {
statusTimer?.invalidate()
statusTimer = nil
if let p = process, p.isRunning {
p.terminate()
let deadline = Date().addingTimeInterval(2)
while p.isRunning && Date() < deadline { Thread.sleep(forTimeInterval: 0.05) }
}
process = nil
stdoutPipe = nil
stderrPipe = nil
isRunning = false
}
private func startLogPump(pipe: Pipe) {
let handle = pipe.fileHandleForReading
handle.readabilityHandler = { [weak self] h in
let data = h.availableData
guard !data.isEmpty, let text = String(data: data, encoding: .utf8) else { return }
DispatchQueue.main.async {
guard let self else { return }
for line in text.split(whereSeparator: { $0 == "\n" || $0 == "\r" }) {
self.lastLogLines.append(String(line))
if self.lastLogLines.count > 200 {
self.lastLogLines.removeFirst(self.lastLogLines.count - 200)
}
}
}
}
}
// MARK: - Status polling via `montana-node status`
private func startStatusPolling() {
statusTimer?.invalidate()
pollStatus()
statusTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { _ in
Task { @MainActor in self.pollStatus() }
}
}
private func pollStatus() {
guard let bin = Self.binaryPath() else { return }
Task.detached { [weak self] in
guard let self else { return }
let out = Pipe()
let p = Process()
p.executableURL = URL(fileURLWithPath: bin)
p.arguments = ["status", "--data-dir", await self.dataDir.path]
p.standardOutput = out
p.standardError = Pipe()
do { try p.run() } catch { return }
p.waitUntilExit()
let data = out.fileHandleForReading.readDataToEndOfFile()
guard let text = String(data: data, encoding: .utf8) else { return }
let parsed = await Self.parseStatusOutput(text)
await MainActor.run { self.status = parsed }
}
}
static func parseStatusOutput(_ text: String) -> NodeStatus {
var s = NodeStatus()
s.lastUpdate = Date()
for line in text.split(separator: "\n") {
let kv = line.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: true).map { $0.trimmingCharacters(in: .whitespaces) }
guard kv.count == 2 else { continue }
let key = kv[0]
let val = kv[1]
switch key {
case "phase":
let raw = val.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
s.phase = NodePhase(rawValue: raw) ?? .unknown
case "account_id":
s.accountIdHex = val
case "node_id":
s.nodeIdHex = val
case "current_window":
s.currentWindow = UInt64(val) ?? 0
case "chain_length":
s.chainLength = UInt64(val) ?? 0
case "tau2":
s.tau2 = UInt64(val) ?? 20160
case "supply_n":
s.supplyNJ = UInt64(val) ?? 0
case "balance_n":
s.balanceNJ = UInt64(val) ?? 0
case "is_node_operator":
s.isActive = (val == "true")
default: break
}
}
return s
}
}