mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
feat: Add Hermes process start/stop/restart controls (#10)
- Add hermesPID() and stopHermes() to HermesFileService for process signal management via SIGTERM - Add process control bar to Health view with running status, PID display, and Start/Stop/Restart buttons - Add Start/Stop/Restart Hermes quick actions to menu bar - Start launches gateway, stop sends SIGTERM, restart combines both Closes #10 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -235,6 +235,10 @@ struct HermesFileService: Sendable {
|
|||||||
// MARK: - Hermes Process
|
// MARK: - Hermes Process
|
||||||
|
|
||||||
func isHermesRunning() -> Bool {
|
func isHermesRunning() -> Bool {
|
||||||
|
hermesPID() != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hermesPID() -> pid_t? {
|
||||||
let pipe = Pipe()
|
let pipe = Pipe()
|
||||||
let process = Process()
|
let process = Process()
|
||||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep")
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep")
|
||||||
@@ -245,12 +249,21 @@ struct HermesFileService: Sendable {
|
|||||||
try process.run()
|
try process.run()
|
||||||
process.waitUntilExit()
|
process.waitUntilExit()
|
||||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
return !data.isEmpty
|
let output = String(data: data, encoding: .utf8) ?? ""
|
||||||
|
guard let firstLine = output.components(separatedBy: "\n").first(where: { !$0.isEmpty }),
|
||||||
|
let pid = pid_t(firstLine.trimmingCharacters(in: .whitespaces)) else { return nil }
|
||||||
|
return pid
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func stopHermes() -> Bool {
|
||||||
|
guard let pid = hermesPID() else { return false }
|
||||||
|
return kill(pid, SIGTERM) == 0
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - File I/O
|
// MARK: - File I/O
|
||||||
|
|
||||||
private func readFile(_ path: String) -> String? {
|
private func readFile(_ path: String) -> String? {
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ struct HealthSection: Identifiable {
|
|||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
final class HealthViewModel {
|
final class HealthViewModel {
|
||||||
|
private let fileService = HermesFileService()
|
||||||
|
|
||||||
var version = ""
|
var version = ""
|
||||||
var updateInfo = ""
|
var updateInfo = ""
|
||||||
var hasUpdate = false
|
var hasUpdate = false
|
||||||
@@ -31,9 +33,13 @@ final class HealthViewModel {
|
|||||||
var warningCount = 0
|
var warningCount = 0
|
||||||
var okCount = 0
|
var okCount = 0
|
||||||
var isLoading = false
|
var isLoading = false
|
||||||
|
var hermesRunning = false
|
||||||
|
var hermesPID: pid_t?
|
||||||
|
var actionMessage: String?
|
||||||
|
|
||||||
func load() {
|
func load() {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
|
refreshProcessStatus()
|
||||||
loadVersion()
|
loadVersion()
|
||||||
let statusOutput = runHermes(["status"]).output
|
let statusOutput = runHermes(["status"]).output
|
||||||
statusSections = parseOutput(statusOutput)
|
statusSections = parseOutput(statusOutput)
|
||||||
@@ -43,6 +49,41 @@ final class HealthViewModel {
|
|||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func refreshProcessStatus() {
|
||||||
|
hermesPID = fileService.hermesPID()
|
||||||
|
hermesRunning = hermesPID != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopHermes() {
|
||||||
|
fileService.stopHermes()
|
||||||
|
actionMessage = "Stop signal sent"
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||||
|
self?.refreshProcessStatus()
|
||||||
|
self?.actionMessage = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startHermes() {
|
||||||
|
runHermes(["gateway", "start"])
|
||||||
|
actionMessage = "Start requested"
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||||
|
self?.refreshProcessStatus()
|
||||||
|
self?.actionMessage = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func restartHermes() {
|
||||||
|
fileService.stopHermes()
|
||||||
|
actionMessage = "Restarting..."
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||||
|
self?.runHermes(["gateway", "start"])
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||||
|
self?.refreshProcessStatus()
|
||||||
|
self?.actionMessage = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func loadVersion() {
|
private func loadVersion() {
|
||||||
let output = runHermes(["version"]).output
|
let output = runHermes(["version"]).output
|
||||||
let lines = output.components(separatedBy: "\n")
|
let lines = output.components(separatedBy: "\n")
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ struct HealthView: View {
|
|||||||
// MARK: - Header
|
// MARK: - Header
|
||||||
|
|
||||||
private var headerBar: some View {
|
private var headerBar: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
HStack(spacing: 16) {
|
HStack(spacing: 16) {
|
||||||
if !viewModel.version.isEmpty {
|
if !viewModel.version.isEmpty {
|
||||||
Text(viewModel.version)
|
Text(viewModel.version)
|
||||||
@@ -59,6 +60,44 @@ struct HealthView: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Circle()
|
||||||
|
.fill(viewModel.hermesRunning ? .green : .red)
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
Text(viewModel.hermesRunning ? "Hermes Running" : "Hermes Stopped")
|
||||||
|
.font(.caption.bold())
|
||||||
|
if let pid = viewModel.hermesPID {
|
||||||
|
Text("PID \(pid)")
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let msg = viewModel.actionMessage {
|
||||||
|
Label(msg, systemImage: "arrow.triangle.2.circlepath")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button("Start") { viewModel.startHermes() }
|
||||||
|
.disabled(viewModel.hermesRunning)
|
||||||
|
Button("Stop") { viewModel.stopHermes() }
|
||||||
|
.disabled(!viewModel.hermesRunning)
|
||||||
|
Button("Restart") { viewModel.restartHermes() }
|
||||||
|
.disabled(!viewModel.hermesRunning)
|
||||||
|
}
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Grid
|
// MARK: - Grid
|
||||||
|
|||||||
@@ -54,6 +54,33 @@ final class MenuBarStatus {
|
|||||||
timer = nil
|
timer = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func startHermes() {
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
|
||||||
|
process.arguments = ["gateway", "start"]
|
||||||
|
process.standardOutput = Pipe()
|
||||||
|
process.standardError = Pipe()
|
||||||
|
try? process.run()
|
||||||
|
process.waitUntilExit()
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopHermes() {
|
||||||
|
fileService.stopHermes()
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func restartHermes() {
|
||||||
|
fileService.stopHermes()
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||||
|
self?.startHermes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func refresh() {
|
private func refresh() {
|
||||||
hermesRunning = fileService.isHermesRunning()
|
hermesRunning = fileService.isHermesRunning()
|
||||||
gatewayRunning = fileService.loadGatewayState()?.isRunning ?? false
|
gatewayRunning = fileService.loadGatewayState()?.isRunning ?? false
|
||||||
@@ -69,6 +96,13 @@ struct MenuBarMenu: View {
|
|||||||
Label(status.hermesRunning ? "Hermes Running" : "Hermes Stopped", systemImage: status.hermesRunning ? "circle.fill" : "circle")
|
Label(status.hermesRunning ? "Hermes Running" : "Hermes Stopped", systemImage: status.hermesRunning ? "circle.fill" : "circle")
|
||||||
Label(status.gatewayRunning ? "Gateway Running" : "Gateway Stopped", systemImage: status.gatewayRunning ? "circle.fill" : "circle")
|
Label(status.gatewayRunning ? "Gateway Running" : "Gateway Stopped", systemImage: status.gatewayRunning ? "circle.fill" : "circle")
|
||||||
Divider()
|
Divider()
|
||||||
|
Button("Start Hermes") { status.startHermes() }
|
||||||
|
.disabled(status.hermesRunning)
|
||||||
|
Button("Stop Hermes") { status.stopHermes() }
|
||||||
|
.disabled(!status.hermesRunning)
|
||||||
|
Button("Restart Hermes") { status.restartHermes() }
|
||||||
|
.disabled(!status.hermesRunning)
|
||||||
|
Divider()
|
||||||
Button("Open Dashboard") {
|
Button("Open Dashboard") {
|
||||||
coordinator.selectedSection = .dashboard
|
coordinator.selectedSection = .dashboard
|
||||||
NSApplication.shared.activate()
|
NSApplication.shared.activate()
|
||||||
|
|||||||
Reference in New Issue
Block a user