232 lines
8.3 KiB
Swift
232 lines
8.3 KiB
Swift
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
|
||
}
|
||
}
|