From 00ca7229df4355ab79eca5f30d48da4ffba0803c Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Sat, 18 Apr 2026 17:42:17 -0700 Subject: [PATCH] feat: multi-window + remote SSH server support (Phases 0-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the ability to manage multiple Hermes installations — local and remote over SSH — from the same Scarf app, each in its own window. Architecture: - ServerContext value type carries per-server identity + paths through every VM and service. ContentView routes serverContext into each feature view's init; all 22 routed views thread it through to their @State VMs. - ServerTransport protocol with LocalTransport (FileManager/Process/ FSEvents) and SSHTransport (system ssh + scp + ControlMaster). Services were ported from direct Foundation I/O to transport-routed helpers so the same code runs against local or remote. - WindowGroup(for: ServerID.self) gives each window its own AppCoordinator + HermesFileWatcher + ChatViewModel. File menu has Open Server commands with keyboard shortcuts (⌘1..⌘9). MenuBarExtra fans out per-server with start/stop/restart controls. - ServerRegistry persists connections to ~/Library/Application Support/scarf/servers.json. Add Server sheet probes the remote with ssh -v to capture the full handshake on failure. - Connection-status pill in remote-window toolbars with silent reconnect (3s retry on first failure, escalate to red after 2 consecutive), known-hosts-mismatch + ssh-add hint cards with copy buttons. Concurrency / UX hardening (the parts learned the hard way during dogfooding — captured in the feedback memory): - ServerContext exposes context.readText / readData / writeText / fileExists / runHermes / openInLocalEditor as the canonical I/O surface. Every VM uses these; never raw FileManager / Process() / NSWorkspace.open with a Hermes path. - SSHTransport.remotePathArg rewrites ~/foo to "$HOME/foo" so paths expand correctly inside the sh -c command we build (POSIX shells don't expand ~ inside any quotes). - Heavy VM load() methods detach to a background task and commit results back via MainActor.run, so synchronous ssh round-trips don't beach-ball the UI. Applied to Dashboard, Memory, Settings, MCPServers, Cron, Plugins, Personalities, QuickCommands, Skills, Gateway, Health, CredentialPools. - LoadingOverlay modifier shows a spinner over empty/stale section content during background reloads. - enrichedShellEnv (zsh -l -i probe, up to 8s) is now warmed at app launch off-main so first MainActor caller doesn't block. - Drop the file watcher's 5s heartbeat — FSEvents covers real changes and the heartbeat was triggering wasted reloads across every subscribing view. Chat polish: - ChatViewModel.hermesBinaryExists is a stored bool probed once at init, not a sync transport call evaluated on every body re-render. - MessageGroupView identifies assistant bubbles by array offset rather than message.id, so the streaming → finalized id transition no longer destroys + recreates the bubble. - Static scroll anchor in RichChatMessageList prevents two onChange handlers from racing on isWorking flips. Branch state: feature complete, in active dogfooding. Plan + per-phase status live at ~/.claude/plans/we-developed-an-application-harmonic-stroustrup.md; the four hard-won transport/concurrency rules are saved in the ServerContext-pattern feedback memory for future sessions. Co-Authored-By: Claude Opus 4.7 (1M context) --- scarf/scarf/ContentView.swift | 76 ++- scarf/scarf/Core/Models/HermesConstants.swift | 96 ++-- scarf/scarf/Core/Models/HermesPathSet.swift | 92 ++++ scarf/scarf/Core/Models/ServerContext.swift | 189 +++++++ .../Core/Persistence/ServerRegistry.swift | 138 +++++ scarf/scarf/Core/Services/ACPClient.swift | 47 +- .../Core/Services/HermesDataService.swift | 65 ++- .../Core/Services/HermesEnvService.swift | 43 +- .../Core/Services/HermesFileService.swift | 241 +++++---- .../Core/Services/HermesFileWatcher.swift | 79 ++- .../Core/Services/HermesLogService.swift | 82 ++- .../Core/Services/ModelCatalogService.swift | 12 +- .../Services/ProjectDashboardService.swift | 31 +- .../scarf/Core/Transport/LocalTransport.swift | 188 +++++++ scarf/scarf/Core/Transport/SSHTransport.swift | 480 ++++++++++++++++++ .../Core/Transport/ServerTransport.swift | 102 ++++ .../Core/Transport/TransportErrors.swift | 86 ++++ .../ViewModels/ActivityViewModel.swift | 9 +- .../Activity/Views/ActivityView.swift | 7 +- .../Chat/ViewModels/ChatViewModel.swift | 90 +++- .../Chat/ViewModels/RichChatViewModel.swift | 9 +- .../scarf/Features/Chat/Views/ChatView.swift | 4 +- .../Chat/Views/RichChatMessageList.swift | 60 ++- .../Features/Chat/Views/RichChatView.swift | 23 +- .../Features/Common/LoadingOverlay.swift | 74 +++ .../ViewModels/CredentialPoolsViewModel.swift | 66 +-- .../ViewModels/OAuthFlowController.swift | 24 +- .../Views/CredentialPoolsView.swift | 14 +- .../Cron/ViewModels/CronViewModel.swift | 39 +- .../scarf/Features/Cron/Views/CronView.swift | 8 +- .../ViewModels/DashboardViewModel.swift | 26 +- .../Dashboard/Views/DashboardView.swift | 12 +- .../Gateway/ViewModels/GatewayViewModel.swift | 144 +++--- .../Features/Gateway/Views/GatewayView.swift | 7 +- .../Health/ViewModels/HealthViewModel.swift | 165 +++++- .../Features/Health/Views/HealthView.swift | 12 +- .../ViewModels/InsightsViewModel.swift | 9 +- .../Insights/Views/InsightsView.swift | 7 +- .../Logs/ViewModels/LogsViewModel.swift | 24 +- .../scarf/Features/Logs/Views/LogsView.swift | 7 +- .../ViewModels/MCPServerEditorViewModel.swift | 9 +- .../ViewModels/MCPServersViewModel.swift | 26 +- .../MCPServers/Views/MCPServersView.swift | 12 +- .../Memory/ViewModels/MemoryViewModel.swift | 73 ++- .../Features/Memory/Views/MemoryView.swift | 12 +- .../ViewModels/PersonalitiesViewModel.swift | 76 ++- .../Views/PersonalitiesView.swift | 7 +- .../PlatformSetup/DiscordSetupViewModel.swift | 9 +- .../PlatformSetup/EmailSetupViewModel.swift | 12 +- .../PlatformSetup/FeishuSetupViewModel.swift | 7 +- .../HomeAssistantSetupViewModel.swift | 14 +- .../IMessageSetupViewModel.swift | 7 +- .../PlatformSetup/MatrixSetupViewModel.swift | 9 +- .../MattermostSetupViewModel.swift | 9 +- .../PlatformSetup/PlatformSetupHelpers.swift | 21 +- .../PlatformSetup/SignalSetupViewModel.swift | 7 +- .../PlatformSetup/SlackSetupViewModel.swift | 9 +- .../TelegramSetupViewModel.swift | 9 +- .../PlatformSetup/WebhookSetupViewModel.swift | 7 +- .../WhatsAppSetupViewModel.swift | 14 +- .../ViewModels/PlatformsViewModel.swift | 13 +- .../PlatformSetup/DiscordSetupView.swift | 4 +- .../Views/PlatformSetup/EmailSetupView.swift | 4 +- .../Views/PlatformSetup/FeishuSetupView.swift | 4 +- .../HomeAssistantSetupView.swift | 4 +- .../PlatformSetup/IMessageSetupView.swift | 4 +- .../Views/PlatformSetup/MatrixSetupView.swift | 4 +- .../PlatformSetup/MattermostSetupView.swift | 4 +- .../Views/PlatformSetup/SignalSetupView.swift | 4 +- .../Views/PlatformSetup/SlackSetupView.swift | 4 +- .../PlatformSetup/TelegramSetupView.swift | 4 +- .../PlatformSetup/WebhookSetupView.swift | 4 +- .../PlatformSetup/WhatsAppSetupView.swift | 4 +- .../Platforms/Views/PlatformsView.swift | 35 +- .../Plugins/ViewModels/PluginsViewModel.swift | 76 +-- .../Features/Plugins/Views/PluginsView.swift | 12 +- .../ViewModels/ProfilesViewModel.swift | 9 +- .../Profiles/Views/ProfilesView.swift | 7 +- .../ViewModels/ProjectsViewModel.swift | 9 +- .../Projects/Views/ProjectsView.swift | 12 +- .../ViewModels/QuickCommandsViewModel.swift | 64 ++- .../Views/QuickCommandsView.swift | 7 +- .../ViewModels/AddServerViewModel.swift | 110 ++++ .../ConnectionStatusViewModel.swift | 127 +++++ .../ViewModels/TestConnectionProbe.swift | 179 +++++++ .../Servers/Views/AddServerSheet.swift | 192 +++++++ .../Servers/Views/ConnectionStatusPill.swift | 229 +++++++++ .../Servers/Views/ManageServersView.swift | 112 ++++ .../Servers/Views/MissingServerView.swift | 57 +++ .../Servers/Views/ServerSwitcherToolbar.swift | 63 +++ .../ViewModels/SessionsViewModel.swift | 37 +- .../Sessions/Views/SessionsView.swift | 7 +- .../ViewModels/SettingsViewModel.swift | 66 ++- .../Views/Components/ModelPickerSheet.swift | 3 +- .../Settings/Views/SettingsView.swift | 12 +- .../Settings/Views/Tabs/AdvancedTab.swift | 19 +- .../Skills/ViewModels/SkillsViewModel.swift | 23 +- .../Features/Skills/Views/SkillsView.swift | 7 +- .../Tools/ViewModels/ToolsViewModel.swift | 38 +- .../Features/Tools/Views/ToolsView.swift | 7 +- .../ViewModels/WebhooksViewModel.swift | 9 +- .../Webhooks/Views/WebhooksView.swift | 14 +- scarf/scarf/scarfApp.swift | 353 ++++++++++--- 103 files changed, 4369 insertions(+), 823 deletions(-) create mode 100644 scarf/scarf/Core/Models/HermesPathSet.swift create mode 100644 scarf/scarf/Core/Models/ServerContext.swift create mode 100644 scarf/scarf/Core/Persistence/ServerRegistry.swift create mode 100644 scarf/scarf/Core/Transport/LocalTransport.swift create mode 100644 scarf/scarf/Core/Transport/SSHTransport.swift create mode 100644 scarf/scarf/Core/Transport/ServerTransport.swift create mode 100644 scarf/scarf/Core/Transport/TransportErrors.swift create mode 100644 scarf/scarf/Features/Common/LoadingOverlay.swift create mode 100644 scarf/scarf/Features/Servers/ViewModels/AddServerViewModel.swift create mode 100644 scarf/scarf/Features/Servers/ViewModels/ConnectionStatusViewModel.swift create mode 100644 scarf/scarf/Features/Servers/ViewModels/TestConnectionProbe.swift create mode 100644 scarf/scarf/Features/Servers/Views/AddServerSheet.swift create mode 100644 scarf/scarf/Features/Servers/Views/ConnectionStatusPill.swift create mode 100644 scarf/scarf/Features/Servers/Views/ManageServersView.swift create mode 100644 scarf/scarf/Features/Servers/Views/MissingServerView.swift create mode 100644 scarf/scarf/Features/Servers/Views/ServerSwitcherToolbar.swift diff --git a/scarf/scarf/ContentView.swift b/scarf/scarf/ContentView.swift index 18a2e64..8a44b76 100644 --- a/scarf/scarf/ContentView.swift +++ b/scarf/scarf/ContentView.swift @@ -2,40 +2,74 @@ import SwiftUI struct ContentView: View { @Environment(AppCoordinator.self) private var coordinator + @Environment(\.serverContext) private var serverContext + /// Per-window connection status. Constructed from the window's + /// `serverContext` once; lifetime matches the window. + @State private var connectionStatus: ConnectionStatusViewModel + + init() { + _connectionStatus = State(initialValue: ConnectionStatusViewModel(context: .local)) + } var body: some View { NavigationSplitView { SidebarView() } detail: { detailView + .toolbar { + ToolbarItem(placement: .navigation) { + ServerSwitcherToolbar() + } + if serverContext.isRemote { + ToolbarItem(placement: .principal) { + ConnectionStatusPill(status: connectionStatus) + } + } + } + .onAppear { + // The actual context is injected via @Environment, which + // isn't available in `init`. Rebuild the monitor here + // the first time we know the real context. Safe to call + // repeatedly; `startMonitoring()` cancels + restarts. + if connectionStatus.context.id != serverContext.id { + connectionStatus = ConnectionStatusViewModel(context: serverContext) + } + connectionStatus.startMonitoring() + } + .onDisappear { connectionStatus.stopMonitoring() } } } @ViewBuilder private var detailView: some View { + // Each routed view receives the window's `serverContext` in its + // init so its `@State` ViewModel is constructed bound to the right + // server. This is what makes multi-window work — without it, + // every window's VMs default-construct with `.local` even though + // the surrounding env has the right context. switch coordinator.selectedSection { - case .dashboard: DashboardView() - case .insights: InsightsView() - case .sessions: SessionsView() - case .activity: ActivityView() - case .projects: ProjectsView() + case .dashboard: DashboardView(context: serverContext) + case .insights: InsightsView(context: serverContext) + case .sessions: SessionsView(context: serverContext) + case .activity: ActivityView(context: serverContext) + case .projects: ProjectsView(context: serverContext) case .chat: ChatView() - case .memory: MemoryView() - case .skills: SkillsView() - case .platforms: PlatformsView() - case .personalities: PersonalitiesView() - case .quickCommands: QuickCommandsView() - case .credentialPools: CredentialPoolsView() - case .plugins: PluginsView() - case .webhooks: WebhooksView() - case .profiles: ProfilesView() - case .tools: ToolsView() - case .mcpServers: MCPServersView() - case .gateway: GatewayView() - case .cron: CronView() - case .health: HealthView() - case .logs: LogsView() - case .settings: SettingsView() + case .memory: MemoryView(context: serverContext) + case .skills: SkillsView(context: serverContext) + case .platforms: PlatformsView(context: serverContext) + case .personalities: PersonalitiesView(context: serverContext) + case .quickCommands: QuickCommandsView(context: serverContext) + case .credentialPools: CredentialPoolsView(context: serverContext) + case .plugins: PluginsView(context: serverContext) + case .webhooks: WebhooksView(context: serverContext) + case .profiles: ProfilesView(context: serverContext) + case .tools: ToolsView(context: serverContext) + case .mcpServers: MCPServersView(context: serverContext) + case .gateway: GatewayView(context: serverContext) + case .cron: CronView(context: serverContext) + case .health: HealthView(context: serverContext) + case .logs: LogsView(context: serverContext) + case .settings: SettingsView(context: serverContext) } } } diff --git a/scarf/scarf/Core/Models/HermesConstants.swift b/scarf/scarf/Core/Models/HermesConstants.swift index f304dfd..4baf015 100644 --- a/scarf/scarf/Core/Models/HermesConstants.swift +++ b/scarf/scarf/Core/Models/HermesConstants.swift @@ -1,48 +1,70 @@ import Foundation import SQLite3 +/// Deprecated module-level path statics. Preserved as thin forwarders to +/// `ServerContext.local.paths` so existing call sites continue to compile +/// while Phase 1 migrates them to a per-server `ServerContext`. +/// +/// New code should accept a `ServerContext` and read `context.paths.`. enum HermesPaths: Sendable { - private nonisolated static let userHome: String = ProcessInfo.processInfo.environment["HOME"] - ?? NSHomeDirectory() + @available(*, deprecated, message: "use ServerContext.paths.home") + nonisolated static var home: String { ServerContext.local.paths.home } - nonisolated static let home: String = userHome + "/.hermes" - nonisolated static let stateDB: String = home + "/state.db" - nonisolated static let configYAML: String = home + "/config.yaml" - nonisolated static let memoriesDir: String = home + "/memories" - nonisolated static let memoryMD: String = memoriesDir + "/MEMORY.md" - nonisolated static let userMD: String = memoriesDir + "/USER.md" - nonisolated static let sessionsDir: String = home + "/sessions" - nonisolated static let cronJobsJSON: String = home + "/cron/jobs.json" - nonisolated static let cronOutputDir: String = home + "/cron/output" - nonisolated static let gatewayStateJSON: String = home + "/gateway_state.json" - nonisolated static let skillsDir: String = home + "/skills" - nonisolated static let errorsLog: String = home + "/logs/errors.log" - nonisolated static let agentLog: String = home + "/logs/agent.log" - nonisolated static let gatewayLog: String = home + "/logs/gateway.log" - nonisolated static let scarfDir: String = home + "/scarf" - nonisolated static let projectsRegistry: String = scarfDir + "/projects.json" - nonisolated static let mcpTokensDir: String = home + "/mcp-tokens" + @available(*, deprecated, message: "use ServerContext.paths.stateDB") + nonisolated static var stateDB: String { ServerContext.local.paths.stateDB } - /// Install locations we look for the `hermes` binary in, in priority order. - /// Checked every access so a user installing via a different method doesn't - /// need to relaunch Scarf. - nonisolated static let hermesBinaryCandidates: [String] = [ - userHome + "/.local/bin/hermes", // pipx / pip --user (default) - "/opt/homebrew/bin/hermes", // Homebrew on Apple Silicon - "/usr/local/bin/hermes", // Homebrew on Intel / manual install - userHome + "/.hermes/bin/hermes" // Some self-install layouts - ] + @available(*, deprecated, message: "use ServerContext.paths.configYAML") + nonisolated static var configYAML: String { ServerContext.local.paths.configYAML } - /// Resolved path to the `hermes` executable. Returns the first candidate - /// that exists and is executable; falls back to the pipx default so error - /// messages ("Expected at …") still make sense on a fresh machine. - nonisolated static var hermesBinary: String { - for path in hermesBinaryCandidates - where FileManager.default.isExecutableFile(atPath: path) { - return path - } - return hermesBinaryCandidates[0] + @available(*, deprecated, message: "use ServerContext.paths.memoriesDir") + nonisolated static var memoriesDir: String { ServerContext.local.paths.memoriesDir } + + @available(*, deprecated, message: "use ServerContext.paths.memoryMD") + nonisolated static var memoryMD: String { ServerContext.local.paths.memoryMD } + + @available(*, deprecated, message: "use ServerContext.paths.userMD") + nonisolated static var userMD: String { ServerContext.local.paths.userMD } + + @available(*, deprecated, message: "use ServerContext.paths.sessionsDir") + nonisolated static var sessionsDir: String { ServerContext.local.paths.sessionsDir } + + @available(*, deprecated, message: "use ServerContext.paths.cronJobsJSON") + nonisolated static var cronJobsJSON: String { ServerContext.local.paths.cronJobsJSON } + + @available(*, deprecated, message: "use ServerContext.paths.cronOutputDir") + nonisolated static var cronOutputDir: String { ServerContext.local.paths.cronOutputDir } + + @available(*, deprecated, message: "use ServerContext.paths.gatewayStateJSON") + nonisolated static var gatewayStateJSON: String { ServerContext.local.paths.gatewayStateJSON } + + @available(*, deprecated, message: "use ServerContext.paths.skillsDir") + nonisolated static var skillsDir: String { ServerContext.local.paths.skillsDir } + + @available(*, deprecated, message: "use ServerContext.paths.errorsLog") + nonisolated static var errorsLog: String { ServerContext.local.paths.errorsLog } + + @available(*, deprecated, message: "use ServerContext.paths.agentLog") + nonisolated static var agentLog: String { ServerContext.local.paths.agentLog } + + @available(*, deprecated, message: "use ServerContext.paths.gatewayLog") + nonisolated static var gatewayLog: String { ServerContext.local.paths.gatewayLog } + + @available(*, deprecated, message: "use ServerContext.paths.scarfDir") + nonisolated static var scarfDir: String { ServerContext.local.paths.scarfDir } + + @available(*, deprecated, message: "use ServerContext.paths.projectsRegistry") + nonisolated static var projectsRegistry: String { ServerContext.local.paths.projectsRegistry } + + @available(*, deprecated, message: "use ServerContext.paths.mcpTokensDir") + nonisolated static var mcpTokensDir: String { ServerContext.local.paths.mcpTokensDir } + + @available(*, deprecated, message: "use HermesPathSet.hermesBinaryCandidates") + nonisolated static var hermesBinaryCandidates: [String] { + HermesPathSet.hermesBinaryCandidates } + + @available(*, deprecated, message: "use ServerContext.paths.hermesBinary") + nonisolated static var hermesBinary: String { ServerContext.local.paths.hermesBinary } } // MARK: - SQLite Constants diff --git a/scarf/scarf/Core/Models/HermesPathSet.swift b/scarf/scarf/Core/Models/HermesPathSet.swift new file mode 100644 index 0000000..a2ee814 --- /dev/null +++ b/scarf/scarf/Core/Models/HermesPathSet.swift @@ -0,0 +1,92 @@ +import Foundation + +/// The filesystem layout of a Hermes installation, parameterized by the +/// `home` directory. The same layout is used for local installations (where +/// `home` is an absolute macOS path like `/Users/alan/.hermes`) and for +/// remote installations reached over SSH (where `home` is a remote path like +/// `/home/deploy/.hermes` or an unexpanded `~/.hermes` that the remote shell +/// will resolve). +/// +/// Every path that used to live as a module-level static on `HermesPaths` is +/// an instance property here. `ServerContext.paths` is the canonical way to +/// reach these values; the old `HermesPaths` statics are preserved as +/// deprecated forwarders so Phase 1 can migrate call sites incrementally. +struct HermesPathSet: Sendable, Hashable { + let home: String + /// `true` when this path set belongs to a remote installation. Affects + /// only `hermesBinary` resolution — every other path is identical in + /// shape between local and remote. + let isRemote: Bool + /// Pre-resolved remote binary path (e.g. `/home/deploy/.local/bin/hermes`). + /// Populated by `SSHTransport` once `command -v hermes` has run on the + /// target host. Unused when `isRemote == false`. + let binaryHint: String? + + // MARK: - Defaults + + /// Absolute path to the local user's `~/.hermes` directory. + static let defaultLocalHome: String = { + let user = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory() + return user + "/.hermes" + }() + + /// Default remote home when the user doesn't override it in `SSHConfig`. + /// We leave `~` unexpanded on purpose — the remote shell resolves it. + static let defaultRemoteHome: String = "~/.hermes" + + // MARK: - Paths (mirror of the old HermesPaths layout) + + var stateDB: String { home + "/state.db" } + var configYAML: String { home + "/config.yaml" } + var envFile: String { home + "/.env" } + var authJSON: String { home + "/auth.json" } + var soulMD: String { home + "/SOUL.md" } + var pluginsDir: String { home + "/plugins" } + var memoriesDir: String { home + "/memories" } + var memoryMD: String { memoriesDir + "/MEMORY.md" } + var userMD: String { memoriesDir + "/USER.md" } + var sessionsDir: String { home + "/sessions" } + var cronJobsJSON: String { home + "/cron/jobs.json" } + var cronOutputDir: String { home + "/cron/output" } + var gatewayStateJSON: String { home + "/gateway_state.json" } + var skillsDir: String { home + "/skills" } + var errorsLog: String { home + "/logs/errors.log" } + var agentLog: String { home + "/logs/agent.log" } + var gatewayLog: String { home + "/logs/gateway.log" } + var scarfDir: String { home + "/scarf" } + var projectsRegistry: String { scarfDir + "/projects.json" } + var mcpTokensDir: String { home + "/mcp-tokens" } + + // MARK: - Binary resolution + + /// Install locations we probe for the local `hermes` binary, in priority + /// order. Checked on every access so a user installing via a different + /// method doesn't need to relaunch Scarf. + static let hermesBinaryCandidates: [String] = { + let user = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory() + return [ + user + "/.local/bin/hermes", // pipx / pip --user (default) + "/opt/homebrew/bin/hermes", // Homebrew on Apple Silicon + "/usr/local/bin/hermes", // Homebrew on Intel / manual install + user + "/.hermes/bin/hermes" // Some self-install layouts + ] + }() + + /// Resolved path to the `hermes` executable for this installation. + /// + /// Local: returns the first executable candidate, falling back to the + /// pipx default so error messages still make sense on a fresh machine. + /// + /// Remote: returns `binaryHint` (populated at connect time) or bare + /// `"hermes"` as a last-resort default that relies on the remote `$PATH`. + var hermesBinary: String { + if isRemote { + return binaryHint ?? "hermes" + } + for path in Self.hermesBinaryCandidates + where FileManager.default.isExecutableFile(atPath: path) { + return path + } + return Self.hermesBinaryCandidates[0] + } +} diff --git a/scarf/scarf/Core/Models/ServerContext.swift b/scarf/scarf/Core/Models/ServerContext.swift new file mode 100644 index 0000000..9cd0611 --- /dev/null +++ b/scarf/scarf/Core/Models/ServerContext.swift @@ -0,0 +1,189 @@ +import Foundation +import SwiftUI +import AppKit + +/// Stable identifier for a server entry in the user's registry. Backed by +/// `UUID` so it round-trips through `servers.json` and SwiftUI window-state +/// restoration without collisions. +typealias ServerID = UUID + +/// Connection parameters for a remote Hermes installation reached over SSH. +/// All fields are optional except `host` — unset values defer to the user's +/// `~/.ssh/config` and the OpenSSH defaults. +struct SSHConfig: Sendable, Hashable, Codable { + /// Hostname or `~/.ssh/config` alias. + var host: String + /// Remote username. `nil` → defer to `~/.ssh/config` or the local user. + var user: String? + /// TCP port. `nil` → 22 (or whatever `~/.ssh/config` says). + var port: Int? + /// Absolute path to a private key. `nil` → defer to ssh-agent / + /// `~/.ssh/config` identity files. + var identityFile: String? + /// Override for the remote `$HOME/.hermes` directory. `nil` uses + /// `HermesPathSet.defaultRemoteHome` (`~/.hermes`, shell-expanded on the + /// remote side). + var remoteHome: String? + /// Resolved remote path to the `hermes` binary. Populated by + /// `SSHTransport` after the first `command -v hermes` probe; cached here + /// so subsequent calls skip the round trip. + var hermesBinaryHint: String? +} + +/// Distinguishes a local installation (the user's own `~/.hermes`) from a +/// remote one reached over SSH. Service behavior is identical in shape but +/// dispatches to different I/O primitives in Phase 2. +enum ServerKind: Sendable, Hashable, Codable { + case local + case ssh(SSHConfig) +} + +/// The per-server value that flows through `.environment` and gets handed to +/// every service and ViewModel in Phase 1. One `ServerContext` corresponds to +/// one Hermes installation; multi-window scenes in Phase 3 will construct +/// one per window. +struct ServerContext: Sendable, Hashable, Identifiable { + let id: ServerID + var displayName: String + var kind: ServerKind + + /// Path layout for this server. Cheap — all path components are computed + /// on demand from `home`, no I/O. + var paths: HermesPathSet { + switch kind { + case .local: + return HermesPathSet( + home: HermesPathSet.defaultLocalHome, + isRemote: false, + binaryHint: nil + ) + case .ssh(let config): + return HermesPathSet( + home: config.remoteHome ?? HermesPathSet.defaultRemoteHome, + isRemote: true, + binaryHint: config.hermesBinaryHint + ) + } + } + + var isRemote: Bool { + if case .ssh = kind { return true } + return false + } + + /// Construct the `ServerTransport` for this context. Local contexts get + /// a `LocalTransport`; SSH contexts get an `SSHTransport` configured + /// from `SSHConfig`. Each call returns a fresh value — transports are + /// cheap and stateless beyond disk caches. + func makeTransport() -> any ServerTransport { + switch kind { + case .local: + return LocalTransport(contextID: id) + case .ssh(let config): + return SSHTransport(contextID: id, config: config, displayName: displayName) + } + } + + // MARK: - Well-known singletons + + /// Stable UUID for the built-in "this machine" entry. Hard-coded so the + /// local context has the same identity across launches, and so persisted + /// window-state restorations that reference it continue to resolve even + /// if `servers.json` hasn't been touched yet. + private static let localID = ServerID(uuidString: "00000000-0000-0000-0000-000000000001")! + + /// The default "this machine" context. Used everywhere in Phase 0/1 and + /// remains the fallback when no remote server is selected. + static let local = ServerContext( + id: localID, + displayName: "Local", + kind: .local + ) +} + +// MARK: - Convenience file I/O via the right transport + +/// Centralized file I/O entry points for VMs that don't own a service. Every +/// call goes through the context's transport, so reads/writes hit the local +/// disk for `.local` and ssh/scp for `.ssh` automatically. +/// +/// **Always** prefer `context.readText(...)` over `String(contentsOfFile: ...)` +/// when the path comes from `context.paths`. The Foundation file APIs are +/// LOCAL ONLY — using them with a remote path silently returns nil because +/// the remote path doesn't exist on this Mac. +extension ServerContext { + /// Read a UTF-8 text file. `nil` on any error (missing, transport down, + /// invalid encoding). + func readText(_ path: String) -> String? { + guard let data = try? makeTransport().readFile(path) else { return nil } + return String(data: data, encoding: .utf8) + } + + /// Read raw bytes. `nil` on any error. + func readData(_ path: String) -> Data? { + try? makeTransport().readFile(path) + } + + /// Atomic write. Returns `true` on success, `false` on any error + /// (caller is expected to surface failures via UI when relevant). + @discardableResult + func writeText(_ path: String, content: String) -> Bool { + guard let data = content.data(using: .utf8) else { return false } + do { + try makeTransport().writeFile(path, data: data) + return true + } catch { + return false + } + } + + /// Existence check. Local: `FileManager`. Remote: `ssh test -e`. + func fileExists(_ path: String) -> Bool { + makeTransport().fileExists(path) + } + + /// File modification timestamp, or `nil` if the file doesn't exist. + func modificationDate(_ path: String) -> Date? { + makeTransport().stat(path)?.mtime + } + + /// Invoke the `hermes` CLI on this server and return its combined output + /// + exit code. Local: spawns the local binary via `Process`. Remote: + /// rounds through `ssh host hermes …`. Use this from any VM that needs + /// to fire off a CLI command — never spawn `hermes` via `Process()` + /// directly, because that path bypasses the transport for remote. + @discardableResult + func runHermes(_ args: [String], timeout: TimeInterval = 60, stdin: String? = nil) -> (output: String, exitCode: Int32) { + let result = HermesFileService(context: self).runHermesCLI(args: args, timeout: timeout, stdinInput: stdin) + return (result.output, result.exitCode) + } + + /// Reveal the file at `path` in the user's local editor (via + /// `NSWorkspace.open`). For remote contexts this is a no-op — the + /// file doesn't exist on this Mac, so opening it would fail silently + /// or worse, open the wrong file from the local filesystem. + /// Returns `true` if opened, `false` if the call was skipped. + @discardableResult + func openInLocalEditor(_ path: String) -> Bool { + guard !isRemote else { return false } + NSWorkspace.shared.open(URL(fileURLWithPath: path)) + return true + } +} + +// MARK: - SwiftUI environment plumbing + +/// `ServerContext` is a value type, so SwiftUI's `.environment(_:)` (which +/// requires an `@Observable` class) doesn't accept it directly. We expose it +/// through a custom `EnvironmentKey` — views read it with +/// `@Environment(\.serverContext) private var serverContext`. +private struct ServerContextEnvironmentKey: EnvironmentKey { + static let defaultValue: ServerContext = .local +} + +extension EnvironmentValues { + var serverContext: ServerContext { + get { self[ServerContextEnvironmentKey.self] } + set { self[ServerContextEnvironmentKey.self] = newValue } + } +} diff --git a/scarf/scarf/Core/Persistence/ServerRegistry.swift b/scarf/scarf/Core/Persistence/ServerRegistry.swift new file mode 100644 index 0000000..2792615 --- /dev/null +++ b/scarf/scarf/Core/Persistence/ServerRegistry.swift @@ -0,0 +1,138 @@ +import Foundation +import os + +/// Persisted entry for a user-added server. `ServerContext` itself is a value +/// type we rebuild from these fields at runtime — we persist the minimum that +/// uniquely identifies a connection, not the whole context struct, so future +/// fields we add to `ServerContext` don't force a migration. +struct ServerEntry: Identifiable, Codable, Hashable, Sendable { + var id: ServerID + var displayName: String + var kind: ServerKind + /// User preference: open this server in a window on launch. Phase 3 + /// multi-window uses this; Phase 2 ignores it. + var openOnLaunch: Bool = false + + var context: ServerContext { + ServerContext(id: id, displayName: displayName, kind: kind) + } +} + +/// On-disk envelope for `servers.json`. Schema-versioned so future changes +/// can migrate without losing data. +private struct RegistryFile: Codable { + var schemaVersion: Int + var entries: [ServerEntry] +} + +/// App-scoped store for user-added servers. `local` is synthesized (not +/// persisted) and always appears first in `allContexts`. Remote entries are +/// loaded from `~/Library/Application Support/scarf/servers.json`. +/// +/// Observable so SwiftUI views binding to `entries` redraw when a server is +/// added, renamed, or removed. +@Observable +@MainActor +final class ServerRegistry { + private static let logger = Logger(subsystem: "com.scarf", category: "ServerRegistry") + private static let currentSchemaVersion = 1 + + /// Remote (user-added) entries. Observable: views redraw on mutation. + private(set) var entries: [ServerEntry] = [] + + private let storeURL: URL + + init() { + let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSHomeDirectory() + "/Library/Application Support") + let dir = support.appendingPathComponent("scarf", isDirectory: true) + self.storeURL = dir.appendingPathComponent("servers.json") + load() + } + + // MARK: - Lookup + + /// The implicit local server plus every persisted remote entry, in list + /// order. Use this when populating UI like the toolbar switcher. + var allContexts: [ServerContext] { + [.local] + entries.map { $0.context } + } + + /// Resolve an ID to a context, or `nil` if the entry no longer exists. + /// Used by the multi-window root to detect "this window points at a + /// server you've since removed" and show a dedicated empty state. + func context(for id: ServerID) -> ServerContext? { + if id == ServerContext.local.id { return .local } + if let entry = entries.first(where: { $0.id == id }) { + return entry.context + } + return nil + } + + // MARK: - Mutations + + /// Optional callback fired whenever `entries` changes. The app wires + /// this to `ServerLiveStatusRegistry.rebuild()` so the menu-bar fanout + /// stays in sync without polling the entries array. + var onEntriesChanged: (() -> Void)? + + @discardableResult + func addServer(displayName: String, config: SSHConfig) -> ServerEntry { + let entry = ServerEntry( + id: ServerID(), + displayName: displayName, + kind: .ssh(config) + ) + entries.append(entry) + save() + onEntriesChanged?() + return entry + } + + func updateServer(_ id: ServerID, displayName: String?, config: SSHConfig?) { + guard let idx = entries.firstIndex(where: { $0.id == id }) else { return } + if let name = displayName { entries[idx].displayName = name } + if let cfg = config { entries[idx].kind = .ssh(cfg) } + save() + onEntriesChanged?() + } + + func removeServer(_ id: ServerID) { + entries.removeAll { $0.id == id } + save() + onEntriesChanged?() + } + + // MARK: - Persistence + + private func load() { + guard FileManager.default.fileExists(atPath: storeURL.path) else { + entries = [] + return + } + do { + let data = try Data(contentsOf: storeURL) + let file = try JSONDecoder().decode(RegistryFile.self, from: data) + entries = file.entries + } catch { + Self.logger.error("Failed to load servers.json: \(error.localizedDescription)") + entries = [] + } + } + + private func save() { + do { + try FileManager.default.createDirectory( + at: storeURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let file = RegistryFile(schemaVersion: Self.currentSchemaVersion, entries: entries) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(file) + try data.write(to: storeURL, options: .atomic) + } catch { + Self.logger.error("Failed to save servers.json: \(error.localizedDescription)") + } + } +} diff --git a/scarf/scarf/Core/Services/ACPClient.swift b/scarf/scarf/Core/Services/ACPClient.swift index e510683..efca77d 100644 --- a/scarf/scarf/Core/Services/ACPClient.swift +++ b/scarf/scarf/Core/Services/ACPClient.swift @@ -24,6 +24,14 @@ actor ACPClient { private(set) var currentSessionId: String? private(set) var statusMessage = "" + let context: ServerContext + private let transport: any ServerTransport + + init(context: ServerContext = .local) { + self.context = context + self.transport = context.makeTransport() + } + /// Ring buffer of recent stderr lines from `hermes acp` — used to attach /// a diagnostic tail to user-visible errors. Capped to avoid unbounded /// growth when the subprocess logs heavily. @@ -75,9 +83,15 @@ actor ACPClient { self._eventStream = stream self.eventContinuation = continuation - let proc = Process() - proc.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary) - proc.arguments = ["acp"] + // For local: Process is `hermes acp` directly. + // For remote: the transport returns a Process configured as + // `/usr/bin/ssh -T host -- acp`. ACP's JSON-RPC + // over stdio works identically because `-T` keeps the ssh channel + // byte-clean and stdin/stdout travel end-to-end unmodified. + let proc = transport.makeProcess( + executable: context.paths.hermesBinary, + args: ["acp"] + ) let stdin = Pipe() let stdout = Pipe() @@ -88,11 +102,28 @@ actor ACPClient { proc.standardError = stderr // ACP uses JSON-RPC over pipes — do NOT set TERM to avoid terminal escape pollution. - // Use the enriched environment so any tools hermes spawns (MCP servers, - // shell commands) can find brew/nvm/asdf binaries on PATH. - var env = HermesFileService.enrichedEnvironment() - env.removeValue(forKey: "TERM") - proc.environment = env + if context.isRemote { + // Remote: this is the LOCAL ssh process spawning `ssh host … + // hermes acp`. We don't forward our local PATH/credentials to + // the remote (hermes runs under the remote user's login env), + // but the ssh binary itself needs SSH_AUTH_SOCK to reach the + // local ssh-agent for key-based auth. + var env = ProcessInfo.processInfo.environment + let shellEnv = HermesFileService.enrichedEnvironment() + for key in ["SSH_AUTH_SOCK", "SSH_AGENT_PID"] { + if env[key] == nil, let v = shellEnv[key], !v.isEmpty { + env[key] = v + } + } + env.removeValue(forKey: "TERM") + proc.environment = env + } else { + // Local: enriched env so any tools hermes spawns (MCP servers, + // shell commands) can find brew/nvm/asdf binaries on PATH. + var env = HermesFileService.enrichedEnvironment() + env.removeValue(forKey: "TERM") + proc.environment = env + } proc.terminationHandler = { [weak self] proc in Task { await self?.handleTermination(exitCode: proc.terminationStatus) } diff --git a/scarf/scarf/Core/Services/HermesDataService.swift b/scarf/scarf/Core/Services/HermesDataService.swift index 11f87f1..bff3026 100644 --- a/scarf/scarf/Core/Services/HermesDataService.swift +++ b/scarf/scarf/Core/Services/HermesDataService.swift @@ -4,22 +4,68 @@ import SQLite3 actor HermesDataService { private var db: OpaquePointer? private var hasV07Schema = false + /// Local filesystem path we last opened. For remote contexts this is + /// the cached snapshot under `~/Library/Caches/scarf/snapshots//`. + private var openedAtPath: String? + + let context: ServerContext + private let transport: any ServerTransport + + init(context: ServerContext = .local) { + self.context = context + self.transport = context.makeTransport() + } func open() -> Bool { if db != nil { return true } - let path = HermesPaths.stateDB - guard FileManager.default.fileExists(atPath: path) else { return false } - let flags = SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX - let result = sqlite3_open_v2(path, &db, flags, nil) + let localPath: String + if context.isRemote { + // Pull a fresh snapshot from the remote host. Uses `sqlite3 + // .backup` on the remote, which is WAL-safe; a plain cp would + // corrupt. + guard let snapshotURL = try? transport.snapshotSQLite(remotePath: context.paths.stateDB) else { + return false + } + localPath = snapshotURL.path + } else { + localPath = context.paths.stateDB + guard FileManager.default.fileExists(atPath: localPath) else { return false } + } + // Remote snapshots are point-in-time copies that no one writes to; + // opening them with `immutable=1` tells SQLite to skip WAL/SHM and + // locking entirely, which is both faster and avoids spurious + // "unable to open database file" errors if the snapshot ever gets + // pulled mid-checkpoint. Local points at the live Hermes DB where + // the process already has WAL enabled in the header, so a plain + // readonly open is the right thing. + let flags: Int32 + let openPath: String + if context.isRemote { + openPath = "file:\(localPath)?immutable=1" + flags = SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX | SQLITE_OPEN_URI + } else { + openPath = localPath + flags = SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX + } + let result = sqlite3_open_v2(openPath, &db, flags, nil) guard result == SQLITE_OK else { db = nil return false } - sqlite3_exec(db, "PRAGMA journal_mode=WAL", nil, nil, nil) + openedAtPath = localPath detectSchema() return true } + /// Force a fresh snapshot pull + reopen. Used by the file watcher tick + /// and by remote-write code paths that need the UI to reflect changes + /// Hermes just made. Local contexts reopen in place since the on-disk + /// file is already authoritative. + func refresh() { + close() + _ = open() + } + func close() { if let db { sqlite3_close(db) @@ -431,11 +477,10 @@ actor HermesDataService { } func stateDBModificationDate() -> Date? { - let walPath = HermesPaths.stateDB + "-wal" - let dbPath = HermesPaths.stateDB - let fm = FileManager.default - let walDate = (try? fm.attributesOfItem(atPath: walPath))?[.modificationDate] as? Date - let dbDate = (try? fm.attributesOfItem(atPath: dbPath))?[.modificationDate] as? Date + // For remote contexts we stat the remote paths. For local it's the + // same FileManager lookup as before, just via the transport. + let walDate = transport.stat(context.paths.stateDB + "-wal")?.mtime + let dbDate = transport.stat(context.paths.stateDB)?.mtime if let w = walDate, let d = dbDate { return max(w, d) } diff --git a/scarf/scarf/Core/Services/HermesEnvService.swift b/scarf/scarf/Core/Services/HermesEnvService.swift index 408fa6d..3a54479 100644 --- a/scarf/scarf/Core/Services/HermesEnvService.swift +++ b/scarf/scarf/Core/Services/HermesEnvService.swift @@ -22,15 +22,24 @@ struct HermesEnvService: Sendable { /// Path to `~/.hermes/.env`. Kept configurable for tests. let path: String + let transport: any ServerTransport - init(path: String = HermesPaths.home + "/.env") { + init(context: ServerContext = .local) { + self.path = context.paths.envFile + self.transport = context.makeTransport() + } + + /// Escape hatch for tests that want to point at a fixture path directly. + init(path: String) { self.path = path + self.transport = LocalTransport() } /// Read the .env file into a `[key: value]` dict. Comments and commented-out /// assignments are ignored. Missing file returns an empty dict. func load() -> [String: String] { - guard let content = try? String(contentsOfFile: path, encoding: .utf8) else { + guard let data = try? transport.readFile(path), + let content = String(data: data, encoding: .utf8) else { return [:] } var result: [String: String] = [:] @@ -69,7 +78,8 @@ struct HermesEnvService: Sendable { var lines: [String] // Start from existing file contents, or a minimal header if creating new. - if let content = try? String(contentsOfFile: path, encoding: .utf8) { + if let data = try? transport.readFile(path), + let content = String(data: data, encoding: .utf8) { lines = content.components(separatedBy: "\n") // Trim a single trailing empty line from splitting the final newline; // we'll re-add it on write. @@ -105,7 +115,8 @@ struct HermesEnvService: Sendable { /// uncommenting. If the key doesn't exist, this is a no-op. @discardableResult func unset(_ key: String) -> Bool { - guard let content = try? String(contentsOfFile: path, encoding: .utf8) else { + guard let data = try? transport.readFile(path), + let content = String(data: data, encoding: .utf8) else { return true } var lines = content.components(separatedBy: "\n") @@ -125,28 +136,18 @@ struct HermesEnvService: Sendable { // MARK: - Internals - /// Writes the entire file in one shot via a tmp + rename to avoid corrupting - /// `.env` if the process is killed mid-write. Preserves `0600` permissions - /// since `.env` typically holds secrets. + /// Writes the entire file in one shot through the transport. For local + /// contexts this ends up doing the same atomic-rename dance as before + /// (via `LocalTransport.writeFile`). For remote contexts this goes + /// through `scp` + remote `mv`, still atomic from Hermes's point of + /// view. private func atomicWrite(_ content: String) -> Bool { - let tmp = path + ".tmp" + guard let data = content.data(using: .utf8) else { return false } do { - try content.write(toFile: tmp, atomically: false, encoding: .utf8) - // Mirror the typical `.env` mode of `0600` (owner read/write only). - try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: tmp) - // Swap into place. FileManager.replaceItem handles the replacement - // atomically on the same volume; fall back to a two-step rename. - let destURL = URL(fileURLWithPath: path) - let tmpURL = URL(fileURLWithPath: tmp) - if FileManager.default.fileExists(atPath: path) { - _ = try FileManager.default.replaceItemAt(destURL, withItemAt: tmpURL) - } else { - try FileManager.default.moveItem(at: tmpURL, to: destURL) - } + try transport.writeFile(path, data: data) return true } catch { logger.error("Failed to write .env: \(error.localizedDescription)") - try? FileManager.default.removeItem(atPath: tmp) return false } } diff --git a/scarf/scarf/Core/Services/HermesFileService.swift b/scarf/scarf/Core/Services/HermesFileService.swift index 099cd16..be80bb6 100644 --- a/scarf/scarf/Core/Services/HermesFileService.swift +++ b/scarf/scarf/Core/Services/HermesFileService.swift @@ -2,10 +2,18 @@ import Foundation struct HermesFileService: Sendable { + let context: ServerContext + let transport: any ServerTransport + + init(context: ServerContext = .local) { + self.context = context + self.transport = context.makeTransport() + } + // MARK: - Config func loadConfig() -> HermesConfig { - guard let content = readFile(HermesPaths.configYAML) else { return .empty } + guard let content = readFile(context.paths.configYAML) else { return .empty } return parseConfig(content) } @@ -373,7 +381,7 @@ struct HermesFileService: Sendable { // MARK: - Gateway State func loadGatewayState() -> GatewayState? { - guard let data = readFileData(HermesPaths.gatewayStateJSON) else { return nil } + guard let data = readFileData(context.paths.gatewayStateJSON) else { return nil } do { return try JSONDecoder().decode(GatewayState.self, from: data) } catch { @@ -385,12 +393,10 @@ struct HermesFileService: Sendable { // MARK: - Memory func loadMemoryProfiles() -> [String] { - let fm = FileManager.default - guard let entries = try? fm.contentsOfDirectory(atPath: HermesPaths.memoriesDir) else { return [] } + guard let entries = try? transport.listDirectory(context.paths.memoriesDir) else { return [] } return entries.filter { name in - var isDir: ObjCBool = false - let path = HermesPaths.memoriesDir + "/" + name - return fm.fileExists(atPath: path, isDirectory: &isDir) && isDir.boolValue + let path = context.paths.memoriesDir + "/" + name + return transport.stat(path)?.isDirectory == true }.sorted() } @@ -416,15 +422,15 @@ struct HermesFileService: Sendable { private func memoryPath(profile: String, file: String) -> String { if profile.isEmpty { - return HermesPaths.memoriesDir + "/" + file + return context.paths.memoriesDir + "/" + file } - return HermesPaths.memoriesDir + "/" + profile + "/" + file + return context.paths.memoriesDir + "/" + profile + "/" + file } // MARK: - Cron func loadCronJobs() -> [HermesCronJob] { - guard let data = readFileData(HermesPaths.cronJobsJSON) else { return [] } + guard let data = readFileData(context.paths.cronJobsJSON) else { return [] } do { let file = try JSONDecoder().decode(CronJobsFile.self, from: data) return file.jobs @@ -435,9 +441,8 @@ struct HermesFileService: Sendable { } func loadCronOutput(jobId: String) -> String? { - let dir = HermesPaths.cronOutputDir - let fm = FileManager.default - guard let files = try? fm.contentsOfDirectory(atPath: dir) else { return nil } + let dir = context.paths.cronOutputDir + guard let files = try? transport.listDirectory(dir) else { return nil } let matching = files.filter { $0.contains(jobId) }.sorted().last guard let filename = matching else { return nil } return readFile(dir + "/" + filename) @@ -446,21 +451,18 @@ struct HermesFileService: Sendable { // MARK: - Skills func loadSkills() -> [HermesSkillCategory] { - let dir = HermesPaths.skillsDir - let fm = FileManager.default - guard let categories = try? fm.contentsOfDirectory(atPath: dir) else { return [] } + let dir = context.paths.skillsDir + guard let categories = try? transport.listDirectory(dir) else { return [] } return categories.sorted().compactMap { categoryName in let categoryPath = dir + "/" + categoryName - var isDir: ObjCBool = false - guard fm.fileExists(atPath: categoryPath, isDirectory: &isDir), isDir.boolValue else { return nil } - guard let skillNames = try? fm.contentsOfDirectory(atPath: categoryPath) else { return nil } + guard transport.stat(categoryPath)?.isDirectory == true else { return nil } + guard let skillNames = try? transport.listDirectory(categoryPath) else { return nil } let skills = skillNames.sorted().compactMap { skillName -> HermesSkill? in let skillPath = categoryPath + "/" + skillName - var isSkillDir: ObjCBool = false - guard fm.fileExists(atPath: skillPath, isDirectory: &isSkillDir), isSkillDir.boolValue else { return nil } - let files = (try? fm.contentsOfDirectory(atPath: skillPath)) ?? [] + guard transport.stat(skillPath)?.isDirectory == true else { return nil } + let files = (try? transport.listDirectory(skillPath)) ?? [] let requiredConfig = parseSkillRequiredConfig(skillPath + "/skill.yaml") return HermesSkill( id: categoryName + "/" + skillName, @@ -488,7 +490,7 @@ struct HermesFileService: Sendable { } private func isValidSkillPath(_ path: String) -> Bool { - guard !path.contains(".."), path.hasPrefix(HermesPaths.skillsDir) else { + guard !path.contains(".."), path.hasPrefix(context.paths.skillsDir) else { print("[Scarf] Rejected skill path outside skills directory: \(path)") return false } @@ -522,12 +524,11 @@ struct HermesFileService: Sendable { // MARK: - MCP Servers func loadMCPServers() -> [HermesMCPServer] { - guard let yaml = readFile(HermesPaths.configYAML) else { return [] } + guard let yaml = readFile(context.paths.configYAML) else { return [] } let parsed = parseMCPServersBlock(yaml: yaml) - let fm = FileManager.default return parsed.map { server in - let tokenPath = HermesPaths.mcpTokensDir + "/" + server.name + ".json" - let hasToken = fm.fileExists(atPath: tokenPath) + let tokenPath = context.paths.mcpTokensDir + "/" + server.name + ".json" + let hasToken = transport.fileExists(tokenPath) guard hasToken != server.hasOAuthToken else { return server } return HermesMCPServer( name: server.name, @@ -674,9 +675,9 @@ struct HermesFileService: Sendable { @discardableResult func deleteMCPOAuthToken(name: String) -> Bool { - let path = HermesPaths.mcpTokensDir + "/" + name + ".json" + let path = context.paths.mcpTokensDir + "/" + name + ".json" do { - try FileManager.default.removeItem(atPath: path) + try transport.removeFile(path) return true } catch { return false @@ -876,7 +877,7 @@ struct HermesFileService: Sendable { // MARK: - MCP YAML: surgical patcher private func patchMCPServerField(name: String, mutate: (inout [String]) -> Void) -> Bool { - guard let yaml = readFile(HermesPaths.configYAML) else { return false } + guard let yaml = readFile(context.paths.configYAML) else { return false } let location = extractMCPBlock(yaml: yaml) guard !location.block.isEmpty else { return false } @@ -925,7 +926,7 @@ struct HermesFileService: Sendable { combined.append(contentsOf: block) combined.append(contentsOf: location.suffix) let newYAML = combined.joined(separator: "\n") - writeFile(HermesPaths.configYAML, content: newYAML) + writeFile(context.paths.configYAML, content: newYAML) return true } @@ -1172,23 +1173,22 @@ struct HermesFileService: Sendable { } func hermesPID() -> pid_t? { - let pipe = Pipe() - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep") - process.arguments = ["-f", "hermes"] - process.standardOutput = pipe - process.standardError = Pipe() - do { - try process.run() - process.waitUntilExit() - let data = pipe.fileHandleForReading.readDataToEndOfFile() - 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 { - return nil - } + // Run `pgrep -f hermes` either locally or via the transport. On + // remote hosts we trust `pgrep` to be present — it's standard on + // Linux and macOS. On failure we conservatively return nil rather + // than pretending Hermes is down: the caller will see + // isHermesRunning==false, which is already the "unknown" UX. + let result = try? transport.runProcess( + executable: "/usr/bin/pgrep", + args: ["-f", "hermes"], + stdin: nil, + timeout: 5 + ) + guard let result, let firstLine = result.stdoutString + .components(separatedBy: "\n") + .first(where: { !$0.isEmpty }), + let pid = pid_t(firstLine.trimmingCharacters(in: .whitespaces)) else { return nil } + return pid } @discardableResult @@ -1199,14 +1199,25 @@ struct HermesFileService: Sendable { return true } guard let pid = hermesPID() else { return false } + // For remote we can't issue a raw `kill(2)` — route through `kill(1)` + // via the transport. Local uses the syscall for its minimal overhead. + if context.isRemote { + let result = try? transport.runProcess( + executable: "/bin/kill", + args: ["-TERM", String(pid)], + stdin: nil, + timeout: 5 + ) + return (result?.exitCode ?? -1) == 0 + } return kill(pid, SIGTERM) == 0 } nonisolated func hermesBinaryPath() -> String? { // Single source of truth for install-location candidates lives in - // HermesPaths.hermesBinaryCandidates — keeps pipx/brew/manual lookups + // HermesPathSet.hermesBinaryCandidates — keeps pipx/brew/manual lookups // consistent across the app. - return HermesPaths.hermesBinaryCandidates + return HermesPathSet.hermesBinaryCandidates .first { FileManager.default.isExecutableFile(atPath: $0) } } @@ -1223,7 +1234,13 @@ struct HermesFileService: Sendable { "OPENROUTER_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY", "GROQ_API_KEY", "MISTRAL_API_KEY", "XAI_API_KEY", - "CLAUDE_CODE_OAUTH_TOKEN" + "CLAUDE_CODE_OAUTH_TOKEN", + // SSH agent socket — set by 1Password / Secretive / a manual + // `ssh-add` in the user's shell rc. GUI-launched apps don't inherit + // these by default, so without harvesting them here, `ssh` spawned + // from Scarf can't reach the agent and authentication fails with + // "Permission denied" (exit 255) even though terminal ssh works. + "SSH_AUTH_SOCK", "SSH_AGENT_PID" ] /// Env vars harvested from the user's login shell. Computed once and cached. @@ -1366,19 +1383,27 @@ struct HermesFileService: Sendable { /// delegation tasks /// Used by Chat to warn the user before `hermes acp` fails on send with /// "No Anthropic credentials found". - nonisolated static func hasAnyAICredential() -> Bool { - let credentialKeys = shellEnvKeys.filter { $0 != "PATH" && $0 != "ANTHROPIC_BASE_URL" && $0 != "OPENAI_BASE_URL" } - let env = enrichedEnvironment() - for key in credentialKeys { - if let value = env[key], !value.isEmpty { - return true + /// + /// **Local context:** also checks Scarf's process / login-shell env. + /// **Remote context:** skips that step — our process env has nothing to + /// do with the remote `hermes acp`'s runtime env. The remote `.env` / + /// `auth.json` / `config.yaml` are still checked through the transport. + func hasAnyAICredential() -> Bool { + let credentialKeys = Self.shellEnvKeys.filter { $0 != "PATH" && $0 != "ANTHROPIC_BASE_URL" && $0 != "OPENAI_BASE_URL" } + + if !context.isRemote { + let env = Self.enrichedEnvironment() + for key in credentialKeys { + if let value = env[key], !value.isEmpty { + return true + } } } - // Scan ~/.hermes/.env for KEY= lines. Uses a simple substring check — - // good enough for a preflight hint; hermes itself does the real parse. - let envPath = HermesPaths.home + "/.env" - if let data = try? String(contentsOfFile: envPath, encoding: .utf8) { - for line in data.split(separator: "\n") { + // Scan .env (via transport — local file or scp) for KEY= lines. + // Uses a simple substring check — good enough for a preflight hint; + // hermes itself does the real parse. + if let envText = readFile(context.paths.envFile) { + for line in envText.split(separator: "\n") { let trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.isEmpty || trimmed.hasPrefix("#") { continue } for key in credentialKeys where trimmed.hasPrefix("\(key)=") || trimmed.hasPrefix("export \(key)=") { @@ -1392,12 +1417,11 @@ struct HermesFileService: Sendable { } } } - // Scan ~/.hermes/auth.json — the Credential Pools file written by the - // Configure → Credential Pools UI. Schema is + // Scan auth.json (Credential Pools file written by the Configure → + // Credential Pools UI). Schema: // { "credential_pool": { "": [ { "access_token": "...", ... }, ... ] } } // Defensive parse: any malformed input falls through to the next check. - let authPath = HermesPaths.home + "/auth.json" - if let data = try? Data(contentsOf: URL(fileURLWithPath: authPath)), + if let data = readFileData(context.paths.authJSON), let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let pool = root["credential_pool"] as? [String: Any] { for (_, entries) in pool { @@ -1409,11 +1433,10 @@ struct HermesFileService: Sendable { } } } - // Scan ~/.hermes/config.yaml for `api_key:` lines with a non-empty - // value. Covers both `auxiliary..api_key` and `delegation.api_key` - // without needing to parse the YAML structure — any leaf `api_key: ...` - // with a value means Hermes has a credential to fall back on. - if let text = try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8) { + // Scan config.yaml for `api_key:` lines with a non-empty value. + // Covers both `auxiliary..api_key` and `delegation.api_key` + // without needing to parse YAML structure. + if let text = readFile(context.paths.configYAML) { for line in text.split(separator: "\n") { let trimmed = line.trimmingCharacters(in: .whitespaces) guard trimmed.hasPrefix("api_key:") else { continue } @@ -1427,41 +1450,36 @@ struct HermesFileService: Sendable { @discardableResult nonisolated func runHermesCLI(args: [String], timeout: TimeInterval = 60, stdinInput: String? = nil) -> (exitCode: Int32, output: String) { - guard let binary = hermesBinaryPath() else { return (-1, "") } - let stdoutPipe = Pipe() - let stderrPipe = Pipe() - let stdinPipe: Pipe? = stdinInput != nil ? Pipe() : nil - let process = Process() - process.executableURL = URL(fileURLWithPath: binary) - process.arguments = args - process.environment = Self.enrichedEnvironment() - process.standardOutput = stdoutPipe - process.standardError = stderrPipe - if let stdinPipe { process.standardInput = stdinPipe } - defer { - try? stdoutPipe.fileHandleForReading.close() - try? stdoutPipe.fileHandleForWriting.close() - try? stderrPipe.fileHandleForReading.close() - try? stderrPipe.fileHandleForWriting.close() - try? stdinPipe?.fileHandleForReading.close() - try? stdinPipe?.fileHandleForWriting.close() + // Resolve the executable path — for remote, prefer the cached + // `hermesBinaryHint` on the SSHConfig (populated by the Test + // Connection probe) and fall back to bare `hermes` which relies on + // the remote user's `$PATH`. + let binary: String + if context.isRemote { + binary = context.paths.hermesBinary + } else { + guard let local = hermesBinaryPath() else { return (-1, "") } + binary = local } + + let stdinData = stdinInput?.data(using: .utf8) do { - try process.run() - if let stdinInput, let stdinPipe, let data = stdinInput.data(using: .utf8) { - stdinPipe.fileHandleForWriting.write(data) - try? stdinPipe.fileHandleForWriting.close() - } - let deadline = Date().addingTimeInterval(timeout) - while process.isRunning && Date() < deadline { - Thread.sleep(forTimeInterval: 0.05) - } - if process.isRunning { process.terminate() } - process.waitUntilExit() - let outData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() - let errData = stderrPipe.fileHandleForReading.readDataToEndOfFile() - let combined = (String(data: outData, encoding: .utf8) ?? "") + (String(data: errData, encoding: .utf8) ?? "") - return (process.terminationStatus, combined) + let result = try transport.runProcess( + executable: binary, + args: args, + stdin: stdinData, + timeout: timeout + ) + // Match the legacy signature: combined stdout+stderr in one + // String so callers that grep through output don't need to + // change. Stderr after stdout mirrors what the old Process impl + // produced since both pipes were drained in that order. + let combined = result.stdoutString + result.stderrString + return (result.exitCode, combined) + } catch let error as TransportError { + return (-1, error.diagnosticStderr.isEmpty + ? (error.errorDescription ?? "transport error") + : error.diagnosticStderr) } catch { return (-1, error.localizedDescription) } @@ -1469,17 +1487,26 @@ struct HermesFileService: Sendable { // MARK: - File I/O + /// Read a UTF-8 text file through the transport. Missing files and any + /// transport error surface as `nil` — callers treat missing/unreadable + /// the same way they always have. private func readFile(_ path: String) -> String? { - try? String(contentsOfFile: path, encoding: .utf8) + guard let data = try? transport.readFile(path) else { return nil } + return String(data: data, encoding: .utf8) } private func readFileData(_ path: String) -> Data? { - FileManager.default.contents(atPath: path) + try? transport.readFile(path) } + /// Write a UTF-8 text file atomically through the transport. Matches the + /// old pre-transport behavior (print + swallow on error) because the + /// callers don't have a UI path for surfacing I/O failures — that's + /// planned for Phase 4. private func writeFile(_ path: String, content: String) { + guard let data = content.data(using: .utf8) else { return } do { - try content.write(toFile: path, atomically: true, encoding: .utf8) + try transport.writeFile(path, data: data) } catch { print("[Scarf] Failed to write \(path): \(error.localizedDescription)") } diff --git a/scarf/scarf/Core/Services/HermesFileWatcher.swift b/scarf/scarf/Core/Services/HermesFileWatcher.swift index 69ab653..d1eec99 100644 --- a/scarf/scarf/Core/Services/HermesFileWatcher.swift +++ b/scarf/scarf/Core/Services/HermesFileWatcher.swift @@ -6,33 +6,66 @@ final class HermesFileWatcher { private var coreSources: [DispatchSourceFileSystemObject] = [] private var projectSources: [DispatchSourceFileSystemObject] = [] private var timer: Timer? + /// Remote polling task. Non-nil only when `context.isRemote`. Cancelled + /// on `stopWatching()`. + private var remotePollTask: Task? + + let context: ServerContext + private let transport: any ServerTransport + + init(context: ServerContext = .local) { + self.context = context + self.transport = context.makeTransport() + } + + /// Canonical list of paths we observe. Used for both FSEvents (local) + /// and mtime polling (remote). + private var watchedCorePaths: [String] { + let paths = context.paths + return [ + paths.stateDB, + paths.stateDB + "-wal", + paths.configYAML, + paths.home + "/.env", + paths.memoryMD, + paths.userMD, + paths.cronJobsJSON, + paths.gatewayStateJSON, + paths.agentLog, + paths.errorsLog, + paths.gatewayLog, + paths.projectsRegistry, + paths.mcpTokensDir + ] + } func startWatching() { - let paths = [ - HermesPaths.stateDB, - HermesPaths.stateDB + "-wal", - HermesPaths.configYAML, - HermesPaths.home + "/.env", // Platform setup forms write here. - HermesPaths.memoryMD, - HermesPaths.userMD, - HermesPaths.cronJobsJSON, - HermesPaths.gatewayStateJSON, - HermesPaths.agentLog, - HermesPaths.errorsLog, - HermesPaths.gatewayLog, - HermesPaths.projectsRegistry, - HermesPaths.mcpTokensDir - ] + if context.isRemote { + // FSEvents doesn't reach across SSH. Drive lastChangeDate off + // the transport's AsyncStream, which polls stat mtime on a + // shared ControlMaster channel (~5ms per tick). + let stream = transport.watchPaths(watchedCorePaths) + remotePollTask = Task { [weak self] in + for await _ in stream { + await MainActor.run { [weak self] in + self?.lastChangeDate = Date() + } + } + } + return + } - for path in paths { + for path in watchedCorePaths { if let source = makeSource(for: path) { coreSources.append(source) } } - - timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in - self?.lastChangeDate = Date() - } + // No heartbeat timer: every observing view runs its `.onChange` + // refresh whenever `lastChangeDate` ticks, so a 5s unconditional + // tick was triggering wasted reloads across many subscribers + // (Dashboard, Memory, Cron, Gateway, Platforms, Projects, Chat). + // FSEvents reliably fires on real changes; menu-bar Start/Stop + // touches `gateway_state.json` which the watcher catches. } func stopWatching() { @@ -43,9 +76,15 @@ final class HermesFileWatcher { projectSources.removeAll() timer?.invalidate() timer = nil + remotePollTask?.cancel() + remotePollTask = nil } func updateProjectWatches(_ dashboardPaths: [String]) { + // Remote contexts don't support per-project FSEvents watches today — + // the shared mtime poll covers the core set. Adding per-project + // polling is a Phase 4 polish item. + guard !context.isRemote else { return } for source in projectSources { source.cancel() } diff --git a/scarf/scarf/Core/Services/HermesLogService.swift b/scarf/scarf/Core/Services/HermesLogService.swift index 96ea619..6e92ef5 100644 --- a/scarf/scarf/Core/Services/HermesLogService.swift +++ b/scarf/scarf/Core/Services/HermesLogService.swift @@ -33,10 +33,46 @@ actor HermesLogService { private var currentPath: String? private var entryCounter = 0 + /// Remote tailing state. When set, we're reading from `ssh host tail -F` + /// instead of a local file. Process stdout pipe drives `readNewLines()`; + /// process lifecycle is the actor's responsibility. + private var remoteTailProcess: Process? + private var remoteTailBuffer: String = "" + + let context: ServerContext + private let transport: any ServerTransport + + init(context: ServerContext = .local) { + self.context = context + self.transport = context.makeTransport() + } + func openLog(path: String) { closeLog() currentPath = path - fileHandle = FileHandle(forReadingAtPath: path) + if context.isRemote { + // Spawn `ssh host tail -F` and pipe stdout into our buffer. `-F` + // follows the file through rotations — important for remote + // log rotation setups (logrotate). + let proc = transport.makeProcess( + executable: "/usr/bin/tail", + args: ["-n", String(QueryDefaults.logLineLimit), "-F", path] + ) + let outPipe = Pipe() + proc.standardOutput = outPipe + proc.standardError = Pipe() + do { + try proc.run() + remoteTailProcess = proc + fileHandle = outPipe.fileHandleForReading + } catch { + print("[Scarf] Failed to start remote tail: \(error.localizedDescription)") + remoteTailProcess = nil + fileHandle = nil + } + } else { + fileHandle = FileHandle(forReadingAtPath: path) + } } func closeLog() { @@ -47,11 +83,29 @@ actor HermesLogService { } fileHandle = nil currentPath = nil + if let proc = remoteTailProcess, proc.isRunning { + proc.terminate() + } + remoteTailProcess = nil + remoteTailBuffer = "" } func readLastLines(count: Int = QueryDefaults.logLineLimit) -> [LogEntry] { - guard let path = currentPath, - let data = FileManager.default.contents(atPath: path) else { return [] } + guard let path = currentPath else { return [] } + if context.isRemote { + // For the initial load we bypass the streaming tail and run a + // one-shot `tail -n ` for a clean bounded read. + let result = try? transport.runProcess( + executable: "/usr/bin/tail", + args: ["-n", String(count), path], + stdin: nil, + timeout: 30 + ) + let content = result?.stdoutString ?? "" + let lines = content.components(separatedBy: "\n").filter { !$0.isEmpty } + return lines.map { parseLine($0) } + } + guard let data = FileManager.default.contents(atPath: path) else { return [] } let content = String(data: data, encoding: .utf8) ?? "" let lines = content.components(separatedBy: "\n").filter { !$0.isEmpty } let lastLines = Array(lines.suffix(count)) @@ -62,13 +116,29 @@ actor HermesLogService { guard let handle = fileHandle else { return [] } let data = handle.availableData guard !data.isEmpty else { return [] } - let content = String(data: data, encoding: .utf8) ?? "" - let lines = content.components(separatedBy: "\n").filter { !$0.isEmpty } + let chunk = String(data: data, encoding: .utf8) ?? "" + if context.isRemote { + // Remote tail emits bytes as they arrive — not line-aligned. + // Buffer partials across reads so we don't split a line mid-way. + remoteTailBuffer += chunk + guard let lastNewline = remoteTailBuffer.lastIndex(of: "\n") else { + return [] + } + let complete = String(remoteTailBuffer[.. LogEntry { diff --git a/scarf/scarf/Core/Services/ModelCatalogService.swift b/scarf/scarf/Core/Services/ModelCatalogService.swift index bfe262c..a277057 100644 --- a/scarf/scarf/Core/Services/ModelCatalogService.swift +++ b/scarf/scarf/Core/Services/ModelCatalogService.swift @@ -53,9 +53,17 @@ struct HermesProviderInfo: Sendable, Identifiable, Hashable { struct ModelCatalogService: Sendable { private let logger = Logger(subsystem: "com.scarf", category: "ModelCatalogService") let path: String + let transport: any ServerTransport - init(path: String = HermesPaths.home + "/models_dev_cache.json") { + init(context: ServerContext = .local) { + self.path = context.paths.home + "/models_dev_cache.json" + self.transport = context.makeTransport() + } + + /// Escape hatch for tests. + init(path: String) { self.path = path + self.transport = LocalTransport() } /// All providers, sorted by display name. @@ -159,7 +167,7 @@ struct ModelCatalogService: Sendable { // MARK: - Decoding private func loadCatalog() -> [String: ProviderEntry]? { - guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { + guard let data = try? transport.readFile(path) else { return nil } do { diff --git a/scarf/scarf/Core/Services/ProjectDashboardService.swift b/scarf/scarf/Core/Services/ProjectDashboardService.swift index c17a50a..d16012f 100644 --- a/scarf/scarf/Core/Services/ProjectDashboardService.swift +++ b/scarf/scarf/Core/Services/ProjectDashboardService.swift @@ -2,10 +2,18 @@ import Foundation struct ProjectDashboardService: Sendable { + let context: ServerContext + let transport: any ServerTransport + + init(context: ServerContext = .local) { + self.context = context + self.transport = context.makeTransport() + } + // MARK: - Registry func loadRegistry() -> ProjectRegistry { - guard let data = FileManager.default.contents(atPath: HermesPaths.projectsRegistry) else { + guard let data = try? transport.readFile(context.paths.projectsRegistry) else { return ProjectRegistry(projects: []) } do { @@ -17,10 +25,10 @@ struct ProjectDashboardService: Sendable { } func saveRegistry(_ registry: ProjectRegistry) { - let dir = HermesPaths.scarfDir - if !FileManager.default.fileExists(atPath: dir) { + let dir = context.paths.scarfDir + if !transport.fileExists(dir) { do { - try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + try transport.createDirectory(dir) } catch { print("[Scarf] Failed to create scarf directory: \(error.localizedDescription)") return @@ -28,18 +36,20 @@ struct ProjectDashboardService: Sendable { } guard let data = try? JSONEncoder().encode(registry) else { return } // Pretty-print for readability (agents may read this file) + let writeData: Data if let pretty = try? JSONSerialization.jsonObject(with: data), let formatted = try? JSONSerialization.data(withJSONObject: pretty, options: [.prettyPrinted, .sortedKeys]) { - FileManager.default.createFile(atPath: HermesPaths.projectsRegistry, contents: formatted) + writeData = formatted } else { - FileManager.default.createFile(atPath: HermesPaths.projectsRegistry, contents: data) + writeData = data } + try? transport.writeFile(context.paths.projectsRegistry, data: writeData) } // MARK: - Dashboard func loadDashboard(for project: ProjectEntry) -> ProjectDashboard? { - guard let data = FileManager.default.contents(atPath: project.dashboardPath) else { + guard let data = try? transport.readFile(project.dashboardPath) else { return nil } do { @@ -51,13 +61,10 @@ struct ProjectDashboardService: Sendable { } func dashboardExists(for project: ProjectEntry) -> Bool { - FileManager.default.fileExists(atPath: project.dashboardPath) + transport.fileExists(project.dashboardPath) } func dashboardModificationDate(for project: ProjectEntry) -> Date? { - guard let attrs = try? FileManager.default.attributesOfItem(atPath: project.dashboardPath) else { - return nil - } - return attrs[.modificationDate] as? Date + transport.stat(project.dashboardPath)?.mtime } } diff --git a/scarf/scarf/Core/Transport/LocalTransport.swift b/scarf/scarf/Core/Transport/LocalTransport.swift new file mode 100644 index 0000000..c531743 --- /dev/null +++ b/scarf/scarf/Core/Transport/LocalTransport.swift @@ -0,0 +1,188 @@ +import Foundation +import os + +/// `ServerTransport` over the local filesystem. Thin wrapper around +/// `FileManager`, `Process`, and `DispatchSourceFileSystemObject` — the APIs +/// services were already using before Phase 2. +struct LocalTransport: ServerTransport { + private static let logger = Logger(subsystem: "com.scarf", category: "LocalTransport") + + let contextID: ServerID + let isRemote: Bool = false + + init(contextID: ServerID = ServerContext.local.id) { + self.contextID = contextID + } + + // MARK: - Files + + func readFile(_ path: String) throws -> Data { + do { + return try Data(contentsOf: URL(fileURLWithPath: path)) + } catch { + throw TransportError.fileIO(path: path, underlying: error.localizedDescription) + } + } + + func writeFile(_ path: String, data: Data) throws { + let tmp = path + ".scarf.tmp" + do { + try data.write(to: URL(fileURLWithPath: tmp)) + // Preserve `0600` for dotfiles holding secrets (.env, .auth, ...). + // The existing files already use 0600 via HermesEnvService; we + // mirror that here so a brand-new file created via this write + // also starts with safe permissions. + if Self.shouldEnforcePrivateMode(for: path) { + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: tmp) + } + // Atomic swap onto the final path. + let destURL = URL(fileURLWithPath: path) + let tmpURL = URL(fileURLWithPath: tmp) + if FileManager.default.fileExists(atPath: path) { + _ = try FileManager.default.replaceItemAt(destURL, withItemAt: tmpURL) + } else { + // Ensure parent exists. + let parent = (path as NSString).deletingLastPathComponent + if !parent.isEmpty, !FileManager.default.fileExists(atPath: parent) { + try FileManager.default.createDirectory(atPath: parent, withIntermediateDirectories: true) + } + try FileManager.default.moveItem(at: tmpURL, to: destURL) + } + } catch { + try? FileManager.default.removeItem(atPath: tmp) + throw TransportError.fileIO(path: path, underlying: error.localizedDescription) + } + } + + func fileExists(_ path: String) -> Bool { + FileManager.default.fileExists(atPath: path) + } + + func stat(_ path: String) -> FileStat? { + guard let attrs = try? FileManager.default.attributesOfItem(atPath: path) else { + return nil + } + let size = (attrs[.size] as? Int64) ?? Int64((attrs[.size] as? Int) ?? 0) + let mtime = (attrs[.modificationDate] as? Date) ?? Date(timeIntervalSince1970: 0) + let isDir = (attrs[.type] as? FileAttributeType) == .typeDirectory + return FileStat(size: size, mtime: mtime, isDirectory: isDir) + } + + func listDirectory(_ path: String) throws -> [String] { + do { + return try FileManager.default.contentsOfDirectory(atPath: path) + } catch { + throw TransportError.fileIO(path: path, underlying: error.localizedDescription) + } + } + + func createDirectory(_ path: String) throws { + do { + try FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true) + } catch { + throw TransportError.fileIO(path: path, underlying: error.localizedDescription) + } + } + + func removeFile(_ path: String) throws { + guard FileManager.default.fileExists(atPath: path) else { return } + do { + try FileManager.default.removeItem(atPath: path) + } catch { + throw TransportError.fileIO(path: path, underlying: error.localizedDescription) + } + } + + // MARK: - Processes + + func runProcess(executable: String, args: [String], stdin: Data?, timeout: TimeInterval?) throws -> ProcessResult { + let proc = Process() + proc.executableURL = URL(fileURLWithPath: executable) + proc.arguments = args + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + let stdinPipe = Pipe() + proc.standardOutput = stdoutPipe + proc.standardError = stderrPipe + if stdin != nil { proc.standardInput = stdinPipe } + do { + try proc.run() + } catch { + throw TransportError.other(message: "Failed to launch \(executable): \(error.localizedDescription)") + } + if let stdin { + try? stdinPipe.fileHandleForWriting.write(contentsOf: stdin) + try? stdinPipe.fileHandleForWriting.close() + } + // Timeout handling: poll every 100ms up to timeout, kill on overrun. + if let timeout { + let deadline = Date().addingTimeInterval(timeout) + while proc.isRunning && Date() < deadline { + Thread.sleep(forTimeInterval: 0.1) + } + if proc.isRunning { + proc.terminate() + let partial = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data() + try? stdoutPipe.fileHandleForReading.close() + try? stderrPipe.fileHandleForReading.close() + throw TransportError.timeout(seconds: timeout, partialStdout: partial) + } + } else { + proc.waitUntilExit() + } + let out = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data() + let err = (try? stderrPipe.fileHandleForReading.readToEnd()) ?? Data() + try? stdoutPipe.fileHandleForReading.close() + try? stderrPipe.fileHandleForReading.close() + try? stdinPipe.fileHandleForWriting.close() + return ProcessResult(exitCode: proc.terminationStatus, stdout: out, stderr: err) + } + + func makeProcess(executable: String, args: [String]) -> Process { + let proc = Process() + proc.executableURL = URL(fileURLWithPath: executable) + proc.arguments = args + return proc + } + + // MARK: - SQLite + + func snapshotSQLite(remotePath: String) throws -> URL { + // Local case: no copy needed. Services open the path directly. + URL(fileURLWithPath: remotePath) + } + + // MARK: - Watching + + func watchPaths(_ paths: [String]) -> AsyncStream { + AsyncStream { continuation in + var sources: [DispatchSourceFileSystemObject] = [] + for path in paths { + let fd = Darwin.open(path, O_EVTONLY) + guard fd >= 0 else { continue } + let src = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fd, + eventMask: [.write, .extend, .rename], + queue: .global() + ) + src.setEventHandler { continuation.yield(.anyChanged) } + src.setCancelHandler { Darwin.close(fd) } + src.resume() + sources.append(src) + } + continuation.onTermination = { _ in + for s in sources { s.cancel() } + } + } + } + + // MARK: - Helpers + + /// Heuristic: files that conventionally hold secrets should be created + /// with restrictive permissions so a future `scp` or editor doesn't end + /// up exposing them. + private static func shouldEnforcePrivateMode(for path: String) -> Bool { + let name = (path as NSString).lastPathComponent + return name == ".env" || name == "auth.json" || name.hasSuffix("-tokens.json") + } +} diff --git a/scarf/scarf/Core/Transport/SSHTransport.swift b/scarf/scarf/Core/Transport/SSHTransport.swift new file mode 100644 index 0000000..beec0cf --- /dev/null +++ b/scarf/scarf/Core/Transport/SSHTransport.swift @@ -0,0 +1,480 @@ +import Foundation +import os + +/// `ServerTransport` that reaches a remote Hermes installation through the +/// system `ssh`, `scp`, and `sftp` binaries. +/// +/// Why system ssh (not a native library): the user's `~/.ssh/config`, +/// ssh-agent, 1Password/Secretive agents, ProxyJump, and ControlMaster +/// multiplexing all work for free. OpenSSH also owns crypto — a smaller +/// audit surface than dragging libssh2 along. +/// +/// **ControlMaster matters.** Without it, every remote primitive (stat, cat, +/// cp) authenticates from scratch — 500ms-2s per call. With ControlMaster +/// `auto` + `ControlPersist 600`, the first call authenticates, subsequent +/// calls reuse the same TCP/crypto session at ~5ms each. We point the +/// control socket at `~/Library/Caches/scarf/ssh/%C` so multiple Scarf +/// windows pointed at the same host share one session cleanly. +struct SSHTransport: ServerTransport { + private static let logger = Logger(subsystem: "com.scarf", category: "SSHTransport") + + let contextID: ServerID + let isRemote: Bool = true + + let config: SSHConfig + let displayName: String + + init(contextID: ServerID, config: SSHConfig, displayName: String) { + self.contextID = contextID + self.config = config + self.displayName = displayName + } + + // MARK: - ssh/scp binary discovery + + private var sshBinary: String { "/usr/bin/ssh" } + private var scpBinary: String { "/usr/bin/scp" } + + /// The fully-qualified `user@host` spec (or just `host` if no user set). + private var hostSpec: String { + if let user = config.user, !user.isEmpty { return "\(user)@\(config.host)" } + return config.host + } + + /// Absolute path to this server's ControlMaster socket directory. One + /// socket per server, lives under the app's Caches so macOS can sweep it. + private var controlDir: String { + let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?.path + ?? NSHomeDirectory() + "/Library/Caches" + return base + "/scarf/ssh" + } + + /// Per-server snapshot cache directory (for SQLite `.backup` drops). + private var snapshotDir: String { + let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?.path + ?? NSHomeDirectory() + "/Library/Caches" + return base + "/scarf/snapshots/\(contextID.uuidString)" + } + + /// Common ssh options used by every invocation. Keep every `-o` flag + /// here so we never drift between calls. + /// + /// - `ControlMaster=auto` + `ControlPersist=600` gives us free connection + /// pooling for the bursty stat/cat/cp traffic the services produce. + /// - `StrictHostKeyChecking=accept-new` writes new hosts to + /// `known_hosts` silently the first time but blocks on key mismatch — + /// the UX surfaced by `TransportError.hostKeyMismatch`. + /// - `ServerAliveInterval=30` makes dropped connections surface as a + /// process exit rather than a hang. + /// - `LogLevel=QUIET` suppresses the login banner so ACP's line-delimited + /// JSON stays binary-clean. + private func sshArgs(extra: [String] = []) -> [String] { + var args: [String] = [ + "-o", "ControlMaster=auto", + "-o", "ControlPath=\(controlDir)/%C", + "-o", "ControlPersist=600", + "-o", "ServerAliveInterval=30", + "-o", "ServerAliveCountMax=3", + "-o", "ConnectTimeout=10", + "-o", "StrictHostKeyChecking=accept-new", + "-o", "LogLevel=QUIET", + "-o", "BatchMode=yes" // Never prompt for passphrases; ssh-agent only. + ] + if let port = config.port { args += ["-p", String(port)] } + if let id = config.identityFile, !id.isEmpty { + args += ["-i", id] + } + args += extra + return args + } + + /// Ensure the ControlMaster socket directory exists. Called before every + /// ssh invocation. Cheap — `createDirectory(withIntermediateDirectories: true)` + /// is a no-op when present. + private func ensureControlDir() { + try? FileManager.default.createDirectory(atPath: controlDir, withIntermediateDirectories: true) + // 0700 so socket files aren't visible to other users on the Mac. + try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: controlDir) + } + + /// Shell-quote a single argument for remote execution. The remote shell + /// receives our argv joined with spaces, so anything containing + /// whitespace/metacharacters must be quoted to survive that flattening. + private static func shellQuote(_ s: String) -> String { + if s.isEmpty { return "''" } + // Safe subset: alphanumerics + a few shell-inert characters. + let safe = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@%+=:,./-_") + if s.unicodeScalars.allSatisfy({ safe.contains($0) }) { return s } + // Wrap in single quotes; close/reopen around any embedded single quote. + return "'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + /// Format a path for inclusion in a remote `sh -c` command. **Critical** + /// for any path containing `~/`: bash/zsh do NOT expand `~` inside + /// quotes (single OR double), so a single-quoted `'~/.hermes/foo'` is + /// passed to commands as the literal seven-character string + /// `~/.hermes/foo` and lookups fail. We rewrite the leading `~/` to + /// `$HOME/` (which DOES expand inside double quotes) and emit the path + /// double-quoted so embedded spaces / metacharacters are still safe. + /// + /// Why not single-quote: that would make `$HOME` literal too. We + /// specifically need partial-expansion semantics, which is what double + /// quotes give us. + private static func remotePathArg(_ path: String) -> String { + var p = path + if p.hasPrefix("~/") { + p = "$HOME/" + p.dropFirst(2) + } else if p == "~" { + p = "$HOME" + } + let escaped = p + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + return "\"\(escaped)\"" + } + + /// Run a remote shell command. Wraps in `sh -c ''` and uses + /// the standard ssh-after-host placement (no `--` separator — that + /// would be sent to the remote shell as a literal first token, which + /// most shells reject as "command not found"). The `command` is + /// single-quoted via `shellQuote` so ssh's argv-join-by-space doesn't + /// split it across multiple shell tokens on the remote side. + @discardableResult + private func runRemoteShell(_ command: String, timeout: TimeInterval? = 60) throws -> ProcessResult { + var args = sshArgs() + args.append(hostSpec) + args.append("sh") + args.append("-c") + args.append(Self.shellQuote(command)) + return try runLocal(executable: sshBinary, args: args, stdin: nil, timeout: timeout) + } + + // MARK: - Files + + func readFile(_ path: String) throws -> Data { + // `cat` is the simplest portable "give me file bytes" command; we + // don't need scp's progress machinery for typical config/memory + // files (<1 MB each). + let result = try runRemoteShell("cat \(Self.remotePathArg(path))") + if result.exitCode != 0 { + let errText = result.stderrString + // Missing file looks like exit 1 + "No such file" — surface as a + // typed fileIO error so callers that treat missing == "empty" + // behave the same as they do locally. + if errText.contains("No such file") { + throw TransportError.fileIO(path: path, underlying: "No such file or directory") + } + throw TransportError.classifySSHFailure(host: config.host, exitCode: result.exitCode, stderr: errText) + } + return result.stdout + } + + func writeFile(_ path: String, data: Data) throws { + // Atomic pattern: + // 1. scp to `.scarf.tmp` on the remote + // 2. ssh `mv ` — atomic on POSIX within the same FS + // Hermes never sees a partial write. + let tmp = path + ".scarf.tmp" + + // scp from a local temp file (scp reads from disk, not stdin). + let localTmpURL = FileManager.default.temporaryDirectory.appendingPathComponent( + "scarf-scp-\(UUID().uuidString).tmp" + ) + do { + try data.write(to: localTmpURL) + } catch { + throw TransportError.fileIO(path: path, underlying: "local temp write: \(error.localizedDescription)") + } + defer { try? FileManager.default.removeItem(at: localTmpURL) } + + ensureControlDir() + var scpArgs: [String] = [ + "-o", "ControlMaster=auto", + "-o", "ControlPath=\(controlDir)/%C", + "-o", "ControlPersist=600", + "-o", "StrictHostKeyChecking=accept-new", + "-o", "LogLevel=QUIET", + "-o", "BatchMode=yes" + ] + if let port = config.port { scpArgs += ["-P", String(port)] } + if let id = config.identityFile, !id.isEmpty { scpArgs += ["-i", id] } + scpArgs.append(localTmpURL.path) + scpArgs.append("\(hostSpec):\(tmp)") + + let scpResult = try runLocal(executable: scpBinary, args: scpArgs, stdin: nil, timeout: 60) + if scpResult.exitCode != 0 { + throw TransportError.classifySSHFailure(host: config.host, exitCode: scpResult.exitCode, stderr: scpResult.stderrString) + } + + // Now atomic mv on the remote. Note: scp/sftp DOES expand `~` (it + // goes through the SSH file transfer protocol, not a remote shell), + // so the upload landed at the resolved $HOME path. The mv is a + // shell command and needs the $HOME-rewritten path to find it. + let mvResult = try runRemoteShell("mv \(Self.remotePathArg(tmp)) \(Self.remotePathArg(path))") + if mvResult.exitCode != 0 { + // Best-effort cleanup of the orphan tmp. + _ = try? runRemoteShell("rm -f \(Self.remotePathArg(tmp))") + throw TransportError.classifySSHFailure(host: config.host, exitCode: mvResult.exitCode, stderr: mvResult.stderrString) + } + } + + func fileExists(_ path: String) -> Bool { + guard let result = try? runRemoteShell("test -e \(Self.remotePathArg(path))") else { + return false + } + return result.exitCode == 0 + } + + func stat(_ path: String) -> FileStat? { + // macOS and Linux `stat` differ in flags. `stat -f` is macOS's BSD + // form; `stat -c` is GNU/Linux. We try the GNU form first (typical + // remote target) and fall back to BSD. The format strings use + // double quotes — safe inside our outer single-quoted sh -c. + let linux = try? runRemoteShell(#"stat -c "%s %Y %F" \#(Self.remotePathArg(path))"#) + if let result = linux, result.exitCode == 0 { + return Self.parseStatOutput(result.stdoutString) + } + let bsd = try? runRemoteShell(#"stat -f "%z %m %HT" \#(Self.remotePathArg(path))"#) + if let result = bsd, result.exitCode == 0 { + return Self.parseStatOutput(result.stdoutString) + } + return nil + } + + private static func parseStatOutput(_ s: String) -> FileStat? { + // Expected: " " where is either + // a GNU word ("regular file", "directory") or a BSD word ("Regular + // File", "Directory"). Only the first word of matters for + // isDirectory. + let parts = s.trimmingCharacters(in: .whitespacesAndNewlines).split(separator: " ", maxSplits: 2) + guard parts.count >= 2 else { return nil } + let size = Int64(parts[0]) ?? 0 + let mtimeSecs = TimeInterval(parts[1]) ?? 0 + let typeStr = parts.count == 3 ? parts[2].lowercased() : "" + let isDir = typeStr.contains("directory") + return FileStat(size: size, mtime: Date(timeIntervalSince1970: mtimeSecs), isDirectory: isDir) + } + + func listDirectory(_ path: String) throws -> [String] { + // `ls -A` lists all entries (incl. dotfiles) except `.`/`..`, one per + // line. Sort order matches local FileManager.contentsOfDirectory. + let result = try runRemoteShell("ls -A \(Self.remotePathArg(path))") + if result.exitCode != 0 { + if result.stderrString.contains("No such file") { + throw TransportError.fileIO(path: path, underlying: "No such file or directory") + } + throw TransportError.classifySSHFailure(host: config.host, exitCode: result.exitCode, stderr: result.stderrString) + } + return result.stdoutString + .split(separator: "\n", omittingEmptySubsequences: true) + .map(String.init) + } + + func createDirectory(_ path: String) throws { + let result = try runRemoteShell("mkdir -p \(Self.remotePathArg(path))") + if result.exitCode != 0 { + throw TransportError.classifySSHFailure(host: config.host, exitCode: result.exitCode, stderr: result.stderrString) + } + } + + func removeFile(_ path: String) throws { + let result = try runRemoteShell("rm -f \(Self.remotePathArg(path))") + if result.exitCode != 0 { + throw TransportError.classifySSHFailure(host: config.host, exitCode: result.exitCode, stderr: result.stderrString) + } + } + + // MARK: - Processes + + func runProcess(executable: String, args: [String], stdin: Data?, timeout: TimeInterval?) throws -> ProcessResult { + // Wrap in `sh -c ' '` with `~/`-rewritten paths so + // home-relative args expand on the remote. The executable might be + // `~/.local/bin/hermes` or just `hermes`; either survives. + let cmd = ([executable] + args).map { Self.remotePathArg($0) }.joined(separator: " ") + var sshArgv = sshArgs() + sshArgv.append(hostSpec) + sshArgv.append("sh") + sshArgv.append("-c") + sshArgv.append(Self.shellQuote(cmd)) + return try runLocal(executable: sshBinary, args: sshArgv, stdin: stdin, timeout: timeout) + } + + func makeProcess(executable: String, args: [String]) -> Process { + ensureControlDir() + // `-T` disables pty allocation — critical for binary-clean stdin/stdout + // (ACP JSON-RPC, log tail bytes). Same sh -c wrapping as runProcess + // so home-relative paths in `executable`/`args` actually expand. + let cmd = ([executable] + args).map { Self.remotePathArg($0) }.joined(separator: " ") + var sshArgv = sshArgs() + sshArgv.insert("-T", at: 0) + sshArgv.append(hostSpec) + sshArgv.append("sh") + sshArgv.append("-c") + sshArgv.append(Self.shellQuote(cmd)) + let proc = Process() + proc.executableURL = URL(fileURLWithPath: sshBinary) + proc.arguments = sshArgv + proc.environment = Self.sshSubprocessEnvironment() + return proc + } + + /// Environment for an ssh/scp subprocess: process env merged with + /// SSH_AUTH_SOCK / SSH_AGENT_PID harvested from the user's login shell. + /// Without this, GUI-launched Scarf can't reach 1Password / Secretive / + /// `ssh-add`'d keys that the user's terminal sees fine. + private static func sshSubprocessEnvironment() -> [String: String] { + var env = ProcessInfo.processInfo.environment + let shellEnv = HermesFileService.enrichedEnvironment() + for key in ["SSH_AUTH_SOCK", "SSH_AGENT_PID"] { + if env[key] == nil, let value = shellEnv[key], !value.isEmpty { + env[key] = value + } + } + return env + } + + // MARK: - SQLite snapshot + + func snapshotSQLite(remotePath: String) throws -> URL { + try? FileManager.default.createDirectory(atPath: snapshotDir, withIntermediateDirectories: true) + let localPath = snapshotDir + "/state.db" + // `.backup` is WAL-safe: sqlite takes a consistent snapshot without + // blocking writers. A plain `cp` of a WAL-mode DB could corrupt. + let remoteTmp = "/tmp/scarf-snapshot-\(UUID().uuidString).db" + // sqlite3's `.backup` is a dot-command, not a CLI arg. The whole + // dot-command must be one shell argument (double-quoted) so sqlite3 + // receives it as a single command; the backup path inside it is + // single-quoted so sqlite3 parses it correctly. The DB path is a + // separate shell argument and goes through `remotePathArg` + // (double-quoted, $HOME-aware) so `~/.hermes/state.db` actually + // resolves on the remote. + // + // The second sqlite3 invocation flips the snapshot out of WAL mode + // so the scp'd file is self-contained: `.backup` preserves the + // source's journal_mode in the destination header, so without this + // step the client would need the `-wal`/`-shm` sidecars too, and + // every read would fail with "unable to open database file". + // + // Final shell command on the remote: + // sqlite3 "$HOME/.hermes/state.db" ".backup '/tmp/scarf-snapshot-XYZ.db'" \ + // && sqlite3 '/tmp/scarf-snapshot-XYZ.db' "PRAGMA journal_mode=DELETE;" + let backupScript = #"sqlite3 \#(Self.remotePathArg(remotePath)) ".backup '\#(remoteTmp)'" && sqlite3 '\#(remoteTmp)' "PRAGMA journal_mode=DELETE;" > /dev/null"# + let backup = try runRemoteShell(backupScript) + if backup.exitCode != 0 { + throw TransportError.classifySSHFailure(host: config.host, exitCode: backup.exitCode, stderr: backup.stderrString) + } + // scp the backup down. scp/sftp expands `~` natively (it goes + // through the SSH file-transfer protocol, not a remote shell), so + // remoteTmp's `/tmp/...` absolute path round-trips as-is. + ensureControlDir() + var scpArgs: [String] = [ + "-o", "ControlMaster=auto", + "-o", "ControlPath=\(controlDir)/%C", + "-o", "ControlPersist=600", + "-o", "StrictHostKeyChecking=accept-new", + "-o", "LogLevel=QUIET", + "-o", "BatchMode=yes" + ] + if let port = config.port { scpArgs += ["-P", String(port)] } + if let id = config.identityFile, !id.isEmpty { scpArgs += ["-i", id] } + scpArgs.append("\(hostSpec):\(remoteTmp)") + scpArgs.append(localPath) + let pull = try runLocal(executable: scpBinary, args: scpArgs, stdin: nil, timeout: 120) + // Regardless of pull outcome, try to clean up the remote tmp. + _ = try? runRemoteShell("rm -f \(Self.remotePathArg(remoteTmp))") + if pull.exitCode != 0 { + throw TransportError.classifySSHFailure(host: config.host, exitCode: pull.exitCode, stderr: pull.stderrString) + } + return URL(fileURLWithPath: localPath) + } + + // MARK: - Watching + + func watchPaths(_ paths: [String]) -> AsyncStream { + // Polling: call `stat -c %Y` on all paths every 3s and yield a single + // `.anyChanged` when any mtime changed vs. the prior tick. ControlMaster + // makes each stat ~5ms so the cost is bounded. + AsyncStream { continuation in + let task = Task.detached { [self] in + var lastSignature: String = "" + while !Task.isCancelled { + // Build one shell command that stats all paths in one + // ssh round-trip. Missing paths print "0" which still + // participates correctly in change detection. Paths + // get the `~`→`$HOME` rewrite via remotePathArg. + let argList = paths.map { Self.remotePathArg($0) }.joined(separator: " ") + let cmd = "for p in \(argList); do stat -c %Y \"$p\" 2>/dev/null || stat -f %m \"$p\" 2>/dev/null || echo 0; done" + do { + let result = try runRemoteShell(cmd, timeout: 30) + let signature = result.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines) + if !signature.isEmpty && signature != lastSignature { + if !lastSignature.isEmpty { + continuation.yield(.anyChanged) + } + lastSignature = signature + } + } catch { + // Transient failure (connection drop) — skip this tick. + Self.logger.debug("watchPaths poll failed: \(String(describing: error))") + } + try? await Task.sleep(nanoseconds: 3_000_000_000) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + + // MARK: - Private helpers + + /// Spawn a local process (ssh/scp/etc.) and collect its result. Mirrors + /// `LocalTransport.runProcess` — duplicated rather than shared because + /// SSH-specific code paths live on this type and we want all Process + /// lifecycle in one place per transport. + private func runLocal(executable: String, args: [String], stdin: Data?, timeout: TimeInterval?) throws -> ProcessResult { + ensureControlDir() + let proc = Process() + proc.executableURL = URL(fileURLWithPath: executable) + proc.arguments = args + // Inherit the user's shell environment so ssh can reach the + // ssh-agent socket. GUI-launched apps don't see SSH_AUTH_SOCK by + // default — without this, terminal ssh works (because the user's + // shell exports it) but Scarf-launched ssh fails auth with exit 255. + proc.environment = Self.sshSubprocessEnvironment() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + let stdinPipe = Pipe() + proc.standardOutput = stdoutPipe + proc.standardError = stderrPipe + if stdin != nil { proc.standardInput = stdinPipe } + do { + try proc.run() + } catch { + throw TransportError.other(message: "Failed to launch \(executable): \(error.localizedDescription)") + } + if let stdin { + try? stdinPipe.fileHandleForWriting.write(contentsOf: stdin) + try? stdinPipe.fileHandleForWriting.close() + } + if let timeout { + let deadline = Date().addingTimeInterval(timeout) + while proc.isRunning && Date() < deadline { + Thread.sleep(forTimeInterval: 0.1) + } + if proc.isRunning { + proc.terminate() + let partial = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data() + try? stdoutPipe.fileHandleForReading.close() + try? stderrPipe.fileHandleForReading.close() + throw TransportError.timeout(seconds: timeout, partialStdout: partial) + } + } else { + proc.waitUntilExit() + } + let out = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data() + let err = (try? stderrPipe.fileHandleForReading.readToEnd()) ?? Data() + try? stdoutPipe.fileHandleForReading.close() + try? stderrPipe.fileHandleForReading.close() + try? stdinPipe.fileHandleForWriting.close() + return ProcessResult(exitCode: proc.terminationStatus, stdout: out, stderr: err) + } +} diff --git a/scarf/scarf/Core/Transport/ServerTransport.swift b/scarf/scarf/Core/Transport/ServerTransport.swift new file mode 100644 index 0000000..224edc3 --- /dev/null +++ b/scarf/scarf/Core/Transport/ServerTransport.swift @@ -0,0 +1,102 @@ +import Foundation + +/// Unified I/O surface shared by local and remote Hermes installations. +/// +/// **Design rationale.** The services that read Hermes state (`~/.hermes/…`) +/// and spawn the `hermes` CLI all boil down to a handful of primitives: +/// read/write/list files, stat file attributes, run a process to completion, +/// spawn a long-running stdio process for streaming, take a consistent DB +/// snapshot, observe file changes. `ServerTransport` exposes exactly those +/// primitives so the same service code works against either a local +/// filesystem or a remote host reached over SSH. +/// +/// The primitives are deliberately **synchronous where possible** (file I/O, +/// process `run` + wait) so services don't need to become `async` end-to-end. +/// The two naturally-streaming cases — log tail and ACP stdio — use +/// `makeProcess` which returns a configured `Process`; services own the +/// stdio pipes and lifecycle exactly as they do today. +protocol ServerTransport: Sendable { + /// Identifies the context this transport serves. Used for cache + /// namespacing (e.g. per-server SQLite snapshot directories). + var contextID: ServerID { get } + + /// `true` if this transport talks to a remote host over SSH. + var isRemote: Bool { get } + + // MARK: - Files + + func readFile(_ path: String) throws -> Data + /// Atomic write: the file at `path` is either the previous contents or + /// the new contents, never a partial write. Preserves `0600` mode for + /// paths that match `.env` conventions so secrets stay owner-only. + func writeFile(_ path: String, data: Data) throws + func fileExists(_ path: String) -> Bool + func stat(_ path: String) -> FileStat? + func listDirectory(_ path: String) throws -> [String] + /// Create directories including intermediates. No-op if already present. + func createDirectory(_ path: String) throws + /// Delete a file. No-op if absent. + func removeFile(_ path: String) throws + + // MARK: - Processes + + /// Run a process to completion and capture its stdout/stderr. For remote + /// transports this actually invokes `ssh host -- executable args…` under + /// the hood; for local it spawns `executable` directly. + func runProcess( + executable: String, + args: [String], + stdin: Data?, + timeout: TimeInterval? + ) throws -> ProcessResult + + /// Return a `Process` configured for the target — already pointed at the + /// right executable with the right arguments, but **not yet started**. + /// Callers attach their own `Pipe`s and call `run()`. Used by ACPClient + /// (JSON-RPC over stdio) and by `HermesLogService`'s streaming tail. + /// + /// Local: `executable` + `args` verbatim. + /// Remote: `/usr/bin/ssh` + connection flags + `[host, "--", executable, args…]`. + func makeProcess(executable: String, args: [String]) -> Process + + // MARK: - SQLite + + /// Return a local filesystem URL pointing at a fresh, consistent copy of + /// the SQLite database at `remotePath`. For local transports this is + /// just the remote path unchanged. For SSH transports this performs + /// `sqlite3 .backup` on the remote side and scp's the backup into + /// `~/Library/Caches/scarf//state.db`, returning that URL. + func snapshotSQLite(remotePath: String) throws -> URL + + // MARK: - Watching + + /// Observe changes to a set of paths and yield events when any of them + /// change. Local: FSEvents. Remote: polls `stat` mtime every 3s. + func watchPaths(_ paths: [String]) -> AsyncStream +} + +/// Stat-style file metadata. `nil` (return value) means the file does not +/// exist or couldn't be queried. +struct FileStat: Sendable, Hashable { + let size: Int64 + let mtime: Date + let isDirectory: Bool +} + +/// Result of a one-shot process invocation. +struct ProcessResult: Sendable { + let exitCode: Int32 + let stdout: Data + let stderr: Data + + var stdoutString: String { String(data: stdout, encoding: .utf8) ?? "" } + var stderrString: String { String(data: stderr, encoding: .utf8) ?? "" } +} + +enum WatchEvent: Sendable { + /// Any path in the watched set changed; implementations may coalesce + /// rapid changes into one event. Consumers should treat this as "refresh + /// whatever you were displaying" rather than expecting fine-grained + /// per-path signals. + case anyChanged +} diff --git a/scarf/scarf/Core/Transport/TransportErrors.swift b/scarf/scarf/Core/Transport/TransportErrors.swift new file mode 100644 index 0000000..f81cb9b --- /dev/null +++ b/scarf/scarf/Core/Transport/TransportErrors.swift @@ -0,0 +1,86 @@ +import Foundation + +/// Typed errors surfaced by `ServerTransport` implementations. The UI +/// distinguishes these so user-visible messages can be specific +/// ("authentication failed" vs. "command failed") without having to grep +/// stderr strings. +enum TransportError: LocalizedError { + /// `ssh`/`scp` could not reach the host or hit a protocol-level issue + /// (name resolution, connection refused, route error). + case hostUnreachable(host: String, stderr: String) + /// Remote rejected our credentials. Typically means no ssh-agent key is + /// loaded, or the loaded keys don't match any `authorized_keys` entry. + case authenticationFailed(host: String, stderr: String) + /// Remote `~/.ssh/known_hosts` fingerprint no longer matches. Blocking — + /// we never auto-accept on mismatch. + case hostKeyMismatch(host: String, stderr: String) + /// The command ran on the remote but exited non-zero. + case commandFailed(exitCode: Int32, stderr: String) + /// Local filesystem operation failed (read/write/stat) with the OS error + /// message attached. + case fileIO(path: String, underlying: String) + /// Timed out waiting for a process to finish. `partialStdout` carries + /// whatever output was captured before the timer fired. + case timeout(seconds: TimeInterval, partialStdout: Data) + /// Something we didn't plan for. Fall-through bucket with enough context + /// for a bug report. + case other(message: String) + + var errorDescription: String? { + switch self { + case .hostUnreachable(let host, _): + return "Can't reach \(host). Check the hostname, network, and SSH config." + case .authenticationFailed(let host, _): + return "SSH authentication to \(host) failed. Ensure your key is loaded in ssh-agent." + case .hostKeyMismatch(let host, _): + return "Host key for \(host) has changed. Inspect ~/.ssh/known_hosts before continuing." + case .commandFailed(let code, let stderr): + // Trim stderr to a single line for the summary; full text is in + // the associated value for disclosure views. + let firstLine = stderr.split(separator: "\n").first.map(String.init) ?? "" + return "Remote command exited \(code). \(firstLine)" + case .fileIO(let path, let msg): + return "File I/O failed at \(path): \(msg)" + case .timeout(let secs, _): + return "Command timed out after \(Int(secs))s." + case .other(let msg): + return msg + } + } + + /// Full stderr (if any) for display in a disclosure view. Empty string + /// when there's no additional detail worth showing. + var diagnosticStderr: String { + switch self { + case .hostUnreachable(_, let s), + .authenticationFailed(_, let s), + .hostKeyMismatch(_, let s), + .commandFailed(_, let s): + return s + default: + return "" + } + } + + /// Heuristic classifier: convert the ssh/scp stderr of a failed command + /// into a specific `TransportError`. Used by `SSHTransport` after a + /// non-zero exit. Defaults to `.commandFailed` when no known marker + /// matches. + static func classifySSHFailure(host: String, exitCode: Int32, stderr: String) -> TransportError { + let s = stderr.lowercased() + if s.contains("permission denied") || s.contains("authentication failed") + || s.contains("publickey") && s.contains("denied") { + return .authenticationFailed(host: host, stderr: stderr) + } + if s.contains("host key verification failed") + || s.contains("remote host identification has changed") { + return .hostKeyMismatch(host: host, stderr: stderr) + } + if s.contains("no route to host") || s.contains("connection refused") + || s.contains("connection timed out") || s.contains("could not resolve hostname") + || s.contains("connection closed by") && s.contains("port 22") { + return .hostUnreachable(host: host, stderr: stderr) + } + return .commandFailed(exitCode: exitCode, stderr: stderr) + } +} diff --git a/scarf/scarf/Features/Activity/ViewModels/ActivityViewModel.swift b/scarf/scarf/Features/Activity/ViewModels/ActivityViewModel.swift index 6c7a22e..39bb5c3 100644 --- a/scarf/scarf/Features/Activity/ViewModels/ActivityViewModel.swift +++ b/scarf/scarf/Features/Activity/ViewModels/ActivityViewModel.swift @@ -2,7 +2,14 @@ import Foundation @Observable final class ActivityViewModel { - private let dataService = HermesDataService() + let context: ServerContext + private let dataService: HermesDataService + + init(context: ServerContext = .local) { + self.context = context + self.dataService = HermesDataService(context: context) + } + var toolMessages: [HermesMessage] = [] var filterKind: ToolKind? diff --git a/scarf/scarf/Features/Activity/Views/ActivityView.swift b/scarf/scarf/Features/Activity/Views/ActivityView.swift index 7618fb7..2f163b2 100644 --- a/scarf/scarf/Features/Activity/Views/ActivityView.swift +++ b/scarf/scarf/Features/Activity/Views/ActivityView.swift @@ -1,9 +1,14 @@ import SwiftUI struct ActivityView: View { - @State private var viewModel = ActivityViewModel() + @State private var viewModel: ActivityViewModel @Environment(AppCoordinator.self) private var coordinator + init(context: ServerContext) { + _viewModel = State(initialValue: ActivityViewModel(context: context)) + } + + var body: some View { VStack(spacing: 0) { filterBar diff --git a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift index 3d240d8..41d5540 100644 --- a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift +++ b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift @@ -6,8 +6,29 @@ import os @Observable final class ChatViewModel { private let logger = Logger(subsystem: "com.scarf", category: "ChatViewModel") - private let dataService = HermesDataService() - private let fileService = HermesFileService() + let context: ServerContext + private let dataService: HermesDataService + private let fileService: HermesFileService + + init(context: ServerContext = .local) { + self.context = context + self.dataService = HermesDataService(context: context) + self.fileService = HermesFileService(context: context) + self.richChatViewModel = RichChatViewModel(context: context) + // Probe hermes binary existence once off-main, then cache. Doing + // this synchronously inside `hermesBinaryExists`'s getter would + // block main on every chat-body re-evaluation — for a remote + // context that's a SSH `test -e` round-trip on every streaming + // chunk, which manifests as the chat screen flashing or going + // blank during prompts. + Task.detached(priority: .userInitiated) { [context] in + let exists = context.fileExists(context.paths.hermesBinary) + await MainActor.run { [weak self] in + self?.hermesBinaryExists = exists + } + } + } + var recentSessions: [HermesSession] = [] var sessionPreviews: [String: String] = [:] @@ -17,7 +38,7 @@ final class ChatViewModel { var ttsEnabled = false var isRecording = false var displayMode: ChatDisplayMode = .richChat - let richChatViewModel = RichChatViewModel() + let richChatViewModel: RichChatViewModel private var coordinator: Coordinator? // ACP state @@ -43,14 +64,17 @@ final class ChatViewModel { private static let reconnectBaseDelay: UInt64 = 1_000_000_000 // 1 second private static let maxReconnectDelay: UInt64 = 16_000_000_000 // 16 seconds - var hermesBinaryExists: Bool { - FileManager.default.fileExists(atPath: HermesPaths.hermesBinary) - } + /// Cached result of probing for `hermes` on the target server. Updated + /// once at init by a detached task; defaults to `true` so the chat + /// view doesn't briefly flash "Hermes not found" while the async + /// probe runs. Set to `false` only after the probe confirms the + /// binary really isn't there. + var hermesBinaryExists: Bool = true /// Re-checks env + `~/.hermes/.env` for AI-provider credentials and /// updates `missingCredentials`. Cheap — safe to call from view `.task`. func refreshCredentialPreflight() { - missingCredentials = !HermesFileService.hasAnyAICredential() + missingCredentials = !fileService.hasAnyAICredential() } /// Clears the error/hint/details triplet so future failures overwrite @@ -114,7 +138,14 @@ final class ChatViewModel { // Find most recent session and resume via ACP Task { @MainActor in let opened = await dataService.open() - guard opened else { return } + if !opened { + acpError = context.isRemote + ? "Couldn't reach \(context.displayName). Check the SSH connection and try again." + : "Couldn't open the Hermes state database." + acpErrorHint = nil + acpErrorDetails = nil + return + } let sessionId = await dataService.fetchMostRecentlyActiveSessionId() await dataService.close() if let sessionId { @@ -159,7 +190,7 @@ final class ChatViewModel { } } - let client = ACPClient() + let client = ACPClient(context: context) self.acpClient = client do { @@ -247,7 +278,7 @@ final class ChatViewModel { clearACPErrorState() acpStatus = "Starting..." - let client = ACPClient() + let client = ACPClient(context: context) self.acpClient = client Task { @MainActor in @@ -385,7 +416,7 @@ final class ChatViewModel { guard !Task.isCancelled else { return } } - let client = ACPClient() + let client = ACPClient(context: context) do { try await client.start() @@ -542,11 +573,44 @@ final class ChatViewModel { var env = ProcessInfo.processInfo.environment env["TERM"] = "xterm-256color" env["COLORTERM"] = "truecolor" + // Inherit ssh-agent socket for remote so password-less auth works. + if context.isRemote { + let shellEnv = HermesFileService.enrichedEnvironment() + for key in ["SSH_AUTH_SOCK", "SSH_AGENT_PID"] { + if env[key] == nil, let v = shellEnv[key], !v.isEmpty { + env[key] = v + } + } + } let envArray = env.map { "\($0.key)=\($0.value)" } + // For remote: wrap the invocation in `ssh -t host -- hermes ` + // so the embedded terminal opens a pty against the remote and the + // hermes TUI gets the bytes it expects. `-t` requests a pty (the + // SwiftTerm view is one). + let exe: String + let argv: [String] + if context.isRemote, case .ssh(let cfg) = context.kind { + let host = cfg.user.map { "\($0)@\(cfg.host)" } ?? cfg.host + exe = "/usr/bin/ssh" + var sshArgs: [String] = ["-t"] + if let port = cfg.port { sshArgs += ["-p", String(port)] } + if let id = cfg.identityFile, !id.isEmpty { sshArgs += ["-i", id] } + sshArgs += ["-o", "StrictHostKeyChecking=accept-new"] + sshArgs += ["-o", "BatchMode=yes"] + sshArgs.append(host) + sshArgs.append("--") + sshArgs.append(context.paths.hermesBinary) + sshArgs.append(contentsOf: arguments) + argv = sshArgs + } else { + exe = context.paths.hermesBinary + argv = arguments + } + terminal.startProcess( - executable: HermesPaths.hermesBinary, - args: arguments, + executable: exe, + args: argv, environment: envArray, execName: nil ) diff --git a/scarf/scarf/Features/Chat/ViewModels/RichChatViewModel.swift b/scarf/scarf/Features/Chat/ViewModels/RichChatViewModel.swift index 6def0f2..0b94063 100644 --- a/scarf/scarf/Features/Chat/ViewModels/RichChatViewModel.swift +++ b/scarf/scarf/Features/Chat/ViewModels/RichChatViewModel.swift @@ -25,7 +25,14 @@ struct MessageGroup: Identifiable { @Observable final class RichChatViewModel { - private let dataService = HermesDataService() + let context: ServerContext + private let dataService: HermesDataService + + init(context: ServerContext = .local) { + self.context = context + self.dataService = HermesDataService(context: context) + } + var messages: [HermesMessage] = [] var currentSession: HermesSession? diff --git a/scarf/scarf/Features/Chat/Views/ChatView.swift b/scarf/scarf/Features/Chat/Views/ChatView.swift index b97902d..87fd552 100644 --- a/scarf/scarf/Features/Chat/Views/ChatView.swift +++ b/scarf/scarf/Features/Chat/Views/ChatView.swift @@ -304,7 +304,7 @@ struct ChatView: View { ContentUnavailableView( "Hermes Not Found", systemImage: "terminal", - description: Text("Expected at \(HermesPaths.hermesBinary)") + description: Text("Expected at \(viewModel.context.paths.hermesBinary)") ) .frame(maxWidth: .infinity, maxHeight: .infinity) } @@ -331,7 +331,7 @@ struct ChatView: View { ContentUnavailableView( "Hermes Not Found", systemImage: "terminal", - description: Text("Expected at \(HermesPaths.hermesBinary)") + description: Text("Expected at \(viewModel.context.paths.hermesBinary)") ) .frame(maxWidth: .infinity, maxHeight: .infinity) } diff --git a/scarf/scarf/Features/Chat/Views/RichChatMessageList.swift b/scarf/scarf/Features/Chat/Views/RichChatMessageList.swift index 36e8d74..8295431 100644 --- a/scarf/scarf/Features/Chat/Views/RichChatMessageList.swift +++ b/scarf/scarf/Features/Chat/Views/RichChatMessageList.swift @@ -6,9 +6,11 @@ struct RichChatMessageList: View { /// External trigger to force a scroll-to-bottom (e.g., from "Return to Active Session"). var scrollTrigger: UUID = UUID() - /// Track the last group's assistant content length to detect streaming updates. + /// Stable scroll target. Must NOT depend on `isWorking` — if the anchor + /// flipped between "typing-indicator" and "group-N" at stream start/ + /// finish, two onChange handlers would race to scroll to different + /// targets and the chat would visibly jump. private var scrollAnchor: String { - if isWorking { return "typing-indicator" } if let last = groups.last { return "group-\(last.id)" } return "scroll-top" } @@ -19,6 +21,11 @@ struct RichChatMessageList: View { LazyVStack(alignment: .leading, spacing: 16) { Spacer(minLength: 0) .id("scroll-top") + + if groups.isEmpty && !isWorking { + emptyState + } + ForEach(groups) { group in MessageGroupView(group: group) .id("group-\(group.id)") @@ -32,7 +39,6 @@ struct RichChatMessageList: View { .padding() } .defaultScrollAnchor(.bottom) - // Scroll to bottom when view first appears with content .onAppear { if !groups.isEmpty { DispatchQueue.main.async { @@ -40,33 +46,39 @@ struct RichChatMessageList: View { } } } - // Scroll on new groups + // New turn: animate to the bottom. .onChange(of: groups.count) { scrollToBottom(proxy: proxy) } - // Scroll when agent starts/stops working - .onChange(of: isWorking) { - scrollToBottom(proxy: proxy) - } - // Scroll on streaming content updates (group content changes) - .onChange(of: scrollAnchor) { - scrollToBottom(proxy: proxy) - } - // Scroll on last message content change (streaming text) + // Streaming chunks: track the bottom without animation so the + // text glides instead of bouncing. .onChange(of: groups.last?.assistantMessages.last?.content ?? "") { scrollToBottom(proxy: proxy, animated: false) } - // Scroll on tool call count change - .onChange(of: groups.last?.toolCallCount ?? 0) { - scrollToBottom(proxy: proxy) - } - // Scroll on external trigger (e.g., "Return to Active Session" button) + // Explicit "Return to Active Session" button. .onChange(of: scrollTrigger) { scrollToBottom(proxy: proxy) } } } + private var emptyState: some View { + VStack(spacing: 12) { + Image(systemName: "bubble.left.and.text.bubble.right") + .font(.system(size: 40)) + .foregroundStyle(.tertiary) + Text("Chat Messages") + .font(.title3) + .fontWeight(.semibold) + Text("Messages will appear here as the conversation progresses.") + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 80) + } + private func scrollToBottom(proxy: ScrollViewProxy, animated: Bool = true) { let target = scrollAnchor if animated { @@ -108,7 +120,17 @@ struct MessageGroupView: View { RichMessageBubble(message: user, toolResults: [:]) } - ForEach(group.assistantMessages.filter(\.isAssistant)) { message in + // Identify by array offset rather than `message.id`. The + // streaming assistant message starts with id=0 and gets a + // new negative id when finalized — using `\.id` would make + // SwiftUI think the bubble disappeared and a new one appeared + // (destroying + recreating the view, which manifests as the + // chat flashing or jumping right when the prompt completes). + // Within a single group the assistant messages are + // append-only, so offset is a stable identity for the + // group's lifetime. + let assistantMessages = group.assistantMessages.filter(\.isAssistant) + ForEach(Array(assistantMessages.enumerated()), id: \.offset) { _, message in RichMessageBubble(message: message, toolResults: group.toolResults) } diff --git a/scarf/scarf/Features/Chat/Views/RichChatView.swift b/scarf/scarf/Features/Chat/Views/RichChatView.swift index 5c61d0f..f4e4f85 100644 --- a/scarf/scarf/Features/Chat/Views/RichChatView.swift +++ b/scarf/scarf/Features/Chat/Views/RichChatView.swift @@ -21,20 +21,15 @@ struct RichChatView: View { ) Divider() - if richChat.messageGroups.isEmpty && !richChat.isAgentWorking { - ContentUnavailableView( - "Chat Messages", - systemImage: "bubble.left.and.text.bubble.right", - description: Text("Messages will appear here as the conversation progresses.") - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - RichChatMessageList( - groups: richChat.messageGroups, - isWorking: richChat.isAgentWorking, - scrollTrigger: richChat.scrollTrigger - ) - } + // Always mount RichChatMessageList; empty state lives inside it. + // Swapping between a ContentUnavailableView and the ScrollView + // hierarchy on first message caused a full view tree rebuild, + // which manifests as a white flash. + RichChatMessageList( + groups: richChat.messageGroups, + isWorking: richChat.isAgentWorking, + scrollTrigger: richChat.scrollTrigger + ) Divider() RichChatInputBar( diff --git a/scarf/scarf/Features/Common/LoadingOverlay.swift b/scarf/scarf/Features/Common/LoadingOverlay.swift new file mode 100644 index 0000000..8405065 --- /dev/null +++ b/scarf/scarf/Features/Common/LoadingOverlay.swift @@ -0,0 +1,74 @@ +import SwiftUI + +/// Translucent loading overlay used by feature views while their VM's +/// `load()` runs in the background. Shows a centered ProgressView with +/// optional label; the underlying content stays visible (just dimmed) +/// when it's already populated, or the overlay fully covers an empty +/// section so the user sees activity instead of "nothing here yet". +/// +/// Usage: +/// ```swift +/// SomeContent() +/// .loadingOverlay(viewModel.isLoading, label: "Loading credentials…", isEmpty: viewModel.pools.isEmpty) +/// ``` +/// +/// The `isEmpty` flag controls whether the overlay covers the full view +/// (when there's no stale content to show under it) or just dims it +/// (when refreshing existing data). +struct LoadingOverlay: ViewModifier { + let isLoading: Bool + let label: String + let isEmpty: Bool + + func body(content: Content) -> some View { + content + .overlay { + if isLoading { + if isEmpty { + // Full cover: empty state. User has no data to look at, + // so own the whole pane with the spinner. + VStack(spacing: 12) { + ProgressView() + .controlSize(.large) + Text(label) + .font(.callout) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(NSColor.windowBackgroundColor)) + } else { + // Stale-content refresh: top-trailing pill so the + // user sees data is being refreshed without losing + // their place. + VStack { + HStack { + Spacer() + HStack(spacing: 6) { + ProgressView() + .controlSize(.small) + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(.thinMaterial, in: Capsule()) + .padding(8) + } + Spacer() + } + } + } + } + } +} + +extension View { + /// Show a loading indicator while `isLoading` is true. If `isEmpty` is + /// also true, the indicator covers the full view; otherwise it shows + /// as a small refresh pill in the top-trailing corner so existing + /// content stays visible. + func loadingOverlay(_ isLoading: Bool, label: String = "Loading…", isEmpty: Bool = false) -> some View { + modifier(LoadingOverlay(isLoading: isLoading, label: label, isEmpty: isEmpty)) + } +} diff --git a/scarf/scarf/Features/CredentialPools/ViewModels/CredentialPoolsViewModel.swift b/scarf/scarf/Features/CredentialPools/ViewModels/CredentialPoolsViewModel.swift index 5d0f25a..79cce38 100644 --- a/scarf/scarf/Features/CredentialPools/ViewModels/CredentialPoolsViewModel.swift +++ b/scarf/scarf/Features/CredentialPools/ViewModels/CredentialPoolsViewModel.swift @@ -28,6 +28,12 @@ struct HermesCredentialPool: Identifiable, Sendable { @MainActor final class CredentialPoolsViewModel { private let logger = Logger(subsystem: "com.scarf", category: "CredentialPoolsViewModel") + let context: ServerContext + + init(context: ServerContext = .local) { + self.context = context + self.oauthFlow = OAuthFlowController(context: context) + } var pools: [HermesCredentialPool] = [] var isLoading = false @@ -37,7 +43,7 @@ final class CredentialPoolsViewModel { /// can extract the authorization URL, pop it open with an explicit button, /// and feed the code back via stdin. See OAuthFlowController for why we /// moved off the embedded-terminal approach. - let oauthFlow = OAuthFlowController() + let oauthFlow: OAuthFlowController var oauthProvider: String = "" /// Convenience — the sheet keys a lot of UI off "is the flow running?". var oauthInProgress: Bool { oauthFlow.isRunning } @@ -47,34 +53,42 @@ final class CredentialPoolsViewModel { /// Source of truth is `~/.hermes/auth.json`. Parsing box-drawn `hermes auth list` /// output is fragile — the JSON file is structured, stable, and already stores /// exactly the pool data the UI needs. We never display full tokens. + /// + /// Runs the file reads on a detached task so the synchronous SSH calls + /// (which can block for hundreds of milliseconds even with ControlMaster + /// multiplexing) don't freeze the main thread / spin the beach ball. func load() { isLoading = true - defer { isLoading = false } + let ctx = context + Task.detached { [weak self] in + let authData = ctx.readData(ctx.paths.authJSON) + let yaml = ctx.readText(ctx.paths.configYAML) ?? "" + let strategies = Self.parseStrategies(from: yaml) - let authPath = HermesPaths.home + "/auth.json" - let strategies = parseStrategies() + let decodedPools: [HermesCredentialPool] + if let data = authData, + let decoded = try? JSONDecoder().decode(AuthFile.self, from: data) { + decodedPools = Self.buildPools(from: decoded, strategies: strategies) + } else { + decodedPools = [] + } - guard let data = try? Data(contentsOf: URL(fileURLWithPath: authPath)) else { - pools = [] - return - } - do { - let decoded = try JSONDecoder().decode(AuthFile.self, from: data) - pools = Self.buildPools(from: decoded, strategies: strategies) - } catch { - logger.error("Failed to decode auth.json: \(error.localizedDescription)") - pools = [] + await MainActor.run { [weak self] in + self?.pools = decodedPools + self?.isLoading = false + } } } /// The `credential_pool_strategies:` map lives in config.yaml as `: `. - private func parseStrategies() -> [String: String] { - guard let yaml = try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8) else { return [:] } + /// Pure-function form so it's safe to call from the detached load task. + nonisolated private static func parseStrategies(from yaml: String) -> [String: String] { + guard !yaml.isEmpty else { return [:] } let parsed = HermesFileService.parseNestedYAML(yaml) return parsed.maps["credential_pool_strategies"] ?? [:] } - private static func buildPools(from auth: AuthFile, strategies: [String: String]) -> [HermesCredentialPool] { + nonisolated private static func buildPools(from auth: AuthFile, strategies: [String: String]) -> [HermesCredentialPool] { auth.credential_pool.keys.sorted().map { provider in let entries = auth.credential_pool[provider] ?? [] let creds = entries.enumerated().map { index, entry in @@ -100,7 +114,7 @@ final class CredentialPoolsViewModel { /// Return last 4 chars prefixed with "…", or "" if the token is too short. /// Callers MUST NOT pass the full token anywhere user-visible beyond this. - private static func tail(of token: String) -> String { + nonisolated private static func tail(of token: String) -> String { guard token.count >= 4 else { return "" } return "…" + String(token.suffix(4)) } @@ -206,21 +220,7 @@ final class CredentialPoolsViewModel { @discardableResult private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) { - let process = Process() - process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary) - process.arguments = arguments - process.environment = HermesFileService.enrichedEnvironment() - 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) - } + context.runHermes(arguments) } } diff --git a/scarf/scarf/Features/CredentialPools/ViewModels/OAuthFlowController.swift b/scarf/scarf/Features/CredentialPools/ViewModels/OAuthFlowController.swift index e2e2af7..29500d6 100644 --- a/scarf/scarf/Features/CredentialPools/ViewModels/OAuthFlowController.swift +++ b/scarf/scarf/Features/CredentialPools/ViewModels/OAuthFlowController.swift @@ -27,6 +27,12 @@ import os @MainActor final class OAuthFlowController { private let logger = Logger(subsystem: "com.scarf", category: "OAuthFlowController") + let context: ServerContext + + init(context: ServerContext = .local) { + self.context = context + } + // MARK: - Observable state @@ -82,10 +88,20 @@ final class OAuthFlowController { args += ["--label", trimmedLabel] } - let proc = Process() - proc.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary) - proc.arguments = args - proc.environment = HermesFileService.enrichedEnvironment() + // Use the transport so OAuth works against remote contexts too: + // local spawns hermes directly, remote rounds through ssh -T while + // preserving stdin (for the auth-code prompt) and stdout (for the + // URL parser). + let proc = context.makeTransport().makeProcess( + executable: context.paths.hermesBinary, + args: args + ) + if !context.isRemote { + // Only enrich env locally — the remote ssh process gets the + // remote login env naturally, and exporting our local API keys + // into it would be wrong. + proc.environment = HermesFileService.enrichedEnvironment() + } let outPipe = Pipe() let inPipe = Pipe() diff --git a/scarf/scarf/Features/CredentialPools/Views/CredentialPoolsView.swift b/scarf/scarf/Features/CredentialPools/Views/CredentialPoolsView.swift index ad97eaa..7508b5c 100644 --- a/scarf/scarf/Features/CredentialPools/Views/CredentialPoolsView.swift +++ b/scarf/scarf/Features/CredentialPools/Views/CredentialPoolsView.swift @@ -1,10 +1,15 @@ import SwiftUI struct CredentialPoolsView: View { - @State private var viewModel = CredentialPoolsViewModel() + @State private var viewModel: CredentialPoolsViewModel @State private var showAddSheet = false @State private var pendingRemove: HermesCredential? + init(context: ServerContext) { + _viewModel = State(initialValue: CredentialPoolsViewModel(context: context)) + } + + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { @@ -24,6 +29,11 @@ struct CredentialPoolsView: View { .frame(maxWidth: .infinity, alignment: .topLeading) } .navigationTitle("Credential Pools") + .loadingOverlay( + viewModel.isLoading, + label: "Loading credentials…", + isEmpty: viewModel.pools.isEmpty + ) .onAppear { viewModel.load() } .sheet(isPresented: $showAddSheet) { AddCredentialSheet(viewModel: viewModel) { @@ -194,7 +204,7 @@ private struct AddCredentialSheet: View { @State private var oauthStarted: Bool = false @State private var authCode: String = "" - private let catalog = ModelCatalogService() + private var catalog: ModelCatalogService { ModelCatalogService(context: viewModel.context) } var body: some View { VStack(alignment: .leading, spacing: 16) { diff --git a/scarf/scarf/Features/Cron/ViewModels/CronViewModel.swift b/scarf/scarf/Features/Cron/ViewModels/CronViewModel.swift index 7e1e34d..834e9c8 100644 --- a/scarf/scarf/Features/Cron/ViewModels/CronViewModel.swift +++ b/scarf/scarf/Features/Cron/ViewModels/CronViewModel.swift @@ -5,7 +5,14 @@ import os @Observable final class CronViewModel { private let logger = Logger(subsystem: "com.scarf", category: "CronViewModel") - private let fileService = HermesFileService() + let context: ServerContext + private let fileService: HermesFileService + + init(context: ServerContext = .local) { + self.context = context + self.fileService = HermesFileService(context: context) + } + var jobs: [HermesCronJob] = [] var selectedJob: HermesCronJob? @@ -14,19 +21,37 @@ final class CronViewModel { var message: String? var showCreateSheet = false var editingJob: HermesCronJob? + var isLoading = false func load() { - jobs = fileService.loadCronJobs() - availableSkills = fileService.loadSkills().flatMap { $0.skills.map(\.id) }.sorted() - if let selected = selectedJob, let refreshed = jobs.first(where: { $0.id == selected.id }) { - selectedJob = refreshed - jobOutput = fileService.loadCronOutput(jobId: refreshed.id) + isLoading = true + let svc = fileService + let selectedID = selectedJob?.id + Task.detached { [weak self] in + // Three sync transport ops on remote — keep them off main. + let jobs = svc.loadCronJobs() + let skills = svc.loadSkills().flatMap { $0.skills.map(\.id) }.sorted() + let refreshed = selectedID.flatMap { id in jobs.first(where: { $0.id == id }) } + let output = refreshed.flatMap { svc.loadCronOutput(jobId: $0.id) } + await MainActor.run { [weak self] in + guard let self else { return } + self.jobs = jobs + self.availableSkills = skills + if let refreshed { self.selectedJob = refreshed } + if output != nil { self.jobOutput = output } + self.isLoading = false + } } } func selectJob(_ job: HermesCronJob) { selectedJob = job - jobOutput = fileService.loadCronOutput(jobId: job.id) + let svc = fileService + let jobID = job.id + Task.detached { [weak self] in + let output = svc.loadCronOutput(jobId: jobID) + await MainActor.run { [weak self] in self?.jobOutput = output } + } } // MARK: - CLI wrappers diff --git a/scarf/scarf/Features/Cron/Views/CronView.swift b/scarf/scarf/Features/Cron/Views/CronView.swift index 92c0480..169a495 100644 --- a/scarf/scarf/Features/Cron/Views/CronView.swift +++ b/scarf/scarf/Features/Cron/Views/CronView.swift @@ -1,9 +1,14 @@ import SwiftUI struct CronView: View { - @State private var viewModel = CronViewModel() + @State private var viewModel: CronViewModel @State private var pendingDelete: HermesCronJob? + init(context: ServerContext) { + _viewModel = State(initialValue: CronViewModel(context: context)) + } + + var body: some View { HSplitView { jobsList @@ -12,6 +17,7 @@ struct CronView: View { .frame(minWidth: 400) } .navigationTitle("Cron Jobs") + .loadingOverlay(viewModel.isLoading, label: "Loading cron jobs…", isEmpty: viewModel.jobs.isEmpty) .onAppear { viewModel.load() } .sheet(isPresented: $viewModel.showCreateSheet) { CronJobEditor(mode: .create, availableSkills: viewModel.availableSkills) { form in diff --git a/scarf/scarf/Features/Dashboard/ViewModels/DashboardViewModel.swift b/scarf/scarf/Features/Dashboard/ViewModels/DashboardViewModel.swift index 9c15c3a..f0d3ea9 100644 --- a/scarf/scarf/Features/Dashboard/ViewModels/DashboardViewModel.swift +++ b/scarf/scarf/Features/Dashboard/ViewModels/DashboardViewModel.swift @@ -2,8 +2,16 @@ import Foundation @Observable final class DashboardViewModel { - private let dataService = HermesDataService() - private let fileService = HermesFileService() + let context: ServerContext + private let dataService: HermesDataService + private let fileService: HermesFileService + + init(context: ServerContext = .local) { + self.context = context + self.dataService = HermesDataService(context: context) + self.fileService = HermesFileService(context: context) + } + var stats = HermesDataService.SessionStats.empty var recentSessions: [HermesSession] = [] @@ -22,9 +30,17 @@ final class DashboardViewModel { sessionPreviews = await dataService.fetchSessionPreviews(limit: 5) await dataService.close() } - config = fileService.loadConfig() - gatewayState = fileService.loadGatewayState() - hermesRunning = fileService.isHermesRunning() + // The fileService methods are synchronous and route through the + // transport. For remote contexts each call is a blocking ssh + // round-trip — do them off the main thread to avoid spinning the + // beach ball during the load. + let svc = fileService + let (cfg, gw, running) = await Task.detached { + (svc.loadConfig(), svc.loadGatewayState(), svc.isHermesRunning()) + }.value + config = cfg + gatewayState = gw + hermesRunning = running isLoading = false } } diff --git a/scarf/scarf/Features/Dashboard/Views/DashboardView.swift b/scarf/scarf/Features/Dashboard/Views/DashboardView.swift index d19c517..2f041ba 100644 --- a/scarf/scarf/Features/Dashboard/Views/DashboardView.swift +++ b/scarf/scarf/Features/Dashboard/Views/DashboardView.swift @@ -1,10 +1,15 @@ import SwiftUI struct DashboardView: View { - @State private var viewModel = DashboardViewModel() + @State private var viewModel: DashboardViewModel @Environment(AppCoordinator.self) private var coordinator @Environment(HermesFileWatcher.self) private var fileWatcher + init(context: ServerContext) { + _viewModel = State(initialValue: DashboardViewModel(context: context)) + } + + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { @@ -16,6 +21,11 @@ struct DashboardView: View { .frame(maxWidth: .infinity, alignment: .topLeading) } .navigationTitle("Dashboard") + .loadingOverlay( + viewModel.isLoading, + label: "Loading dashboard…", + isEmpty: viewModel.recentSessions.isEmpty + ) .task { await viewModel.load() } .onChange(of: fileWatcher.lastChangeDate) { Task { await viewModel.load() } diff --git a/scarf/scarf/Features/Gateway/ViewModels/GatewayViewModel.swift b/scarf/scarf/Features/Gateway/ViewModels/GatewayViewModel.swift index 702736a..ecb62d5 100644 --- a/scarf/scarf/Features/Gateway/ViewModels/GatewayViewModel.swift +++ b/scarf/scarf/Features/Gateway/ViewModels/GatewayViewModel.swift @@ -37,6 +37,12 @@ struct PendingPairing: Identifiable { @Observable final class GatewayViewModel { + let context: ServerContext + + init(context: ServerContext = .local) { + self.context = context + } + var gateway = GatewayInfo(pid: nil, state: "unknown", exitReason: nil, startTime: nil, updatedAt: nil, platforms: [], isLoaded: false, isStale: false) var approvedUsers: [PairedUser] = [] var pendingPairings: [PendingPairing] = [] @@ -45,52 +51,26 @@ final class GatewayViewModel { 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 + let ctx = context + Task.detached { [weak self] in + // Two sync transport calls + two CLI invocations — substantial + // remote latency. Detach the whole load and commit at the end. + let status = Self.fetchGatewayStatus(context: ctx) + let pairing = Self.fetchPairing(context: ctx) + await MainActor.run { [weak self] in + guard let self else { return } + self.gateway = status + self.approvedUsers = pairing.approved + self.pendingPairings = pairing.pending + self.isLoading = false + } } } - 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) + /// Static form of the gateway-status walk so the detached load can call + /// it without bouncing back to MainActor. + private static func fetchGatewayStatus(context: ServerContext) -> GatewayInfo { + let stateJSON = context.readData(context.paths.gatewayStateJSON) var pid: Int? var state = "unknown" var exitReason: String? @@ -117,21 +97,21 @@ final class GatewayViewModel { } } - let statusOutput = runHermes(["gateway", "status"]).output + let statusOutput = context.runHermes(["gateway", "status"]).output let isLoaded = statusOutput.contains("service is loaded") let isStale = statusOutput.contains("stale") - gateway = GatewayInfo( + return 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 = [] + private static func fetchPairing(context: ServerContext) -> (approved: [PairedUser], pending: [PendingPairing]) { + let output = context.runHermes(["pairing", "list"]).output + var approved: [PairedUser] = [] + var pending: [PendingPairing] = [] var inApproved = false var inPending = false @@ -147,31 +127,59 @@ final class GatewayViewModel { 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 { + approved.append(PairedUser(platform: platform, userId: userId, name: name)) + } else if inPending && parts.count >= 2 { let platform = String(parts[0]) let code = String(parts[1]) - pendingPairings.append(PendingPairing(platform: platform, code: code)) + pending.append(PendingPairing(platform: platform, code: code)) } } + return (approved, pending) + } + + func startGateway() { + runHermes(["gateway", "start"]) + actionMessage = "Gateway start requested" + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in + self?.load() + self?.actionMessage = nil + } } - @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) + func stopGateway() { + runHermes(["gateway", "stop"]) + actionMessage = "Gateway stop requested" + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in + self?.load() + self?.actionMessage = nil } } + + func restartGateway() { + runHermes(["gateway", "restart"]) + actionMessage = "Gateway restart requested" + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in + self?.load() + self?.actionMessage = nil + } + } + + func approvePairing(platform: String, code: String) { + runHermes(["pairing", "approve", platform, code]) + load() + } + + func revokeUser(_ user: PairedUser) { + runHermes(["pairing", "revoke", user.platform, user.userId]) + approvedUsers.removeAll { $0.id == user.id } + } + + // MARK: - Private + // (loadGatewayStatus / loadPairing were moved to static helpers above + // so the detached load() can run them without touching MainActor state.) + + @discardableResult + private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) { + context.runHermes(arguments) + } } diff --git a/scarf/scarf/Features/Gateway/Views/GatewayView.swift b/scarf/scarf/Features/Gateway/Views/GatewayView.swift index 12034b5..8611b94 100644 --- a/scarf/scarf/Features/Gateway/Views/GatewayView.swift +++ b/scarf/scarf/Features/Gateway/Views/GatewayView.swift @@ -1,9 +1,14 @@ import SwiftUI struct GatewayView: View { - @State private var viewModel = GatewayViewModel() + @State private var viewModel: GatewayViewModel @Environment(HermesFileWatcher.self) private var fileWatcher + init(context: ServerContext) { + _viewModel = State(initialValue: GatewayViewModel(context: context)) + } + + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { diff --git a/scarf/scarf/Features/Health/ViewModels/HealthViewModel.swift b/scarf/scarf/Features/Health/ViewModels/HealthViewModel.swift index a9b1359..384da0b 100644 --- a/scarf/scarf/Features/Health/ViewModels/HealthViewModel.swift +++ b/scarf/scarf/Features/Health/ViewModels/HealthViewModel.swift @@ -22,7 +22,14 @@ struct HealthSection: Identifiable { @Observable final class HealthViewModel { - private let fileService = HermesFileService() + let context: ServerContext + private let fileService: HermesFileService + + init(context: ServerContext = .local) { + self.context = context + self.fileService = HermesFileService(context: context) + } + var version = "" var updateInfo = "" @@ -43,19 +50,50 @@ final class HealthViewModel { func load() { isLoading = true - refreshProcessStatus() - loadVersion() - let statusOutput = runHermes(["status"]).output - statusSections = parseOutput(statusOutput) - let doctorOutput = runHermes(["doctor"]).output - doctorSections = parseOutput(doctorOutput) - computeCounts() - isLoading = false + let ctx = context + let svc = fileService + // Health runs four sync transport-mediated commands plus a process + // probe — that's 4-5 ssh round-trips on remote, easily 1-2s. Detach + // the whole load. + Task.detached { [weak self] in + let pid = svc.hermesPID() + let versionOutput = ctx.runHermes(["version"]).output + let statusOutput = ctx.runHermes(["status"]).output + let doctorOutput = ctx.runHermes(["doctor"]).output + + let lines = versionOutput.components(separatedBy: "\n") + let version = lines.first ?? "" + let updateLine = lines.first(where: { $0.contains("commits behind") }) + let hasUpdate = updateLine != nil + let updateInfo = updateLine?.trimmingCharacters(in: .whitespaces) ?? "" + + let statusSections = Self.parseOutputStatic(statusOutput) + let doctorSections = Self.parseOutputStatic(doctorOutput) + + await MainActor.run { [weak self] in + guard let self else { return } + self.hermesPID = pid + self.hermesRunning = pid != nil + self.version = version + self.updateInfo = updateInfo + self.hasUpdate = hasUpdate + self.statusSections = statusSections + self.doctorSections = doctorSections + self.computeCounts() + self.isLoading = false + } + } } func refreshProcessStatus() { - hermesPID = fileService.hermesPID() - hermesRunning = hermesPID != nil + let svc = fileService + Task.detached { [weak self] in + let pid = svc.hermesPID() + await MainActor.run { [weak self] in + self?.hermesPID = pid + self?.hermesRunning = pid != nil + } + } } func stopHermes() { @@ -101,6 +139,96 @@ final class HealthViewModel { } } + /// Static-callable form for the detached load() task. The instance + /// `parseOutput` below delegates here so existing call sites still work. + nonisolated static func parseOutputStatic(_ output: String) -> [HealthSection] { + var sections: [HealthSection] = [] + var currentTitle = "" + var currentChecks: [HealthCheck] = [] + + for line in output.components(separatedBy: "\n") { + let trimmed = line.trimmingCharacters(in: .whitespaces) + + if trimmed.hasPrefix("◆ ") { + if !currentTitle.isEmpty { + sections.append(HealthSection( + title: currentTitle, + icon: iconForSectionStatic(currentTitle), + checks: currentChecks + )) + } + currentTitle = String(trimmed.dropFirst(2)) + currentChecks = [] + continue + } + + if trimmed.hasPrefix("✓ ") { + let text = String(trimmed.dropFirst(2)) + let (label, detail) = splitCheckStatic(text) + currentChecks.append(HealthCheck(label: label, status: .ok, detail: detail)) + } else if trimmed.hasPrefix("⚠ ") || trimmed.hasPrefix("⚠") { + let text = trimmed.replacingOccurrences(of: "⚠ ", with: "").replacingOccurrences(of: "⚠", with: "") + let (label, detail) = splitCheckStatic(text) + currentChecks.append(HealthCheck(label: label, status: .warning, detail: detail)) + } else if trimmed.hasPrefix("✗ ") { + let text = String(trimmed.dropFirst(2)) + let (label, detail) = splitCheckStatic(text) + currentChecks.append(HealthCheck(label: label, status: .error, detail: detail)) + } else if trimmed.hasPrefix("→ ") || trimmed.hasPrefix("Error:") { + if !currentChecks.isEmpty { + let last = currentChecks.removeLast() + let extra = trimmed.replacingOccurrences(of: "→ ", with: "").replacingOccurrences(of: "Error:", with: "").trimmingCharacters(in: .whitespaces) + let combined = [last.detail, extra].compactMap { $0 }.joined(separator: " ") + currentChecks.append(HealthCheck(label: last.label, status: last.status, detail: combined)) + } + } else if !trimmed.isEmpty && trimmed.contains(":") && !trimmed.hasPrefix("┌") && !trimmed.hasPrefix("│") && !trimmed.hasPrefix("└") && !trimmed.hasPrefix("─") && !trimmed.hasPrefix("Run ") && !trimmed.hasPrefix("Found ") && !trimmed.hasPrefix("Tip:") { + let parts = trimmed.split(separator: ":", maxSplits: 1) + if parts.count == 2 { + let key = parts[0].trimmingCharacters(in: .whitespaces) + let val = parts[1].trimmingCharacters(in: .whitespaces) + if !key.isEmpty && key.count < 30 { + currentChecks.append(HealthCheck(label: key, status: .ok, detail: val)) + } + } + } + } + + if !currentTitle.isEmpty { + sections.append(HealthSection( + title: currentTitle, + icon: iconForSectionStatic(currentTitle), + checks: currentChecks + )) + } + return sections + } + + nonisolated private static func splitCheckStatic(_ text: String) -> (String, String?) { + if let range = text.range(of: ":") { + let label = String(text[.. String { + let lower = title.lowercased() + if lower.contains("system") || lower.contains("environment") { return "desktopcomputer" } + if lower.contains("config") { return "doc.text" } + if lower.contains("model") || lower.contains("provider") { return "brain" } + if lower.contains("memory") { return "memorychip" } + if lower.contains("session") { return "list.bullet" } + if lower.contains("gateway") || lower.contains("platform") { return "antenna.radiowaves.left.and.right" } + if lower.contains("skill") { return "wrench.and.screwdriver" } + if lower.contains("mcp") { return "cube.box" } + if lower.contains("plugin") { return "puzzlepiece" } + if lower.contains("auth") || lower.contains("credential") { return "key" } + if lower.contains("disk") || lower.contains("storage") { return "internaldrive" } + if lower.contains("update") { return "arrow.triangle.2.circlepath" } + return "circle" + } + private func parseOutput(_ output: String) -> [HealthSection] { var sections: [HealthSection] = [] var currentTitle = "" @@ -237,19 +365,6 @@ final class HealthViewModel { @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) - } + context.runHermes(arguments) } } diff --git a/scarf/scarf/Features/Health/Views/HealthView.swift b/scarf/scarf/Features/Health/Views/HealthView.swift index 3c95677..10e1b8a 100644 --- a/scarf/scarf/Features/Health/Views/HealthView.swift +++ b/scarf/scarf/Features/Health/Views/HealthView.swift @@ -1,12 +1,17 @@ import SwiftUI struct HealthView: View { - @State private var viewModel = HealthViewModel() + @State private var viewModel: HealthViewModel @State private var expandedSection: UUID? @State private var selectedTab = 0 @State private var showShareConfirm = false @State private var showDiagnostics = false + init(context: ServerContext) { + _viewModel = State(initialValue: HealthViewModel(context: context)) + } + + var body: some View { VStack(spacing: 0) { headerBar @@ -43,6 +48,11 @@ struct HealthView: View { } } .navigationTitle("Health") + .loadingOverlay( + viewModel.isLoading, + label: "Running health checks…", + isEmpty: viewModel.statusSections.isEmpty && viewModel.doctorSections.isEmpty + ) .onAppear { viewModel.load() } .confirmationDialog("Upload debug report?", isPresented: $showShareConfirm) { Button("Upload", role: .destructive) { diff --git a/scarf/scarf/Features/Insights/ViewModels/InsightsViewModel.swift b/scarf/scarf/Features/Insights/ViewModels/InsightsViewModel.swift index 5a6edab..2292222 100644 --- a/scarf/scarf/Features/Insights/ViewModels/InsightsViewModel.swift +++ b/scarf/scarf/Features/Insights/ViewModels/InsightsViewModel.swift @@ -56,7 +56,14 @@ struct NotableSession: Identifiable { @Observable final class InsightsViewModel { - private let dataService = HermesDataService() + let context: ServerContext + private let dataService: HermesDataService + + init(context: ServerContext = .local) { + self.context = context + self.dataService = HermesDataService(context: context) + } + var period: InsightsPeriod = .month var isLoading = true diff --git a/scarf/scarf/Features/Insights/Views/InsightsView.swift b/scarf/scarf/Features/Insights/Views/InsightsView.swift index cc2b0aa..c6401e6 100644 --- a/scarf/scarf/Features/Insights/Views/InsightsView.swift +++ b/scarf/scarf/Features/Insights/Views/InsightsView.swift @@ -1,9 +1,14 @@ import SwiftUI struct InsightsView: View { - @State private var viewModel = InsightsViewModel() + @State private var viewModel: InsightsViewModel @Environment(AppCoordinator.self) private var coordinator + init(context: ServerContext) { + _viewModel = State(initialValue: InsightsViewModel(context: context)) + } + + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { diff --git a/scarf/scarf/Features/Logs/ViewModels/LogsViewModel.swift b/scarf/scarf/Features/Logs/ViewModels/LogsViewModel.swift index f9158e7..5ed799e 100644 --- a/scarf/scarf/Features/Logs/ViewModels/LogsViewModel.swift +++ b/scarf/scarf/Features/Logs/ViewModels/LogsViewModel.swift @@ -2,7 +2,13 @@ import Foundation @Observable final class LogsViewModel { - private let logService = HermesLogService() + let context: ServerContext + private let logService: HermesLogService + + init(context: ServerContext = .local) { + self.context = context + self.logService = HermesLogService(context: context) + } var entries: [LogEntry] = [] var selectedLogFile: LogFile = .agent @@ -17,13 +23,13 @@ final class LogsViewModel { case gateway = "gateway.log" var id: String { rawValue } + } - var path: String { - switch self { - case .agent: return HermesPaths.agentLog - case .errors: return HermesPaths.errorsLog - case .gateway: return HermesPaths.gatewayLog - } + private func path(for file: LogFile) -> String { + switch file { + case .agent: return context.paths.agentLog + case .errors: return context.paths.errorsLog + case .gateway: return context.paths.gatewayLog } } @@ -62,7 +68,7 @@ final class LogsViewModel { } func load() async { - await logService.openLog(path: selectedLogFile.path) + await logService.openLog(path: path(for: selectedLogFile)) entries = await logService.readLastLines(count: 500) await logService.seekToEnd() startPolling() @@ -71,7 +77,7 @@ final class LogsViewModel { func switchLogFile(_ file: LogFile) async { selectedLogFile = file entries = [] - await logService.openLog(path: file.path) + await logService.openLog(path: path(for: file)) entries = await logService.readLastLines(count: 500) await logService.seekToEnd() } diff --git a/scarf/scarf/Features/Logs/Views/LogsView.swift b/scarf/scarf/Features/Logs/Views/LogsView.swift index 0842d5e..d21aa89 100644 --- a/scarf/scarf/Features/Logs/Views/LogsView.swift +++ b/scarf/scarf/Features/Logs/Views/LogsView.swift @@ -1,7 +1,12 @@ import SwiftUI struct LogsView: View { - @State private var viewModel = LogsViewModel() + @State private var viewModel: LogsViewModel + + init(context: ServerContext) { + _viewModel = State(initialValue: LogsViewModel(context: context)) + } + var body: some View { VStack(spacing: 0) { diff --git a/scarf/scarf/Features/MCPServers/ViewModels/MCPServerEditorViewModel.swift b/scarf/scarf/Features/MCPServers/ViewModels/MCPServerEditorViewModel.swift index fe922d3..e2b30e7 100644 --- a/scarf/scarf/Features/MCPServers/ViewModels/MCPServerEditorViewModel.swift +++ b/scarf/scarf/Features/MCPServers/ViewModels/MCPServerEditorViewModel.swift @@ -8,7 +8,8 @@ final class MCPServerEditorViewModel { var value: String } - private let fileService = HermesFileService() + let context: ServerContext + private let fileService: HermesFileService let server: HermesMCPServer var envDraft: [KeyValueRow] @@ -23,8 +24,10 @@ final class MCPServerEditorViewModel { var isSaving: Bool = false var saveError: String? - init(server: HermesMCPServer) { + init(server: HermesMCPServer, context: ServerContext = .local) { self.server = server + self.context = context + self.fileService = HermesFileService(context: context) self.envDraft = server.env.keys.sorted().map { KeyValueRow(key: $0, value: server.env[$0] ?? "") } self.headersDraft = server.headers.keys.sorted().map { KeyValueRow(key: $0, value: server.headers[$0] ?? "") } self.includeDraft = server.toolsInclude.joined(separator: ", ") @@ -93,7 +96,7 @@ final class MCPServerEditorViewModel { await MainActor.run { self.isSaving = false if !success { - self.saveError = "One or more fields could not be written. Check \(HermesPaths.configYAML)." + self.saveError = "One or more fields could not be written. Check \(self.context.paths.configYAML)." } completion(success) } diff --git a/scarf/scarf/Features/MCPServers/ViewModels/MCPServersViewModel.swift b/scarf/scarf/Features/MCPServers/ViewModels/MCPServersViewModel.swift index dc09c8b..bed1756 100644 --- a/scarf/scarf/Features/MCPServers/ViewModels/MCPServersViewModel.swift +++ b/scarf/scarf/Features/MCPServers/ViewModels/MCPServersViewModel.swift @@ -2,7 +2,14 @@ import Foundation @Observable final class MCPServersViewModel { - private let fileService = HermesFileService() + let context: ServerContext + private let fileService: HermesFileService + + init(context: ServerContext = .local) { + self.context = context + self.fileService = HermesFileService(context: context) + } + var servers: [HermesMCPServer] = [] var selectedServerName: String? @@ -41,10 +48,19 @@ final class MCPServersViewModel { func load() { isLoading = true - servers = fileService.loadMCPServers() - isLoading = false - if let name = selectedServerName, !servers.contains(where: { $0.name == name }) { - selectedServerName = nil + let svc = fileService + Task.detached { [weak self] in + // loadMCPServers reads config.yaml + lists mcp-tokens — both + // are sync transport calls that block on remote ssh round-trips. + let result = svc.loadMCPServers() + await MainActor.run { [weak self] in + guard let self else { return } + self.servers = result + self.isLoading = false + if let name = self.selectedServerName, !result.contains(where: { $0.name == name }) { + self.selectedServerName = nil + } + } } } diff --git a/scarf/scarf/Features/MCPServers/Views/MCPServersView.swift b/scarf/scarf/Features/MCPServers/Views/MCPServersView.swift index ed3e60e..bc54d79 100644 --- a/scarf/scarf/Features/MCPServers/Views/MCPServersView.swift +++ b/scarf/scarf/Features/MCPServers/Views/MCPServersView.swift @@ -1,7 +1,12 @@ import SwiftUI struct MCPServersView: View { - @State private var viewModel = MCPServersViewModel() + @State private var viewModel: MCPServersViewModel + + init(context: ServerContext) { + _viewModel = State(initialValue: MCPServersViewModel(context: context)) + } + var body: some View { HSplitView { @@ -11,6 +16,11 @@ struct MCPServersView: View { .frame(minWidth: 500) } .navigationTitle("MCP Servers (\(viewModel.servers.count))") + .loadingOverlay( + viewModel.isLoading, + label: "Loading MCP servers…", + isEmpty: viewModel.servers.isEmpty + ) .searchable(text: $viewModel.searchText, prompt: "Filter servers...") .toolbar { ToolbarItemGroup(placement: .primaryAction) { diff --git a/scarf/scarf/Features/Memory/ViewModels/MemoryViewModel.swift b/scarf/scarf/Features/Memory/ViewModels/MemoryViewModel.swift index 782940a..01ef546 100644 --- a/scarf/scarf/Features/Memory/ViewModels/MemoryViewModel.swift +++ b/scarf/scarf/Features/Memory/ViewModels/MemoryViewModel.swift @@ -2,7 +2,14 @@ import Foundation @Observable final class MemoryViewModel { - private let fileService = HermesFileService() + let context: ServerContext + private let fileService: HermesFileService + + init(context: ServerContext = .local) { + self.context = context + self.fileService = HermesFileService(context: context) + } + var memoryContent = "" var userContent = "" @@ -12,6 +19,7 @@ final class MemoryViewModel { var editText = "" var profiles: [String] = [] var activeProfile = "" + var isLoading = false enum EditTarget { case memory, user @@ -30,20 +38,40 @@ final class MemoryViewModel { var hasMultipleProfiles: Bool { !profiles.isEmpty } func load() { - let config = fileService.loadConfig() - memoryProvider = config.memoryProvider - profiles = fileService.loadMemoryProfiles() - if activeProfile.isEmpty { - activeProfile = config.memoryProfile + isLoading = true + let svc = fileService + let currentProfile = activeProfile + // Sync transport calls would beach-ball the UI on remote — dispatch + // off main, then commit results back on MainActor. + Task.detached { [weak self] in + let config = svc.loadConfig() + let profiles = svc.loadMemoryProfiles() + let profile = currentProfile.isEmpty ? config.memoryProfile : currentProfile + let memory = svc.loadMemory(profile: profile) + let user = svc.loadUserProfile(profile: profile) + await MainActor.run { [weak self] in + guard let self else { return } + self.memoryProvider = config.memoryProvider + self.profiles = profiles + self.activeProfile = profile + self.memoryContent = memory + self.userContent = user + self.isLoading = false + } } - memoryContent = fileService.loadMemory(profile: activeProfile) - userContent = fileService.loadUserProfile(profile: activeProfile) } func switchProfile(_ profile: String) { activeProfile = profile - memoryContent = fileService.loadMemory(profile: profile) - userContent = fileService.loadUserProfile(profile: profile) + let svc = fileService + Task.detached { [weak self] in + let memory = svc.loadMemory(profile: profile) + let user = svc.loadUserProfile(profile: profile) + await MainActor.run { [weak self] in + self?.memoryContent = memory + self?.userContent = user + } + } } func startEditing(_ target: EditTarget) { @@ -53,15 +81,24 @@ final class MemoryViewModel { } func save() { - switch editingFile { - case .memory: - fileService.saveMemory(editText, profile: activeProfile) - memoryContent = editText - case .user: - fileService.saveUserProfile(editText, profile: activeProfile) - userContent = editText + let svc = fileService + let target = editingFile + let text = editText + let profile = activeProfile + Task.detached { [weak self] in + switch target { + case .memory: svc.saveMemory(text, profile: profile) + case .user: svc.saveUserProfile(text, profile: profile) + } + await MainActor.run { [weak self] in + guard let self else { return } + switch target { + case .memory: self.memoryContent = text + case .user: self.userContent = text + } + self.isEditing = false + } } - isEditing = false } func cancelEditing() { diff --git a/scarf/scarf/Features/Memory/Views/MemoryView.swift b/scarf/scarf/Features/Memory/Views/MemoryView.swift index 76a1cbf..40a34b5 100644 --- a/scarf/scarf/Features/Memory/Views/MemoryView.swift +++ b/scarf/scarf/Features/Memory/Views/MemoryView.swift @@ -1,9 +1,14 @@ import SwiftUI struct MemoryView: View { - @State private var viewModel = MemoryViewModel() + @State private var viewModel: MemoryViewModel @Environment(HermesFileWatcher.self) private var fileWatcher + init(context: ServerContext) { + _viewModel = State(initialValue: MemoryViewModel(context: context)) + } + + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { @@ -43,6 +48,11 @@ struct MemoryView: View { .frame(maxWidth: .infinity, alignment: .topLeading) } .navigationTitle("Memory") + .loadingOverlay( + viewModel.isLoading, + label: "Loading memory…", + isEmpty: viewModel.memoryContent.isEmpty && viewModel.userContent.isEmpty + ) .onAppear { viewModel.load() } .onChange(of: fileWatcher.lastChangeDate) { viewModel.load() diff --git a/scarf/scarf/Features/Personalities/ViewModels/PersonalitiesViewModel.swift b/scarf/scarf/Features/Personalities/ViewModels/PersonalitiesViewModel.swift index b8b646b..638de65 100644 --- a/scarf/scarf/Features/Personalities/ViewModels/PersonalitiesViewModel.swift +++ b/scarf/scarf/Features/Personalities/ViewModels/PersonalitiesViewModel.swift @@ -13,26 +13,63 @@ struct HermesPersonality: Identifiable, Sendable, Equatable { @Observable final class PersonalitiesViewModel { private let logger = Logger(subsystem: "com.scarf", category: "PersonalitiesViewModel") - private let fileService = HermesFileService() + let context: ServerContext + private let fileService: HermesFileService + + init(context: ServerContext = .local) { + self.context = context + self.fileService = HermesFileService(context: context) + } var personalities: [HermesPersonality] = [] var activeName: String = "" var soulMarkdown: String = "" - var soulPath: String { HermesPaths.home + "/SOUL.md" } + var soulPath: String { context.paths.soulMD } var message: String? func load() { - let config = fileService.loadConfig() - activeName = config.personality - personalities = parsePersonalitiesBlock() - soulMarkdown = (try? String(contentsOfFile: soulPath, encoding: .utf8)) ?? "" + let svc = fileService + let ctx = context + let path = soulPath + Task.detached { [weak self] in + let config = svc.loadConfig() + let parsed = Self.parsePersonalitiesBlock(yaml: ctx.readText(ctx.paths.configYAML) ?? "") + let soul = ctx.readText(path) ?? "" + await MainActor.run { [weak self] in + guard let self else { return } + self.activeName = config.personality + self.personalities = parsed + self.soulMarkdown = soul + } + } + } + + /// Static form so the detached load can call into it without touching + /// MainActor-isolated state. The instance form below remains for any + /// other callers that need it. + private static func parsePersonalitiesBlock(yaml: String) -> [HermesPersonality] { + guard !yaml.isEmpty else { return [] } + let parsed = HermesFileService.parseNestedYAML(yaml) + var nameSet: Set = [] + for key in parsed.values.keys where key.hasPrefix("personalities.") { + let parts = key.split(separator: ".", maxSplits: 2, omittingEmptySubsequences: false) + if parts.count >= 2 { nameSet.insert(String(parts[1])) } + } + for key in parsed.lists.keys where key.hasPrefix("personalities.") { + let parts = key.split(separator: ".", maxSplits: 2, omittingEmptySubsequences: false) + if parts.count >= 2 { nameSet.insert(String(parts[1])) } + } + return nameSet.sorted().map { name in + let prompt = parsed.values["personalities.\(name).prompt"] ?? "" + return HermesPersonality(name: name, prompt: HermesFileService.stripYAMLQuotes(prompt)) + } } /// Parse the `personalities:` section of config.yaml using the nested parser. /// Each personality is a top-level key under `personalities`, optionally with /// a `prompt:` child. private func parsePersonalitiesBlock() -> [HermesPersonality] { - guard let yaml = try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8) else { return [] } + guard let yaml = context.readText(context.paths.configYAML) else { return [] } let parsed = HermesFileService.parseNestedYAML(yaml) // Find all keys "personalities.[.subkey]" var nameSet: Set = [] @@ -65,12 +102,11 @@ final class PersonalitiesViewModel { } func saveSOUL(_ content: String) { - do { - try content.write(toFile: soulPath, atomically: true, encoding: .utf8) + if context.writeText(soulPath, content: content) { soulMarkdown = content message = "SOUL.md saved" - } catch { - logger.error("Failed to write SOUL.md: \(error.localizedDescription)") + } else { + logger.error("Failed to write SOUL.md to \(self.context.displayName)") message = "Save failed" } DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in @@ -79,25 +115,11 @@ final class PersonalitiesViewModel { } func openConfigInEditor() { - NSWorkspace.shared.open(URL(fileURLWithPath: HermesPaths.configYAML)) + context.openInLocalEditor(context.paths.configYAML) } @discardableResult private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) { - let process = Process() - process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary) - process.arguments = arguments - process.environment = HermesFileService.enrichedEnvironment() - 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) - } + context.runHermes(arguments) } } diff --git a/scarf/scarf/Features/Personalities/Views/PersonalitiesView.swift b/scarf/scarf/Features/Personalities/Views/PersonalitiesView.swift index 627652f..35e3556 100644 --- a/scarf/scarf/Features/Personalities/Views/PersonalitiesView.swift +++ b/scarf/scarf/Features/Personalities/Views/PersonalitiesView.swift @@ -1,10 +1,15 @@ import SwiftUI struct PersonalitiesView: View { - @State private var viewModel = PersonalitiesViewModel() + @State private var viewModel: PersonalitiesViewModel @State private var soulDraft = "" @State private var editingSOUL = false + init(context: ServerContext) { + _viewModel = State(initialValue: PersonalitiesViewModel(context: context)) + } + + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/DiscordSetupViewModel.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/DiscordSetupViewModel.swift index 0d86793..99bad9a 100644 --- a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/DiscordSetupViewModel.swift +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/DiscordSetupViewModel.swift @@ -6,6 +6,9 @@ import os @Observable @MainActor final class DiscordSetupViewModel { + let context: ServerContext + init(context: ServerContext = .local) { self.context = context } + var botToken: String = "" var allowedUsers: String = "" var homeChannel: String = "" @@ -26,7 +29,7 @@ final class DiscordSetupViewModel { let replyToModeOptions = ["off", "first", "all"] func load() { - let env = HermesEnvService().load() + let env = HermesEnvService(context: context).load() botToken = env["DISCORD_BOT_TOKEN"] ?? "" allowedUsers = env["DISCORD_ALLOWED_USERS"] ?? "" homeChannel = env["DISCORD_HOME_CHANNEL"] ?? "" @@ -34,7 +37,7 @@ final class DiscordSetupViewModel { allowBots = env["DISCORD_ALLOW_BOTS"] ?? "none" replyToMode = env["DISCORD_REPLY_TO_MODE"] ?? "first" - let cfg = HermesFileService().loadConfig().discord + let cfg = HermesFileService(context: context).loadConfig().discord requireMention = cfg.requireMention freeResponseChannels = cfg.freeResponseChannels autoThread = cfg.autoThread @@ -56,7 +59,7 @@ final class DiscordSetupViewModel { "discord.auto_thread": PlatformSetupHelpers.envBool(autoThread), "discord.reactions": PlatformSetupHelpers.envBool(reactions) ] - message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: configKV) + message = PlatformSetupHelpers.saveForm(context: context, envPairs: envPairs, configKV: configKV) DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in self?.message = nil } diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/EmailSetupViewModel.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/EmailSetupViewModel.swift index d34a9fe..df5da2e 100644 --- a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/EmailSetupViewModel.swift +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/EmailSetupViewModel.swift @@ -5,6 +5,12 @@ import Foundation @Observable @MainActor final class EmailSetupViewModel { + let context: ServerContext + + init(context: ServerContext = .local) { + self.context = context + } + var address: String = "" var password: String = "" var imapHost: String = "" @@ -34,7 +40,7 @@ final class EmailSetupViewModel { ] func load() { - let env = HermesEnvService().load() + let env = HermesEnvService(context: context).load() address = env["EMAIL_ADDRESS"] ?? "" password = env["EMAIL_PASSWORD"] ?? "" imapHost = env["EMAIL_IMAP_HOST"] ?? "" @@ -46,7 +52,7 @@ final class EmailSetupViewModel { homeAddress = env["EMAIL_HOME_ADDRESS"] ?? "" allowAllUsers = PlatformSetupHelpers.parseEnvBool(env["EMAIL_ALLOW_ALL_USERS"]) // skip_attachments lives in config.yaml. - let yaml = (try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)) ?? "" + let yaml = context.readText(context.paths.configYAML) ?? "" let parsed = HermesFileService.parseNestedYAML(yaml) skipAttachments = (parsed.values["platforms.email.skip_attachments"] ?? "false") == "true" } @@ -72,7 +78,7 @@ final class EmailSetupViewModel { let configKV: [String: String] = [ "platforms.email.skip_attachments": PlatformSetupHelpers.envBool(skipAttachments) ] - message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: configKV) + message = PlatformSetupHelpers.saveForm(context: context, envPairs: envPairs, configKV: configKV) DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in self?.message = nil } diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/FeishuSetupViewModel.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/FeishuSetupViewModel.swift index 9917376..9e6f182 100644 --- a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/FeishuSetupViewModel.swift +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/FeishuSetupViewModel.swift @@ -5,6 +5,9 @@ import Foundation @Observable @MainActor final class FeishuSetupViewModel { + let context: ServerContext + init(context: ServerContext = .local) { self.context = context } + var appID: String = "" var appSecret: String = "" var domain: String = "lark" @@ -19,7 +22,7 @@ final class FeishuSetupViewModel { let connectionOptions = ["websocket", "webhook"] func load() { - let env = HermesEnvService().load() + let env = HermesEnvService(context: context).load() appID = env["FEISHU_APP_ID"] ?? "" appSecret = env["FEISHU_APP_SECRET"] ?? "" domain = env["FEISHU_DOMAIN"] ?? "lark" @@ -39,7 +42,7 @@ final class FeishuSetupViewModel { "FEISHU_ALLOWED_USERS": allowedUsers, "FEISHU_CONNECTION_MODE": connectionMode == "websocket" ? "" : connectionMode ] - message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: [:]) + message = PlatformSetupHelpers.saveForm(context: context, envPairs: envPairs, configKV: [:]) DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in self?.message = nil } diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/HomeAssistantSetupViewModel.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/HomeAssistantSetupViewModel.swift index 82c4f3a..c35a3ca 100644 --- a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/HomeAssistantSetupViewModel.swift +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/HomeAssistantSetupViewModel.swift @@ -15,6 +15,12 @@ import AppKit @Observable @MainActor final class HomeAssistantSetupViewModel { + let context: ServerContext + + init(context: ServerContext = .local) { + self.context = context + } + var url: String = "http://homeassistant.local:8123" var token: String = "" @@ -30,11 +36,11 @@ final class HomeAssistantSetupViewModel { var message: String? func load() { - let env = HermesEnvService().load() + let env = HermesEnvService(context: context).load() url = env["HASS_URL"] ?? "http://homeassistant.local:8123" token = env["HASS_TOKEN"] ?? "" - let cfg = HermesFileService().loadConfig().homeAssistant + let cfg = HermesFileService(context: context).loadConfig().homeAssistant watchAll = cfg.watchAll cooldownSeconds = cfg.cooldownSeconds watchDomains = cfg.watchDomains @@ -53,7 +59,7 @@ final class HomeAssistantSetupViewModel { "platforms.homeassistant.extra.watch_all": PlatformSetupHelpers.envBool(watchAll), "platforms.homeassistant.extra.cooldown_seconds": String(cooldownSeconds) ] - message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: configKV) + message = PlatformSetupHelpers.saveForm(context: context, envPairs: envPairs, configKV: configKV) DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in self?.message = nil } @@ -62,6 +68,6 @@ final class HomeAssistantSetupViewModel { /// Open config.yaml in the user's default editor so they can manually edit /// the list-valued filter fields. func openConfigForLists() { - NSWorkspace.shared.open(URL(fileURLWithPath: HermesPaths.configYAML)) + context.openInLocalEditor(context.paths.configYAML) } } diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/IMessageSetupViewModel.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/IMessageSetupViewModel.swift index 73fc4ba..d16fb10 100644 --- a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/IMessageSetupViewModel.swift +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/IMessageSetupViewModel.swift @@ -6,6 +6,9 @@ import Foundation @Observable @MainActor final class IMessageSetupViewModel { + let context: ServerContext + init(context: ServerContext = .local) { self.context = context } + var serverURL: String = "" var password: String = "" var webhookHost: String = "127.0.0.1" @@ -19,7 +22,7 @@ final class IMessageSetupViewModel { var message: String? func load() { - let env = HermesEnvService().load() + let env = HermesEnvService(context: context).load() serverURL = env["BLUEBUBBLES_SERVER_URL"] ?? "" password = env["BLUEBUBBLES_PASSWORD"] ?? "" webhookHost = env["BLUEBUBBLES_WEBHOOK_HOST"] ?? "127.0.0.1" @@ -43,7 +46,7 @@ final class IMessageSetupViewModel { "BLUEBUBBLES_ALLOW_ALL_USERS": allowAllUsers ? "true" : "", "BLUEBUBBLES_SEND_READ_RECEIPTS": sendReadReceipts ? "true" : "" ] - message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: [:]) + message = PlatformSetupHelpers.saveForm(context: context, envPairs: envPairs, configKV: [:]) DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in self?.message = nil } diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/MatrixSetupViewModel.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/MatrixSetupViewModel.swift index 2b67d36..6a29996 100644 --- a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/MatrixSetupViewModel.swift +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/MatrixSetupViewModel.swift @@ -5,6 +5,9 @@ import Foundation @Observable @MainActor final class MatrixSetupViewModel { + let context: ServerContext + init(context: ServerContext = .local) { self.context = context } + var homeserver: String = "" var accessToken: String = "" // preferred var userID: String = "" @@ -22,7 +25,7 @@ final class MatrixSetupViewModel { var message: String? func load() { - let env = HermesEnvService().load() + let env = HermesEnvService(context: context).load() homeserver = env["MATRIX_HOMESERVER"] ?? "" accessToken = env["MATRIX_ACCESS_TOKEN"] ?? "" userID = env["MATRIX_USER_ID"] ?? "" @@ -32,7 +35,7 @@ final class MatrixSetupViewModel { recoveryKey = env["MATRIX_RECOVERY_KEY"] ?? "" encryption = PlatformSetupHelpers.parseEnvBool(env["MATRIX_ENCRYPTION"]) - let cfg = HermesFileService().loadConfig().matrix + let cfg = HermesFileService(context: context).loadConfig().matrix requireMention = cfg.requireMention autoThread = cfg.autoThread dmMentionThreads = cfg.dmMentionThreads @@ -54,7 +57,7 @@ final class MatrixSetupViewModel { "matrix.auto_thread": PlatformSetupHelpers.envBool(autoThread), "matrix.dm_mention_threads": PlatformSetupHelpers.envBool(dmMentionThreads) ] - message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: configKV) + message = PlatformSetupHelpers.saveForm(context: context, envPairs: envPairs, configKV: configKV) DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in self?.message = nil } diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/MattermostSetupViewModel.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/MattermostSetupViewModel.swift index d89d1bf..c1e8026 100644 --- a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/MattermostSetupViewModel.swift +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/MattermostSetupViewModel.swift @@ -5,6 +5,9 @@ import Foundation @Observable @MainActor final class MattermostSetupViewModel { + let context: ServerContext + init(context: ServerContext = .local) { self.context = context } + var serverURL: String = "" var token: String = "" var allowedUsers: String = "" @@ -18,7 +21,7 @@ final class MattermostSetupViewModel { let replyModeOptions = ["off", "thread"] func load() { - let env = HermesEnvService().load() + let env = HermesEnvService(context: context).load() serverURL = env["MATTERMOST_URL"] ?? "" token = env["MATTERMOST_TOKEN"] ?? "" allowedUsers = env["MATTERMOST_ALLOWED_USERS"] ?? "" @@ -26,7 +29,7 @@ final class MattermostSetupViewModel { freeResponseChannels = env["MATTERMOST_FREE_RESPONSE_CHANNELS"] ?? "" replyMode = env["MATTERMOST_REPLY_MODE"] ?? "off" - let cfg = HermesFileService().loadConfig().mattermost + let cfg = HermesFileService(context: context).loadConfig().mattermost requireMention = cfg.requireMention } @@ -40,7 +43,7 @@ final class MattermostSetupViewModel { "MATTERMOST_REPLY_MODE": replyMode == "off" ? "" : replyMode, "MATTERMOST_REQUIRE_MENTION": PlatformSetupHelpers.envBool(requireMention) ] - message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: [:]) + message = PlatformSetupHelpers.saveForm(context: context, envPairs: envPairs, configKV: [:]) DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in self?.message = nil } diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/PlatformSetupHelpers.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/PlatformSetupHelpers.swift index d7fbec4..d497cf7 100644 --- a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/PlatformSetupHelpers.swift +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/PlatformSetupHelpers.swift @@ -15,10 +15,11 @@ import os @MainActor enum PlatformSetupHelpers { static let logger = Logger(subsystem: "com.scarf", category: "PlatformSetup") - static let envService = HermesEnvService() - /// Apply a form save in one atomic batch. + /// Apply a form save in one atomic batch against a specific server. /// + /// - `context`: the server whose `.env` and `config.yaml` we're writing. + /// Local goes through `LocalTransport`; remote rounds through ssh+scp. /// - `envPairs`: values to write into `.env`. Empty strings trigger `unset()` /// (commenting the line out) rather than storing a literal empty value. /// - `configKV`: scalar config.yaml paths to set via `hermes config set`. @@ -27,7 +28,9 @@ enum PlatformSetupHelpers { /// /// Returns a user-facing summary message. @discardableResult - static func saveForm(envPairs: [String: String], configKV: [String: String]) -> String { + static func saveForm(context: ServerContext, envPairs: [String: String], configKV: [String: String]) -> String { + let envService = HermesEnvService(context: context) + // Split env pairs into set vs. unset. var toSet: [String: String] = [:] var toUnset: [String] = [] @@ -49,7 +52,7 @@ enum PlatformSetupHelpers { var configFailures: [String] = [] for (key, value) in configKV { - let result = runHermesCLI(args: ["config", "set", key, value]) + let result = runHermesCLI(context: context, args: ["config", "set", key, value]) if result.exitCode != 0 { configFailures.append(key) logger.warning("hermes config set \(key) failed: \(result.output)") @@ -61,11 +64,11 @@ enum PlatformSetupHelpers { return "Saved — restart gateway to apply" } - /// Synchronous hermes CLI invocation. Use only for fast commands like - /// `config set`; longer commands should use `HermesFileService.runHermesCLI` - /// from a `Task.detached`. - static func runHermesCLI(args: [String], timeout: TimeInterval = 15) -> (exitCode: Int32, output: String) { - HermesFileService().runHermesCLI(args: args, timeout: timeout) + /// Synchronous hermes CLI invocation against the given server. Use only + /// for fast commands like `config set`; longer commands should use + /// `HermesFileService.runHermesCLI` from a `Task.detached`. + static func runHermesCLI(context: ServerContext, args: [String], timeout: TimeInterval = 15) -> (exitCode: Int32, output: String) { + HermesFileService(context: context).runHermesCLI(args: args, timeout: timeout) } /// Ask the user's default browser to open a URL (typically a hermes doc page diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/SignalSetupViewModel.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/SignalSetupViewModel.swift index 8f23478..61c744a 100644 --- a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/SignalSetupViewModel.swift +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/SignalSetupViewModel.swift @@ -9,6 +9,9 @@ import Foundation @Observable @MainActor final class SignalSetupViewModel { + let context: ServerContext + init(context: ServerContext = .local) { self.context = context } + var httpURL: String = "http://127.0.0.1:8080" var account: String = "" // E.164 phone, e.g. +15551234567 var allowedUsers: String = "" @@ -29,7 +32,7 @@ final class SignalSetupViewModel { } func load() { - let env = HermesEnvService().load() + let env = HermesEnvService(context: context).load() httpURL = env["SIGNAL_HTTP_URL"] ?? "http://127.0.0.1:8080" account = env["SIGNAL_ACCOUNT"] ?? "" allowedUsers = env["SIGNAL_ALLOWED_USERS"] ?? "" @@ -60,7 +63,7 @@ final class SignalSetupViewModel { "SIGNAL_HOME_CHANNEL": homeChannel, "SIGNAL_ALLOW_ALL_USERS": allowAllUsers ? "true" : "" ] - message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: [:]) + message = PlatformSetupHelpers.saveForm(context: context, envPairs: envPairs, configKV: [:]) clearMessageAfterDelay() } diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/SlackSetupViewModel.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/SlackSetupViewModel.swift index 38d552d..a659ed0 100644 --- a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/SlackSetupViewModel.swift +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/SlackSetupViewModel.swift @@ -5,6 +5,9 @@ import Foundation @Observable @MainActor final class SlackSetupViewModel { + let context: ServerContext + init(context: ServerContext = .local) { self.context = context } + var botToken: String = "" // xoxb-... var appToken: String = "" // xapp-... var allowedUsers: String = "" @@ -21,14 +24,14 @@ final class SlackSetupViewModel { let replyToModeOptions = ["off", "first", "all"] func load() { - let env = HermesEnvService().load() + let env = HermesEnvService(context: context).load() botToken = env["SLACK_BOT_TOKEN"] ?? "" appToken = env["SLACK_APP_TOKEN"] ?? "" allowedUsers = env["SLACK_ALLOWED_USERS"] ?? "" homeChannel = env["SLACK_HOME_CHANNEL"] ?? "" homeChannelName = env["SLACK_HOME_CHANNEL_NAME"] ?? "" - let cfg = HermesFileService().loadConfig().slack + let cfg = HermesFileService(context: context).loadConfig().slack replyToMode = cfg.replyToMode requireMention = cfg.requireMention replyInThread = cfg.replyInThread @@ -50,7 +53,7 @@ final class SlackSetupViewModel { "platforms.slack.extra.reply_in_thread": PlatformSetupHelpers.envBool(replyInThread), "platforms.slack.extra.reply_broadcast": PlatformSetupHelpers.envBool(replyBroadcast) ] - message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: configKV) + message = PlatformSetupHelpers.saveForm(context: context, envPairs: envPairs, configKV: configKV) DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in self?.message = nil } diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/TelegramSetupViewModel.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/TelegramSetupViewModel.swift index dcebfef..adecd15 100644 --- a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/TelegramSetupViewModel.swift +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/TelegramSetupViewModel.swift @@ -8,6 +8,9 @@ import os @Observable @MainActor final class TelegramSetupViewModel { + let context: ServerContext + init(context: ServerContext = .local) { self.context = context } + // Required var botToken: String = "" var allowedUsers: String = "" @@ -23,7 +26,7 @@ final class TelegramSetupViewModel { var message: String? func load() { - let env = HermesEnvService().load() + let env = HermesEnvService(context: context).load() botToken = env["TELEGRAM_BOT_TOKEN"] ?? "" allowedUsers = env["TELEGRAM_ALLOWED_USERS"] ?? "" homeChannel = env["TELEGRAM_HOME_CHANNEL"] ?? "" @@ -31,7 +34,7 @@ final class TelegramSetupViewModel { webhookPort = env["TELEGRAM_WEBHOOK_PORT"] ?? "" webhookSecret = env["TELEGRAM_WEBHOOK_SECRET"] ?? "" - let cfg = HermesFileService().loadConfig() + let cfg = HermesFileService(context: context).loadConfig() requireMention = cfg.telegram.requireMention reactions = cfg.telegram.reactions } @@ -49,7 +52,7 @@ final class TelegramSetupViewModel { "telegram.require_mention": PlatformSetupHelpers.envBool(requireMention), "telegram.reactions": PlatformSetupHelpers.envBool(reactions) ] - message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: configKV) + message = PlatformSetupHelpers.saveForm(context: context, envPairs: envPairs, configKV: configKV) clearMessageAfterDelay() } diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/WebhookSetupViewModel.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/WebhookSetupViewModel.swift index d81e481..85afee9 100644 --- a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/WebhookSetupViewModel.swift +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/WebhookSetupViewModel.swift @@ -7,6 +7,9 @@ import Foundation @Observable @MainActor final class WebhookSetupViewModel { + let context: ServerContext + init(context: ServerContext = .local) { self.context = context } + var enabled: Bool = false var port: String = "8644" var secret: String = "" @@ -14,7 +17,7 @@ final class WebhookSetupViewModel { var message: String? func load() { - let env = HermesEnvService().load() + let env = HermesEnvService(context: context).load() enabled = PlatformSetupHelpers.parseEnvBool(env["WEBHOOK_ENABLED"]) port = env["WEBHOOK_PORT"] ?? "8644" secret = env["WEBHOOK_SECRET"] ?? "" @@ -26,7 +29,7 @@ final class WebhookSetupViewModel { "WEBHOOK_PORT": port, "WEBHOOK_SECRET": secret ] - message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: [:]) + message = PlatformSetupHelpers.saveForm(context: context, envPairs: envPairs, configKV: [:]) DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in self?.message = nil } diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/WhatsAppSetupViewModel.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/WhatsAppSetupViewModel.swift index 2445834..ee2a1ac 100644 --- a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/WhatsAppSetupViewModel.swift +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/WhatsAppSetupViewModel.swift @@ -8,6 +8,12 @@ import Foundation @Observable @MainActor final class WhatsAppSetupViewModel { + let context: ServerContext + + init(context: ServerContext = .local) { + self.context = context + } + var enabled: Bool = false var mode: String = "bot" // "bot" | "self-chat" var allowedUsers: String = "" // Comma-separated phone numbers (no +) @@ -27,7 +33,7 @@ final class WhatsAppSetupViewModel { var pairingInProgress: Bool = false func load() { - let env = HermesEnvService().load() + let env = HermesEnvService(context: context).load() enabled = PlatformSetupHelpers.parseEnvBool(env["WHATSAPP_ENABLED"]) mode = env["WHATSAPP_MODE"] ?? "bot" allowedUsers = env["WHATSAPP_ALLOWED_USERS"] ?? "" @@ -40,7 +46,7 @@ final class WhatsAppSetupViewModel { allowedUsers = "" } - let cfg = HermesFileService().loadConfig().whatsapp + let cfg = HermesFileService(context: context).loadConfig().whatsapp unauthorizedDMBehavior = cfg.unauthorizedDMBehavior replyPrefix = cfg.replyPrefix } @@ -57,7 +63,7 @@ final class WhatsAppSetupViewModel { "whatsapp.unauthorized_dm_behavior": unauthorizedDMBehavior, "whatsapp.reply_prefix": replyPrefix ] - message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: configKV) + message = PlatformSetupHelpers.saveForm(context: context, envPairs: envPairs, configKV: configKV) clearMessageAfterDelay() } @@ -72,7 +78,7 @@ final class WhatsAppSetupViewModel { self?.clearMessageAfterDelay() } terminalController.start( - executable: HermesPaths.hermesBinary, + executable: context.paths.hermesBinary, arguments: ["whatsapp"] ) } diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformsViewModel.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformsViewModel.swift index 4e845b5..5a5468c 100644 --- a/scarf/scarf/Features/Platforms/ViewModels/PlatformsViewModel.swift +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformsViewModel.swift @@ -9,7 +9,14 @@ import os @MainActor final class PlatformsViewModel { private let logger = Logger(subsystem: "com.scarf", category: "PlatformsViewModel") - private let fileService = HermesFileService() + let context: ServerContext + private let fileService: HermesFileService + + init(context: ServerContext = .local) { + self.context = context + self.fileService = HermesFileService(context: context) + } + var gatewayState: GatewayState? var selected: HermesToolPlatform = KnownPlatforms.cli @@ -41,14 +48,14 @@ final class PlatformsViewModel { /// until the first YAML edit. func hasConfigBlock(for platform: HermesToolPlatform) -> Bool { if platform.name == "cli" { return true } - let yaml = (try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)) ?? "" + let yaml = context.readText(context.paths.configYAML) ?? "" for line in yaml.components(separatedBy: "\n") where !line.hasPrefix(" ") && !line.hasPrefix("\t") { if line.trimmingCharacters(in: .whitespaces) == "\(platform.name):" { return true } } // Env-var fallback: any identifying env var for this platform counts // as "configured". Uses the shared `identifyingEnvVar(for:)` mapping. if let key = Self.identifyingEnvVar(for: platform.name) { - let env = HermesEnvService().load() + let env = HermesEnvService(context: context).load() if let value = env[key], !value.isEmpty { return true } } return false diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/DiscordSetupView.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/DiscordSetupView.swift index ca330bb..1543e85 100644 --- a/scarf/scarf/Features/Platforms/Views/PlatformSetup/DiscordSetupView.swift +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/DiscordSetupView.swift @@ -1,7 +1,9 @@ import SwiftUI struct DiscordSetupView: View { - @State private var viewModel = DiscordSetupViewModel() + @State private var viewModel: DiscordSetupViewModel + init(context: ServerContext) { _viewModel = State(initialValue: DiscordSetupViewModel(context: context)) } + var body: some View { VStack(alignment: .leading, spacing: 16) { diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/EmailSetupView.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/EmailSetupView.swift index 225240e..e634b6d 100644 --- a/scarf/scarf/Features/Platforms/Views/PlatformSetup/EmailSetupView.swift +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/EmailSetupView.swift @@ -1,7 +1,9 @@ import SwiftUI struct EmailSetupView: View { - @State private var viewModel = EmailSetupViewModel() + @State private var viewModel: EmailSetupViewModel + init(context: ServerContext) { _viewModel = State(initialValue: EmailSetupViewModel(context: context)) } + var body: some View { VStack(alignment: .leading, spacing: 16) { diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/FeishuSetupView.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/FeishuSetupView.swift index 8bb8e98..f25151a 100644 --- a/scarf/scarf/Features/Platforms/Views/PlatformSetup/FeishuSetupView.swift +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/FeishuSetupView.swift @@ -1,7 +1,9 @@ import SwiftUI struct FeishuSetupView: View { - @State private var viewModel = FeishuSetupViewModel() + @State private var viewModel: FeishuSetupViewModel + init(context: ServerContext) { _viewModel = State(initialValue: FeishuSetupViewModel(context: context)) } + var body: some View { VStack(alignment: .leading, spacing: 16) { diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/HomeAssistantSetupView.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/HomeAssistantSetupView.swift index ed5054e..ba46ad0 100644 --- a/scarf/scarf/Features/Platforms/Views/PlatformSetup/HomeAssistantSetupView.swift +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/HomeAssistantSetupView.swift @@ -1,7 +1,9 @@ import SwiftUI struct HomeAssistantSetupView: View { - @State private var viewModel = HomeAssistantSetupViewModel() + @State private var viewModel: HomeAssistantSetupViewModel + init(context: ServerContext) { _viewModel = State(initialValue: HomeAssistantSetupViewModel(context: context)) } + var body: some View { VStack(alignment: .leading, spacing: 16) { diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/IMessageSetupView.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/IMessageSetupView.swift index ed34592..627754b 100644 --- a/scarf/scarf/Features/Platforms/Views/PlatformSetup/IMessageSetupView.swift +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/IMessageSetupView.swift @@ -1,7 +1,9 @@ import SwiftUI struct IMessageSetupView: View { - @State private var viewModel = IMessageSetupViewModel() + @State private var viewModel: IMessageSetupViewModel + init(context: ServerContext) { _viewModel = State(initialValue: IMessageSetupViewModel(context: context)) } + var body: some View { VStack(alignment: .leading, spacing: 16) { diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/MatrixSetupView.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/MatrixSetupView.swift index e478792..004933e 100644 --- a/scarf/scarf/Features/Platforms/Views/PlatformSetup/MatrixSetupView.swift +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/MatrixSetupView.swift @@ -1,7 +1,9 @@ import SwiftUI struct MatrixSetupView: View { - @State private var viewModel = MatrixSetupViewModel() + @State private var viewModel: MatrixSetupViewModel + init(context: ServerContext) { _viewModel = State(initialValue: MatrixSetupViewModel(context: context)) } + var body: some View { VStack(alignment: .leading, spacing: 16) { diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/MattermostSetupView.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/MattermostSetupView.swift index 261b664..b5c2383 100644 --- a/scarf/scarf/Features/Platforms/Views/PlatformSetup/MattermostSetupView.swift +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/MattermostSetupView.swift @@ -1,7 +1,9 @@ import SwiftUI struct MattermostSetupView: View { - @State private var viewModel = MattermostSetupViewModel() + @State private var viewModel: MattermostSetupViewModel + init(context: ServerContext) { _viewModel = State(initialValue: MattermostSetupViewModel(context: context)) } + var body: some View { VStack(alignment: .leading, spacing: 16) { diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/SignalSetupView.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/SignalSetupView.swift index 54f5b36..ec49419 100644 --- a/scarf/scarf/Features/Platforms/Views/PlatformSetup/SignalSetupView.swift +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/SignalSetupView.swift @@ -1,7 +1,9 @@ import SwiftUI struct SignalSetupView: View { - @State private var viewModel = SignalSetupViewModel() + @State private var viewModel: SignalSetupViewModel + init(context: ServerContext) { _viewModel = State(initialValue: SignalSetupViewModel(context: context)) } + var body: some View { VStack(alignment: .leading, spacing: 16) { diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/SlackSetupView.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/SlackSetupView.swift index 3b7a64e..7310cdf 100644 --- a/scarf/scarf/Features/Platforms/Views/PlatformSetup/SlackSetupView.swift +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/SlackSetupView.swift @@ -1,7 +1,9 @@ import SwiftUI struct SlackSetupView: View { - @State private var viewModel = SlackSetupViewModel() + @State private var viewModel: SlackSetupViewModel + init(context: ServerContext) { _viewModel = State(initialValue: SlackSetupViewModel(context: context)) } + var body: some View { VStack(alignment: .leading, spacing: 16) { diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/TelegramSetupView.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/TelegramSetupView.swift index 1593e61..3db06a1 100644 --- a/scarf/scarf/Features/Platforms/Views/PlatformSetup/TelegramSetupView.swift +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/TelegramSetupView.swift @@ -1,7 +1,9 @@ import SwiftUI struct TelegramSetupView: View { - @State private var viewModel = TelegramSetupViewModel() + @State private var viewModel: TelegramSetupViewModel + init(context: ServerContext) { _viewModel = State(initialValue: TelegramSetupViewModel(context: context)) } + var body: some View { VStack(alignment: .leading, spacing: 16) { diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/WebhookSetupView.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/WebhookSetupView.swift index 13fe3e6..ea5e2c0 100644 --- a/scarf/scarf/Features/Platforms/Views/PlatformSetup/WebhookSetupView.swift +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/WebhookSetupView.swift @@ -1,7 +1,9 @@ import SwiftUI struct WebhookSetupView: View { - @State private var viewModel = WebhookSetupViewModel() + @State private var viewModel: WebhookSetupViewModel + init(context: ServerContext) { _viewModel = State(initialValue: WebhookSetupViewModel(context: context)) } + var body: some View { VStack(alignment: .leading, spacing: 16) { diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/WhatsAppSetupView.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/WhatsAppSetupView.swift index f22f061..0356bf4 100644 --- a/scarf/scarf/Features/Platforms/Views/PlatformSetup/WhatsAppSetupView.swift +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/WhatsAppSetupView.swift @@ -1,7 +1,9 @@ import SwiftUI struct WhatsAppSetupView: View { - @State private var viewModel = WhatsAppSetupViewModel() + @State private var viewModel: WhatsAppSetupViewModel + init(context: ServerContext) { _viewModel = State(initialValue: WhatsAppSetupViewModel(context: context)) } + var body: some View { VStack(alignment: .leading, spacing: 16) { diff --git a/scarf/scarf/Features/Platforms/Views/PlatformsView.swift b/scarf/scarf/Features/Platforms/Views/PlatformsView.swift index beff5e3..fe7a798 100644 --- a/scarf/scarf/Features/Platforms/Views/PlatformsView.swift +++ b/scarf/scarf/Features/Platforms/Views/PlatformsView.swift @@ -1,9 +1,14 @@ import SwiftUI struct PlatformsView: View { - @State private var viewModel = PlatformsViewModel() + @State private var viewModel: PlatformsViewModel @Environment(HermesFileWatcher.self) private var fileWatcher + init(context: ServerContext) { + _viewModel = State(initialValue: PlatformsViewModel(context: context)) + } + + // HSplitView (not nested NavigationSplitView) because ContentView already // hosts the outer NavigationSplitView — nesting them breaks layout on macOS. var body: some View { @@ -114,23 +119,25 @@ struct PlatformsView: View { /// Dispatch to the right per-platform setup view based on the selection. /// Each setup view owns its own `@State` view model and handles load/save - /// independently; we don't push state down from this container. + /// independently; the parent's `context` is forwarded so writes go to the + /// right server. @ViewBuilder private var platformForm: some View { + let ctx = viewModel.context switch viewModel.selected.name { case "cli": cliPanel - case "telegram": TelegramSetupView() - case "discord": DiscordSetupView() - case "slack": SlackSetupView() - case "whatsapp": WhatsAppSetupView() - case "signal": SignalSetupView() - case "email": EmailSetupView() - case "matrix": MatrixSetupView() - case "mattermost": MattermostSetupView() - case "feishu": FeishuSetupView() - case "imessage": IMessageSetupView() - case "homeassistant": HomeAssistantSetupView() - case "webhook": WebhookSetupView() + case "telegram": TelegramSetupView(context: ctx) + case "discord": DiscordSetupView(context: ctx) + case "slack": SlackSetupView(context: ctx) + case "whatsapp": WhatsAppSetupView(context: ctx) + case "signal": SignalSetupView(context: ctx) + case "email": EmailSetupView(context: ctx) + case "matrix": MatrixSetupView(context: ctx) + case "mattermost": MattermostSetupView(context: ctx) + case "feishu": FeishuSetupView(context: ctx) + case "imessage": IMessageSetupView(context: ctx) + case "homeassistant": HomeAssistantSetupView(context: ctx) + case "webhook": WebhookSetupView(context: ctx) default: SettingsSection(title: viewModel.selected.displayName, icon: KnownPlatforms.icon(for: viewModel.selected.name)) { ReadOnlyRow(label: "Setup", value: "No setup form for this platform yet.") diff --git a/scarf/scarf/Features/Plugins/ViewModels/PluginsViewModel.swift b/scarf/scarf/Features/Plugins/ViewModels/PluginsViewModel.swift index dcb54c6..df78f5a 100644 --- a/scarf/scarf/Features/Plugins/ViewModels/PluginsViewModel.swift +++ b/scarf/scarf/Features/Plugins/ViewModels/PluginsViewModel.swift @@ -13,59 +13,67 @@ struct HermesPlugin: Identifiable, Sendable, Equatable { @Observable final class PluginsViewModel { private let logger = Logger(subsystem: "com.scarf", category: "PluginsViewModel") - private let fileService = HermesFileService() + let context: ServerContext + private let fileService: HermesFileService + + init(context: ServerContext = .local) { + self.context = context + self.fileService = HermesFileService(context: context) + } var plugins: [HermesPlugin] = [] var isLoading = false var message: String? - private var pluginsDir: String { HermesPaths.home + "/plugins" } + private var pluginsDir: String { context.paths.pluginsDir } /// Source of truth is the `~/.hermes/plugins/` directory. Each plugin is a /// subdirectory — we read its `plugin.json` (if present) for source/version /// metadata. Parsing `hermes plugins list` box-drawn output is fragile. func load() { isLoading = true - defer { isLoading = false } - - let fm = FileManager.default - guard let entries = try? fm.contentsOfDirectory(atPath: pluginsDir) else { - plugins = [] - return + let dir = pluginsDir + let ctx = context + // listDirectory + (stat × N entries) + (readManifest × N) is a lot + // of sync transport ops on remote — definitively a beach ball if + // run on main. Detach the whole walk. + Task.detached { [weak self] in + let transport = ctx.makeTransport() + var result: [HermesPlugin] = [] + if let entries = try? transport.listDirectory(dir) { + for entry in entries.sorted() where !entry.hasPrefix(".") { + let path = dir + "/" + entry + guard transport.stat(path)?.isDirectory == true else { continue } + let manifest = Self.readManifestStatic(path: path, context: ctx) + let disabled = transport.fileExists(path + "/.disabled") + result.append(HermesPlugin( + name: entry, + source: manifest.source, + enabled: !disabled, + version: manifest.version, + path: path + )) + } + } + await MainActor.run { [weak self] in + self?.plugins = result + self?.isLoading = false + } } - var result: [HermesPlugin] = [] - for entry in entries.sorted() where !entry.hasPrefix(".") { - let path = pluginsDir + "/" + entry - var isDir: ObjCBool = false - guard fm.fileExists(atPath: path, isDirectory: &isDir), isDir.boolValue else { continue } - - let manifest = Self.readManifest(path: path) - let disabled = fm.fileExists(atPath: path + "/.disabled") - result.append(HermesPlugin( - name: entry, - source: manifest.source, - enabled: !disabled, - version: manifest.version, - path: path - )) - } - plugins = result } - /// Best-effort manifest read. Supports both plugin.json and plugin.yaml shapes. - private static func readManifest(path: String) -> (source: String, version: String) { - let fm = FileManager.default + /// Static form of readManifest used by the detached load task. The + /// instance form delegates to this so both call paths share logic. + fileprivate static func readManifestStatic(path: String, context: ServerContext) -> (source: String, version: String) { let jsonPath = path + "/plugin.json" - if fm.fileExists(atPath: jsonPath), - let data = try? Data(contentsOf: URL(fileURLWithPath: jsonPath)), + if let data = context.readData(jsonPath), let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { let source = (obj["source"] as? String) ?? (obj["repository"] as? String) ?? (obj["url"] as? String) ?? "" let version = (obj["version"] as? String) ?? "" return (source, version) } let yamlPath = path + "/plugin.yaml" - if fm.fileExists(atPath: yamlPath), - let yaml = try? String(contentsOfFile: yamlPath, encoding: .utf8) { + if let yaml = context.readText(yamlPath) { let parsed = HermesFileService.parseNestedYAML(yaml) let source = HermesFileService.stripYAMLQuotes(parsed.values["source"] ?? parsed.values["repository"] ?? parsed.values["url"] ?? "") let version = HermesFileService.stripYAMLQuotes(parsed.values["version"] ?? "") @@ -74,6 +82,10 @@ final class PluginsViewModel { return ("", "") } + // (readManifestStatic above is the new implementation; the instance + // version was removed because the only caller was the load() walk, + // which now runs detached and uses the static form.) + func install(_ identifier: String) { isLoading = true message = "Installing \(identifier)…" diff --git a/scarf/scarf/Features/Plugins/Views/PluginsView.swift b/scarf/scarf/Features/Plugins/Views/PluginsView.swift index 007c4eb..28e56f9 100644 --- a/scarf/scarf/Features/Plugins/Views/PluginsView.swift +++ b/scarf/scarf/Features/Plugins/Views/PluginsView.swift @@ -1,11 +1,16 @@ import SwiftUI struct PluginsView: View { - @State private var viewModel = PluginsViewModel() + @State private var viewModel: PluginsViewModel @State private var installIdentifier = "" @State private var showInstall = false @State private var pendingRemove: HermesPlugin? + init(context: ServerContext) { + _viewModel = State(initialValue: PluginsViewModel(context: context)) + } + + var body: some View { VStack(spacing: 0) { header @@ -19,6 +24,11 @@ struct PluginsView: View { } } .navigationTitle("Plugins") + .loadingOverlay( + viewModel.isLoading, + label: "Loading plugins…", + isEmpty: viewModel.plugins.isEmpty + ) .onAppear { viewModel.load() } .sheet(isPresented: $showInstall) { installSheet } .confirmationDialog( diff --git a/scarf/scarf/Features/Profiles/ViewModels/ProfilesViewModel.swift b/scarf/scarf/Features/Profiles/ViewModels/ProfilesViewModel.swift index 8945373..1142d2f 100644 --- a/scarf/scarf/Features/Profiles/ViewModels/ProfilesViewModel.swift +++ b/scarf/scarf/Features/Profiles/ViewModels/ProfilesViewModel.swift @@ -11,7 +11,14 @@ struct HermesProfile: Identifiable, Sendable, Equatable { @Observable final class ProfilesViewModel { private let logger = Logger(subsystem: "com.scarf", category: "ProfilesViewModel") - private let fileService = HermesFileService() + let context: ServerContext + private let fileService: HermesFileService + + init(context: ServerContext = .local) { + self.context = context + self.fileService = HermesFileService(context: context) + } + var profiles: [HermesProfile] = [] var activeName: String = "default" diff --git a/scarf/scarf/Features/Profiles/Views/ProfilesView.swift b/scarf/scarf/Features/Profiles/Views/ProfilesView.swift index ecd7bd5..042a711 100644 --- a/scarf/scarf/Features/Profiles/Views/ProfilesView.swift +++ b/scarf/scarf/Features/Profiles/Views/ProfilesView.swift @@ -3,13 +3,18 @@ import AppKit import UniformTypeIdentifiers struct ProfilesView: View { - @State private var viewModel = ProfilesViewModel() + @State private var viewModel: ProfilesViewModel @State private var selected: HermesProfile? @State private var showCreate = false @State private var createName = "" @State private var createCloneConfig = true @State private var createCloneAll = false @State private var showRename = false + + init(context: ServerContext) { + _viewModel = State(initialValue: ProfilesViewModel(context: context)) + } + @State private var renameTarget: HermesProfile? @State private var renameNewName = "" @State private var pendingDelete: HermesProfile? diff --git a/scarf/scarf/Features/Projects/ViewModels/ProjectsViewModel.swift b/scarf/scarf/Features/Projects/ViewModels/ProjectsViewModel.swift index 8827024..cd46faf 100644 --- a/scarf/scarf/Features/Projects/ViewModels/ProjectsViewModel.swift +++ b/scarf/scarf/Features/Projects/ViewModels/ProjectsViewModel.swift @@ -2,7 +2,14 @@ import Foundation @Observable final class ProjectsViewModel { - private let service = ProjectDashboardService() + let context: ServerContext + private let service: ProjectDashboardService + + init(context: ServerContext = .local) { + self.context = context + self.service = ProjectDashboardService(context: context) + } + var projects: [ProjectEntry] = [] var selectedProject: ProjectEntry? diff --git a/scarf/scarf/Features/Projects/Views/ProjectsView.swift b/scarf/scarf/Features/Projects/Views/ProjectsView.swift index c84b451..0eadb21 100644 --- a/scarf/scarf/Features/Projects/Views/ProjectsView.swift +++ b/scarf/scarf/Features/Projects/Views/ProjectsView.swift @@ -6,10 +6,15 @@ private enum DashboardTab: String, CaseIterable { } struct ProjectsView: View { - @State private var viewModel = ProjectsViewModel() + @State private var viewModel: ProjectsViewModel @Environment(AppCoordinator.self) private var coordinator @Environment(HermesFileWatcher.self) private var fileWatcher @State private var showingAddSheet = false + + init(context: ServerContext) { + _viewModel = State(initialValue: ProjectsViewModel(context: context)) + } + @State private var selectedTab: DashboardTab = .dashboard var body: some View { @@ -209,7 +214,10 @@ struct ProjectsView: View { } private func openInFinder(_ path: String) { - NSWorkspace.shared.open(URL(fileURLWithPath: path)) + // Project paths come from the registry on the active server. For + // remote, the path is on that machine's filesystem and can't be + // shown in this Mac's Finder — no-op via the helper. + viewModel.context.openInLocalEditor(path) } } diff --git a/scarf/scarf/Features/QuickCommands/ViewModels/QuickCommandsViewModel.swift b/scarf/scarf/Features/QuickCommands/ViewModels/QuickCommandsViewModel.swift index 08f0308..2dbfdeb 100644 --- a/scarf/scarf/Features/QuickCommands/ViewModels/QuickCommandsViewModel.swift +++ b/scarf/scarf/Features/QuickCommands/ViewModels/QuickCommandsViewModel.swift @@ -13,31 +13,39 @@ struct HermesQuickCommand: Identifiable, Sendable, Equatable { @Observable final class QuickCommandsViewModel { private let logger = Logger(subsystem: "com.scarf", category: "QuickCommandsViewModel") + let context: ServerContext + + init(context: ServerContext = .local) { + self.context = context + } var commands: [HermesQuickCommand] = [] var message: String? func load() { - guard let yaml = try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8) else { - commands = [] - return + let ctx = context + Task.detached { [weak self] in + let yaml = ctx.readText(ctx.paths.configYAML) + let result: [HermesQuickCommand] = { + guard let yaml else { return [] } + let parsed = HermesFileService.parseNestedYAML(yaml) + var byName: [String: (type: String, command: String)] = [:] + for (key, value) in parsed.values where key.hasPrefix("quick_commands.") { + let parts = key.split(separator: ".", maxSplits: 2, omittingEmptySubsequences: false) + guard parts.count == 3 else { continue } + let name = String(parts[1]) + let field = String(parts[2]) + var existing = byName[name] ?? (type: "exec", command: "") + let stripped = HermesFileService.stripYAMLQuotes(value) + if field == "type" { existing.type = stripped } + if field == "command" { existing.command = stripped } + byName[name] = existing + } + return byName.map { HermesQuickCommand(name: $0.key, type: $0.value.type, command: $0.value.command) } + .sorted { $0.name < $1.name } + }() + await MainActor.run { [weak self] in self?.commands = result } } - let parsed = HermesFileService.parseNestedYAML(yaml) - // Each quick command is `quick_commands..type` + `quick_commands..command`. - var byName: [String: (type: String, command: String)] = [:] - for (key, value) in parsed.values where key.hasPrefix("quick_commands.") { - let parts = key.split(separator: ".", maxSplits: 2, omittingEmptySubsequences: false) - guard parts.count == 3 else { continue } - let name = String(parts[1]) - let field = String(parts[2]) - var existing = byName[name] ?? (type: "exec", command: "") - let stripped = HermesFileService.stripYAMLQuotes(value) - if field == "type" { existing.type = stripped } - if field == "command" { existing.command = stripped } - byName[name] = existing - } - commands = byName.map { HermesQuickCommand(name: $0.key, type: $0.value.type, command: $0.value.command) } - .sorted { $0.name < $1.name } } /// Check for obviously destructive shell strings. Display-only; we do not block. @@ -70,25 +78,11 @@ final class QuickCommandsViewModel { /// Removal requires editing config.yaml directly — `hermes config set` has no /// unset for nested keys. Open the file in the editor for manual removal. func openConfigForRemoval() { - NSWorkspace.shared.open(URL(fileURLWithPath: HermesPaths.configYAML)) + context.openInLocalEditor(context.paths.configYAML) } @discardableResult private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) { - let process = Process() - process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary) - process.arguments = arguments - process.environment = HermesFileService.enrichedEnvironment() - 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) - } + context.runHermes(arguments) } } diff --git a/scarf/scarf/Features/QuickCommands/Views/QuickCommandsView.swift b/scarf/scarf/Features/QuickCommands/Views/QuickCommandsView.swift index 9326bd1..3131106 100644 --- a/scarf/scarf/Features/QuickCommands/Views/QuickCommandsView.swift +++ b/scarf/scarf/Features/QuickCommands/Views/QuickCommandsView.swift @@ -1,10 +1,15 @@ import SwiftUI struct QuickCommandsView: View { - @State private var viewModel = QuickCommandsViewModel() + @State private var viewModel: QuickCommandsViewModel @State private var showAddSheet = false @State private var editTarget: HermesQuickCommand? + init(context: ServerContext) { + _viewModel = State(initialValue: QuickCommandsViewModel(context: context)) + } + + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { diff --git a/scarf/scarf/Features/Servers/ViewModels/AddServerViewModel.swift b/scarf/scarf/Features/Servers/ViewModels/AddServerViewModel.swift new file mode 100644 index 0000000..8be7769 --- /dev/null +++ b/scarf/scarf/Features/Servers/ViewModels/AddServerViewModel.swift @@ -0,0 +1,110 @@ +import Foundation +import AppKit + +/// Drives the Add Server sheet. Exposed state maps 1:1 to form fields, plus +/// a reachability test that runs `ssh host 'command -v hermes && ls .hermes/state.db'` +/// and surfaces stderr inline on failure. +@Observable +@MainActor +final class AddServerViewModel { + /// Name shown in the server picker (defaults to host if the user leaves + /// it blank). + var displayName: String = "" + var host: String = "" + var user: String = "" + var port: String = "" + var identityFile: String = "" + /// Override for `~/.hermes` on the remote. Empty = default. + var remoteHome: String = "" + + var isTesting: Bool = false + /// Outcome of the most recent Test Connection run. `nil` = not yet run. + var testResult: TestResult? + + enum TestResult: Equatable { + case success(hermesPath: String, dbFound: Bool) + /// `command` is the full ssh invocation we attempted (so the user can + /// paste it into Terminal to see what their shell does with it). + /// `stderr` is whatever ssh / the remote shell wrote to stderr. + case failure(message: String, stderr: String, command: String) + } + + /// The config the form currently represents — built on demand, not + /// persisted until the user clicks Save. + var draftConfig: SSHConfig { + SSHConfig( + host: host.trimmingCharacters(in: .whitespaces), + user: nonEmpty(user), + port: Int(port), + identityFile: nonEmpty(identityFile), + remoteHome: nonEmpty(remoteHome), + hermesBinaryHint: nil + ) + } + + /// Hostname or alias is the only required field; everything else + /// defaults to `~/.ssh/config` / ssh-agent. + var canSave: Bool { + !host.trimmingCharacters(in: .whitespaces).isEmpty + } + + var resolvedDisplayName: String { + let trimmed = displayName.trimmingCharacters(in: .whitespaces) + if !trimmed.isEmpty { return trimmed } + return host.trimmingCharacters(in: .whitespaces) + } + + // MARK: - Identity file picker + + func pickIdentityFile() { + let panel = NSOpenPanel() + panel.message = "Choose an SSH private key" + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + // Default to ~/.ssh so users land in the right place. + if let sshDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first? + .deletingLastPathComponent().appendingPathComponent(".ssh", isDirectory: true) { + panel.directoryURL = sshDir + } + if panel.runModal() == .OK, let url = panel.url { + identityFile = url.path + } + } + + // MARK: - Test Connection + + /// Run a single ssh round-trip to verify auth + discover the remote + /// hermes binary. Populates `testResult` with either a success (so the + /// user knows the binary was found and the DB is readable) or a + /// failure with stderr for debugging. + /// + /// Uses `ssh -v` for the test probe so we capture the full handshake + /// trace — even if auth fails before the remote shell starts, ssh's + /// own diagnostic output gives the user (and us) something to act on. + func testConnection() async { + isTesting = true + defer { isTesting = false } + + let config = draftConfig + let probe = TestConnectionProbe(config: config) + testResult = await probe.run() + } + + /// If the test succeeded, we prefer to save the probed binary path into + /// `hermesBinaryHint` so subsequent calls don't need to re-resolve it. + func configForSave() -> SSHConfig { + var cfg = draftConfig + if case .success(let path, _) = testResult { + cfg.hermesBinaryHint = path + } + return cfg + } + + // MARK: - Helpers + + private func nonEmpty(_ s: String) -> String? { + let trimmed = s.trimmingCharacters(in: .whitespaces) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/scarf/scarf/Features/Servers/ViewModels/ConnectionStatusViewModel.swift b/scarf/scarf/Features/Servers/ViewModels/ConnectionStatusViewModel.swift new file mode 100644 index 0000000..35ce655 --- /dev/null +++ b/scarf/scarf/Features/Servers/ViewModels/ConnectionStatusViewModel.swift @@ -0,0 +1,127 @@ +import Foundation +import os + +/// Tracks connection health for the current window's server. Remote contexts +/// get a lightweight 15s heartbeat (a no-op `true` remote command) that +/// flips the status between green / yellow / red. Local contexts are always +/// green since there's no connection to lose. +@Observable +@MainActor +final class ConnectionStatusViewModel { + private let logger = Logger(subsystem: "com.scarf", category: "ConnectionStatus") + + enum Status: Equatable { + /// Healthy: most recent probe succeeded. + case connected + /// No probe yet or the previous probe timed out but we haven't + /// confirmed failure. Shown as yellow to tell the user "checking…". + case idle + /// Last probe failed. `message` is a terse human summary; `stderr` + /// is the raw diagnostic text for a disclosure panel. + case error(message: String, stderr: String) + } + + private(set) var status: Status = .idle + /// Timestamp of the last successful probe. Used by the UI to show how + /// fresh the status indicator is ("just now", "2m ago"…). + private(set) var lastSuccess: Date? + /// Number of consecutive probe failures. Surfaced as a yellow "Reconnecting…" + /// state for the first failure (silent retry), then promoted to red after + /// `consecutiveFailureThreshold` failures so flaky connections don't + /// flap the indicator on every dropped packet. + private(set) var consecutiveFailures = 0 + private let consecutiveFailureThreshold = 2 + + let context: ServerContext + private let transport: any ServerTransport + private var probeTask: Task? + + init(context: ServerContext) { + self.context = context + self.transport = context.makeTransport() + if !context.isRemote { + // Local contexts are always considered connected — no network + // or auth can fail. + self.status = .connected + self.lastSuccess = Date() + } + } + + /// Kick off a background heartbeat loop. Safe to call multiple times; + /// subsequent calls cancel the prior task and restart. + func startMonitoring() { + guard context.isRemote else { return } + probeTask?.cancel() + probeTask = Task { [weak self] in + while !Task.isCancelled { + await self?.probeOnce() + try? await Task.sleep(nanoseconds: 15_000_000_000) // 15s + } + } + } + + func stopMonitoring() { + probeTask?.cancel() + probeTask = nil + } + + /// Manual probe — also invoked by the toolbar "Retry" button on error. + func retry() { + Task { await probeOnce() } + } + + private func probeOnce() async { + let snapshot = transport + let result: Result + // Transport IO on a detached task so we don't block MainActor. + result = await Task.detached { + do { + let probe = try snapshot.runProcess( + executable: "/bin/sh", + args: ["-c", "true"], + stdin: nil, + timeout: 10 + ) + if probe.exitCode == 0 { + return .success(()) + } + return .failure(.commandFailed(exitCode: probe.exitCode, stderr: probe.stderrString)) + } catch let e as TransportError { + return .failure(e) + } catch { + return .failure(.other(message: error.localizedDescription)) + } + }.value + + switch result { + case .success: + status = .connected + lastSuccess = Date() + consecutiveFailures = 0 + case .failure(let err): + consecutiveFailures += 1 + // First failure → silent yellow "Reconnecting…" while we try + // again on the next 15s tick. Only flip to red after we've + // failed `consecutiveFailureThreshold` times in a row, so a + // single dropped packet (laptop sleep/wake, transient WiFi) + // doesn't visually scare the user. + if consecutiveFailures < consecutiveFailureThreshold { + status = .idle + // Try again sooner than the regular tick — gives the + // typical "WiFi reconnected within 5s" case a chance to + // self-heal before the next 15s heartbeat. + Task { [weak self] in + try? await Task.sleep(nanoseconds: 3_000_000_000) + if self?.consecutiveFailures ?? 0 > 0 { + await self?.probeOnce() + } + } + } else { + status = .error( + message: err.errorDescription ?? "Unreachable", + stderr: err.diagnosticStderr + ) + } + } + } +} diff --git a/scarf/scarf/Features/Servers/ViewModels/TestConnectionProbe.swift b/scarf/scarf/Features/Servers/ViewModels/TestConnectionProbe.swift new file mode 100644 index 0000000..ede566a --- /dev/null +++ b/scarf/scarf/Features/Servers/ViewModels/TestConnectionProbe.swift @@ -0,0 +1,179 @@ +import Foundation + +/// Bypasses `SSHTransport`'s normal terse-error path so the Add Server sheet +/// can show the user a full diagnostic on failure: the exact ssh command we +/// invoked, the verbose `ssh -v` handshake trace, and any remote shell +/// output. This is the difference between "Remote command exited 255" with +/// no further info, and "ssh said 'Permission denied (publickey)' on line N +/// of the trace, here's the command we ran, here's what was in your env". +struct TestConnectionProbe { + let config: SSHConfig + + func run() async -> AddServerViewModel.TestResult { + let host = config.host.trimmingCharacters(in: .whitespaces) + guard !host.isEmpty else { + return .failure(message: "Host is empty", stderr: "", command: "") + } + + // Same options SSHTransport uses, plus -v for verbose ssh trace. + // We deliberately skip ControlMaster here so the probe is a fresh + // connection — a stale control socket from a previous failed run + // shouldn't mask current state. + var sshArgs: [String] = [ + "-v", + "-o", "ServerAliveInterval=30", + "-o", "ConnectTimeout=10", + "-o", "StrictHostKeyChecking=accept-new", + "-o", "BatchMode=yes", + "-o", "LogLevel=ERROR" // Errors only on stderr; -v puts handshake on stderr separately + ] + if let port = config.port { sshArgs += ["-p", String(port)] } + if let id = config.identityFile, !id.isEmpty { + sshArgs += ["-i", id] + } + let hostSpec: String + if let user = config.user, !user.isEmpty { hostSpec = "\(user)@\(host)" } + else { hostSpec = host } + sshArgs.append(hostSpec) + sshArgs.append("--") + + // Remote probe script. Tries three strategies in order: + // 1. `command -v hermes` against the bare non-interactive PATH — + // works if the user put their install location in ~/.zshenv. + // 2. Source common login rc files (.zprofile, .bash_profile, + // .profile) and re-probe — picks up PATH set in login shells. + // 3. Probe the well-known install candidates directly. Mirrors + // `HermesPathSet.hermesBinaryCandidates` so behavior matches + // Scarf's local resolution. + // The matched absolute path is stored as `hermesBinaryHint` on the + // SSHConfig so subsequent CLI/ACP invocations don't have to re-probe. + let script = #""" + hpath=$(command -v hermes 2>/dev/null) + if [ -z "$hpath" ]; then + for rc in "$HOME/.zshenv" "$HOME/.zprofile" "$HOME/.bash_profile" "$HOME/.profile"; do + [ -f "$rc" ] && . "$rc" 2>/dev/null + done + hpath=$(command -v hermes 2>/dev/null) + fi + if [ -z "$hpath" ]; then + for cand in "$HOME/.local/bin/hermes" "/opt/homebrew/bin/hermes" "/usr/local/bin/hermes" "$HOME/.hermes/bin/hermes"; do + if [ -x "$cand" ]; then hpath="$cand"; break; fi + done + fi + echo "HERMES:$hpath" + if [ -e "$HOME/.hermes/state.db" ]; then echo DB:ok; else echo DB:missing; fi + """# + sshArgs.append("/bin/sh") + sshArgs.append("-c") + sshArgs.append(script) + + // Build the displayable command string. Show exactly what `ssh ...` + // would look like in the user's terminal (with single-quoting for + // the script). Doesn't have to be byte-equivalent to what + // `Process` invokes — just a faithful reproduction the user can + // paste into Terminal to compare. + let displayCommand = "/usr/bin/ssh " + sshArgs.map { Self.shellDisplayQuote($0) }.joined(separator: " ") + + let probe = await Task.detached { () -> (Int32, String, String) in + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + proc.arguments = sshArgs + // Inherit shell-derived SSH_AUTH_SOCK so ssh can reach the agent. + // Without this, GUI-launched Scarf can't see the user's + // ssh-add'd keys (terminal works because shell sets the var). + var env = ProcessInfo.processInfo.environment + let shellEnv = HermesFileService.enrichedEnvironment() + for key in ["SSH_AUTH_SOCK", "SSH_AGENT_PID"] { + if env[key] == nil, let value = shellEnv[key], !value.isEmpty { + env[key] = value + } + } + proc.environment = env + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + proc.standardOutput = stdoutPipe + proc.standardError = stderrPipe + do { + try proc.run() + } catch { + return (-1, "", "Failed to launch /usr/bin/ssh: \(error.localizedDescription)") + } + // Bound the probe so a hung connection doesn't lock the UI. + let deadline = Date().addingTimeInterval(20) + while proc.isRunning && Date() < deadline { + Thread.sleep(forTimeInterval: 0.1) + } + if proc.isRunning { + proc.terminate() + let partial = (try? stderrPipe.fileHandleForReading.readToEnd()) ?? Data() + return (-1, "", "Timed out after 20s.\n\nssh trace so far:\n" + (String(data: partial, encoding: .utf8) ?? "")) + } + let out = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data() + let err = (try? stderrPipe.fileHandleForReading.readToEnd()) ?? Data() + return ( + proc.terminationStatus, + String(data: out, encoding: .utf8) ?? "", + String(data: err, encoding: .utf8) ?? "" + ) + }.value + + let (exitCode, stdout, stderr) = probe + + // Diagnostic envelope: always include the ssh command + the + // SSH_AUTH_SOCK presence at the top of the stderr blob so the + // user immediately sees whether agent inheritance worked. + let agentEnv = ProcessInfo.processInfo.environment["SSH_AUTH_SOCK"] + ?? HermesFileService.enrichedEnvironment()["SSH_AUTH_SOCK"] + ?? "(not set)" + let envSummary = "SSH_AUTH_SOCK = \(agentEnv)\n\n" + + if exitCode == 0 { + let lines = stdout.split(separator: "\n").map(String.init) + let hermesPath = lines.first(where: { $0.hasPrefix("HERMES:") })? + .dropFirst("HERMES:".count).trimmingCharacters(in: .whitespaces) ?? "" + let dbFound = lines.contains(where: { $0 == "DB:ok" }) + if hermesPath.isEmpty { + return .failure( + message: "hermes binary not found in remote $PATH", + stderr: envSummary + "Add hermes to the remote PATH (e.g. ~/.zshenv).\n\nRemote stdout:\n\(stdout)", + command: displayCommand + ) + } + return .success(hermesPath: String(hermesPath), dbFound: dbFound) + } + + // Classify common failures by scanning the stderr trace. + let lower = stderr.lowercased() + let summary: String + if lower.contains("permission denied") { + summary = "Permission denied — check that your key is loaded in ssh-agent (run `ssh-add -l` in Terminal) and that the remote accepts it." + } else if lower.contains("host key verification failed") { + summary = "Host key mismatch — run `ssh-keygen -R \(host)` in Terminal, then retry." + } else if lower.contains("connection refused") || lower.contains("no route to host") { + summary = "Can't reach the host — check the IP/network/firewall." + } else if lower.contains("could not resolve hostname") { + summary = "Hostname did not resolve." + } else if exitCode == 255 { + summary = "ssh failed (exit 255). See the trace below." + } else { + summary = "Remote command exited \(exitCode)." + } + + return .failure( + message: summary, + stderr: envSummary + (stderr.isEmpty ? "(ssh produced no stderr — this usually means the process itself failed to start, the executable couldn't be located, or stdin/stdout was closed unexpectedly.)" : stderr), + command: displayCommand + ) + } + + /// Quote an argument for display in a copy-pasteable ssh command. Always + /// wraps in single quotes if it contains anything beyond a basic safe set + /// — visually noisier than minimal quoting but unambiguous. + private static func shellDisplayQuote(_ s: String) -> String { + if s.isEmpty { return "''" } + let safe = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@%+=:,./-_") + if s.unicodeScalars.allSatisfy({ safe.contains($0) }) { return s } + return "'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'" + } +} diff --git a/scarf/scarf/Features/Servers/Views/AddServerSheet.swift b/scarf/scarf/Features/Servers/Views/AddServerSheet.swift new file mode 100644 index 0000000..2cfbdc0 --- /dev/null +++ b/scarf/scarf/Features/Servers/Views/AddServerSheet.swift @@ -0,0 +1,192 @@ +import SwiftUI + +/// Sheet for adding a new remote server. Collects SSH connection details, +/// runs a "Test Connection" probe, and — on save — hands the persisted +/// `SSHConfig` (with `hermesBinaryHint` populated by the probe) to the +/// caller via the `onSave` closure. +struct AddServerSheet: View { + @State private var viewModel = AddServerViewModel() + @Environment(\.dismiss) private var dismiss + + /// Called when the user confirms. Caller persists via `ServerRegistry` + /// and typically switches the active window's context to the new server. + let onSave: (_ displayName: String, _ config: SSHConfig) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + Divider() + ScrollView { + VStack(alignment: .leading, spacing: 16) { + connectionSection + Divider() + testSection + } + .padding(20) + } + Divider() + footer + } + .frame(width: 560, height: 680) + } + + private var header: some View { + HStack { + Image(systemName: "server.rack") + .font(.title2) + Text("Add Remote Server") + .font(.headline) + Spacer() + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + } + + private var connectionSection: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Connection") + .font(.subheadline).bold() + .foregroundStyle(.secondary) + + LabeledField("Name") { + TextField("Optional — defaults to hostname", text: $viewModel.displayName) + .textFieldStyle(.roundedBorder) + } + + LabeledField("Host") { + TextField("hermes.example.com or a ~/.ssh/config alias", text: $viewModel.host) + .textFieldStyle(.roundedBorder) + .autocorrectionDisabled() + } + + LabeledField("User") { + TextField("Defaults to ~/.ssh/config or current user", text: $viewModel.user) + .textFieldStyle(.roundedBorder) + .autocorrectionDisabled() + } + + LabeledField("Port") { + TextField("22", text: $viewModel.port) + .textFieldStyle(.roundedBorder) + .frame(width: 100) + Spacer() + } + + LabeledField("Identity file") { + HStack(spacing: 8) { + TextField("ssh-agent (leave blank)", text: $viewModel.identityFile) + .textFieldStyle(.roundedBorder) + .autocorrectionDisabled() + Button("Choose…") { viewModel.pickIdentityFile() } + } + } + + LabeledField("Remote ~/.hermes override") { + TextField("Leave blank for default", text: $viewModel.remoteHome) + .textFieldStyle(.roundedBorder) + .autocorrectionDisabled() + } + + Text("Scarf uses ssh-agent for authentication. If your key has a passphrase, run `ssh-add` before connecting — Scarf never prompts for or stores passphrases.") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.top, 4) + } + } + + private var testSection: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Probe").font(.subheadline).bold().foregroundStyle(.secondary) + Spacer() + Button { + Task { await viewModel.testConnection() } + } label: { + if viewModel.isTesting { + ProgressView().controlSize(.small) + } else { + Text("Test Connection") + } + } + .disabled(viewModel.isTesting || !viewModel.canSave) + } + + if let result = viewModel.testResult { + switch result { + case .success(let path, let dbFound): + VStack(alignment: .leading, spacing: 4) { + Label("Connected", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + Text("hermes at \(path)").font(.caption).monospaced() + Text(dbFound ? "state.db found" : "state.db not found — Hermes may not have run yet on the remote") + .font(.caption) + .foregroundStyle(dbFound ? Color.secondary : Color.orange) + } + case .failure(let message, let stderr, let command): + VStack(alignment: .leading, spacing: 6) { + Label(message, systemImage: "xmark.octagon.fill") + .foregroundStyle(.red) + DisclosureGroup("ssh trace") { + ScrollView { + Text(stderr.isEmpty ? "(no output)" : stderr) + .font(.system(size: 11, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: 180) + } + .font(.caption) + DisclosureGroup("Command") { + ScrollView { + Text(command) + .font(.system(size: 11, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: 100) + } + .font(.caption) + } + } + } + } + } + + private var footer: some View { + HStack { + Spacer() + Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) + Button("Save") { + onSave(viewModel.resolvedDisplayName, viewModel.configForSave()) + dismiss() + } + .keyboardShortcut(.defaultAction) + .disabled(!viewModel.canSave) + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + } +} + +/// Form-field helper: label on the left, editable field on the right. +private struct LabeledField: View { + let label: String + let content: Content + + init(_ label: String, @ViewBuilder content: () -> Content) { + self.label = label + self.content = content() + } + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text(label) + .font(.callout) + .foregroundStyle(.secondary) + .frame(width: 140, alignment: .trailing) + content + Spacer(minLength: 0) + } + } +} diff --git a/scarf/scarf/Features/Servers/Views/ConnectionStatusPill.swift b/scarf/scarf/Features/Servers/Views/ConnectionStatusPill.swift new file mode 100644 index 0000000..3533585 --- /dev/null +++ b/scarf/scarf/Features/Servers/Views/ConnectionStatusPill.swift @@ -0,0 +1,229 @@ +import SwiftUI + +/// Small colored pill shown in the toolbar reflecting the server's reach- +/// ability. Green = connected, yellow = probing, red = unreachable. +/// +/// Clicking the pill (when red) surfaces the raw stderr so users can +/// diagnose SSH issues without digging through Console. +struct ConnectionStatusPill: View { + let status: ConnectionStatusViewModel + @State private var showDetails = false + + var body: some View { + Button { + switch status.status { + case .error: + showDetails = true + case .connected, .idle: + status.retry() + } + } label: { + HStack(spacing: 4) { + Circle() + .fill(color) + .frame(width: 8, height: 8) + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.secondary.opacity(0.08), in: Capsule()) + } + .buttonStyle(.plain) + .help(tooltip) + .popover(isPresented: $showDetails, arrowEdge: .bottom) { + errorDetails.frame(width: 400) + } + } + + private var color: Color { + switch status.status { + case .connected: return .green + case .idle: return .yellow + case .error: return .red + } + } + + private var label: String { + switch status.status { + case .connected: return "Connected" + case .idle: return "Checking…" + case .error(let message, _): return message + } + } + + private var tooltip: String { + switch status.status { + case .connected: + if let ts = status.lastSuccess { + let fmt = RelativeDateTimeFormatter() + return "Last probe: \(fmt.localizedString(for: ts, relativeTo: Date()))" + } + return "Connected" + case .idle: return "Waiting for first probe" + case .error(_, _): return "Click for details" + } + } + + @ViewBuilder + private var errorDetails: some View { + if case .error(let message, let stderr) = status.status { + VStack(alignment: .leading, spacing: 10) { + HStack { + Label(message, systemImage: "xmark.octagon.fill") + .foregroundStyle(.red) + .font(.headline) + Spacer() + Button("Retry") { + status.retry() + showDetails = false + } + } + Divider() + + // Specific guidance based on stderr classification. + if stderr.isEmpty { + Text("No additional output. Check ~/.ssh/config and ssh-agent.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + ScrollView { + Text(stderr) + .font(.system(size: 11, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: 200) + } + + // Tailored hint per failure class. We avoid auto-running + // anything (Scarf can't safely invoke ssh-add or ssh-keygen + // on the user's behalf), but copy-paste commands so the fix + // is one paste away in Terminal. + hintFor(stderr: stderr) + } + .padding(14) + .frame(width: 440) + } + } + + @ViewBuilder + private func hintFor(stderr: String) -> some View { + let lower = stderr.lowercased() + if lower.contains("host key verification failed") + || lower.contains("remote host identification has changed") { + // Known-hosts mismatch: this is the "blocking alert with + // fingerprints" Phase 4 calls for. We can't safely auto-trust + // a new key, so we offer the exact remediation command. + HostKeyMismatchHint(serverHost: extractHostHint(from: stderr)) + } else if lower.contains("permission denied") + || (lower.contains("publickey") && lower.contains("denied")) { + SshAddHint() + } else { + Text("If this is the first connection, ensure your key is loaded with `ssh-add` and that the remote accepts it.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + /// Pull the host out of an ssh stderr line like + /// "Host key verification failed for 192.168.0.82". Best-effort — falls + /// back to a placeholder when no match is found. + private func extractHostHint(from stderr: String) -> String { + // Look for "Offending ECDSA key in /Users/.../.ssh/known_hosts:5" + // or "Host key verification failed." — neither of which directly + // contains the host. We fall back to scanning for an IP-like or + // hostname-like token in the trace. + let pattern = #"(?:host|key for) ['\"]?([A-Za-z0-9._-]+)['\"]?"# + if let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive), + let match = regex.firstMatch(in: stderr, range: NSRange(stderr.startIndex..., in: stderr)), + match.numberOfRanges >= 2, + let range = Range(match.range(at: 1), in: stderr) { + return String(stderr[range]) + } + return "" + } +} + +/// Specific remediation card for "host key verification failed" — the +/// blocking case where ssh refuses because the remote's fingerprint changed. +/// We never auto-accept; the user runs ssh-keygen -R themselves. +private struct HostKeyMismatchHint: View { + let serverHost: String + @State private var copied = false + + private var command: String { "ssh-keygen -R \(serverHost)" } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Label("Host key changed", systemImage: "exclamationmark.shield") + .font(.subheadline).bold() + .foregroundStyle(.orange) + Text("The remote's SSH fingerprint no longer matches what your `~/.ssh/known_hosts` file expected. This usually means the remote was reinstalled — or, less commonly, that someone is intercepting the connection.") + .font(.caption) + .foregroundStyle(.secondary) + Text("If you trust the change, remove the stale entry and reconnect:") + .font(.caption) + .foregroundStyle(.secondary) + HStack { + Text(command) + .font(.system(size: 11, design: .monospaced)) + .textSelection(.enabled) + .padding(6) + .background(Color.secondary.opacity(0.12), in: RoundedRectangle(cornerRadius: 4)) + Spacer() + Button(copied ? "Copied" : "Copy") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(command, forType: .string) + copied = true + Task { try? await Task.sleep(nanoseconds: 1_500_000_000); copied = false } + } + .buttonStyle(.borderless) + } + } + .padding(8) + .background(Color.orange.opacity(0.1), in: RoundedRectangle(cornerRadius: 6)) + } +} + +/// Hint for "Permission denied" failures — almost always means ssh-agent +/// doesn't have the right key loaded. We can't run ssh-add for the user +/// (no UI to handle the passphrase prompt), but we provide the exact +/// command + a copy button. +private struct SshAddHint: View { + @State private var copied = false + private let command = "ssh-add ~/.ssh/id_ed25519" + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Label("Authentication uses ssh-agent", systemImage: "key.viewfinder") + .font(.subheadline).bold() + .foregroundStyle(.blue) + Text("Scarf never prompts for passphrases. Add your key to ssh-agent in Terminal, then click Retry. If your key isn't `id_ed25519`, swap the path:") + .font(.caption) + .foregroundStyle(.secondary) + HStack { + Text(command) + .font(.system(size: 11, design: .monospaced)) + .textSelection(.enabled) + .padding(6) + .background(Color.secondary.opacity(0.12), in: RoundedRectangle(cornerRadius: 4)) + Spacer() + Button(copied ? "Copied" : "Copy") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(command, forType: .string) + copied = true + Task { try? await Task.sleep(nanoseconds: 1_500_000_000); copied = false } + } + .buttonStyle(.borderless) + } + Text("To skip the passphrase prompt at every reboot, add `--apple-use-keychain` to cache it in macOS Keychain.") + .font(.caption) + .foregroundStyle(.tertiary) + } + .padding(8) + .background(Color.blue.opacity(0.08), in: RoundedRectangle(cornerRadius: 6)) + } +} diff --git a/scarf/scarf/Features/Servers/Views/ManageServersView.swift b/scarf/scarf/Features/Servers/Views/ManageServersView.swift new file mode 100644 index 0000000..994c789 --- /dev/null +++ b/scarf/scarf/Features/Servers/Views/ManageServersView.swift @@ -0,0 +1,112 @@ +import SwiftUI + +/// List of registered remote servers with add/remove actions. Rendered as a +/// popover from the toolbar switcher. +struct ManageServersView: View { + @Environment(ServerRegistry.self) private var registry + @State private var showAddSheet = false + @State private var pendingRemoveID: ServerID? + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + Divider() + if registry.entries.isEmpty { + empty + } else { + list + } + } + .frame(width: 380, height: 360) + .sheet(isPresented: $showAddSheet) { + AddServerSheet { name, config in + _ = registry.addServer(displayName: name, config: config) + } + } + .confirmationDialog( + "Remove this server?", + isPresented: Binding( + get: { pendingRemoveID != nil }, + set: { if !$0 { pendingRemoveID = nil } } + ), + actions: { + Button("Remove", role: .destructive) { + if let id = pendingRemoveID { registry.removeServer(id) } + pendingRemoveID = nil + } + Button("Cancel", role: .cancel) { pendingRemoveID = nil } + }, + message: { + Text("The server's SSH configuration is removed from Scarf. Your remote files are untouched.") + } + ) + } + + private var header: some View { + HStack { + Text("Servers").font(.headline) + Spacer() + Button { + showAddSheet = true + } label: { + Label("Add", systemImage: "plus") + } + .buttonStyle(.borderless) + } + .padding(12) + } + + private var empty: some View { + VStack(spacing: 8) { + Image(systemName: "server.rack") + .font(.system(size: 28)) + .foregroundStyle(.secondary) + Text("No remote servers").font(.headline) + Text("Click Add to connect to a remote Hermes installation over SSH.") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 280) + } + .padding(32) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var list: some View { + List { + ForEach(registry.entries) { entry in + HStack(spacing: 10) { + Image(systemName: "server.rack") + .foregroundStyle(.blue) + VStack(alignment: .leading, spacing: 2) { + Text(entry.displayName).font(.body) + if case .ssh(let config) = entry.kind { + Text(summary(for: config)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer() + Button { + pendingRemoveID = entry.id + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + .foregroundStyle(.red) + } + .padding(.vertical, 4) + } + } + .listStyle(.inset) + } + + private func summary(for config: SSHConfig) -> String { + var s = "" + if let user = config.user, !user.isEmpty { s += "\(user)@" } + s += config.host + if let port = config.port { s += ":\(port)" } + if let home = config.remoteHome, !home.isEmpty { s += " (\(home))" } + return s + } +} diff --git a/scarf/scarf/Features/Servers/Views/MissingServerView.swift b/scarf/scarf/Features/Servers/Views/MissingServerView.swift new file mode 100644 index 0000000..d6eab23 --- /dev/null +++ b/scarf/scarf/Features/Servers/Views/MissingServerView.swift @@ -0,0 +1,57 @@ +import SwiftUI + +/// Shown when a window is restored after the user removed the server it +/// was bound to. Lets them open Local or any remaining registered server +/// in this same window without quitting + relaunching. +struct MissingServerView: View { + @Environment(ServerRegistry.self) private var registry + @Environment(\.openWindow) private var openWindow + @Environment(\.dismissWindow) private var dismissWindow + + let removedServerID: ServerID + + var body: some View { + VStack(spacing: 16) { + Image(systemName: "questionmark.folder") + .font(.system(size: 56)) + .foregroundStyle(.secondary) + Text("Server No Longer Exists") + .font(.title2).bold() + Text("The server this window was opened with has been removed from your registry.") + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .frame(maxWidth: 380) + Text("ID: \(removedServerID.uuidString)") + .font(.caption) + .monospaced() + .foregroundStyle(.tertiary) + .textSelection(.enabled) + + HStack(spacing: 8) { + Button("Open Local") { + openWindow(value: ServerContext.local.id) + dismissWindow() + } + .buttonStyle(.borderedProminent) + + if !registry.entries.isEmpty { + Menu { + ForEach(registry.entries) { entry in + Button(entry.displayName) { + openWindow(value: entry.id) + dismissWindow() + } + } + } label: { + Text("Open Other Server…") + } + } + + Button("Close Window") { dismissWindow() } + } + .padding(.top, 4) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(40) + } +} diff --git a/scarf/scarf/Features/Servers/Views/ServerSwitcherToolbar.swift b/scarf/scarf/Features/Servers/Views/ServerSwitcherToolbar.swift new file mode 100644 index 0000000..c7c5dc0 --- /dev/null +++ b/scarf/scarf/Features/Servers/Views/ServerSwitcherToolbar.swift @@ -0,0 +1,63 @@ +import SwiftUI + +/// Toolbar control that shows the current window's server and exposes a +/// menu for opening *other* servers in additional windows. Multi-window is +/// the primary interaction model — each window is bound to one server for +/// its whole lifetime — so the dropdown action is "Open in new window", +/// not "switch in place". +struct ServerSwitcherToolbar: View { + @Environment(\.serverContext) private var current + @Environment(ServerRegistry.self) private var registry + @Environment(\.openWindow) private var openWindow + @State private var showManage = false + + var body: some View { + Menu { + Text("Current: \(current.displayName)") + .font(.caption) + Divider() + Section("Open in new window") { + if current.id != ServerContext.local.id { + openRow(.local) + } + ForEach(registry.entries) { entry in + if entry.id != current.id { + openRow(entry.context) + } + } + } + Divider() + Button { + showManage = true + } label: { + Label("Manage Servers…", systemImage: "server.rack") + } + } label: { + HStack(spacing: 6) { + Circle() + .fill(current.isRemote ? Color.blue : Color.green) + .frame(width: 8, height: 8) + Text(current.displayName) + .font(.callout) + .lineLimit(1) + Image(systemName: "chevron.down") + .font(.caption2) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + } + .menuIndicator(.hidden) + .popover(isPresented: $showManage, arrowEdge: .bottom) { + ManageServersView() + } + } + + @ViewBuilder + private func openRow(_ context: ServerContext) -> some View { + Button { + openWindow(value: context.id) + } label: { + Label(context.displayName, systemImage: context.isRemote ? "server.rack" : "laptopcomputer") + } + } +} diff --git a/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift b/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift index d4fecd8..e4072b9 100644 --- a/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift +++ b/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift @@ -11,7 +11,14 @@ struct SessionStoreStats { @Observable final class SessionsViewModel { - private let dataService = HermesDataService() + let context: ServerContext + private let dataService: HermesDataService + + init(context: ServerContext = .local) { + self.context = context + self.dataService = HermesDataService(context: context) + } + var sessions: [HermesSession] = [] var sessionPreviews: [String: String] = [:] @@ -146,14 +153,14 @@ final class SessionsViewModel { } let sorted = platformCounts.sorted { $0.value > $1.value }.map { (platform: $0.key, count: $0.value) } - let dbPath = HermesPaths.stateDB + let dbPath = context.paths.stateDB let fileSize: String - if let attrs = try? FileManager.default.attributesOfItem(atPath: dbPath), - let size = attrs[.size] as? Int { - if Double(size) >= FileSizeUnit.megabyte { - fileSize = String(format: "%.1f MB", Double(size) / FileSizeUnit.megabyte) + if let stat = context.makeTransport().stat(dbPath) { + let size = Double(stat.size) + if size >= FileSizeUnit.megabyte { + fileSize = String(format: "%.1f MB", size / FileSizeUnit.megabyte) } else { - fileSize = String(format: "%.0f KB", Double(size) / FileSizeUnit.kilobyte) + fileSize = String(format: "%.0f KB", size / FileSizeUnit.kilobyte) } } else { fileSize = "unknown" @@ -171,20 +178,6 @@ final class SessionsViewModel { @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() - let output = String(data: data, encoding: .utf8) ?? "" - return (output, process.terminationStatus) - } catch { - return ("", -1) - } + context.runHermes(arguments) } } diff --git a/scarf/scarf/Features/Sessions/Views/SessionsView.swift b/scarf/scarf/Features/Sessions/Views/SessionsView.swift index 1815f58..7559ff4 100644 --- a/scarf/scarf/Features/Sessions/Views/SessionsView.swift +++ b/scarf/scarf/Features/Sessions/Views/SessionsView.swift @@ -1,9 +1,14 @@ import SwiftUI struct SessionsView: View { - @State private var viewModel = SessionsViewModel() + @State private var viewModel: SessionsViewModel @Environment(AppCoordinator.self) private var coordinator + init(context: ServerContext) { + _viewModel = State(initialValue: SessionsViewModel(context: context)) + } + + var body: some View { VStack(spacing: 0) { if let stats = viewModel.storeStats { diff --git a/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift b/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift index 92a1bd0..8d1bd91 100644 --- a/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift +++ b/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift @@ -6,7 +6,14 @@ import os @Observable final class SettingsViewModel { private let logger = Logger(subsystem: "com.scarf", category: "SettingsViewModel") - private let fileService = HermesFileService() + let context: ServerContext + private let fileService: HermesFileService + + init(context: ServerContext = .local) { + self.context = context + self.fileService = HermesFileService(context: context) + } + var config = HermesConfig.empty var gatewayState: GatewayState? @@ -20,18 +27,35 @@ final class SettingsViewModel { var sttProviders = ["local", "groq", "openai", "mistral"] var memoryProviders = ["", "honcho", "openviking", "mem0", "hindsight", "holographic", "retaindb", "byterover", "supermemory"] var saveMessage: String? + var isLoading = false func load() { - config = fileService.loadConfig() - gatewayState = fileService.loadGatewayState() - hermesRunning = fileService.isHermesRunning() - do { - rawConfigYAML = try String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8) - } catch { - logger.error("Failed to read config.yaml: \(error.localizedDescription)") - rawConfigYAML = "" + isLoading = true + let svc = fileService + let ctx = context + let displayName = ctx.displayName + let log = logger + // Heavy load: config + gateway state + isRunning + raw YAML are + // four sync transport calls. On remote each is a blocking ssh + // round-trip; doing them on MainActor would beach-ball for ~1s. + Task.detached { [weak self] in + let cfg = svc.loadConfig() + let gw = svc.loadGatewayState() + let running = svc.isHermesRunning() + let raw = ctx.readText(ctx.paths.configYAML) + if raw == nil { + log.error("Failed to read config.yaml from \(displayName)") + } + await MainActor.run { [weak self] in + guard let self else { return } + self.config = cfg + self.gatewayState = gw + self.hermesRunning = running + self.rawConfigYAML = raw ?? "" + self.personalities = self.parsePersonalities() + self.isLoading = false + } } - personalities = parsePersonalities() } /// Set a scalar config value via `hermes config set ` and reload @@ -278,7 +302,10 @@ final class SettingsViewModel { } func openConfigInEditor() { - NSWorkspace.shared.open(URL(fileURLWithPath: HermesPaths.configYAML)) + // No-op for remote contexts — the file is on the remote host, not + // this Mac. The Settings tab's in-app editor is the supported way + // to edit remote configs. + context.openInLocalEditor(context.paths.configYAML) } private func parsePersonalities() -> [String] { @@ -308,21 +335,6 @@ final class SettingsViewModel { @discardableResult private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) { - let process = Process() - process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary) - process.arguments = arguments - process.environment = HermesFileService.enrichedEnvironment() - 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 { - logger.error("Failed to run hermes \(arguments.joined(separator: " ")): \(error.localizedDescription)") - return ("", -1) - } + context.runHermes(arguments) } } diff --git a/scarf/scarf/Features/Settings/Views/Components/ModelPickerSheet.swift b/scarf/scarf/Features/Settings/Views/Components/ModelPickerSheet.swift index b458717..674d60f 100644 --- a/scarf/scarf/Features/Settings/Views/Components/ModelPickerSheet.swift +++ b/scarf/scarf/Features/Settings/Views/Components/ModelPickerSheet.swift @@ -21,7 +21,8 @@ struct ModelPickerSheet: View { @State private var customModelID: String = "" @State private var customProviderID: String = "" - private let catalog = ModelCatalogService() + @Environment(\.serverContext) private var serverContext + private var catalog: ModelCatalogService { ModelCatalogService(context: serverContext) } var body: some View { VStack(spacing: 0) { diff --git a/scarf/scarf/Features/Settings/Views/SettingsView.swift b/scarf/scarf/Features/Settings/Views/SettingsView.swift index cfa3237..fc6b4f5 100644 --- a/scarf/scarf/Features/Settings/Views/SettingsView.swift +++ b/scarf/scarf/Features/Settings/Views/SettingsView.swift @@ -5,9 +5,14 @@ import SwiftUI /// extracted view file under `Tabs/` — per CLAUDE.md guidance, splitting avoids /// SwiftUI type-checker timeouts and keeps each section testable in isolation. struct SettingsView: View { - @State private var viewModel = SettingsViewModel() + @State private var viewModel: SettingsViewModel @State private var selectedTab: SettingsTab = .general + init(context: ServerContext) { + _viewModel = State(initialValue: SettingsViewModel(context: context)) + } + + enum SettingsTab: String, CaseIterable, Identifiable { case general = "General" case display = "Display" @@ -58,6 +63,11 @@ struct SettingsView: View { } } .navigationTitle("Settings") + .loadingOverlay( + viewModel.isLoading, + label: "Loading settings…", + isEmpty: viewModel.rawConfigYAML.isEmpty + ) .onAppear { viewModel.load() } } diff --git a/scarf/scarf/Features/Settings/Views/Tabs/AdvancedTab.swift b/scarf/scarf/Features/Settings/Views/Tabs/AdvancedTab.swift index 6c908f4..3952777 100644 --- a/scarf/scarf/Features/Settings/Views/Tabs/AdvancedTab.swift +++ b/scarf/scarf/Features/Settings/Views/Tabs/AdvancedTab.swift @@ -142,15 +142,16 @@ struct AdvancedTab: View { } private var pathsSection: some View { - SettingsSection(title: "Paths", icon: "folder") { - PathRow(label: "Hermes Home", path: HermesPaths.home) - PathRow(label: "State DB", path: HermesPaths.stateDB) - PathRow(label: "Config", path: HermesPaths.configYAML) - PathRow(label: "Memory", path: HermesPaths.memoriesDir) - PathRow(label: "Sessions", path: HermesPaths.sessionsDir) - PathRow(label: "Skills", path: HermesPaths.skillsDir) - PathRow(label: "Agent Log", path: HermesPaths.agentLog) - PathRow(label: "Error Log", path: HermesPaths.errorsLog) + let paths = viewModel.context.paths + return SettingsSection(title: "Paths", icon: "folder") { + PathRow(label: "Hermes Home", path: paths.home) + PathRow(label: "State DB", path: paths.stateDB) + PathRow(label: "Config", path: paths.configYAML) + PathRow(label: "Memory", path: paths.memoriesDir) + PathRow(label: "Sessions", path: paths.sessionsDir) + PathRow(label: "Skills", path: paths.skillsDir) + PathRow(label: "Agent Log", path: paths.agentLog) + PathRow(label: "Error Log", path: paths.errorsLog) } } diff --git a/scarf/scarf/Features/Skills/ViewModels/SkillsViewModel.swift b/scarf/scarf/Features/Skills/ViewModels/SkillsViewModel.swift index 550464f..8178e0a 100644 --- a/scarf/scarf/Features/Skills/ViewModels/SkillsViewModel.swift +++ b/scarf/scarf/Features/Skills/ViewModels/SkillsViewModel.swift @@ -21,7 +21,14 @@ struct HermesSkillUpdate: Identifiable, Sendable, Equatable { @Observable final class SkillsViewModel { private let logger = Logger(subsystem: "com.scarf", category: "SkillsViewModel") - private let fileService = HermesFileService() + let context: ServerContext + private let fileService: HermesFileService + + init(context: ServerContext = .local) { + self.context = context + self.fileService = HermesFileService(context: context) + } + // MARK: - Installed skills (existing behavior) var categories: [HermesSkillCategory] = [] @@ -61,8 +68,16 @@ final class SkillsViewModel { } func load() { - categories = fileService.loadSkills() - currentConfig = fileService.loadConfig() + let svc = fileService + // loadSkills walks ~/.hermes/skills/* — many transport ops on remote. + Task.detached { [weak self] in + let cats = svc.loadSkills() + let cfg = svc.loadConfig() + await MainActor.run { [weak self] in + self?.categories = cats + self?.currentConfig = cfg + } + } } func selectSkill(_ skill: HermesSkill) { @@ -80,7 +95,7 @@ final class SkillsViewModel { private func computeMissingConfig(for skill: HermesSkill) -> [String] { guard !skill.requiredConfig.isEmpty else { return [] } - guard let yaml = try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8) else { + guard let yaml = context.readText(context.paths.configYAML) else { return skill.requiredConfig } return skill.requiredConfig.filter { key in diff --git a/scarf/scarf/Features/Skills/Views/SkillsView.swift b/scarf/scarf/Features/Skills/Views/SkillsView.swift index 84601ae..4c9edd0 100644 --- a/scarf/scarf/Features/Skills/Views/SkillsView.swift +++ b/scarf/scarf/Features/Skills/Views/SkillsView.swift @@ -1,9 +1,14 @@ import SwiftUI struct SkillsView: View { - @State private var viewModel = SkillsViewModel() + @State private var viewModel: SkillsViewModel @State private var currentTab: Tab = .installed + init(context: ServerContext) { + _viewModel = State(initialValue: SkillsViewModel(context: context)) + } + + enum Tab: String, CaseIterable, Identifiable { case installed = "Installed" case hub = "Browse Hub" diff --git a/scarf/scarf/Features/Tools/ViewModels/ToolsViewModel.swift b/scarf/scarf/Features/Tools/ViewModels/ToolsViewModel.swift index 7c8b78c..9b8800e 100644 --- a/scarf/scarf/Features/Tools/ViewModels/ToolsViewModel.swift +++ b/scarf/scarf/Features/Tools/ViewModels/ToolsViewModel.swift @@ -12,6 +12,11 @@ enum PlatformConnectivity: Sendable, Equatable { @Observable final class ToolsViewModel { private let logger = Logger(subsystem: "com.scarf", category: "ToolsViewModel") + let context: ServerContext + + init(context: ServerContext = .local) { + self.context = context + } var selectedPlatform: HermesToolPlatform = KnownPlatforms.cli var toolsets: [HermesToolset] = [] @@ -59,12 +64,13 @@ final class ToolsViewModel { /// - `~/.hermes/config.yaml` top-level keys (`discord:`, `whatsapp:`, etc.) tell us which have been configured. @MainActor private func loadPlatforms() async { + let ctx = context let yaml: String = await Task.detached { - (try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)) ?? "" + ctx.readText(ctx.paths.configYAML) ?? "" }.value let gatewayState: GatewayState? = await Task.detached { - HermesFileService().loadGatewayState() + HermesFileService(context: ctx).loadGatewayState() }.value let configuredNames = Self.parseConfiguredPlatforms(yaml: yaml) @@ -168,31 +174,7 @@ final class ToolsViewModel { } private nonisolated func runHermes(_ arguments: [String]) async -> (output: String, exitCode: Int32) { - await Task.detached { - let process = Process() - process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary) - process.arguments = arguments - let stdoutPipe = Pipe() - let stderrPipe = Pipe() - process.standardOutput = stdoutPipe - process.standardError = stderrPipe - do { - try process.run() - process.waitUntilExit() - let data = stdoutPipe.fileHandleForReading.readDataToEndOfFile() - let output = String(data: data, encoding: .utf8) ?? "" - try? stdoutPipe.fileHandleForReading.close() - try? stdoutPipe.fileHandleForWriting.close() - try? stderrPipe.fileHandleForReading.close() - try? stderrPipe.fileHandleForWriting.close() - return (output, process.terminationStatus) - } catch { - try? stdoutPipe.fileHandleForReading.close() - try? stdoutPipe.fileHandleForWriting.close() - try? stderrPipe.fileHandleForReading.close() - try? stderrPipe.fileHandleForWriting.close() - return ("", -1) - } - }.value + let ctx = context + return await Task.detached { ctx.runHermes(arguments) }.value } } diff --git a/scarf/scarf/Features/Tools/Views/ToolsView.swift b/scarf/scarf/Features/Tools/Views/ToolsView.swift index bfc1bd8..9c202b2 100644 --- a/scarf/scarf/Features/Tools/Views/ToolsView.swift +++ b/scarf/scarf/Features/Tools/Views/ToolsView.swift @@ -1,7 +1,12 @@ import SwiftUI struct ToolsView: View { - @State private var viewModel = ToolsViewModel() + @State private var viewModel: ToolsViewModel + + init(context: ServerContext) { + _viewModel = State(initialValue: ToolsViewModel(context: context)) + } + var body: some View { VStack(spacing: 0) { diff --git a/scarf/scarf/Features/Webhooks/ViewModels/WebhooksViewModel.swift b/scarf/scarf/Features/Webhooks/ViewModels/WebhooksViewModel.swift index ecd8d3a..19e1446 100644 --- a/scarf/scarf/Features/Webhooks/ViewModels/WebhooksViewModel.swift +++ b/scarf/scarf/Features/Webhooks/ViewModels/WebhooksViewModel.swift @@ -13,7 +13,14 @@ struct HermesWebhook: Identifiable, Sendable, Equatable { @Observable final class WebhooksViewModel { private let logger = Logger(subsystem: "com.scarf", category: "WebhooksViewModel") - private let fileService = HermesFileService() + let context: ServerContext + private let fileService: HermesFileService + + init(context: ServerContext = .local) { + self.context = context + self.fileService = HermesFileService(context: context) + } + var webhooks: [HermesWebhook] = [] var isLoading = false diff --git a/scarf/scarf/Features/Webhooks/Views/WebhooksView.swift b/scarf/scarf/Features/Webhooks/Views/WebhooksView.swift index c12fcd8..f6580f7 100644 --- a/scarf/scarf/Features/Webhooks/Views/WebhooksView.swift +++ b/scarf/scarf/Features/Webhooks/Views/WebhooksView.swift @@ -2,10 +2,15 @@ import SwiftUI import AppKit struct WebhooksView: View { - @State private var viewModel = WebhooksViewModel() + @State private var viewModel: WebhooksViewModel @State private var showAddSheet = false @State private var pendingRemove: HermesWebhook? + init(context: ServerContext) { + _viewModel = State(initialValue: WebhooksViewModel(context: context)) + } + + // Add form state @State private var addName = "" @State private var addPrompt = "" @@ -90,7 +95,7 @@ struct WebhooksView: View { .buttonStyle(.borderedProminent) .controlSize(.small) Button { - NSWorkspace.shared.open(URL(fileURLWithPath: HermesPaths.configYAML)) + viewModel.context.openInLocalEditor(viewModel.context.paths.configYAML) } label: { Label("Edit config.yaml", systemImage: "doc.text") } @@ -102,7 +107,10 @@ struct WebhooksView: View { } private func openGatewaySetupInTerminal() { - guard let hermes = HermesFileService().hermesBinaryPath() else { return } + // Always use the local hermes binary — Terminal launches on this Mac, + // not the remote. (Webhook setup is itself local Hermes anyway since + // the gateway runs on the machine talking to messaging platforms.) + let hermes = ServerContext.local.paths.hermesBinary let script = "tell application \"Terminal\"\n activate\n do script \"\(hermes) gateway setup\"\nend tell" let appleScript = NSAppleScript(source: script) var err: NSDictionary? diff --git a/scarf/scarf/scarfApp.swift b/scarf/scarf/scarfApp.swift index 03b40e5..7a89790 100644 --- a/scarf/scarf/scarfApp.swift +++ b/scarf/scarf/scarfApp.swift @@ -2,125 +2,314 @@ import SwiftUI @main struct ScarfApp: App { - @State private var coordinator = AppCoordinator() - @State private var fileWatcher = HermesFileWatcher() - @State private var menuBarStatus = MenuBarStatus() - @State private var chatViewModel = ChatViewModel() + /// User-editable list of remote servers. Loaded from + /// `~/Library/Application Support/scarf/servers.json` at launch. + @State private var registry = ServerRegistry() + /// One live status per registered server (Local + every remote). Polled + /// in the background to keep the menu bar fresh without making it own + /// per-window state. + @State private var liveRegistry: ServerLiveStatusRegistry @State private var updater = UpdaterService() + init() { + let registry = ServerRegistry() + let live = ServerLiveStatusRegistry(registry: registry) + // Re-fan-out statuses whenever the user adds/removes/renames a + // server in the picker. Without this, new servers wouldn't appear + // in the menu bar until the next full app launch. + registry.onEntriesChanged = { [weak live] in live?.rebuild() } + _registry = State(initialValue: registry) + _liveRegistry = State(initialValue: live) + + // Warm up the login-shell env probe off-main at launch. Without + // this, the first MainActor caller (chat preflight, OAuth flow, + // signal-cli detect, etc.) blocks for 5-8 seconds while + // `zsh -l -i` runs. Doing it eagerly on a detached task means the + // static let is already populated by the time any UI needs it. + Task.detached(priority: .utility) { + _ = HermesFileService.enrichedEnvironment() + } + } + var body: some Scene { - WindowGroup { - ContentView() - .environment(coordinator) - .environment(fileWatcher) - .environment(chatViewModel) - .environment(updater) - .onAppear { - fileWatcher.startWatching() - menuBarStatus.startPolling() - } - .onDisappear { - fileWatcher.stopWatching() - menuBarStatus.stopPolling() - } + // Multi-window: each window is bound to one `ServerID`. Opening a + // second server via `openWindow(value:)` creates a second window + // with its own coordinator + services; they're independent and can + // run side-by-side. SwiftUI handles window-state restoration + // automatically — quit + relaunch reopens the same windows with the + // same server bindings. + WindowGroup("Hermes", for: ServerID.self) { $serverID in + // `nil` means the user removed this server since the window was + // last open. Show a dedicated "server removed" view rather than + // silently falling back to local — falling back would mislead + // the user into thinking they're looking at the right server. + if let ctx = registry.context(for: serverID ?? ServerContext.local.id) { + ContextBoundRoot(context: ctx) + .environment(registry) + .environment(\.serverContext, ctx) + .environment(updater) + // Sync the live-status set whenever a window appears — + // covers the case where the user added a server in + // another window since this one last opened. + .onAppear { liveRegistry.rebuild() } + } else { + MissingServerView(removedServerID: serverID ?? ServerContext.local.id) + .environment(registry) + .environment(updater) + } + } defaultValue: { + ServerContext.local.id } .defaultSize(width: 1100, height: 700) .commands { CommandGroup(after: .appInfo) { Button("Check for Updates…") { updater.checkForUpdates() } } + // File → Open Server submenu: one entry per registered server + // (including Local). Each opens or focuses a window bound to + // that server. + CommandGroup(after: .newItem) { + OpenServerCommands() + .environment(registry) + } } - MenuBarExtra("Scarf", systemImage: menuBarStatus.icon) { - MenuBarMenu(status: menuBarStatus, coordinator: coordinator, updater: updater) + MenuBarExtra( + "Scarf", + systemImage: liveRegistry.anyRunning ? "hare.fill" : "hare" + ) { + MenuBarMenu(liveRegistry: liveRegistry, updater: updater) } } } +/// Renders the `File → Open Server →` submenu plus per-server number +/// shortcuts (⌘1…⌘9). Uses `@Environment(\.openWindow)` so each menu item +/// opens (or focuses) a window keyed to that server's `ServerID`. Extracted +/// into its own View so the `@Environment` access happens inside a View +/// context — `.commands` closures can't access it directly. +private struct OpenServerCommands: View { + @Environment(ServerRegistry.self) private var registry + @Environment(\.openWindow) private var openWindow + + var body: some View { + Menu("Open Server") { + // Local is always slot 1 (⌘1). + Button { + openWindow(value: ServerContext.local.id) + } label: { + Label("Local", systemImage: "laptopcomputer") + } + .keyboardShortcut("1", modifiers: .command) + + if !registry.entries.isEmpty { + Divider() + // First 8 remote entries get ⌘2…⌘9. Beyond 9 servers, + // entries lose their shortcut but remain clickable. + ForEach(Array(registry.entries.prefix(8).enumerated()), id: \.element.id) { index, entry in + Button { + openWindow(value: entry.id) + } label: { + Label(entry.displayName, systemImage: "server.rack") + } + .keyboardShortcut(KeyEquivalent(Character("\(index + 2)")), modifiers: .command) + } + if registry.entries.count > 8 { + ForEach(registry.entries.dropFirst(8)) { entry in + Button { + openWindow(value: entry.id) + } label: { + Label(entry.displayName, systemImage: "server.rack") + } + } + } + } + Divider() + // Quick "open the picker" shortcut. Uses ⌘⇧S because ⌘⇧O is + // commonly bound to "Open in new tab" by browser/IDE muscle memory + // and we want to feel additive, not conflicting. + Button { + openWindow(value: ServerContext.local.id) + } label: { + Label("Manage Servers…", systemImage: "server.rack") + } + .keyboardShortcut("s", modifiers: [.command, .shift]) + } + } +} + +/// Wrapper View whose lifetime is scoped to one `ServerContext`. All +/// per-server `@State` — file watcher, coordinator, chat — lives here so +/// that the enclosing `.id(context.id)` modifier in `ScarfApp` cleanly +/// reinitializes everything when the user switches servers. +private struct ContextBoundRoot: View { + let context: ServerContext + + @State private var coordinator: AppCoordinator + @State private var fileWatcher: HermesFileWatcher + @State private var chatViewModel: ChatViewModel + + init(context: ServerContext) { + self.context = context + _coordinator = State(initialValue: AppCoordinator()) + _fileWatcher = State(initialValue: HermesFileWatcher(context: context)) + _chatViewModel = State(initialValue: ChatViewModel(context: context)) + } + + var body: some View { + ContentView() + .environment(coordinator) + .environment(fileWatcher) + .environment(chatViewModel) + // Per-window title shows which server this window is bound to. + // Local: "Scarf — Local". Remote: "Scarf — Mardon Mac Mini". + // The colored dot lives inside the toolbar switcher; the window + // title gives macOS Mission Control / ⌘` cycling a meaningful + // label so users can pick the right window without focusing it. + .navigationTitle("Scarf — \(context.displayName)") + .onAppear { fileWatcher.startWatching() } + .onDisappear { fileWatcher.stopWatching() } + } +} + +/// Per-server live state for the menu bar: is hermes running on this +/// server, is its gateway up, and the file service used to start/stop it. +/// One of these per registered server (plus local) so the menu bar can +/// fan out across multiple Hermes installations. @Observable -final class MenuBarStatus { - private let fileService = HermesFileService() - private var timer: Timer? +@MainActor +final class ServerLiveStatus: Identifiable { + let context: ServerContext + private let fileService: HermesFileService + private var pollTask: Task? var hermesRunning = false var gatewayRunning = false - var icon: String { - hermesRunning ? "hare.fill" : "hare" + var id: ServerID { context.id } + + init(context: ServerContext) { + self.context = context + self.fileService = HermesFileService(context: context) } func startPolling() { + stopPolling() + // First refresh inline so the icon doesn't flash "stopped" for the + // first 10s after launch. refresh() - timer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: true) { [weak self] _ in - self?.refresh() + pollTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 10_000_000_000) + if Task.isCancelled { return } + await self?.refresh() + } } } func stopPolling() { - timer?.invalidate() - timer = nil + pollTask?.cancel() + pollTask = 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() + Task.detached { [context] in + _ = context.runHermes(["gateway", "start"]) + } + // Refresh after a short delay to pick up the new state. + Task { [weak self] in + try? await Task.sleep(nanoseconds: 3_000_000_000) + await self?.refresh() } } func stopHermes() { - fileService.stopHermes() - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in - self?.refresh() + Task.detached { [fileService] in _ = fileService.stopHermes() } + Task { [weak self] in + try? await Task.sleep(nanoseconds: 2_000_000_000) + await self?.refresh() } } func restartHermes() { - fileService.stopHermes() - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in + Task.detached { [fileService] in + _ = fileService.stopHermes() + } + Task { [weak self] in + try? await Task.sleep(nanoseconds: 2_000_000_000) self?.startHermes() } } private func refresh() { - hermesRunning = fileService.isHermesRunning() - gatewayRunning = fileService.loadGatewayState()?.isRunning ?? false + let svc = fileService + Task.detached { [weak self] in + let running = svc.isHermesRunning() + let gateway = svc.loadGatewayState()?.isRunning ?? false + await MainActor.run { + self?.hermesRunning = running + self?.gatewayRunning = gateway + } + } } } +/// App-scoped registry of `ServerLiveStatus` — one per known server. Adds / +/// removes in lockstep with `ServerRegistry`, so the menu bar accurately +/// reflects the current set of registered servers. +@Observable +@MainActor +final class ServerLiveStatusRegistry { + private(set) var statuses: [ServerLiveStatus] = [] + private let registry: ServerRegistry + + init(registry: ServerRegistry) { + self.registry = registry + rebuild() + } + + /// Recompute the status list from the source registry. Re-uses any + /// existing status object whose ID still matches so we don't lose + /// in-flight polling state on a server add/rename. + func rebuild() { + var newStatuses: [ServerLiveStatus] = [] + let allContexts = registry.allContexts + for ctx in allContexts { + if let existing = statuses.first(where: { $0.id == ctx.id }) { + newStatuses.append(existing) + } else { + let status = ServerLiveStatus(context: ctx) + status.startPolling() + newStatuses.append(status) + } + } + // Stop polling on statuses that were removed. + for old in statuses where !newStatuses.contains(where: { $0.id == old.id }) { + old.stopPolling() + } + statuses = newStatuses + } + + /// True if any registered server reports hermes running. Drives the + /// menu bar icon (filled vs. outline hare). + var anyRunning: Bool { statuses.contains(where: { $0.hermesRunning }) } +} + struct MenuBarMenu: View { - let status: MenuBarStatus - let coordinator: AppCoordinator + let liveRegistry: ServerLiveStatusRegistry let updater: UpdaterService + @Environment(\.openWindow) private var openWindow var body: some View { - VStack { - 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") - 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") { - coordinator.selectedSection = .dashboard - NSApplication.shared.activate() + VStack(alignment: .leading, spacing: 0) { + // One section per server with its run state + start/stop/restart. + // Iterating registered statuses keeps the menu in sync as the + // user adds/removes servers in the picker. + ForEach(liveRegistry.statuses) { status in + serverSection(status) + Divider() } - Button("New Chat") { - coordinator.selectedSection = .chat - NSApplication.shared.activate() - } - Button("View Sessions") { - coordinator.selectedSection = .sessions + Button("Open Scarf") { NSApplication.shared.activate() } Divider() @@ -132,4 +321,34 @@ struct MenuBarMenu: View { .keyboardShortcut("q") } } + + @ViewBuilder + private func serverSection(_ status: ServerLiveStatus) -> some View { + Group { + // Server name as a header, with the open-window action on click. + Button { + openWindow(value: status.context.id) + NSApplication.shared.activate() + } label: { + HStack(spacing: 4) { + Image(systemName: status.context.isRemote ? "server.rack" : "laptopcomputer") + Text(status.context.displayName).bold() + } + } + 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" + ) + Button("Start Hermes") { status.startHermes() } + .disabled(status.hermesRunning) + Button("Stop Hermes") { status.stopHermes() } + .disabled(!status.hermesRunning) + Button("Restart Hermes") { status.restartHermes() } + .disabled(!status.hermesRunning) + } + } }