From c09f1677607cc5bdde60ebf0309cedefd5553863 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Tue, 31 Mar 2026 12:32:29 -0400 Subject: [PATCH] Add Gateway Control Center with service control and pairing management New Gateway section in the Manage group: - Service controls: Start/Stop/Restart buttons calling hermes gateway CLI - Status display: state (running/stopped), PID, loaded indicator, stale service warning, exit reason, last update timestamp - Platform cards: each connected messaging platform with connection state (reads from gateway_state.json) - Pairing management: approved users list with revoke button, pending pairing codes with approve button - Auto-refreshes via HermesFileWatcher when gateway state changes Co-Authored-By: Claude Opus 4.6 (1M context) --- scarf/scarf/ContentView.swift | 2 + .../Gateway/ViewModels/GatewayViewModel.swift | 187 ++++++++++++++++ .../Features/Gateway/Views/GatewayView.swift | 205 ++++++++++++++++++ scarf/scarf/Navigation/AppCoordinator.swift | 2 + scarf/scarf/Navigation/SidebarView.swift | 2 +- 5 files changed, 397 insertions(+), 1 deletion(-) create mode 100644 scarf/scarf/Features/Gateway/ViewModels/GatewayViewModel.swift create mode 100644 scarf/scarf/Features/Gateway/Views/GatewayView.swift diff --git a/scarf/scarf/ContentView.swift b/scarf/scarf/ContentView.swift index a9abad1..d317d13 100644 --- a/scarf/scarf/ContentView.swift +++ b/scarf/scarf/ContentView.swift @@ -30,6 +30,8 @@ struct ContentView: View { SkillsView() case .tools: ToolsView() + case .gateway: + GatewayView() case .cron: CronView() case .logs: diff --git a/scarf/scarf/Features/Gateway/ViewModels/GatewayViewModel.swift b/scarf/scarf/Features/Gateway/ViewModels/GatewayViewModel.swift new file mode 100644 index 0000000..7eac5a2 --- /dev/null +++ b/scarf/scarf/Features/Gateway/ViewModels/GatewayViewModel.swift @@ -0,0 +1,187 @@ +import Foundation + +struct GatewayInfo { + let pid: Int? + let state: String + let exitReason: String? + let startTime: String? + let updatedAt: String? + let platforms: [PlatformInfo] + let isLoaded: Bool + let isStale: Bool +} + +struct PlatformInfo: Identifiable { + var id: String { name } + let name: String + let state: String + let updatedAt: String? + + var isConnected: Bool { state == "connected" } + + var icon: String { + switch name { + case "telegram": return "paperplane" + case "discord": return "bubble.left.and.bubble.right" + case "slack": return "number" + case "whatsapp": return "phone.bubble" + case "signal": return "lock.shield" + case "email": return "envelope" + default: return "bubble.left" + } + } +} + +struct PairedUser: Identifiable { + var id: String { platform + userId } + let platform: String + let userId: String + let name: String +} + +struct PendingPairing: Identifiable { + var id: String { platform + code } + let platform: String + let code: String +} + +@Observable +final class GatewayViewModel { + var gateway = GatewayInfo(pid: nil, state: "unknown", exitReason: nil, startTime: nil, updatedAt: nil, platforms: [], isLoaded: false, isStale: false) + var approvedUsers: [PairedUser] = [] + var pendingPairings: [PendingPairing] = [] + var isLoading = false + var actionMessage: String? + + func load() { + isLoading = true + loadGatewayStatus() + loadPairing() + isLoading = false + } + + func startGateway() { + runHermes(["gateway", "start"]) + actionMessage = "Gateway start requested" + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in + self?.loadGatewayStatus() + self?.actionMessage = nil + } + } + + func stopGateway() { + runHermes(["gateway", "stop"]) + actionMessage = "Gateway stop requested" + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in + self?.loadGatewayStatus() + self?.actionMessage = nil + } + } + + func restartGateway() { + runHermes(["gateway", "restart"]) + actionMessage = "Gateway restart requested" + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in + self?.loadGatewayStatus() + self?.actionMessage = nil + } + } + + func approvePairing(platform: String, code: String) { + runHermes(["pairing", "approve", platform, code]) + loadPairing() + } + + func revokeUser(_ user: PairedUser) { + runHermes(["pairing", "revoke", user.platform, user.userId]) + approvedUsers.removeAll { $0.id == user.id } + } + + // MARK: - Private + + private func loadGatewayStatus() { + let stateJSON = FileManager.default.contents(atPath: HermesPaths.gatewayStateJSON) + var pid: Int? + var state = "unknown" + var exitReason: String? + var startTime: String? + var updatedAt: String? + var platforms: [PlatformInfo] = [] + + if let data = stateJSON, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + pid = json["pid"] as? Int + state = json["gateway_state"] as? String ?? "unknown" + exitReason = json["exit_reason"] as? String + startTime = json["start_time"] as? String + updatedAt = json["updated_at"] as? String + if let plats = json["platforms"] as? [String: Any] { + platforms = plats.compactMap { key, value in + guard let info = value as? [String: Any] else { return nil } + return PlatformInfo( + name: key, + state: info["state"] as? String ?? "unknown", + updatedAt: info["updated_at"] as? String + ) + }.sorted { $0.name < $1.name } + } + } + + let statusOutput = runHermes(["gateway", "status"]).output + let isLoaded = statusOutput.contains("service is loaded") + let isStale = statusOutput.contains("stale") + + gateway = GatewayInfo( + pid: pid, state: state, exitReason: exitReason, + startTime: startTime, updatedAt: updatedAt, + platforms: platforms, isLoaded: isLoaded, isStale: isStale + ) + } + + private func loadPairing() { + let output = runHermes(["pairing", "list"]).output + approvedUsers = [] + pendingPairings = [] + + var inApproved = false + var inPending = false + + for line in output.components(separatedBy: "\n") { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.contains("Approved Users") { inApproved = true; inPending = false; continue } + if trimmed.contains("Pending") { inPending = true; inApproved = false; continue } + if trimmed.isEmpty || trimmed.hasPrefix("Platform") || trimmed.hasPrefix("--------") { continue } + + let parts = trimmed.split(separator: " ", omittingEmptySubsequences: true) + if inApproved && parts.count >= 3 { + let platform = String(parts[0]) + let userId = String(parts[1]) + let name = parts[2...].joined(separator: " ") + approvedUsers.append(PairedUser(platform: platform, userId: userId, name: name)) + } + if inPending && parts.count >= 2 { + let platform = String(parts[0]) + let code = String(parts[1]) + pendingPairings.append(PendingPairing(platform: platform, code: code)) + } + } + } + + @discardableResult + private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) { + let process = Process() + process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary) + process.arguments = arguments + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + do { + try process.run() + process.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + return (String(data: data, encoding: .utf8) ?? "", process.terminationStatus) + } catch { + return ("", -1) + } + } +} diff --git a/scarf/scarf/Features/Gateway/Views/GatewayView.swift b/scarf/scarf/Features/Gateway/Views/GatewayView.swift new file mode 100644 index 0000000..f4bfc6a --- /dev/null +++ b/scarf/scarf/Features/Gateway/Views/GatewayView.swift @@ -0,0 +1,205 @@ +import SwiftUI + +struct GatewayView: View { + @State private var viewModel = GatewayViewModel() + @Environment(HermesFileWatcher.self) private var fileWatcher + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + serviceSection + platformsSection + pairingSection + } + .padding() + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .navigationTitle("Gateway") + .onAppear { viewModel.load() } + .onChange(of: fileWatcher.lastChangeDate) { viewModel.load() } + } + + // MARK: - Service + + private var serviceSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Service") + .font(.headline) + Spacer() + if let msg = viewModel.actionMessage { + Text(msg) + .font(.caption) + .foregroundStyle(.secondary) + } + HStack(spacing: 8) { + Button("Start") { viewModel.startGateway() } + Button("Stop") { viewModel.stopGateway() } + Button("Restart") { viewModel.restartGateway() } + } + .controlSize(.small) + } + + HStack(spacing: 16) { + StatusBadge( + label: viewModel.gateway.state, + isActive: viewModel.gateway.state == "running" + ) + if let pid = viewModel.gateway.pid { + Label("PID \(pid)", systemImage: "number") + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + if viewModel.gateway.isLoaded { + Label("Loaded", systemImage: "checkmark.circle") + .font(.caption) + .foregroundStyle(.green) + } + if viewModel.gateway.isStale { + Label("Service definition stale", systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.orange) + } + } + + if let reason = viewModel.gateway.exitReason, !reason.isEmpty { + HStack(spacing: 4) { + Image(systemName: "info.circle") + .foregroundStyle(.secondary) + Text(reason) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + if let updated = viewModel.gateway.updatedAt { + Text("Last updated: \(updated)") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + } + + // MARK: - Platforms + + private var platformsSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Platforms") + .font(.headline) + if viewModel.gateway.platforms.isEmpty { + Text("No platforms connected") + .font(.caption) + .foregroundStyle(.secondary) + } else { + HStack(spacing: 12) { + ForEach(viewModel.gateway.platforms) { platform in + VStack(spacing: 6) { + Image(systemName: platform.icon) + .font(.title2) + .foregroundStyle(platform.isConnected ? Color.accentColor : .secondary) + Text(platform.name.capitalized) + .font(.caption.bold()) + StatusBadge( + label: platform.state, + isActive: platform.isConnected + ) + } + .frame(maxWidth: .infinity) + .padding(12) + .background(.quaternary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } + } + } + } + + // MARK: - Pairing + + private var pairingSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Paired Users") + .font(.headline) + + if !viewModel.pendingPairings.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Label("Pending Approvals", systemImage: "clock.badge.questionmark") + .font(.caption.bold()) + .foregroundStyle(.orange) + ForEach(viewModel.pendingPairings) { pending in + HStack { + Label(pending.platform.capitalized, systemImage: platformIcon(pending.platform)) + Text("Code: \(pending.code)") + .font(.caption.monospaced()) + Spacer() + Button("Approve") { + viewModel.approvePairing(platform: pending.platform, code: pending.code) + } + .controlSize(.small) + .buttonStyle(.borderedProminent) + } + .font(.caption) + .padding(8) + .background(.orange.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + } + } + + if viewModel.approvedUsers.isEmpty && viewModel.pendingPairings.isEmpty { + Text("No paired users") + .font(.caption) + .foregroundStyle(.secondary) + } else { + ForEach(viewModel.approvedUsers) { user in + HStack { + Image(systemName: platformIcon(user.platform)) + .foregroundStyle(.secondary) + .frame(width: 20) + VStack(alignment: .leading, spacing: 2) { + Text(user.name) + Text("\(user.platform.capitalized) ยท \(user.userId)") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Button("Revoke", role: .destructive) { + viewModel.revokeUser(user) + } + .controlSize(.small) + } + .padding(8) + .background(.quaternary.opacity(0.3)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + } + } + } + + private func platformIcon(_ platform: String) -> String { + switch platform { + case "telegram": return "paperplane" + case "discord": return "bubble.left.and.bubble.right" + case "slack": return "number" + case "whatsapp": return "phone.bubble" + case "signal": return "lock.shield" + case "email": return "envelope" + default: return "bubble.left" + } + } +} + +struct StatusBadge: View { + let label: String + let isActive: Bool + + var body: some View { + HStack(spacing: 4) { + Circle() + .fill(isActive ? .green : .secondary) + .frame(width: 6, height: 6) + Text(label) + .font(.caption) + } + } +} diff --git a/scarf/scarf/Navigation/AppCoordinator.swift b/scarf/scarf/Navigation/AppCoordinator.swift index afae5c9..33b7ce1 100644 --- a/scarf/scarf/Navigation/AppCoordinator.swift +++ b/scarf/scarf/Navigation/AppCoordinator.swift @@ -9,6 +9,7 @@ enum SidebarSection: String, CaseIterable, Identifiable { case memory = "Memory" case skills = "Skills" case tools = "Tools" + case gateway = "Gateway" case cron = "Cron" case logs = "Logs" case settings = "Settings" @@ -25,6 +26,7 @@ enum SidebarSection: String, CaseIterable, Identifiable { case .memory: return "brain" case .skills: return "lightbulb" case .tools: return "wrench.and.screwdriver" + case .gateway: return "antenna.radiowaves.left.and.right" case .cron: return "clock.arrow.2.circlepath" case .logs: return "doc.text" case .settings: return "gearshape" diff --git a/scarf/scarf/Navigation/SidebarView.swift b/scarf/scarf/Navigation/SidebarView.swift index 59f6634..647cbe0 100644 --- a/scarf/scarf/Navigation/SidebarView.swift +++ b/scarf/scarf/Navigation/SidebarView.swift @@ -19,7 +19,7 @@ struct SidebarView: View { } } Section("Manage") { - ForEach([SidebarSection.tools, .cron, .logs, .settings]) { section in + ForEach([SidebarSection.tools, .gateway, .cron, .logs, .settings]) { section in Label(section.rawValue, systemImage: section.icon) .tag(section) }