mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
feat: multi-window + remote SSH server support (Phases 0-4)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -2,40 +2,74 @@ import SwiftUI
|
|||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@Environment(AppCoordinator.self) private var coordinator
|
@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 {
|
var body: some View {
|
||||||
NavigationSplitView {
|
NavigationSplitView {
|
||||||
SidebarView()
|
SidebarView()
|
||||||
} detail: {
|
} detail: {
|
||||||
detailView
|
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
|
@ViewBuilder
|
||||||
private var detailView: some View {
|
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 {
|
switch coordinator.selectedSection {
|
||||||
case .dashboard: DashboardView()
|
case .dashboard: DashboardView(context: serverContext)
|
||||||
case .insights: InsightsView()
|
case .insights: InsightsView(context: serverContext)
|
||||||
case .sessions: SessionsView()
|
case .sessions: SessionsView(context: serverContext)
|
||||||
case .activity: ActivityView()
|
case .activity: ActivityView(context: serverContext)
|
||||||
case .projects: ProjectsView()
|
case .projects: ProjectsView(context: serverContext)
|
||||||
case .chat: ChatView()
|
case .chat: ChatView()
|
||||||
case .memory: MemoryView()
|
case .memory: MemoryView(context: serverContext)
|
||||||
case .skills: SkillsView()
|
case .skills: SkillsView(context: serverContext)
|
||||||
case .platforms: PlatformsView()
|
case .platforms: PlatformsView(context: serverContext)
|
||||||
case .personalities: PersonalitiesView()
|
case .personalities: PersonalitiesView(context: serverContext)
|
||||||
case .quickCommands: QuickCommandsView()
|
case .quickCommands: QuickCommandsView(context: serverContext)
|
||||||
case .credentialPools: CredentialPoolsView()
|
case .credentialPools: CredentialPoolsView(context: serverContext)
|
||||||
case .plugins: PluginsView()
|
case .plugins: PluginsView(context: serverContext)
|
||||||
case .webhooks: WebhooksView()
|
case .webhooks: WebhooksView(context: serverContext)
|
||||||
case .profiles: ProfilesView()
|
case .profiles: ProfilesView(context: serverContext)
|
||||||
case .tools: ToolsView()
|
case .tools: ToolsView(context: serverContext)
|
||||||
case .mcpServers: MCPServersView()
|
case .mcpServers: MCPServersView(context: serverContext)
|
||||||
case .gateway: GatewayView()
|
case .gateway: GatewayView(context: serverContext)
|
||||||
case .cron: CronView()
|
case .cron: CronView(context: serverContext)
|
||||||
case .health: HealthView()
|
case .health: HealthView(context: serverContext)
|
||||||
case .logs: LogsView()
|
case .logs: LogsView(context: serverContext)
|
||||||
case .settings: SettingsView()
|
case .settings: SettingsView(context: serverContext)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +1,70 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SQLite3
|
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.<field>`.
|
||||||
enum HermesPaths: Sendable {
|
enum HermesPaths: Sendable {
|
||||||
private nonisolated static let userHome: String = ProcessInfo.processInfo.environment["HOME"]
|
@available(*, deprecated, message: "use ServerContext.paths.home")
|
||||||
?? NSHomeDirectory()
|
nonisolated static var home: String { ServerContext.local.paths.home }
|
||||||
|
|
||||||
nonisolated static let home: String = userHome + "/.hermes"
|
@available(*, deprecated, message: "use ServerContext.paths.stateDB")
|
||||||
nonisolated static let stateDB: String = home + "/state.db"
|
nonisolated static var stateDB: String { ServerContext.local.paths.stateDB }
|
||||||
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"
|
|
||||||
|
|
||||||
/// Install locations we look for the `hermes` binary in, in priority order.
|
@available(*, deprecated, message: "use ServerContext.paths.configYAML")
|
||||||
/// Checked every access so a user installing via a different method doesn't
|
nonisolated static var configYAML: String { ServerContext.local.paths.configYAML }
|
||||||
/// 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
|
|
||||||
]
|
|
||||||
|
|
||||||
/// Resolved path to the `hermes` executable. Returns the first candidate
|
@available(*, deprecated, message: "use ServerContext.paths.memoriesDir")
|
||||||
/// that exists and is executable; falls back to the pipx default so error
|
nonisolated static var memoriesDir: String { ServerContext.local.paths.memoriesDir }
|
||||||
/// messages ("Expected at …") still make sense on a fresh machine.
|
|
||||||
nonisolated static var hermesBinary: String {
|
@available(*, deprecated, message: "use ServerContext.paths.memoryMD")
|
||||||
for path in hermesBinaryCandidates
|
nonisolated static var memoryMD: String { ServerContext.local.paths.memoryMD }
|
||||||
where FileManager.default.isExecutableFile(atPath: path) {
|
|
||||||
return path
|
@available(*, deprecated, message: "use ServerContext.paths.userMD")
|
||||||
}
|
nonisolated static var userMD: String { ServerContext.local.paths.userMD }
|
||||||
return hermesBinaryCandidates[0]
|
|
||||||
|
@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
|
// MARK: - SQLite Constants
|
||||||
|
|||||||
@@ -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]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,14 @@ actor ACPClient {
|
|||||||
private(set) var currentSessionId: String?
|
private(set) var currentSessionId: String?
|
||||||
private(set) var statusMessage = ""
|
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
|
/// Ring buffer of recent stderr lines from `hermes acp` — used to attach
|
||||||
/// a diagnostic tail to user-visible errors. Capped to avoid unbounded
|
/// a diagnostic tail to user-visible errors. Capped to avoid unbounded
|
||||||
/// growth when the subprocess logs heavily.
|
/// growth when the subprocess logs heavily.
|
||||||
@@ -75,9 +83,15 @@ actor ACPClient {
|
|||||||
self._eventStream = stream
|
self._eventStream = stream
|
||||||
self.eventContinuation = continuation
|
self.eventContinuation = continuation
|
||||||
|
|
||||||
let proc = Process()
|
// For local: Process is `hermes acp` directly.
|
||||||
proc.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
|
// For remote: the transport returns a Process configured as
|
||||||
proc.arguments = ["acp"]
|
// `/usr/bin/ssh -T <opts> host -- <hermes> 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 stdin = Pipe()
|
||||||
let stdout = Pipe()
|
let stdout = Pipe()
|
||||||
@@ -88,11 +102,28 @@ actor ACPClient {
|
|||||||
proc.standardError = stderr
|
proc.standardError = stderr
|
||||||
|
|
||||||
// ACP uses JSON-RPC over pipes — do NOT set TERM to avoid terminal escape pollution.
|
// 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,
|
if context.isRemote {
|
||||||
// shell commands) can find brew/nvm/asdf binaries on PATH.
|
// Remote: this is the LOCAL ssh process spawning `ssh host …
|
||||||
var env = HermesFileService.enrichedEnvironment()
|
// hermes acp`. We don't forward our local PATH/credentials to
|
||||||
env.removeValue(forKey: "TERM")
|
// the remote (hermes runs under the remote user's login env),
|
||||||
proc.environment = 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
|
proc.terminationHandler = { [weak self] proc in
|
||||||
Task { await self?.handleTermination(exitCode: proc.terminationStatus) }
|
Task { await self?.handleTermination(exitCode: proc.terminationStatus) }
|
||||||
|
|||||||
@@ -4,22 +4,68 @@ import SQLite3
|
|||||||
actor HermesDataService {
|
actor HermesDataService {
|
||||||
private var db: OpaquePointer?
|
private var db: OpaquePointer?
|
||||||
private var hasV07Schema = false
|
private var hasV07Schema = false
|
||||||
|
/// Local filesystem path we last opened. For remote contexts this is
|
||||||
|
/// the cached snapshot under `~/Library/Caches/scarf/snapshots/<id>/`.
|
||||||
|
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 {
|
func open() -> Bool {
|
||||||
if db != nil { return true }
|
if db != nil { return true }
|
||||||
let path = HermesPaths.stateDB
|
let localPath: String
|
||||||
guard FileManager.default.fileExists(atPath: path) else { return false }
|
if context.isRemote {
|
||||||
let flags = SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX
|
// Pull a fresh snapshot from the remote host. Uses `sqlite3
|
||||||
let result = sqlite3_open_v2(path, &db, flags, nil)
|
// .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 {
|
guard result == SQLITE_OK else {
|
||||||
db = nil
|
db = nil
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
sqlite3_exec(db, "PRAGMA journal_mode=WAL", nil, nil, nil)
|
openedAtPath = localPath
|
||||||
detectSchema()
|
detectSchema()
|
||||||
return true
|
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() {
|
func close() {
|
||||||
if let db {
|
if let db {
|
||||||
sqlite3_close(db)
|
sqlite3_close(db)
|
||||||
@@ -431,11 +477,10 @@ actor HermesDataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func stateDBModificationDate() -> Date? {
|
func stateDBModificationDate() -> Date? {
|
||||||
let walPath = HermesPaths.stateDB + "-wal"
|
// For remote contexts we stat the remote paths. For local it's the
|
||||||
let dbPath = HermesPaths.stateDB
|
// same FileManager lookup as before, just via the transport.
|
||||||
let fm = FileManager.default
|
let walDate = transport.stat(context.paths.stateDB + "-wal")?.mtime
|
||||||
let walDate = (try? fm.attributesOfItem(atPath: walPath))?[.modificationDate] as? Date
|
let dbDate = transport.stat(context.paths.stateDB)?.mtime
|
||||||
let dbDate = (try? fm.attributesOfItem(atPath: dbPath))?[.modificationDate] as? Date
|
|
||||||
if let w = walDate, let d = dbDate {
|
if let w = walDate, let d = dbDate {
|
||||||
return max(w, d)
|
return max(w, d)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,15 +22,24 @@ struct HermesEnvService: Sendable {
|
|||||||
|
|
||||||
/// Path to `~/.hermes/.env`. Kept configurable for tests.
|
/// Path to `~/.hermes/.env`. Kept configurable for tests.
|
||||||
let path: String
|
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.path = path
|
||||||
|
self.transport = LocalTransport()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read the .env file into a `[key: value]` dict. Comments and commented-out
|
/// Read the .env file into a `[key: value]` dict. Comments and commented-out
|
||||||
/// assignments are ignored. Missing file returns an empty dict.
|
/// assignments are ignored. Missing file returns an empty dict.
|
||||||
func load() -> [String: String] {
|
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 [:]
|
return [:]
|
||||||
}
|
}
|
||||||
var result: [String: String] = [:]
|
var result: [String: String] = [:]
|
||||||
@@ -69,7 +78,8 @@ struct HermesEnvService: Sendable {
|
|||||||
var lines: [String]
|
var lines: [String]
|
||||||
|
|
||||||
// Start from existing file contents, or a minimal header if creating new.
|
// 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")
|
lines = content.components(separatedBy: "\n")
|
||||||
// Trim a single trailing empty line from splitting the final newline;
|
// Trim a single trailing empty line from splitting the final newline;
|
||||||
// we'll re-add it on write.
|
// 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.
|
/// uncommenting. If the key doesn't exist, this is a no-op.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func unset(_ key: String) -> Bool {
|
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
|
return true
|
||||||
}
|
}
|
||||||
var lines = content.components(separatedBy: "\n")
|
var lines = content.components(separatedBy: "\n")
|
||||||
@@ -125,28 +136,18 @@ struct HermesEnvService: Sendable {
|
|||||||
|
|
||||||
// MARK: - Internals
|
// MARK: - Internals
|
||||||
|
|
||||||
/// Writes the entire file in one shot via a tmp + rename to avoid corrupting
|
/// Writes the entire file in one shot through the transport. For local
|
||||||
/// `.env` if the process is killed mid-write. Preserves `0600` permissions
|
/// contexts this ends up doing the same atomic-rename dance as before
|
||||||
/// since `.env` typically holds secrets.
|
/// (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 {
|
private func atomicWrite(_ content: String) -> Bool {
|
||||||
let tmp = path + ".tmp"
|
guard let data = content.data(using: .utf8) else { return false }
|
||||||
do {
|
do {
|
||||||
try content.write(toFile: tmp, atomically: false, encoding: .utf8)
|
try transport.writeFile(path, data: data)
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("Failed to write .env: \(error.localizedDescription)")
|
logger.error("Failed to write .env: \(error.localizedDescription)")
|
||||||
try? FileManager.default.removeItem(atPath: tmp)
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,18 @@ import Foundation
|
|||||||
|
|
||||||
struct HermesFileService: Sendable {
|
struct HermesFileService: Sendable {
|
||||||
|
|
||||||
|
let context: ServerContext
|
||||||
|
let transport: any ServerTransport
|
||||||
|
|
||||||
|
init(context: ServerContext = .local) {
|
||||||
|
self.context = context
|
||||||
|
self.transport = context.makeTransport()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Config
|
// MARK: - Config
|
||||||
|
|
||||||
func loadConfig() -> HermesConfig {
|
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)
|
return parseConfig(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,7 +381,7 @@ struct HermesFileService: Sendable {
|
|||||||
// MARK: - Gateway State
|
// MARK: - Gateway State
|
||||||
|
|
||||||
func loadGatewayState() -> GatewayState? {
|
func loadGatewayState() -> GatewayState? {
|
||||||
guard let data = readFileData(HermesPaths.gatewayStateJSON) else { return nil }
|
guard let data = readFileData(context.paths.gatewayStateJSON) else { return nil }
|
||||||
do {
|
do {
|
||||||
return try JSONDecoder().decode(GatewayState.self, from: data)
|
return try JSONDecoder().decode(GatewayState.self, from: data)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -385,12 +393,10 @@ struct HermesFileService: Sendable {
|
|||||||
// MARK: - Memory
|
// MARK: - Memory
|
||||||
|
|
||||||
func loadMemoryProfiles() -> [String] {
|
func loadMemoryProfiles() -> [String] {
|
||||||
let fm = FileManager.default
|
guard let entries = try? transport.listDirectory(context.paths.memoriesDir) else { return [] }
|
||||||
guard let entries = try? fm.contentsOfDirectory(atPath: HermesPaths.memoriesDir) else { return [] }
|
|
||||||
return entries.filter { name in
|
return entries.filter { name in
|
||||||
var isDir: ObjCBool = false
|
let path = context.paths.memoriesDir + "/" + name
|
||||||
let path = HermesPaths.memoriesDir + "/" + name
|
return transport.stat(path)?.isDirectory == true
|
||||||
return fm.fileExists(atPath: path, isDirectory: &isDir) && isDir.boolValue
|
|
||||||
}.sorted()
|
}.sorted()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,15 +422,15 @@ struct HermesFileService: Sendable {
|
|||||||
|
|
||||||
private func memoryPath(profile: String, file: String) -> String {
|
private func memoryPath(profile: String, file: String) -> String {
|
||||||
if profile.isEmpty {
|
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
|
// MARK: - Cron
|
||||||
|
|
||||||
func loadCronJobs() -> [HermesCronJob] {
|
func loadCronJobs() -> [HermesCronJob] {
|
||||||
guard let data = readFileData(HermesPaths.cronJobsJSON) else { return [] }
|
guard let data = readFileData(context.paths.cronJobsJSON) else { return [] }
|
||||||
do {
|
do {
|
||||||
let file = try JSONDecoder().decode(CronJobsFile.self, from: data)
|
let file = try JSONDecoder().decode(CronJobsFile.self, from: data)
|
||||||
return file.jobs
|
return file.jobs
|
||||||
@@ -435,9 +441,8 @@ struct HermesFileService: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadCronOutput(jobId: String) -> String? {
|
func loadCronOutput(jobId: String) -> String? {
|
||||||
let dir = HermesPaths.cronOutputDir
|
let dir = context.paths.cronOutputDir
|
||||||
let fm = FileManager.default
|
guard let files = try? transport.listDirectory(dir) else { return nil }
|
||||||
guard let files = try? fm.contentsOfDirectory(atPath: dir) else { return nil }
|
|
||||||
let matching = files.filter { $0.contains(jobId) }.sorted().last
|
let matching = files.filter { $0.contains(jobId) }.sorted().last
|
||||||
guard let filename = matching else { return nil }
|
guard let filename = matching else { return nil }
|
||||||
return readFile(dir + "/" + filename)
|
return readFile(dir + "/" + filename)
|
||||||
@@ -446,21 +451,18 @@ struct HermesFileService: Sendable {
|
|||||||
// MARK: - Skills
|
// MARK: - Skills
|
||||||
|
|
||||||
func loadSkills() -> [HermesSkillCategory] {
|
func loadSkills() -> [HermesSkillCategory] {
|
||||||
let dir = HermesPaths.skillsDir
|
let dir = context.paths.skillsDir
|
||||||
let fm = FileManager.default
|
guard let categories = try? transport.listDirectory(dir) else { return [] }
|
||||||
guard let categories = try? fm.contentsOfDirectory(atPath: dir) else { return [] }
|
|
||||||
|
|
||||||
return categories.sorted().compactMap { categoryName in
|
return categories.sorted().compactMap { categoryName in
|
||||||
let categoryPath = dir + "/" + categoryName
|
let categoryPath = dir + "/" + categoryName
|
||||||
var isDir: ObjCBool = false
|
guard transport.stat(categoryPath)?.isDirectory == true else { return nil }
|
||||||
guard fm.fileExists(atPath: categoryPath, isDirectory: &isDir), isDir.boolValue else { return nil }
|
guard let skillNames = try? transport.listDirectory(categoryPath) else { return nil }
|
||||||
guard let skillNames = try? fm.contentsOfDirectory(atPath: categoryPath) else { return nil }
|
|
||||||
|
|
||||||
let skills = skillNames.sorted().compactMap { skillName -> HermesSkill? in
|
let skills = skillNames.sorted().compactMap { skillName -> HermesSkill? in
|
||||||
let skillPath = categoryPath + "/" + skillName
|
let skillPath = categoryPath + "/" + skillName
|
||||||
var isSkillDir: ObjCBool = false
|
guard transport.stat(skillPath)?.isDirectory == true else { return nil }
|
||||||
guard fm.fileExists(atPath: skillPath, isDirectory: &isSkillDir), isSkillDir.boolValue else { return nil }
|
let files = (try? transport.listDirectory(skillPath)) ?? []
|
||||||
let files = (try? fm.contentsOfDirectory(atPath: skillPath)) ?? []
|
|
||||||
let requiredConfig = parseSkillRequiredConfig(skillPath + "/skill.yaml")
|
let requiredConfig = parseSkillRequiredConfig(skillPath + "/skill.yaml")
|
||||||
return HermesSkill(
|
return HermesSkill(
|
||||||
id: categoryName + "/" + skillName,
|
id: categoryName + "/" + skillName,
|
||||||
@@ -488,7 +490,7 @@ struct HermesFileService: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func isValidSkillPath(_ path: String) -> Bool {
|
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)")
|
print("[Scarf] Rejected skill path outside skills directory: \(path)")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -522,12 +524,11 @@ struct HermesFileService: Sendable {
|
|||||||
// MARK: - MCP Servers
|
// MARK: - MCP Servers
|
||||||
|
|
||||||
func loadMCPServers() -> [HermesMCPServer] {
|
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 parsed = parseMCPServersBlock(yaml: yaml)
|
||||||
let fm = FileManager.default
|
|
||||||
return parsed.map { server in
|
return parsed.map { server in
|
||||||
let tokenPath = HermesPaths.mcpTokensDir + "/" + server.name + ".json"
|
let tokenPath = context.paths.mcpTokensDir + "/" + server.name + ".json"
|
||||||
let hasToken = fm.fileExists(atPath: tokenPath)
|
let hasToken = transport.fileExists(tokenPath)
|
||||||
guard hasToken != server.hasOAuthToken else { return server }
|
guard hasToken != server.hasOAuthToken else { return server }
|
||||||
return HermesMCPServer(
|
return HermesMCPServer(
|
||||||
name: server.name,
|
name: server.name,
|
||||||
@@ -674,9 +675,9 @@ struct HermesFileService: Sendable {
|
|||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func deleteMCPOAuthToken(name: String) -> Bool {
|
func deleteMCPOAuthToken(name: String) -> Bool {
|
||||||
let path = HermesPaths.mcpTokensDir + "/" + name + ".json"
|
let path = context.paths.mcpTokensDir + "/" + name + ".json"
|
||||||
do {
|
do {
|
||||||
try FileManager.default.removeItem(atPath: path)
|
try transport.removeFile(path)
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
@@ -876,7 +877,7 @@ struct HermesFileService: Sendable {
|
|||||||
// MARK: - MCP YAML: surgical patcher
|
// MARK: - MCP YAML: surgical patcher
|
||||||
|
|
||||||
private func patchMCPServerField(name: String, mutate: (inout [String]) -> Void) -> Bool {
|
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)
|
let location = extractMCPBlock(yaml: yaml)
|
||||||
guard !location.block.isEmpty else { return false }
|
guard !location.block.isEmpty else { return false }
|
||||||
|
|
||||||
@@ -925,7 +926,7 @@ struct HermesFileService: Sendable {
|
|||||||
combined.append(contentsOf: block)
|
combined.append(contentsOf: block)
|
||||||
combined.append(contentsOf: location.suffix)
|
combined.append(contentsOf: location.suffix)
|
||||||
let newYAML = combined.joined(separator: "\n")
|
let newYAML = combined.joined(separator: "\n")
|
||||||
writeFile(HermesPaths.configYAML, content: newYAML)
|
writeFile(context.paths.configYAML, content: newYAML)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1172,23 +1173,22 @@ struct HermesFileService: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func hermesPID() -> pid_t? {
|
func hermesPID() -> pid_t? {
|
||||||
let pipe = Pipe()
|
// Run `pgrep -f hermes` either locally or via the transport. On
|
||||||
let process = Process()
|
// remote hosts we trust `pgrep` to be present — it's standard on
|
||||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep")
|
// Linux and macOS. On failure we conservatively return nil rather
|
||||||
process.arguments = ["-f", "hermes"]
|
// than pretending Hermes is down: the caller will see
|
||||||
process.standardOutput = pipe
|
// isHermesRunning==false, which is already the "unknown" UX.
|
||||||
process.standardError = Pipe()
|
let result = try? transport.runProcess(
|
||||||
do {
|
executable: "/usr/bin/pgrep",
|
||||||
try process.run()
|
args: ["-f", "hermes"],
|
||||||
process.waitUntilExit()
|
stdin: nil,
|
||||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
timeout: 5
|
||||||
let output = String(data: data, encoding: .utf8) ?? ""
|
)
|
||||||
guard let firstLine = output.components(separatedBy: "\n").first(where: { !$0.isEmpty }),
|
guard let result, let firstLine = result.stdoutString
|
||||||
let pid = pid_t(firstLine.trimmingCharacters(in: .whitespaces)) else { return nil }
|
.components(separatedBy: "\n")
|
||||||
return pid
|
.first(where: { !$0.isEmpty }),
|
||||||
} catch {
|
let pid = pid_t(firstLine.trimmingCharacters(in: .whitespaces)) else { return nil }
|
||||||
return nil
|
return pid
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
@@ -1199,14 +1199,25 @@ struct HermesFileService: Sendable {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
guard let pid = hermesPID() else { return false }
|
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
|
return kill(pid, SIGTERM) == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func hermesBinaryPath() -> String? {
|
nonisolated func hermesBinaryPath() -> String? {
|
||||||
// Single source of truth for install-location candidates lives in
|
// 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.
|
// consistent across the app.
|
||||||
return HermesPaths.hermesBinaryCandidates
|
return HermesPathSet.hermesBinaryCandidates
|
||||||
.first { FileManager.default.isExecutableFile(atPath: $0) }
|
.first { FileManager.default.isExecutableFile(atPath: $0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1223,7 +1234,13 @@ struct HermesFileService: Sendable {
|
|||||||
"OPENROUTER_API_KEY",
|
"OPENROUTER_API_KEY",
|
||||||
"GEMINI_API_KEY", "GOOGLE_API_KEY",
|
"GEMINI_API_KEY", "GOOGLE_API_KEY",
|
||||||
"GROQ_API_KEY", "MISTRAL_API_KEY", "XAI_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.
|
/// Env vars harvested from the user's login shell. Computed once and cached.
|
||||||
@@ -1366,19 +1383,27 @@ struct HermesFileService: Sendable {
|
|||||||
/// delegation tasks
|
/// delegation tasks
|
||||||
/// Used by Chat to warn the user before `hermes acp` fails on send with
|
/// Used by Chat to warn the user before `hermes acp` fails on send with
|
||||||
/// "No Anthropic credentials found".
|
/// "No Anthropic credentials found".
|
||||||
nonisolated static func hasAnyAICredential() -> Bool {
|
///
|
||||||
let credentialKeys = shellEnvKeys.filter { $0 != "PATH" && $0 != "ANTHROPIC_BASE_URL" && $0 != "OPENAI_BASE_URL" }
|
/// **Local context:** also checks Scarf's process / login-shell env.
|
||||||
let env = enrichedEnvironment()
|
/// **Remote context:** skips that step — our process env has nothing to
|
||||||
for key in credentialKeys {
|
/// do with the remote `hermes acp`'s runtime env. The remote `.env` /
|
||||||
if let value = env[key], !value.isEmpty {
|
/// `auth.json` / `config.yaml` are still checked through the transport.
|
||||||
return true
|
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 —
|
// Scan .env (via transport — local file or scp) for KEY= lines.
|
||||||
// good enough for a preflight hint; hermes itself does the real parse.
|
// Uses a simple substring check — good enough for a preflight hint;
|
||||||
let envPath = HermesPaths.home + "/.env"
|
// hermes itself does the real parse.
|
||||||
if let data = try? String(contentsOfFile: envPath, encoding: .utf8) {
|
if let envText = readFile(context.paths.envFile) {
|
||||||
for line in data.split(separator: "\n") {
|
for line in envText.split(separator: "\n") {
|
||||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
|
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
|
||||||
for key in credentialKeys where trimmed.hasPrefix("\(key)=") || trimmed.hasPrefix("export \(key)=") {
|
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
|
// Scan auth.json (Credential Pools file written by the Configure →
|
||||||
// Configure → Credential Pools UI. Schema is
|
// Credential Pools UI). Schema:
|
||||||
// { "credential_pool": { "<provider>": [ { "access_token": "...", ... }, ... ] } }
|
// { "credential_pool": { "<provider>": [ { "access_token": "...", ... }, ... ] } }
|
||||||
// Defensive parse: any malformed input falls through to the next check.
|
// Defensive parse: any malformed input falls through to the next check.
|
||||||
let authPath = HermesPaths.home + "/auth.json"
|
if let data = readFileData(context.paths.authJSON),
|
||||||
if let data = try? Data(contentsOf: URL(fileURLWithPath: authPath)),
|
|
||||||
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
let pool = root["credential_pool"] as? [String: Any] {
|
let pool = root["credential_pool"] as? [String: Any] {
|
||||||
for (_, entries) in pool {
|
for (_, entries) in pool {
|
||||||
@@ -1409,11 +1433,10 @@ struct HermesFileService: Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Scan ~/.hermes/config.yaml for `api_key:` lines with a non-empty
|
// Scan config.yaml for `api_key:` lines with a non-empty value.
|
||||||
// value. Covers both `auxiliary.<task>.api_key` and `delegation.api_key`
|
// Covers both `auxiliary.<task>.api_key` and `delegation.api_key`
|
||||||
// without needing to parse the YAML structure — any leaf `api_key: ...`
|
// without needing to parse YAML structure.
|
||||||
// with a value means Hermes has a credential to fall back on.
|
if let text = readFile(context.paths.configYAML) {
|
||||||
if let text = try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8) {
|
|
||||||
for line in text.split(separator: "\n") {
|
for line in text.split(separator: "\n") {
|
||||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
guard trimmed.hasPrefix("api_key:") else { continue }
|
guard trimmed.hasPrefix("api_key:") else { continue }
|
||||||
@@ -1427,41 +1450,36 @@ struct HermesFileService: Sendable {
|
|||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
nonisolated func runHermesCLI(args: [String], timeout: TimeInterval = 60, stdinInput: String? = nil) -> (exitCode: Int32, output: String) {
|
nonisolated func runHermesCLI(args: [String], timeout: TimeInterval = 60, stdinInput: String? = nil) -> (exitCode: Int32, output: String) {
|
||||||
guard let binary = hermesBinaryPath() else { return (-1, "") }
|
// Resolve the executable path — for remote, prefer the cached
|
||||||
let stdoutPipe = Pipe()
|
// `hermesBinaryHint` on the SSHConfig (populated by the Test
|
||||||
let stderrPipe = Pipe()
|
// Connection probe) and fall back to bare `hermes` which relies on
|
||||||
let stdinPipe: Pipe? = stdinInput != nil ? Pipe() : nil
|
// the remote user's `$PATH`.
|
||||||
let process = Process()
|
let binary: String
|
||||||
process.executableURL = URL(fileURLWithPath: binary)
|
if context.isRemote {
|
||||||
process.arguments = args
|
binary = context.paths.hermesBinary
|
||||||
process.environment = Self.enrichedEnvironment()
|
} else {
|
||||||
process.standardOutput = stdoutPipe
|
guard let local = hermesBinaryPath() else { return (-1, "") }
|
||||||
process.standardError = stderrPipe
|
binary = local
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let stdinData = stdinInput?.data(using: .utf8)
|
||||||
do {
|
do {
|
||||||
try process.run()
|
let result = try transport.runProcess(
|
||||||
if let stdinInput, let stdinPipe, let data = stdinInput.data(using: .utf8) {
|
executable: binary,
|
||||||
stdinPipe.fileHandleForWriting.write(data)
|
args: args,
|
||||||
try? stdinPipe.fileHandleForWriting.close()
|
stdin: stdinData,
|
||||||
}
|
timeout: timeout
|
||||||
let deadline = Date().addingTimeInterval(timeout)
|
)
|
||||||
while process.isRunning && Date() < deadline {
|
// Match the legacy signature: combined stdout+stderr in one
|
||||||
Thread.sleep(forTimeInterval: 0.05)
|
// String so callers that grep through output don't need to
|
||||||
}
|
// change. Stderr after stdout mirrors what the old Process impl
|
||||||
if process.isRunning { process.terminate() }
|
// produced since both pipes were drained in that order.
|
||||||
process.waitUntilExit()
|
let combined = result.stdoutString + result.stderrString
|
||||||
let outData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
|
return (result.exitCode, combined)
|
||||||
let errData = stderrPipe.fileHandleForReading.readDataToEndOfFile()
|
} catch let error as TransportError {
|
||||||
let combined = (String(data: outData, encoding: .utf8) ?? "") + (String(data: errData, encoding: .utf8) ?? "")
|
return (-1, error.diagnosticStderr.isEmpty
|
||||||
return (process.terminationStatus, combined)
|
? (error.errorDescription ?? "transport error")
|
||||||
|
: error.diagnosticStderr)
|
||||||
} catch {
|
} catch {
|
||||||
return (-1, error.localizedDescription)
|
return (-1, error.localizedDescription)
|
||||||
}
|
}
|
||||||
@@ -1469,17 +1487,26 @@ struct HermesFileService: Sendable {
|
|||||||
|
|
||||||
// MARK: - File I/O
|
// 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? {
|
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? {
|
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) {
|
private func writeFile(_ path: String, content: String) {
|
||||||
|
guard let data = content.data(using: .utf8) else { return }
|
||||||
do {
|
do {
|
||||||
try content.write(toFile: path, atomically: true, encoding: .utf8)
|
try transport.writeFile(path, data: data)
|
||||||
} catch {
|
} catch {
|
||||||
print("[Scarf] Failed to write \(path): \(error.localizedDescription)")
|
print("[Scarf] Failed to write \(path): \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,33 +6,66 @@ final class HermesFileWatcher {
|
|||||||
private var coreSources: [DispatchSourceFileSystemObject] = []
|
private var coreSources: [DispatchSourceFileSystemObject] = []
|
||||||
private var projectSources: [DispatchSourceFileSystemObject] = []
|
private var projectSources: [DispatchSourceFileSystemObject] = []
|
||||||
private var timer: Timer?
|
private var timer: Timer?
|
||||||
|
/// Remote polling task. Non-nil only when `context.isRemote`. Cancelled
|
||||||
|
/// on `stopWatching()`.
|
||||||
|
private var remotePollTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
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() {
|
func startWatching() {
|
||||||
let paths = [
|
if context.isRemote {
|
||||||
HermesPaths.stateDB,
|
// FSEvents doesn't reach across SSH. Drive lastChangeDate off
|
||||||
HermesPaths.stateDB + "-wal",
|
// the transport's AsyncStream, which polls stat mtime on a
|
||||||
HermesPaths.configYAML,
|
// shared ControlMaster channel (~5ms per tick).
|
||||||
HermesPaths.home + "/.env", // Platform setup forms write here.
|
let stream = transport.watchPaths(watchedCorePaths)
|
||||||
HermesPaths.memoryMD,
|
remotePollTask = Task { [weak self] in
|
||||||
HermesPaths.userMD,
|
for await _ in stream {
|
||||||
HermesPaths.cronJobsJSON,
|
await MainActor.run { [weak self] in
|
||||||
HermesPaths.gatewayStateJSON,
|
self?.lastChangeDate = Date()
|
||||||
HermesPaths.agentLog,
|
}
|
||||||
HermesPaths.errorsLog,
|
}
|
||||||
HermesPaths.gatewayLog,
|
}
|
||||||
HermesPaths.projectsRegistry,
|
return
|
||||||
HermesPaths.mcpTokensDir
|
}
|
||||||
]
|
|
||||||
|
|
||||||
for path in paths {
|
for path in watchedCorePaths {
|
||||||
if let source = makeSource(for: path) {
|
if let source = makeSource(for: path) {
|
||||||
coreSources.append(source)
|
coreSources.append(source)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// No heartbeat timer: every observing view runs its `.onChange`
|
||||||
timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
|
// refresh whenever `lastChangeDate` ticks, so a 5s unconditional
|
||||||
self?.lastChangeDate = Date()
|
// 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() {
|
func stopWatching() {
|
||||||
@@ -43,9 +76,15 @@ final class HermesFileWatcher {
|
|||||||
projectSources.removeAll()
|
projectSources.removeAll()
|
||||||
timer?.invalidate()
|
timer?.invalidate()
|
||||||
timer = nil
|
timer = nil
|
||||||
|
remotePollTask?.cancel()
|
||||||
|
remotePollTask = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateProjectWatches(_ dashboardPaths: [String]) {
|
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 {
|
for source in projectSources {
|
||||||
source.cancel()
|
source.cancel()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,10 +33,46 @@ actor HermesLogService {
|
|||||||
private var currentPath: String?
|
private var currentPath: String?
|
||||||
private var entryCounter = 0
|
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) {
|
func openLog(path: String) {
|
||||||
closeLog()
|
closeLog()
|
||||||
currentPath = path
|
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() {
|
func closeLog() {
|
||||||
@@ -47,11 +83,29 @@ actor HermesLogService {
|
|||||||
}
|
}
|
||||||
fileHandle = nil
|
fileHandle = nil
|
||||||
currentPath = nil
|
currentPath = nil
|
||||||
|
if let proc = remoteTailProcess, proc.isRunning {
|
||||||
|
proc.terminate()
|
||||||
|
}
|
||||||
|
remoteTailProcess = nil
|
||||||
|
remoteTailBuffer = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func readLastLines(count: Int = QueryDefaults.logLineLimit) -> [LogEntry] {
|
func readLastLines(count: Int = QueryDefaults.logLineLimit) -> [LogEntry] {
|
||||||
guard let path = currentPath,
|
guard let path = currentPath else { return [] }
|
||||||
let data = FileManager.default.contents(atPath: path) else { return [] }
|
if context.isRemote {
|
||||||
|
// For the initial load we bypass the streaming tail and run a
|
||||||
|
// one-shot `tail -n <count>` 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 content = String(data: data, encoding: .utf8) ?? ""
|
||||||
let lines = content.components(separatedBy: "\n").filter { !$0.isEmpty }
|
let lines = content.components(separatedBy: "\n").filter { !$0.isEmpty }
|
||||||
let lastLines = Array(lines.suffix(count))
|
let lastLines = Array(lines.suffix(count))
|
||||||
@@ -62,13 +116,29 @@ actor HermesLogService {
|
|||||||
guard let handle = fileHandle else { return [] }
|
guard let handle = fileHandle else { return [] }
|
||||||
let data = handle.availableData
|
let data = handle.availableData
|
||||||
guard !data.isEmpty else { return [] }
|
guard !data.isEmpty else { return [] }
|
||||||
let content = String(data: data, encoding: .utf8) ?? ""
|
let chunk = String(data: data, encoding: .utf8) ?? ""
|
||||||
let lines = content.components(separatedBy: "\n").filter { !$0.isEmpty }
|
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[..<lastNewline])
|
||||||
|
remoteTailBuffer = String(remoteTailBuffer[remoteTailBuffer.index(after: lastNewline)...])
|
||||||
|
let lines = complete.components(separatedBy: "\n").filter { !$0.isEmpty }
|
||||||
|
return lines.map { parseLine($0) }
|
||||||
|
}
|
||||||
|
let lines = chunk.components(separatedBy: "\n").filter { !$0.isEmpty }
|
||||||
return lines.map { parseLine($0) }
|
return lines.map { parseLine($0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
func seekToEnd() {
|
func seekToEnd() {
|
||||||
fileHandle?.seekToEndOfFile()
|
// Only meaningful for local FileHandles — remote tail starts at the
|
||||||
|
// end implicitly after `readLastLines` drained the initial load.
|
||||||
|
if !context.isRemote {
|
||||||
|
fileHandle?.seekToEndOfFile()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func parseLine(_ line: String) -> LogEntry {
|
private func parseLine(_ line: String) -> LogEntry {
|
||||||
|
|||||||
@@ -53,9 +53,17 @@ struct HermesProviderInfo: Sendable, Identifiable, Hashable {
|
|||||||
struct ModelCatalogService: Sendable {
|
struct ModelCatalogService: Sendable {
|
||||||
private let logger = Logger(subsystem: "com.scarf", category: "ModelCatalogService")
|
private let logger = Logger(subsystem: "com.scarf", category: "ModelCatalogService")
|
||||||
let path: String
|
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.path = path
|
||||||
|
self.transport = LocalTransport()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// All providers, sorted by display name.
|
/// All providers, sorted by display name.
|
||||||
@@ -159,7 +167,7 @@ struct ModelCatalogService: Sendable {
|
|||||||
// MARK: - Decoding
|
// MARK: - Decoding
|
||||||
|
|
||||||
private func loadCatalog() -> [String: ProviderEntry]? {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
|
|||||||
@@ -2,10 +2,18 @@ import Foundation
|
|||||||
|
|
||||||
struct ProjectDashboardService: Sendable {
|
struct ProjectDashboardService: Sendable {
|
||||||
|
|
||||||
|
let context: ServerContext
|
||||||
|
let transport: any ServerTransport
|
||||||
|
|
||||||
|
init(context: ServerContext = .local) {
|
||||||
|
self.context = context
|
||||||
|
self.transport = context.makeTransport()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Registry
|
// MARK: - Registry
|
||||||
|
|
||||||
func loadRegistry() -> ProjectRegistry {
|
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: [])
|
return ProjectRegistry(projects: [])
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
@@ -17,10 +25,10 @@ struct ProjectDashboardService: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func saveRegistry(_ registry: ProjectRegistry) {
|
func saveRegistry(_ registry: ProjectRegistry) {
|
||||||
let dir = HermesPaths.scarfDir
|
let dir = context.paths.scarfDir
|
||||||
if !FileManager.default.fileExists(atPath: dir) {
|
if !transport.fileExists(dir) {
|
||||||
do {
|
do {
|
||||||
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
|
try transport.createDirectory(dir)
|
||||||
} catch {
|
} catch {
|
||||||
print("[Scarf] Failed to create scarf directory: \(error.localizedDescription)")
|
print("[Scarf] Failed to create scarf directory: \(error.localizedDescription)")
|
||||||
return
|
return
|
||||||
@@ -28,18 +36,20 @@ struct ProjectDashboardService: Sendable {
|
|||||||
}
|
}
|
||||||
guard let data = try? JSONEncoder().encode(registry) else { return }
|
guard let data = try? JSONEncoder().encode(registry) else { return }
|
||||||
// Pretty-print for readability (agents may read this file)
|
// Pretty-print for readability (agents may read this file)
|
||||||
|
let writeData: Data
|
||||||
if let pretty = try? JSONSerialization.jsonObject(with: data),
|
if let pretty = try? JSONSerialization.jsonObject(with: data),
|
||||||
let formatted = try? JSONSerialization.data(withJSONObject: pretty, options: [.prettyPrinted, .sortedKeys]) {
|
let formatted = try? JSONSerialization.data(withJSONObject: pretty, options: [.prettyPrinted, .sortedKeys]) {
|
||||||
FileManager.default.createFile(atPath: HermesPaths.projectsRegistry, contents: formatted)
|
writeData = formatted
|
||||||
} else {
|
} else {
|
||||||
FileManager.default.createFile(atPath: HermesPaths.projectsRegistry, contents: data)
|
writeData = data
|
||||||
}
|
}
|
||||||
|
try? transport.writeFile(context.paths.projectsRegistry, data: writeData)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Dashboard
|
// MARK: - Dashboard
|
||||||
|
|
||||||
func loadDashboard(for project: ProjectEntry) -> ProjectDashboard? {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
@@ -51,13 +61,10 @@ struct ProjectDashboardService: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func dashboardExists(for project: ProjectEntry) -> Bool {
|
func dashboardExists(for project: ProjectEntry) -> Bool {
|
||||||
FileManager.default.fileExists(atPath: project.dashboardPath)
|
transport.fileExists(project.dashboardPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func dashboardModificationDate(for project: ProjectEntry) -> Date? {
|
func dashboardModificationDate(for project: ProjectEntry) -> Date? {
|
||||||
guard let attrs = try? FileManager.default.attributesOfItem(atPath: project.dashboardPath) else {
|
transport.stat(project.dashboardPath)?.mtime
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return attrs[.modificationDate] as? Date
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<WatchEvent> {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 '<command>'` 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 `<path>.scarf.tmp` on the remote
|
||||||
|
// 2. ssh `mv <tmp> <path>` — 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: "<bytes> <unix-epoch-secs> <type>" where <type> is either
|
||||||
|
// a GNU word ("regular file", "directory") or a BSD word ("Regular
|
||||||
|
// File", "Directory"). Only the first word of <type> 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 '<exe> <arg> <arg>'` 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<WatchEvent> {
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/<serverID>/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<WatchEvent>
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,14 @@ import Foundation
|
|||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
final class ActivityViewModel {
|
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 toolMessages: [HermesMessage] = []
|
||||||
var filterKind: ToolKind?
|
var filterKind: ToolKind?
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ActivityView: View {
|
struct ActivityView: View {
|
||||||
@State private var viewModel = ActivityViewModel()
|
@State private var viewModel: ActivityViewModel
|
||||||
@Environment(AppCoordinator.self) private var coordinator
|
@Environment(AppCoordinator.self) private var coordinator
|
||||||
|
|
||||||
|
init(context: ServerContext) {
|
||||||
|
_viewModel = State(initialValue: ActivityViewModel(context: context))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
filterBar
|
filterBar
|
||||||
|
|||||||
@@ -6,8 +6,29 @@ import os
|
|||||||
@Observable
|
@Observable
|
||||||
final class ChatViewModel {
|
final class ChatViewModel {
|
||||||
private let logger = Logger(subsystem: "com.scarf", category: "ChatViewModel")
|
private let logger = Logger(subsystem: "com.scarf", category: "ChatViewModel")
|
||||||
private let dataService = HermesDataService()
|
let context: ServerContext
|
||||||
private let fileService = HermesFileService()
|
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 recentSessions: [HermesSession] = []
|
||||||
var sessionPreviews: [String: String] = [:]
|
var sessionPreviews: [String: String] = [:]
|
||||||
@@ -17,7 +38,7 @@ final class ChatViewModel {
|
|||||||
var ttsEnabled = false
|
var ttsEnabled = false
|
||||||
var isRecording = false
|
var isRecording = false
|
||||||
var displayMode: ChatDisplayMode = .richChat
|
var displayMode: ChatDisplayMode = .richChat
|
||||||
let richChatViewModel = RichChatViewModel()
|
let richChatViewModel: RichChatViewModel
|
||||||
private var coordinator: Coordinator?
|
private var coordinator: Coordinator?
|
||||||
|
|
||||||
// ACP state
|
// ACP state
|
||||||
@@ -43,14 +64,17 @@ final class ChatViewModel {
|
|||||||
private static let reconnectBaseDelay: UInt64 = 1_000_000_000 // 1 second
|
private static let reconnectBaseDelay: UInt64 = 1_000_000_000 // 1 second
|
||||||
private static let maxReconnectDelay: UInt64 = 16_000_000_000 // 16 seconds
|
private static let maxReconnectDelay: UInt64 = 16_000_000_000 // 16 seconds
|
||||||
|
|
||||||
var hermesBinaryExists: Bool {
|
/// Cached result of probing for `hermes` on the target server. Updated
|
||||||
FileManager.default.fileExists(atPath: HermesPaths.hermesBinary)
|
/// 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
|
/// Re-checks env + `~/.hermes/.env` for AI-provider credentials and
|
||||||
/// updates `missingCredentials`. Cheap — safe to call from view `.task`.
|
/// updates `missingCredentials`. Cheap — safe to call from view `.task`.
|
||||||
func refreshCredentialPreflight() {
|
func refreshCredentialPreflight() {
|
||||||
missingCredentials = !HermesFileService.hasAnyAICredential()
|
missingCredentials = !fileService.hasAnyAICredential()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clears the error/hint/details triplet so future failures overwrite
|
/// 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
|
// Find most recent session and resume via ACP
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
let opened = await dataService.open()
|
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()
|
let sessionId = await dataService.fetchMostRecentlyActiveSessionId()
|
||||||
await dataService.close()
|
await dataService.close()
|
||||||
if let sessionId {
|
if let sessionId {
|
||||||
@@ -159,7 +190,7 @@ final class ChatViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let client = ACPClient()
|
let client = ACPClient(context: context)
|
||||||
self.acpClient = client
|
self.acpClient = client
|
||||||
|
|
||||||
do {
|
do {
|
||||||
@@ -247,7 +278,7 @@ final class ChatViewModel {
|
|||||||
clearACPErrorState()
|
clearACPErrorState()
|
||||||
acpStatus = "Starting..."
|
acpStatus = "Starting..."
|
||||||
|
|
||||||
let client = ACPClient()
|
let client = ACPClient(context: context)
|
||||||
self.acpClient = client
|
self.acpClient = client
|
||||||
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
@@ -385,7 +416,7 @@ final class ChatViewModel {
|
|||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
}
|
}
|
||||||
|
|
||||||
let client = ACPClient()
|
let client = ACPClient(context: context)
|
||||||
do {
|
do {
|
||||||
try await client.start()
|
try await client.start()
|
||||||
|
|
||||||
@@ -542,11 +573,44 @@ final class ChatViewModel {
|
|||||||
var env = ProcessInfo.processInfo.environment
|
var env = ProcessInfo.processInfo.environment
|
||||||
env["TERM"] = "xterm-256color"
|
env["TERM"] = "xterm-256color"
|
||||||
env["COLORTERM"] = "truecolor"
|
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)" }
|
let envArray = env.map { "\($0.key)=\($0.value)" }
|
||||||
|
|
||||||
|
// For remote: wrap the invocation in `ssh -t host -- hermes <args>`
|
||||||
|
// 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(
|
terminal.startProcess(
|
||||||
executable: HermesPaths.hermesBinary,
|
executable: exe,
|
||||||
args: arguments,
|
args: argv,
|
||||||
environment: envArray,
|
environment: envArray,
|
||||||
execName: nil
|
execName: nil
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,7 +25,14 @@ struct MessageGroup: Identifiable {
|
|||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
final class RichChatViewModel {
|
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 messages: [HermesMessage] = []
|
||||||
var currentSession: HermesSession?
|
var currentSession: HermesSession?
|
||||||
|
|||||||
@@ -304,7 +304,7 @@ struct ChatView: View {
|
|||||||
ContentUnavailableView(
|
ContentUnavailableView(
|
||||||
"Hermes Not Found",
|
"Hermes Not Found",
|
||||||
systemImage: "terminal",
|
systemImage: "terminal",
|
||||||
description: Text("Expected at \(HermesPaths.hermesBinary)")
|
description: Text("Expected at \(viewModel.context.paths.hermesBinary)")
|
||||||
)
|
)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
@@ -331,7 +331,7 @@ struct ChatView: View {
|
|||||||
ContentUnavailableView(
|
ContentUnavailableView(
|
||||||
"Hermes Not Found",
|
"Hermes Not Found",
|
||||||
systemImage: "terminal",
|
systemImage: "terminal",
|
||||||
description: Text("Expected at \(HermesPaths.hermesBinary)")
|
description: Text("Expected at \(viewModel.context.paths.hermesBinary)")
|
||||||
)
|
)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ struct RichChatMessageList: View {
|
|||||||
/// External trigger to force a scroll-to-bottom (e.g., from "Return to Active Session").
|
/// External trigger to force a scroll-to-bottom (e.g., from "Return to Active Session").
|
||||||
var scrollTrigger: UUID = UUID()
|
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 {
|
private var scrollAnchor: String {
|
||||||
if isWorking { return "typing-indicator" }
|
|
||||||
if let last = groups.last { return "group-\(last.id)" }
|
if let last = groups.last { return "group-\(last.id)" }
|
||||||
return "scroll-top"
|
return "scroll-top"
|
||||||
}
|
}
|
||||||
@@ -19,6 +21,11 @@ struct RichChatMessageList: View {
|
|||||||
LazyVStack(alignment: .leading, spacing: 16) {
|
LazyVStack(alignment: .leading, spacing: 16) {
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
.id("scroll-top")
|
.id("scroll-top")
|
||||||
|
|
||||||
|
if groups.isEmpty && !isWorking {
|
||||||
|
emptyState
|
||||||
|
}
|
||||||
|
|
||||||
ForEach(groups) { group in
|
ForEach(groups) { group in
|
||||||
MessageGroupView(group: group)
|
MessageGroupView(group: group)
|
||||||
.id("group-\(group.id)")
|
.id("group-\(group.id)")
|
||||||
@@ -32,7 +39,6 @@ struct RichChatMessageList: View {
|
|||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
.defaultScrollAnchor(.bottom)
|
.defaultScrollAnchor(.bottom)
|
||||||
// Scroll to bottom when view first appears with content
|
|
||||||
.onAppear {
|
.onAppear {
|
||||||
if !groups.isEmpty {
|
if !groups.isEmpty {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
@@ -40,33 +46,39 @@ struct RichChatMessageList: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Scroll on new groups
|
// New turn: animate to the bottom.
|
||||||
.onChange(of: groups.count) {
|
.onChange(of: groups.count) {
|
||||||
scrollToBottom(proxy: proxy)
|
scrollToBottom(proxy: proxy)
|
||||||
}
|
}
|
||||||
// Scroll when agent starts/stops working
|
// Streaming chunks: track the bottom without animation so the
|
||||||
.onChange(of: isWorking) {
|
// text glides instead of bouncing.
|
||||||
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)
|
|
||||||
.onChange(of: groups.last?.assistantMessages.last?.content ?? "") {
|
.onChange(of: groups.last?.assistantMessages.last?.content ?? "") {
|
||||||
scrollToBottom(proxy: proxy, animated: false)
|
scrollToBottom(proxy: proxy, animated: false)
|
||||||
}
|
}
|
||||||
// Scroll on tool call count change
|
// Explicit "Return to Active Session" button.
|
||||||
.onChange(of: groups.last?.toolCallCount ?? 0) {
|
|
||||||
scrollToBottom(proxy: proxy)
|
|
||||||
}
|
|
||||||
// Scroll on external trigger (e.g., "Return to Active Session" button)
|
|
||||||
.onChange(of: scrollTrigger) {
|
.onChange(of: scrollTrigger) {
|
||||||
scrollToBottom(proxy: proxy)
|
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) {
|
private func scrollToBottom(proxy: ScrollViewProxy, animated: Bool = true) {
|
||||||
let target = scrollAnchor
|
let target = scrollAnchor
|
||||||
if animated {
|
if animated {
|
||||||
@@ -108,7 +120,17 @@ struct MessageGroupView: View {
|
|||||||
RichMessageBubble(message: user, toolResults: [:])
|
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)
|
RichMessageBubble(message: message, toolResults: group.toolResults)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,20 +21,15 @@ struct RichChatView: View {
|
|||||||
)
|
)
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
if richChat.messageGroups.isEmpty && !richChat.isAgentWorking {
|
// Always mount RichChatMessageList; empty state lives inside it.
|
||||||
ContentUnavailableView(
|
// Swapping between a ContentUnavailableView and the ScrollView
|
||||||
"Chat Messages",
|
// hierarchy on first message caused a full view tree rebuild,
|
||||||
systemImage: "bubble.left.and.text.bubble.right",
|
// which manifests as a white flash.
|
||||||
description: Text("Messages will appear here as the conversation progresses.")
|
RichChatMessageList(
|
||||||
)
|
groups: richChat.messageGroups,
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
isWorking: richChat.isAgentWorking,
|
||||||
} else {
|
scrollTrigger: richChat.scrollTrigger
|
||||||
RichChatMessageList(
|
)
|
||||||
groups: richChat.messageGroups,
|
|
||||||
isWorking: richChat.isAgentWorking,
|
|
||||||
scrollTrigger: richChat.scrollTrigger
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
RichChatInputBar(
|
RichChatInputBar(
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,12 @@ struct HermesCredentialPool: Identifiable, Sendable {
|
|||||||
@MainActor
|
@MainActor
|
||||||
final class CredentialPoolsViewModel {
|
final class CredentialPoolsViewModel {
|
||||||
private let logger = Logger(subsystem: "com.scarf", category: "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 pools: [HermesCredentialPool] = []
|
||||||
var isLoading = false
|
var isLoading = false
|
||||||
@@ -37,7 +43,7 @@ final class CredentialPoolsViewModel {
|
|||||||
/// can extract the authorization URL, pop it open with an explicit button,
|
/// can extract the authorization URL, pop it open with an explicit button,
|
||||||
/// and feed the code back via stdin. See OAuthFlowController for why we
|
/// and feed the code back via stdin. See OAuthFlowController for why we
|
||||||
/// moved off the embedded-terminal approach.
|
/// moved off the embedded-terminal approach.
|
||||||
let oauthFlow = OAuthFlowController()
|
let oauthFlow: OAuthFlowController
|
||||||
var oauthProvider: String = ""
|
var oauthProvider: String = ""
|
||||||
/// Convenience — the sheet keys a lot of UI off "is the flow running?".
|
/// Convenience — the sheet keys a lot of UI off "is the flow running?".
|
||||||
var oauthInProgress: Bool { oauthFlow.isRunning }
|
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`
|
/// 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
|
/// output is fragile — the JSON file is structured, stable, and already stores
|
||||||
/// exactly the pool data the UI needs. We never display full tokens.
|
/// 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() {
|
func load() {
|
||||||
isLoading = true
|
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 decodedPools: [HermesCredentialPool]
|
||||||
let strategies = parseStrategies()
|
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 {
|
await MainActor.run { [weak self] in
|
||||||
pools = []
|
self?.pools = decodedPools
|
||||||
return
|
self?.isLoading = false
|
||||||
}
|
}
|
||||||
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 = []
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `credential_pool_strategies:` map lives in config.yaml as `<provider>: <strategy>`.
|
/// The `credential_pool_strategies:` map lives in config.yaml as `<provider>: <strategy>`.
|
||||||
private func parseStrategies() -> [String: String] {
|
/// Pure-function form so it's safe to call from the detached load task.
|
||||||
guard let yaml = try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8) else { return [:] }
|
nonisolated private static func parseStrategies(from yaml: String) -> [String: String] {
|
||||||
|
guard !yaml.isEmpty else { return [:] }
|
||||||
let parsed = HermesFileService.parseNestedYAML(yaml)
|
let parsed = HermesFileService.parseNestedYAML(yaml)
|
||||||
return parsed.maps["credential_pool_strategies"] ?? [:]
|
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
|
auth.credential_pool.keys.sorted().map { provider in
|
||||||
let entries = auth.credential_pool[provider] ?? []
|
let entries = auth.credential_pool[provider] ?? []
|
||||||
let creds = entries.enumerated().map { index, entry in
|
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.
|
/// 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.
|
/// 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 "" }
|
guard token.count >= 4 else { return "" }
|
||||||
return "…" + String(token.suffix(4))
|
return "…" + String(token.suffix(4))
|
||||||
}
|
}
|
||||||
@@ -206,21 +220,7 @@ final class CredentialPoolsViewModel {
|
|||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
||||||
let process = Process()
|
context.runHermes(arguments)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ import os
|
|||||||
@MainActor
|
@MainActor
|
||||||
final class OAuthFlowController {
|
final class OAuthFlowController {
|
||||||
private let logger = Logger(subsystem: "com.scarf", category: "OAuthFlowController")
|
private let logger = Logger(subsystem: "com.scarf", category: "OAuthFlowController")
|
||||||
|
let context: ServerContext
|
||||||
|
|
||||||
|
init(context: ServerContext = .local) {
|
||||||
|
self.context = context
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Observable state
|
// MARK: - Observable state
|
||||||
|
|
||||||
@@ -82,10 +88,20 @@ final class OAuthFlowController {
|
|||||||
args += ["--label", trimmedLabel]
|
args += ["--label", trimmedLabel]
|
||||||
}
|
}
|
||||||
|
|
||||||
let proc = Process()
|
// Use the transport so OAuth works against remote contexts too:
|
||||||
proc.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
|
// local spawns hermes directly, remote rounds through ssh -T while
|
||||||
proc.arguments = args
|
// preserving stdin (for the auth-code prompt) and stdout (for the
|
||||||
proc.environment = HermesFileService.enrichedEnvironment()
|
// 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 outPipe = Pipe()
|
||||||
let inPipe = Pipe()
|
let inPipe = Pipe()
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct CredentialPoolsView: View {
|
struct CredentialPoolsView: View {
|
||||||
@State private var viewModel = CredentialPoolsViewModel()
|
@State private var viewModel: CredentialPoolsViewModel
|
||||||
@State private var showAddSheet = false
|
@State private var showAddSheet = false
|
||||||
@State private var pendingRemove: HermesCredential?
|
@State private var pendingRemove: HermesCredential?
|
||||||
|
|
||||||
|
init(context: ServerContext) {
|
||||||
|
_viewModel = State(initialValue: CredentialPoolsViewModel(context: context))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
@@ -24,6 +29,11 @@ struct CredentialPoolsView: View {
|
|||||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
}
|
}
|
||||||
.navigationTitle("Credential Pools")
|
.navigationTitle("Credential Pools")
|
||||||
|
.loadingOverlay(
|
||||||
|
viewModel.isLoading,
|
||||||
|
label: "Loading credentials…",
|
||||||
|
isEmpty: viewModel.pools.isEmpty
|
||||||
|
)
|
||||||
.onAppear { viewModel.load() }
|
.onAppear { viewModel.load() }
|
||||||
.sheet(isPresented: $showAddSheet) {
|
.sheet(isPresented: $showAddSheet) {
|
||||||
AddCredentialSheet(viewModel: viewModel) {
|
AddCredentialSheet(viewModel: viewModel) {
|
||||||
@@ -194,7 +204,7 @@ private struct AddCredentialSheet: View {
|
|||||||
@State private var oauthStarted: Bool = false
|
@State private var oauthStarted: Bool = false
|
||||||
@State private var authCode: String = ""
|
@State private var authCode: String = ""
|
||||||
|
|
||||||
private let catalog = ModelCatalogService()
|
private var catalog: ModelCatalogService { ModelCatalogService(context: viewModel.context) }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
|||||||
@@ -5,7 +5,14 @@ import os
|
|||||||
@Observable
|
@Observable
|
||||||
final class CronViewModel {
|
final class CronViewModel {
|
||||||
private let logger = Logger(subsystem: "com.scarf", category: "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 jobs: [HermesCronJob] = []
|
||||||
var selectedJob: HermesCronJob?
|
var selectedJob: HermesCronJob?
|
||||||
@@ -14,19 +21,37 @@ final class CronViewModel {
|
|||||||
var message: String?
|
var message: String?
|
||||||
var showCreateSheet = false
|
var showCreateSheet = false
|
||||||
var editingJob: HermesCronJob?
|
var editingJob: HermesCronJob?
|
||||||
|
var isLoading = false
|
||||||
|
|
||||||
func load() {
|
func load() {
|
||||||
jobs = fileService.loadCronJobs()
|
isLoading = true
|
||||||
availableSkills = fileService.loadSkills().flatMap { $0.skills.map(\.id) }.sorted()
|
let svc = fileService
|
||||||
if let selected = selectedJob, let refreshed = jobs.first(where: { $0.id == selected.id }) {
|
let selectedID = selectedJob?.id
|
||||||
selectedJob = refreshed
|
Task.detached { [weak self] in
|
||||||
jobOutput = fileService.loadCronOutput(jobId: refreshed.id)
|
// 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) {
|
func selectJob(_ job: HermesCronJob) {
|
||||||
selectedJob = job
|
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
|
// MARK: - CLI wrappers
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct CronView: View {
|
struct CronView: View {
|
||||||
@State private var viewModel = CronViewModel()
|
@State private var viewModel: CronViewModel
|
||||||
@State private var pendingDelete: HermesCronJob?
|
@State private var pendingDelete: HermesCronJob?
|
||||||
|
|
||||||
|
init(context: ServerContext) {
|
||||||
|
_viewModel = State(initialValue: CronViewModel(context: context))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HSplitView {
|
HSplitView {
|
||||||
jobsList
|
jobsList
|
||||||
@@ -12,6 +17,7 @@ struct CronView: View {
|
|||||||
.frame(minWidth: 400)
|
.frame(minWidth: 400)
|
||||||
}
|
}
|
||||||
.navigationTitle("Cron Jobs")
|
.navigationTitle("Cron Jobs")
|
||||||
|
.loadingOverlay(viewModel.isLoading, label: "Loading cron jobs…", isEmpty: viewModel.jobs.isEmpty)
|
||||||
.onAppear { viewModel.load() }
|
.onAppear { viewModel.load() }
|
||||||
.sheet(isPresented: $viewModel.showCreateSheet) {
|
.sheet(isPresented: $viewModel.showCreateSheet) {
|
||||||
CronJobEditor(mode: .create, availableSkills: viewModel.availableSkills) { form in
|
CronJobEditor(mode: .create, availableSkills: viewModel.availableSkills) { form in
|
||||||
|
|||||||
@@ -2,8 +2,16 @@ import Foundation
|
|||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
final class DashboardViewModel {
|
final class DashboardViewModel {
|
||||||
private let dataService = HermesDataService()
|
let context: ServerContext
|
||||||
private let fileService = HermesFileService()
|
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 stats = HermesDataService.SessionStats.empty
|
||||||
var recentSessions: [HermesSession] = []
|
var recentSessions: [HermesSession] = []
|
||||||
@@ -22,9 +30,17 @@ final class DashboardViewModel {
|
|||||||
sessionPreviews = await dataService.fetchSessionPreviews(limit: 5)
|
sessionPreviews = await dataService.fetchSessionPreviews(limit: 5)
|
||||||
await dataService.close()
|
await dataService.close()
|
||||||
}
|
}
|
||||||
config = fileService.loadConfig()
|
// The fileService methods are synchronous and route through the
|
||||||
gatewayState = fileService.loadGatewayState()
|
// transport. For remote contexts each call is a blocking ssh
|
||||||
hermesRunning = fileService.isHermesRunning()
|
// 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
|
isLoading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct DashboardView: View {
|
struct DashboardView: View {
|
||||||
@State private var viewModel = DashboardViewModel()
|
@State private var viewModel: DashboardViewModel
|
||||||
@Environment(AppCoordinator.self) private var coordinator
|
@Environment(AppCoordinator.self) private var coordinator
|
||||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||||
|
|
||||||
|
init(context: ServerContext) {
|
||||||
|
_viewModel = State(initialValue: DashboardViewModel(context: context))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
@@ -16,6 +21,11 @@ struct DashboardView: View {
|
|||||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
}
|
}
|
||||||
.navigationTitle("Dashboard")
|
.navigationTitle("Dashboard")
|
||||||
|
.loadingOverlay(
|
||||||
|
viewModel.isLoading,
|
||||||
|
label: "Loading dashboard…",
|
||||||
|
isEmpty: viewModel.recentSessions.isEmpty
|
||||||
|
)
|
||||||
.task { await viewModel.load() }
|
.task { await viewModel.load() }
|
||||||
.onChange(of: fileWatcher.lastChangeDate) {
|
.onChange(of: fileWatcher.lastChangeDate) {
|
||||||
Task { await viewModel.load() }
|
Task { await viewModel.load() }
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ struct PendingPairing: Identifiable {
|
|||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
final class GatewayViewModel {
|
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 gateway = GatewayInfo(pid: nil, state: "unknown", exitReason: nil, startTime: nil, updatedAt: nil, platforms: [], isLoaded: false, isStale: false)
|
||||||
var approvedUsers: [PairedUser] = []
|
var approvedUsers: [PairedUser] = []
|
||||||
var pendingPairings: [PendingPairing] = []
|
var pendingPairings: [PendingPairing] = []
|
||||||
@@ -45,52 +51,26 @@ final class GatewayViewModel {
|
|||||||
|
|
||||||
func load() {
|
func load() {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
loadGatewayStatus()
|
let ctx = context
|
||||||
loadPairing()
|
Task.detached { [weak self] in
|
||||||
isLoading = false
|
// 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)
|
||||||
func startGateway() {
|
let pairing = Self.fetchPairing(context: ctx)
|
||||||
runHermes(["gateway", "start"])
|
await MainActor.run { [weak self] in
|
||||||
actionMessage = "Gateway start requested"
|
guard let self else { return }
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
self.gateway = status
|
||||||
self?.loadGatewayStatus()
|
self.approvedUsers = pairing.approved
|
||||||
self?.actionMessage = nil
|
self.pendingPairings = pairing.pending
|
||||||
|
self.isLoading = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopGateway() {
|
/// Static form of the gateway-status walk so the detached load can call
|
||||||
runHermes(["gateway", "stop"])
|
/// it without bouncing back to MainActor.
|
||||||
actionMessage = "Gateway stop requested"
|
private static func fetchGatewayStatus(context: ServerContext) -> GatewayInfo {
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
let stateJSON = context.readData(context.paths.gatewayStateJSON)
|
||||||
self?.loadGatewayStatus()
|
|
||||||
self?.actionMessage = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func restartGateway() {
|
|
||||||
runHermes(["gateway", "restart"])
|
|
||||||
actionMessage = "Gateway restart requested"
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
|
||||||
self?.loadGatewayStatus()
|
|
||||||
self?.actionMessage = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func approvePairing(platform: String, code: String) {
|
|
||||||
runHermes(["pairing", "approve", platform, code])
|
|
||||||
loadPairing()
|
|
||||||
}
|
|
||||||
|
|
||||||
func revokeUser(_ user: PairedUser) {
|
|
||||||
runHermes(["pairing", "revoke", user.platform, user.userId])
|
|
||||||
approvedUsers.removeAll { $0.id == user.id }
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private
|
|
||||||
|
|
||||||
private func loadGatewayStatus() {
|
|
||||||
let stateJSON = FileManager.default.contents(atPath: HermesPaths.gatewayStateJSON)
|
|
||||||
var pid: Int?
|
var pid: Int?
|
||||||
var state = "unknown"
|
var state = "unknown"
|
||||||
var exitReason: String?
|
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 isLoaded = statusOutput.contains("service is loaded")
|
||||||
let isStale = statusOutput.contains("stale")
|
let isStale = statusOutput.contains("stale")
|
||||||
|
|
||||||
gateway = GatewayInfo(
|
return GatewayInfo(
|
||||||
pid: pid, state: state, exitReason: exitReason,
|
pid: pid, state: state, exitReason: exitReason,
|
||||||
startTime: startTime, updatedAt: updatedAt,
|
startTime: startTime, updatedAt: updatedAt,
|
||||||
platforms: platforms, isLoaded: isLoaded, isStale: isStale
|
platforms: platforms, isLoaded: isLoaded, isStale: isStale
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadPairing() {
|
private static func fetchPairing(context: ServerContext) -> (approved: [PairedUser], pending: [PendingPairing]) {
|
||||||
let output = runHermes(["pairing", "list"]).output
|
let output = context.runHermes(["pairing", "list"]).output
|
||||||
approvedUsers = []
|
var approved: [PairedUser] = []
|
||||||
pendingPairings = []
|
var pending: [PendingPairing] = []
|
||||||
|
|
||||||
var inApproved = false
|
var inApproved = false
|
||||||
var inPending = false
|
var inPending = false
|
||||||
@@ -147,31 +127,59 @@ final class GatewayViewModel {
|
|||||||
let platform = String(parts[0])
|
let platform = String(parts[0])
|
||||||
let userId = String(parts[1])
|
let userId = String(parts[1])
|
||||||
let name = parts[2...].joined(separator: " ")
|
let name = parts[2...].joined(separator: " ")
|
||||||
approvedUsers.append(PairedUser(platform: platform, userId: userId, name: name))
|
approved.append(PairedUser(platform: platform, userId: userId, name: name))
|
||||||
}
|
} else if inPending && parts.count >= 2 {
|
||||||
if inPending && parts.count >= 2 {
|
|
||||||
let platform = String(parts[0])
|
let platform = String(parts[0])
|
||||||
let code = String(parts[1])
|
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
|
func stopGateway() {
|
||||||
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
runHermes(["gateway", "stop"])
|
||||||
let process = Process()
|
actionMessage = "Gateway stop requested"
|
||||||
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||||
process.arguments = arguments
|
self?.load()
|
||||||
let pipe = Pipe()
|
self?.actionMessage = nil
|
||||||
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 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct GatewayView: View {
|
struct GatewayView: View {
|
||||||
@State private var viewModel = GatewayViewModel()
|
@State private var viewModel: GatewayViewModel
|
||||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||||
|
|
||||||
|
init(context: ServerContext) {
|
||||||
|
_viewModel = State(initialValue: GatewayViewModel(context: context))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 24) {
|
VStack(alignment: .leading, spacing: 24) {
|
||||||
|
|||||||
@@ -22,7 +22,14 @@ struct HealthSection: Identifiable {
|
|||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
final class HealthViewModel {
|
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 version = ""
|
||||||
var updateInfo = ""
|
var updateInfo = ""
|
||||||
@@ -43,19 +50,50 @@ final class HealthViewModel {
|
|||||||
|
|
||||||
func load() {
|
func load() {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
refreshProcessStatus()
|
let ctx = context
|
||||||
loadVersion()
|
let svc = fileService
|
||||||
let statusOutput = runHermes(["status"]).output
|
// Health runs four sync transport-mediated commands plus a process
|
||||||
statusSections = parseOutput(statusOutput)
|
// probe — that's 4-5 ssh round-trips on remote, easily 1-2s. Detach
|
||||||
let doctorOutput = runHermes(["doctor"]).output
|
// the whole load.
|
||||||
doctorSections = parseOutput(doctorOutput)
|
Task.detached { [weak self] in
|
||||||
computeCounts()
|
let pid = svc.hermesPID()
|
||||||
isLoading = false
|
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() {
|
func refreshProcessStatus() {
|
||||||
hermesPID = fileService.hermesPID()
|
let svc = fileService
|
||||||
hermesRunning = hermesPID != nil
|
Task.detached { [weak self] in
|
||||||
|
let pid = svc.hermesPID()
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.hermesPID = pid
|
||||||
|
self?.hermesRunning = pid != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopHermes() {
|
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[..<range.lowerBound]).trimmingCharacters(in: .whitespaces)
|
||||||
|
let detail = String(text[range.upperBound...]).trimmingCharacters(in: .whitespaces)
|
||||||
|
return (label, detail.isEmpty ? nil : detail)
|
||||||
|
}
|
||||||
|
return (text, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func iconForSectionStatic(_ title: String) -> 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] {
|
private func parseOutput(_ output: String) -> [HealthSection] {
|
||||||
var sections: [HealthSection] = []
|
var sections: [HealthSection] = []
|
||||||
var currentTitle = ""
|
var currentTitle = ""
|
||||||
@@ -237,19 +365,6 @@ final class HealthViewModel {
|
|||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
||||||
let process = Process()
|
context.runHermes(arguments)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct HealthView: View {
|
struct HealthView: View {
|
||||||
@State private var viewModel = HealthViewModel()
|
@State private var viewModel: HealthViewModel
|
||||||
@State private var expandedSection: UUID?
|
@State private var expandedSection: UUID?
|
||||||
@State private var selectedTab = 0
|
@State private var selectedTab = 0
|
||||||
@State private var showShareConfirm = false
|
@State private var showShareConfirm = false
|
||||||
@State private var showDiagnostics = false
|
@State private var showDiagnostics = false
|
||||||
|
|
||||||
|
init(context: ServerContext) {
|
||||||
|
_viewModel = State(initialValue: HealthViewModel(context: context))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
headerBar
|
headerBar
|
||||||
@@ -43,6 +48,11 @@ struct HealthView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Health")
|
.navigationTitle("Health")
|
||||||
|
.loadingOverlay(
|
||||||
|
viewModel.isLoading,
|
||||||
|
label: "Running health checks…",
|
||||||
|
isEmpty: viewModel.statusSections.isEmpty && viewModel.doctorSections.isEmpty
|
||||||
|
)
|
||||||
.onAppear { viewModel.load() }
|
.onAppear { viewModel.load() }
|
||||||
.confirmationDialog("Upload debug report?", isPresented: $showShareConfirm) {
|
.confirmationDialog("Upload debug report?", isPresented: $showShareConfirm) {
|
||||||
Button("Upload", role: .destructive) {
|
Button("Upload", role: .destructive) {
|
||||||
|
|||||||
@@ -56,7 +56,14 @@ struct NotableSession: Identifiable {
|
|||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
final class InsightsViewModel {
|
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 period: InsightsPeriod = .month
|
||||||
var isLoading = true
|
var isLoading = true
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct InsightsView: View {
|
struct InsightsView: View {
|
||||||
@State private var viewModel = InsightsViewModel()
|
@State private var viewModel: InsightsViewModel
|
||||||
@Environment(AppCoordinator.self) private var coordinator
|
@Environment(AppCoordinator.self) private var coordinator
|
||||||
|
|
||||||
|
init(context: ServerContext) {
|
||||||
|
_viewModel = State(initialValue: InsightsViewModel(context: context))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 24) {
|
VStack(alignment: .leading, spacing: 24) {
|
||||||
|
|||||||
@@ -2,7 +2,13 @@ import Foundation
|
|||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
final class LogsViewModel {
|
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 entries: [LogEntry] = []
|
||||||
var selectedLogFile: LogFile = .agent
|
var selectedLogFile: LogFile = .agent
|
||||||
@@ -17,13 +23,13 @@ final class LogsViewModel {
|
|||||||
case gateway = "gateway.log"
|
case gateway = "gateway.log"
|
||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
}
|
||||||
|
|
||||||
var path: String {
|
private func path(for file: LogFile) -> String {
|
||||||
switch self {
|
switch file {
|
||||||
case .agent: return HermesPaths.agentLog
|
case .agent: return context.paths.agentLog
|
||||||
case .errors: return HermesPaths.errorsLog
|
case .errors: return context.paths.errorsLog
|
||||||
case .gateway: return HermesPaths.gatewayLog
|
case .gateway: return context.paths.gatewayLog
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +68,7 @@ final class LogsViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func load() async {
|
func load() async {
|
||||||
await logService.openLog(path: selectedLogFile.path)
|
await logService.openLog(path: path(for: selectedLogFile))
|
||||||
entries = await logService.readLastLines(count: 500)
|
entries = await logService.readLastLines(count: 500)
|
||||||
await logService.seekToEnd()
|
await logService.seekToEnd()
|
||||||
startPolling()
|
startPolling()
|
||||||
@@ -71,7 +77,7 @@ final class LogsViewModel {
|
|||||||
func switchLogFile(_ file: LogFile) async {
|
func switchLogFile(_ file: LogFile) async {
|
||||||
selectedLogFile = file
|
selectedLogFile = file
|
||||||
entries = []
|
entries = []
|
||||||
await logService.openLog(path: file.path)
|
await logService.openLog(path: path(for: file))
|
||||||
entries = await logService.readLastLines(count: 500)
|
entries = await logService.readLastLines(count: 500)
|
||||||
await logService.seekToEnd()
|
await logService.seekToEnd()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct LogsView: View {
|
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 {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ final class MCPServerEditorViewModel {
|
|||||||
var value: String
|
var value: String
|
||||||
}
|
}
|
||||||
|
|
||||||
private let fileService = HermesFileService()
|
let context: ServerContext
|
||||||
|
private let fileService: HermesFileService
|
||||||
let server: HermesMCPServer
|
let server: HermesMCPServer
|
||||||
|
|
||||||
var envDraft: [KeyValueRow]
|
var envDraft: [KeyValueRow]
|
||||||
@@ -23,8 +24,10 @@ final class MCPServerEditorViewModel {
|
|||||||
var isSaving: Bool = false
|
var isSaving: Bool = false
|
||||||
var saveError: String?
|
var saveError: String?
|
||||||
|
|
||||||
init(server: HermesMCPServer) {
|
init(server: HermesMCPServer, context: ServerContext = .local) {
|
||||||
self.server = server
|
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.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.headersDraft = server.headers.keys.sorted().map { KeyValueRow(key: $0, value: server.headers[$0] ?? "") }
|
||||||
self.includeDraft = server.toolsInclude.joined(separator: ", ")
|
self.includeDraft = server.toolsInclude.joined(separator: ", ")
|
||||||
@@ -93,7 +96,7 @@ final class MCPServerEditorViewModel {
|
|||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.isSaving = false
|
self.isSaving = false
|
||||||
if !success {
|
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)
|
completion(success)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,14 @@ import Foundation
|
|||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
final class MCPServersViewModel {
|
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 servers: [HermesMCPServer] = []
|
||||||
var selectedServerName: String?
|
var selectedServerName: String?
|
||||||
@@ -41,10 +48,19 @@ final class MCPServersViewModel {
|
|||||||
|
|
||||||
func load() {
|
func load() {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
servers = fileService.loadMCPServers()
|
let svc = fileService
|
||||||
isLoading = false
|
Task.detached { [weak self] in
|
||||||
if let name = selectedServerName, !servers.contains(where: { $0.name == name }) {
|
// loadMCPServers reads config.yaml + lists mcp-tokens — both
|
||||||
selectedServerName = nil
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct MCPServersView: View {
|
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 {
|
var body: some View {
|
||||||
HSplitView {
|
HSplitView {
|
||||||
@@ -11,6 +16,11 @@ struct MCPServersView: View {
|
|||||||
.frame(minWidth: 500)
|
.frame(minWidth: 500)
|
||||||
}
|
}
|
||||||
.navigationTitle("MCP Servers (\(viewModel.servers.count))")
|
.navigationTitle("MCP Servers (\(viewModel.servers.count))")
|
||||||
|
.loadingOverlay(
|
||||||
|
viewModel.isLoading,
|
||||||
|
label: "Loading MCP servers…",
|
||||||
|
isEmpty: viewModel.servers.isEmpty
|
||||||
|
)
|
||||||
.searchable(text: $viewModel.searchText, prompt: "Filter servers...")
|
.searchable(text: $viewModel.searchText, prompt: "Filter servers...")
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItemGroup(placement: .primaryAction) {
|
ToolbarItemGroup(placement: .primaryAction) {
|
||||||
|
|||||||
@@ -2,7 +2,14 @@ import Foundation
|
|||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
final class MemoryViewModel {
|
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 memoryContent = ""
|
||||||
var userContent = ""
|
var userContent = ""
|
||||||
@@ -12,6 +19,7 @@ final class MemoryViewModel {
|
|||||||
var editText = ""
|
var editText = ""
|
||||||
var profiles: [String] = []
|
var profiles: [String] = []
|
||||||
var activeProfile = ""
|
var activeProfile = ""
|
||||||
|
var isLoading = false
|
||||||
|
|
||||||
enum EditTarget {
|
enum EditTarget {
|
||||||
case memory, user
|
case memory, user
|
||||||
@@ -30,20 +38,40 @@ final class MemoryViewModel {
|
|||||||
var hasMultipleProfiles: Bool { !profiles.isEmpty }
|
var hasMultipleProfiles: Bool { !profiles.isEmpty }
|
||||||
|
|
||||||
func load() {
|
func load() {
|
||||||
let config = fileService.loadConfig()
|
isLoading = true
|
||||||
memoryProvider = config.memoryProvider
|
let svc = fileService
|
||||||
profiles = fileService.loadMemoryProfiles()
|
let currentProfile = activeProfile
|
||||||
if activeProfile.isEmpty {
|
// Sync transport calls would beach-ball the UI on remote — dispatch
|
||||||
activeProfile = config.memoryProfile
|
// 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) {
|
func switchProfile(_ profile: String) {
|
||||||
activeProfile = profile
|
activeProfile = profile
|
||||||
memoryContent = fileService.loadMemory(profile: profile)
|
let svc = fileService
|
||||||
userContent = fileService.loadUserProfile(profile: profile)
|
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) {
|
func startEditing(_ target: EditTarget) {
|
||||||
@@ -53,15 +81,24 @@ final class MemoryViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func save() {
|
func save() {
|
||||||
switch editingFile {
|
let svc = fileService
|
||||||
case .memory:
|
let target = editingFile
|
||||||
fileService.saveMemory(editText, profile: activeProfile)
|
let text = editText
|
||||||
memoryContent = editText
|
let profile = activeProfile
|
||||||
case .user:
|
Task.detached { [weak self] in
|
||||||
fileService.saveUserProfile(editText, profile: activeProfile)
|
switch target {
|
||||||
userContent = editText
|
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() {
|
func cancelEditing() {
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct MemoryView: View {
|
struct MemoryView: View {
|
||||||
@State private var viewModel = MemoryViewModel()
|
@State private var viewModel: MemoryViewModel
|
||||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||||
|
|
||||||
|
init(context: ServerContext) {
|
||||||
|
_viewModel = State(initialValue: MemoryViewModel(context: context))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
@@ -43,6 +48,11 @@ struct MemoryView: View {
|
|||||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
}
|
}
|
||||||
.navigationTitle("Memory")
|
.navigationTitle("Memory")
|
||||||
|
.loadingOverlay(
|
||||||
|
viewModel.isLoading,
|
||||||
|
label: "Loading memory…",
|
||||||
|
isEmpty: viewModel.memoryContent.isEmpty && viewModel.userContent.isEmpty
|
||||||
|
)
|
||||||
.onAppear { viewModel.load() }
|
.onAppear { viewModel.load() }
|
||||||
.onChange(of: fileWatcher.lastChangeDate) {
|
.onChange(of: fileWatcher.lastChangeDate) {
|
||||||
viewModel.load()
|
viewModel.load()
|
||||||
|
|||||||
@@ -13,26 +13,63 @@ struct HermesPersonality: Identifiable, Sendable, Equatable {
|
|||||||
@Observable
|
@Observable
|
||||||
final class PersonalitiesViewModel {
|
final class PersonalitiesViewModel {
|
||||||
private let logger = Logger(subsystem: "com.scarf", category: "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 personalities: [HermesPersonality] = []
|
||||||
var activeName: String = ""
|
var activeName: String = ""
|
||||||
var soulMarkdown: String = ""
|
var soulMarkdown: String = ""
|
||||||
var soulPath: String { HermesPaths.home + "/SOUL.md" }
|
var soulPath: String { context.paths.soulMD }
|
||||||
var message: String?
|
var message: String?
|
||||||
|
|
||||||
func load() {
|
func load() {
|
||||||
let config = fileService.loadConfig()
|
let svc = fileService
|
||||||
activeName = config.personality
|
let ctx = context
|
||||||
personalities = parsePersonalitiesBlock()
|
let path = soulPath
|
||||||
soulMarkdown = (try? String(contentsOfFile: soulPath, encoding: .utf8)) ?? ""
|
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<String> = []
|
||||||
|
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.
|
/// Parse the `personalities:` section of config.yaml using the nested parser.
|
||||||
/// Each personality is a top-level key under `personalities`, optionally with
|
/// Each personality is a top-level key under `personalities`, optionally with
|
||||||
/// a `prompt:` child.
|
/// a `prompt:` child.
|
||||||
private func parsePersonalitiesBlock() -> [HermesPersonality] {
|
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)
|
let parsed = HermesFileService.parseNestedYAML(yaml)
|
||||||
// Find all keys "personalities.<name>[.subkey]"
|
// Find all keys "personalities.<name>[.subkey]"
|
||||||
var nameSet: Set<String> = []
|
var nameSet: Set<String> = []
|
||||||
@@ -65,12 +102,11 @@ final class PersonalitiesViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func saveSOUL(_ content: String) {
|
func saveSOUL(_ content: String) {
|
||||||
do {
|
if context.writeText(soulPath, content: content) {
|
||||||
try content.write(toFile: soulPath, atomically: true, encoding: .utf8)
|
|
||||||
soulMarkdown = content
|
soulMarkdown = content
|
||||||
message = "SOUL.md saved"
|
message = "SOUL.md saved"
|
||||||
} catch {
|
} else {
|
||||||
logger.error("Failed to write SOUL.md: \(error.localizedDescription)")
|
logger.error("Failed to write SOUL.md to \(self.context.displayName)")
|
||||||
message = "Save failed"
|
message = "Save failed"
|
||||||
}
|
}
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||||
@@ -79,25 +115,11 @@ final class PersonalitiesViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func openConfigInEditor() {
|
func openConfigInEditor() {
|
||||||
NSWorkspace.shared.open(URL(fileURLWithPath: HermesPaths.configYAML))
|
context.openInLocalEditor(context.paths.configYAML)
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
||||||
let process = Process()
|
context.runHermes(arguments)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct PersonalitiesView: View {
|
struct PersonalitiesView: View {
|
||||||
@State private var viewModel = PersonalitiesViewModel()
|
@State private var viewModel: PersonalitiesViewModel
|
||||||
@State private var soulDraft = ""
|
@State private var soulDraft = ""
|
||||||
@State private var editingSOUL = false
|
@State private var editingSOUL = false
|
||||||
|
|
||||||
|
init(context: ServerContext) {
|
||||||
|
_viewModel = State(initialValue: PersonalitiesViewModel(context: context))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import os
|
|||||||
@Observable
|
@Observable
|
||||||
@MainActor
|
@MainActor
|
||||||
final class DiscordSetupViewModel {
|
final class DiscordSetupViewModel {
|
||||||
|
let context: ServerContext
|
||||||
|
init(context: ServerContext = .local) { self.context = context }
|
||||||
|
|
||||||
var botToken: String = ""
|
var botToken: String = ""
|
||||||
var allowedUsers: String = ""
|
var allowedUsers: String = ""
|
||||||
var homeChannel: String = ""
|
var homeChannel: String = ""
|
||||||
@@ -26,7 +29,7 @@ final class DiscordSetupViewModel {
|
|||||||
let replyToModeOptions = ["off", "first", "all"]
|
let replyToModeOptions = ["off", "first", "all"]
|
||||||
|
|
||||||
func load() {
|
func load() {
|
||||||
let env = HermesEnvService().load()
|
let env = HermesEnvService(context: context).load()
|
||||||
botToken = env["DISCORD_BOT_TOKEN"] ?? ""
|
botToken = env["DISCORD_BOT_TOKEN"] ?? ""
|
||||||
allowedUsers = env["DISCORD_ALLOWED_USERS"] ?? ""
|
allowedUsers = env["DISCORD_ALLOWED_USERS"] ?? ""
|
||||||
homeChannel = env["DISCORD_HOME_CHANNEL"] ?? ""
|
homeChannel = env["DISCORD_HOME_CHANNEL"] ?? ""
|
||||||
@@ -34,7 +37,7 @@ final class DiscordSetupViewModel {
|
|||||||
allowBots = env["DISCORD_ALLOW_BOTS"] ?? "none"
|
allowBots = env["DISCORD_ALLOW_BOTS"] ?? "none"
|
||||||
replyToMode = env["DISCORD_REPLY_TO_MODE"] ?? "first"
|
replyToMode = env["DISCORD_REPLY_TO_MODE"] ?? "first"
|
||||||
|
|
||||||
let cfg = HermesFileService().loadConfig().discord
|
let cfg = HermesFileService(context: context).loadConfig().discord
|
||||||
requireMention = cfg.requireMention
|
requireMention = cfg.requireMention
|
||||||
freeResponseChannels = cfg.freeResponseChannels
|
freeResponseChannels = cfg.freeResponseChannels
|
||||||
autoThread = cfg.autoThread
|
autoThread = cfg.autoThread
|
||||||
@@ -56,7 +59,7 @@ final class DiscordSetupViewModel {
|
|||||||
"discord.auto_thread": PlatformSetupHelpers.envBool(autoThread),
|
"discord.auto_thread": PlatformSetupHelpers.envBool(autoThread),
|
||||||
"discord.reactions": PlatformSetupHelpers.envBool(reactions)
|
"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
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||||
self?.message = nil
|
self?.message = nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ import Foundation
|
|||||||
@Observable
|
@Observable
|
||||||
@MainActor
|
@MainActor
|
||||||
final class EmailSetupViewModel {
|
final class EmailSetupViewModel {
|
||||||
|
let context: ServerContext
|
||||||
|
|
||||||
|
init(context: ServerContext = .local) {
|
||||||
|
self.context = context
|
||||||
|
}
|
||||||
|
|
||||||
var address: String = ""
|
var address: String = ""
|
||||||
var password: String = ""
|
var password: String = ""
|
||||||
var imapHost: String = ""
|
var imapHost: String = ""
|
||||||
@@ -34,7 +40,7 @@ final class EmailSetupViewModel {
|
|||||||
]
|
]
|
||||||
|
|
||||||
func load() {
|
func load() {
|
||||||
let env = HermesEnvService().load()
|
let env = HermesEnvService(context: context).load()
|
||||||
address = env["EMAIL_ADDRESS"] ?? ""
|
address = env["EMAIL_ADDRESS"] ?? ""
|
||||||
password = env["EMAIL_PASSWORD"] ?? ""
|
password = env["EMAIL_PASSWORD"] ?? ""
|
||||||
imapHost = env["EMAIL_IMAP_HOST"] ?? ""
|
imapHost = env["EMAIL_IMAP_HOST"] ?? ""
|
||||||
@@ -46,7 +52,7 @@ final class EmailSetupViewModel {
|
|||||||
homeAddress = env["EMAIL_HOME_ADDRESS"] ?? ""
|
homeAddress = env["EMAIL_HOME_ADDRESS"] ?? ""
|
||||||
allowAllUsers = PlatformSetupHelpers.parseEnvBool(env["EMAIL_ALLOW_ALL_USERS"])
|
allowAllUsers = PlatformSetupHelpers.parseEnvBool(env["EMAIL_ALLOW_ALL_USERS"])
|
||||||
// skip_attachments lives in config.yaml.
|
// 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)
|
let parsed = HermesFileService.parseNestedYAML(yaml)
|
||||||
skipAttachments = (parsed.values["platforms.email.skip_attachments"] ?? "false") == "true"
|
skipAttachments = (parsed.values["platforms.email.skip_attachments"] ?? "false") == "true"
|
||||||
}
|
}
|
||||||
@@ -72,7 +78,7 @@ final class EmailSetupViewModel {
|
|||||||
let configKV: [String: String] = [
|
let configKV: [String: String] = [
|
||||||
"platforms.email.skip_attachments": PlatformSetupHelpers.envBool(skipAttachments)
|
"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
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||||
self?.message = nil
|
self?.message = nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import Foundation
|
|||||||
@Observable
|
@Observable
|
||||||
@MainActor
|
@MainActor
|
||||||
final class FeishuSetupViewModel {
|
final class FeishuSetupViewModel {
|
||||||
|
let context: ServerContext
|
||||||
|
init(context: ServerContext = .local) { self.context = context }
|
||||||
|
|
||||||
var appID: String = ""
|
var appID: String = ""
|
||||||
var appSecret: String = ""
|
var appSecret: String = ""
|
||||||
var domain: String = "lark"
|
var domain: String = "lark"
|
||||||
@@ -19,7 +22,7 @@ final class FeishuSetupViewModel {
|
|||||||
let connectionOptions = ["websocket", "webhook"]
|
let connectionOptions = ["websocket", "webhook"]
|
||||||
|
|
||||||
func load() {
|
func load() {
|
||||||
let env = HermesEnvService().load()
|
let env = HermesEnvService(context: context).load()
|
||||||
appID = env["FEISHU_APP_ID"] ?? ""
|
appID = env["FEISHU_APP_ID"] ?? ""
|
||||||
appSecret = env["FEISHU_APP_SECRET"] ?? ""
|
appSecret = env["FEISHU_APP_SECRET"] ?? ""
|
||||||
domain = env["FEISHU_DOMAIN"] ?? "lark"
|
domain = env["FEISHU_DOMAIN"] ?? "lark"
|
||||||
@@ -39,7 +42,7 @@ final class FeishuSetupViewModel {
|
|||||||
"FEISHU_ALLOWED_USERS": allowedUsers,
|
"FEISHU_ALLOWED_USERS": allowedUsers,
|
||||||
"FEISHU_CONNECTION_MODE": connectionMode == "websocket" ? "" : connectionMode
|
"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
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||||
self?.message = nil
|
self?.message = nil
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-4
@@ -15,6 +15,12 @@ import AppKit
|
|||||||
@Observable
|
@Observable
|
||||||
@MainActor
|
@MainActor
|
||||||
final class HomeAssistantSetupViewModel {
|
final class HomeAssistantSetupViewModel {
|
||||||
|
let context: ServerContext
|
||||||
|
|
||||||
|
init(context: ServerContext = .local) {
|
||||||
|
self.context = context
|
||||||
|
}
|
||||||
|
|
||||||
var url: String = "http://homeassistant.local:8123"
|
var url: String = "http://homeassistant.local:8123"
|
||||||
var token: String = ""
|
var token: String = ""
|
||||||
|
|
||||||
@@ -30,11 +36,11 @@ final class HomeAssistantSetupViewModel {
|
|||||||
var message: String?
|
var message: String?
|
||||||
|
|
||||||
func load() {
|
func load() {
|
||||||
let env = HermesEnvService().load()
|
let env = HermesEnvService(context: context).load()
|
||||||
url = env["HASS_URL"] ?? "http://homeassistant.local:8123"
|
url = env["HASS_URL"] ?? "http://homeassistant.local:8123"
|
||||||
token = env["HASS_TOKEN"] ?? ""
|
token = env["HASS_TOKEN"] ?? ""
|
||||||
|
|
||||||
let cfg = HermesFileService().loadConfig().homeAssistant
|
let cfg = HermesFileService(context: context).loadConfig().homeAssistant
|
||||||
watchAll = cfg.watchAll
|
watchAll = cfg.watchAll
|
||||||
cooldownSeconds = cfg.cooldownSeconds
|
cooldownSeconds = cfg.cooldownSeconds
|
||||||
watchDomains = cfg.watchDomains
|
watchDomains = cfg.watchDomains
|
||||||
@@ -53,7 +59,7 @@ final class HomeAssistantSetupViewModel {
|
|||||||
"platforms.homeassistant.extra.watch_all": PlatformSetupHelpers.envBool(watchAll),
|
"platforms.homeassistant.extra.watch_all": PlatformSetupHelpers.envBool(watchAll),
|
||||||
"platforms.homeassistant.extra.cooldown_seconds": String(cooldownSeconds)
|
"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
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||||
self?.message = nil
|
self?.message = nil
|
||||||
}
|
}
|
||||||
@@ -62,6 +68,6 @@ final class HomeAssistantSetupViewModel {
|
|||||||
/// Open config.yaml in the user's default editor so they can manually edit
|
/// Open config.yaml in the user's default editor so they can manually edit
|
||||||
/// the list-valued filter fields.
|
/// the list-valued filter fields.
|
||||||
func openConfigForLists() {
|
func openConfigForLists() {
|
||||||
NSWorkspace.shared.open(URL(fileURLWithPath: HermesPaths.configYAML))
|
context.openInLocalEditor(context.paths.configYAML)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-2
@@ -6,6 +6,9 @@ import Foundation
|
|||||||
@Observable
|
@Observable
|
||||||
@MainActor
|
@MainActor
|
||||||
final class IMessageSetupViewModel {
|
final class IMessageSetupViewModel {
|
||||||
|
let context: ServerContext
|
||||||
|
init(context: ServerContext = .local) { self.context = context }
|
||||||
|
|
||||||
var serverURL: String = ""
|
var serverURL: String = ""
|
||||||
var password: String = ""
|
var password: String = ""
|
||||||
var webhookHost: String = "127.0.0.1"
|
var webhookHost: String = "127.0.0.1"
|
||||||
@@ -19,7 +22,7 @@ final class IMessageSetupViewModel {
|
|||||||
var message: String?
|
var message: String?
|
||||||
|
|
||||||
func load() {
|
func load() {
|
||||||
let env = HermesEnvService().load()
|
let env = HermesEnvService(context: context).load()
|
||||||
serverURL = env["BLUEBUBBLES_SERVER_URL"] ?? ""
|
serverURL = env["BLUEBUBBLES_SERVER_URL"] ?? ""
|
||||||
password = env["BLUEBUBBLES_PASSWORD"] ?? ""
|
password = env["BLUEBUBBLES_PASSWORD"] ?? ""
|
||||||
webhookHost = env["BLUEBUBBLES_WEBHOOK_HOST"] ?? "127.0.0.1"
|
webhookHost = env["BLUEBUBBLES_WEBHOOK_HOST"] ?? "127.0.0.1"
|
||||||
@@ -43,7 +46,7 @@ final class IMessageSetupViewModel {
|
|||||||
"BLUEBUBBLES_ALLOW_ALL_USERS": allowAllUsers ? "true" : "",
|
"BLUEBUBBLES_ALLOW_ALL_USERS": allowAllUsers ? "true" : "",
|
||||||
"BLUEBUBBLES_SEND_READ_RECEIPTS": sendReadReceipts ? "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
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||||
self?.message = nil
|
self?.message = nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import Foundation
|
|||||||
@Observable
|
@Observable
|
||||||
@MainActor
|
@MainActor
|
||||||
final class MatrixSetupViewModel {
|
final class MatrixSetupViewModel {
|
||||||
|
let context: ServerContext
|
||||||
|
init(context: ServerContext = .local) { self.context = context }
|
||||||
|
|
||||||
var homeserver: String = ""
|
var homeserver: String = ""
|
||||||
var accessToken: String = "" // preferred
|
var accessToken: String = "" // preferred
|
||||||
var userID: String = ""
|
var userID: String = ""
|
||||||
@@ -22,7 +25,7 @@ final class MatrixSetupViewModel {
|
|||||||
var message: String?
|
var message: String?
|
||||||
|
|
||||||
func load() {
|
func load() {
|
||||||
let env = HermesEnvService().load()
|
let env = HermesEnvService(context: context).load()
|
||||||
homeserver = env["MATRIX_HOMESERVER"] ?? ""
|
homeserver = env["MATRIX_HOMESERVER"] ?? ""
|
||||||
accessToken = env["MATRIX_ACCESS_TOKEN"] ?? ""
|
accessToken = env["MATRIX_ACCESS_TOKEN"] ?? ""
|
||||||
userID = env["MATRIX_USER_ID"] ?? ""
|
userID = env["MATRIX_USER_ID"] ?? ""
|
||||||
@@ -32,7 +35,7 @@ final class MatrixSetupViewModel {
|
|||||||
recoveryKey = env["MATRIX_RECOVERY_KEY"] ?? ""
|
recoveryKey = env["MATRIX_RECOVERY_KEY"] ?? ""
|
||||||
encryption = PlatformSetupHelpers.parseEnvBool(env["MATRIX_ENCRYPTION"])
|
encryption = PlatformSetupHelpers.parseEnvBool(env["MATRIX_ENCRYPTION"])
|
||||||
|
|
||||||
let cfg = HermesFileService().loadConfig().matrix
|
let cfg = HermesFileService(context: context).loadConfig().matrix
|
||||||
requireMention = cfg.requireMention
|
requireMention = cfg.requireMention
|
||||||
autoThread = cfg.autoThread
|
autoThread = cfg.autoThread
|
||||||
dmMentionThreads = cfg.dmMentionThreads
|
dmMentionThreads = cfg.dmMentionThreads
|
||||||
@@ -54,7 +57,7 @@ final class MatrixSetupViewModel {
|
|||||||
"matrix.auto_thread": PlatformSetupHelpers.envBool(autoThread),
|
"matrix.auto_thread": PlatformSetupHelpers.envBool(autoThread),
|
||||||
"matrix.dm_mention_threads": PlatformSetupHelpers.envBool(dmMentionThreads)
|
"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
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||||
self?.message = nil
|
self?.message = nil
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-3
@@ -5,6 +5,9 @@ import Foundation
|
|||||||
@Observable
|
@Observable
|
||||||
@MainActor
|
@MainActor
|
||||||
final class MattermostSetupViewModel {
|
final class MattermostSetupViewModel {
|
||||||
|
let context: ServerContext
|
||||||
|
init(context: ServerContext = .local) { self.context = context }
|
||||||
|
|
||||||
var serverURL: String = ""
|
var serverURL: String = ""
|
||||||
var token: String = ""
|
var token: String = ""
|
||||||
var allowedUsers: String = ""
|
var allowedUsers: String = ""
|
||||||
@@ -18,7 +21,7 @@ final class MattermostSetupViewModel {
|
|||||||
let replyModeOptions = ["off", "thread"]
|
let replyModeOptions = ["off", "thread"]
|
||||||
|
|
||||||
func load() {
|
func load() {
|
||||||
let env = HermesEnvService().load()
|
let env = HermesEnvService(context: context).load()
|
||||||
serverURL = env["MATTERMOST_URL"] ?? ""
|
serverURL = env["MATTERMOST_URL"] ?? ""
|
||||||
token = env["MATTERMOST_TOKEN"] ?? ""
|
token = env["MATTERMOST_TOKEN"] ?? ""
|
||||||
allowedUsers = env["MATTERMOST_ALLOWED_USERS"] ?? ""
|
allowedUsers = env["MATTERMOST_ALLOWED_USERS"] ?? ""
|
||||||
@@ -26,7 +29,7 @@ final class MattermostSetupViewModel {
|
|||||||
freeResponseChannels = env["MATTERMOST_FREE_RESPONSE_CHANNELS"] ?? ""
|
freeResponseChannels = env["MATTERMOST_FREE_RESPONSE_CHANNELS"] ?? ""
|
||||||
replyMode = env["MATTERMOST_REPLY_MODE"] ?? "off"
|
replyMode = env["MATTERMOST_REPLY_MODE"] ?? "off"
|
||||||
|
|
||||||
let cfg = HermesFileService().loadConfig().mattermost
|
let cfg = HermesFileService(context: context).loadConfig().mattermost
|
||||||
requireMention = cfg.requireMention
|
requireMention = cfg.requireMention
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +43,7 @@ final class MattermostSetupViewModel {
|
|||||||
"MATTERMOST_REPLY_MODE": replyMode == "off" ? "" : replyMode,
|
"MATTERMOST_REPLY_MODE": replyMode == "off" ? "" : replyMode,
|
||||||
"MATTERMOST_REQUIRE_MENTION": PlatformSetupHelpers.envBool(requireMention)
|
"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
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||||
self?.message = nil
|
self?.message = nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,10 +15,11 @@ import os
|
|||||||
@MainActor
|
@MainActor
|
||||||
enum PlatformSetupHelpers {
|
enum PlatformSetupHelpers {
|
||||||
static let logger = Logger(subsystem: "com.scarf", category: "PlatformSetup")
|
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()`
|
/// - `envPairs`: values to write into `.env`. Empty strings trigger `unset()`
|
||||||
/// (commenting the line out) rather than storing a literal empty value.
|
/// (commenting the line out) rather than storing a literal empty value.
|
||||||
/// - `configKV`: scalar config.yaml paths to set via `hermes config set`.
|
/// - `configKV`: scalar config.yaml paths to set via `hermes config set`.
|
||||||
@@ -27,7 +28,9 @@ enum PlatformSetupHelpers {
|
|||||||
///
|
///
|
||||||
/// Returns a user-facing summary message.
|
/// Returns a user-facing summary message.
|
||||||
@discardableResult
|
@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.
|
// Split env pairs into set vs. unset.
|
||||||
var toSet: [String: String] = [:]
|
var toSet: [String: String] = [:]
|
||||||
var toUnset: [String] = []
|
var toUnset: [String] = []
|
||||||
@@ -49,7 +52,7 @@ enum PlatformSetupHelpers {
|
|||||||
|
|
||||||
var configFailures: [String] = []
|
var configFailures: [String] = []
|
||||||
for (key, value) in configKV {
|
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 {
|
if result.exitCode != 0 {
|
||||||
configFailures.append(key)
|
configFailures.append(key)
|
||||||
logger.warning("hermes config set \(key) failed: \(result.output)")
|
logger.warning("hermes config set \(key) failed: \(result.output)")
|
||||||
@@ -61,11 +64,11 @@ enum PlatformSetupHelpers {
|
|||||||
return "Saved — restart gateway to apply"
|
return "Saved — restart gateway to apply"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Synchronous hermes CLI invocation. Use only for fast commands like
|
/// Synchronous hermes CLI invocation against the given server. Use only
|
||||||
/// `config set`; longer commands should use `HermesFileService.runHermesCLI`
|
/// for fast commands like `config set`; longer commands should use
|
||||||
/// from a `Task.detached`.
|
/// `HermesFileService.runHermesCLI` from a `Task.detached`.
|
||||||
static func runHermesCLI(args: [String], timeout: TimeInterval = 15) -> (exitCode: Int32, output: String) {
|
static func runHermesCLI(context: ServerContext, args: [String], timeout: TimeInterval = 15) -> (exitCode: Int32, output: String) {
|
||||||
HermesFileService().runHermesCLI(args: args, timeout: timeout)
|
HermesFileService(context: context).runHermesCLI(args: args, timeout: timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ask the user's default browser to open a URL (typically a hermes doc page
|
/// Ask the user's default browser to open a URL (typically a hermes doc page
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import Foundation
|
|||||||
@Observable
|
@Observable
|
||||||
@MainActor
|
@MainActor
|
||||||
final class SignalSetupViewModel {
|
final class SignalSetupViewModel {
|
||||||
|
let context: ServerContext
|
||||||
|
init(context: ServerContext = .local) { self.context = context }
|
||||||
|
|
||||||
var httpURL: String = "http://127.0.0.1:8080"
|
var httpURL: String = "http://127.0.0.1:8080"
|
||||||
var account: String = "" // E.164 phone, e.g. +15551234567
|
var account: String = "" // E.164 phone, e.g. +15551234567
|
||||||
var allowedUsers: String = ""
|
var allowedUsers: String = ""
|
||||||
@@ -29,7 +32,7 @@ final class SignalSetupViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func load() {
|
func load() {
|
||||||
let env = HermesEnvService().load()
|
let env = HermesEnvService(context: context).load()
|
||||||
httpURL = env["SIGNAL_HTTP_URL"] ?? "http://127.0.0.1:8080"
|
httpURL = env["SIGNAL_HTTP_URL"] ?? "http://127.0.0.1:8080"
|
||||||
account = env["SIGNAL_ACCOUNT"] ?? ""
|
account = env["SIGNAL_ACCOUNT"] ?? ""
|
||||||
allowedUsers = env["SIGNAL_ALLOWED_USERS"] ?? ""
|
allowedUsers = env["SIGNAL_ALLOWED_USERS"] ?? ""
|
||||||
@@ -60,7 +63,7 @@ final class SignalSetupViewModel {
|
|||||||
"SIGNAL_HOME_CHANNEL": homeChannel,
|
"SIGNAL_HOME_CHANNEL": homeChannel,
|
||||||
"SIGNAL_ALLOW_ALL_USERS": allowAllUsers ? "true" : ""
|
"SIGNAL_ALLOW_ALL_USERS": allowAllUsers ? "true" : ""
|
||||||
]
|
]
|
||||||
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: [:])
|
message = PlatformSetupHelpers.saveForm(context: context, envPairs: envPairs, configKV: [:])
|
||||||
clearMessageAfterDelay()
|
clearMessageAfterDelay()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import Foundation
|
|||||||
@Observable
|
@Observable
|
||||||
@MainActor
|
@MainActor
|
||||||
final class SlackSetupViewModel {
|
final class SlackSetupViewModel {
|
||||||
|
let context: ServerContext
|
||||||
|
init(context: ServerContext = .local) { self.context = context }
|
||||||
|
|
||||||
var botToken: String = "" // xoxb-...
|
var botToken: String = "" // xoxb-...
|
||||||
var appToken: String = "" // xapp-...
|
var appToken: String = "" // xapp-...
|
||||||
var allowedUsers: String = ""
|
var allowedUsers: String = ""
|
||||||
@@ -21,14 +24,14 @@ final class SlackSetupViewModel {
|
|||||||
let replyToModeOptions = ["off", "first", "all"]
|
let replyToModeOptions = ["off", "first", "all"]
|
||||||
|
|
||||||
func load() {
|
func load() {
|
||||||
let env = HermesEnvService().load()
|
let env = HermesEnvService(context: context).load()
|
||||||
botToken = env["SLACK_BOT_TOKEN"] ?? ""
|
botToken = env["SLACK_BOT_TOKEN"] ?? ""
|
||||||
appToken = env["SLACK_APP_TOKEN"] ?? ""
|
appToken = env["SLACK_APP_TOKEN"] ?? ""
|
||||||
allowedUsers = env["SLACK_ALLOWED_USERS"] ?? ""
|
allowedUsers = env["SLACK_ALLOWED_USERS"] ?? ""
|
||||||
homeChannel = env["SLACK_HOME_CHANNEL"] ?? ""
|
homeChannel = env["SLACK_HOME_CHANNEL"] ?? ""
|
||||||
homeChannelName = env["SLACK_HOME_CHANNEL_NAME"] ?? ""
|
homeChannelName = env["SLACK_HOME_CHANNEL_NAME"] ?? ""
|
||||||
|
|
||||||
let cfg = HermesFileService().loadConfig().slack
|
let cfg = HermesFileService(context: context).loadConfig().slack
|
||||||
replyToMode = cfg.replyToMode
|
replyToMode = cfg.replyToMode
|
||||||
requireMention = cfg.requireMention
|
requireMention = cfg.requireMention
|
||||||
replyInThread = cfg.replyInThread
|
replyInThread = cfg.replyInThread
|
||||||
@@ -50,7 +53,7 @@ final class SlackSetupViewModel {
|
|||||||
"platforms.slack.extra.reply_in_thread": PlatformSetupHelpers.envBool(replyInThread),
|
"platforms.slack.extra.reply_in_thread": PlatformSetupHelpers.envBool(replyInThread),
|
||||||
"platforms.slack.extra.reply_broadcast": PlatformSetupHelpers.envBool(replyBroadcast)
|
"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
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||||
self?.message = nil
|
self?.message = nil
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-3
@@ -8,6 +8,9 @@ import os
|
|||||||
@Observable
|
@Observable
|
||||||
@MainActor
|
@MainActor
|
||||||
final class TelegramSetupViewModel {
|
final class TelegramSetupViewModel {
|
||||||
|
let context: ServerContext
|
||||||
|
init(context: ServerContext = .local) { self.context = context }
|
||||||
|
|
||||||
// Required
|
// Required
|
||||||
var botToken: String = ""
|
var botToken: String = ""
|
||||||
var allowedUsers: String = ""
|
var allowedUsers: String = ""
|
||||||
@@ -23,7 +26,7 @@ final class TelegramSetupViewModel {
|
|||||||
var message: String?
|
var message: String?
|
||||||
|
|
||||||
func load() {
|
func load() {
|
||||||
let env = HermesEnvService().load()
|
let env = HermesEnvService(context: context).load()
|
||||||
botToken = env["TELEGRAM_BOT_TOKEN"] ?? ""
|
botToken = env["TELEGRAM_BOT_TOKEN"] ?? ""
|
||||||
allowedUsers = env["TELEGRAM_ALLOWED_USERS"] ?? ""
|
allowedUsers = env["TELEGRAM_ALLOWED_USERS"] ?? ""
|
||||||
homeChannel = env["TELEGRAM_HOME_CHANNEL"] ?? ""
|
homeChannel = env["TELEGRAM_HOME_CHANNEL"] ?? ""
|
||||||
@@ -31,7 +34,7 @@ final class TelegramSetupViewModel {
|
|||||||
webhookPort = env["TELEGRAM_WEBHOOK_PORT"] ?? ""
|
webhookPort = env["TELEGRAM_WEBHOOK_PORT"] ?? ""
|
||||||
webhookSecret = env["TELEGRAM_WEBHOOK_SECRET"] ?? ""
|
webhookSecret = env["TELEGRAM_WEBHOOK_SECRET"] ?? ""
|
||||||
|
|
||||||
let cfg = HermesFileService().loadConfig()
|
let cfg = HermesFileService(context: context).loadConfig()
|
||||||
requireMention = cfg.telegram.requireMention
|
requireMention = cfg.telegram.requireMention
|
||||||
reactions = cfg.telegram.reactions
|
reactions = cfg.telegram.reactions
|
||||||
}
|
}
|
||||||
@@ -49,7 +52,7 @@ final class TelegramSetupViewModel {
|
|||||||
"telegram.require_mention": PlatformSetupHelpers.envBool(requireMention),
|
"telegram.require_mention": PlatformSetupHelpers.envBool(requireMention),
|
||||||
"telegram.reactions": PlatformSetupHelpers.envBool(reactions)
|
"telegram.reactions": PlatformSetupHelpers.envBool(reactions)
|
||||||
]
|
]
|
||||||
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: configKV)
|
message = PlatformSetupHelpers.saveForm(context: context, envPairs: envPairs, configKV: configKV)
|
||||||
clearMessageAfterDelay()
|
clearMessageAfterDelay()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import Foundation
|
|||||||
@Observable
|
@Observable
|
||||||
@MainActor
|
@MainActor
|
||||||
final class WebhookSetupViewModel {
|
final class WebhookSetupViewModel {
|
||||||
|
let context: ServerContext
|
||||||
|
init(context: ServerContext = .local) { self.context = context }
|
||||||
|
|
||||||
var enabled: Bool = false
|
var enabled: Bool = false
|
||||||
var port: String = "8644"
|
var port: String = "8644"
|
||||||
var secret: String = ""
|
var secret: String = ""
|
||||||
@@ -14,7 +17,7 @@ final class WebhookSetupViewModel {
|
|||||||
var message: String?
|
var message: String?
|
||||||
|
|
||||||
func load() {
|
func load() {
|
||||||
let env = HermesEnvService().load()
|
let env = HermesEnvService(context: context).load()
|
||||||
enabled = PlatformSetupHelpers.parseEnvBool(env["WEBHOOK_ENABLED"])
|
enabled = PlatformSetupHelpers.parseEnvBool(env["WEBHOOK_ENABLED"])
|
||||||
port = env["WEBHOOK_PORT"] ?? "8644"
|
port = env["WEBHOOK_PORT"] ?? "8644"
|
||||||
secret = env["WEBHOOK_SECRET"] ?? ""
|
secret = env["WEBHOOK_SECRET"] ?? ""
|
||||||
@@ -26,7 +29,7 @@ final class WebhookSetupViewModel {
|
|||||||
"WEBHOOK_PORT": port,
|
"WEBHOOK_PORT": port,
|
||||||
"WEBHOOK_SECRET": secret
|
"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
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||||
self?.message = nil
|
self?.message = nil
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-4
@@ -8,6 +8,12 @@ import Foundation
|
|||||||
@Observable
|
@Observable
|
||||||
@MainActor
|
@MainActor
|
||||||
final class WhatsAppSetupViewModel {
|
final class WhatsAppSetupViewModel {
|
||||||
|
let context: ServerContext
|
||||||
|
|
||||||
|
init(context: ServerContext = .local) {
|
||||||
|
self.context = context
|
||||||
|
}
|
||||||
|
|
||||||
var enabled: Bool = false
|
var enabled: Bool = false
|
||||||
var mode: String = "bot" // "bot" | "self-chat"
|
var mode: String = "bot" // "bot" | "self-chat"
|
||||||
var allowedUsers: String = "" // Comma-separated phone numbers (no +)
|
var allowedUsers: String = "" // Comma-separated phone numbers (no +)
|
||||||
@@ -27,7 +33,7 @@ final class WhatsAppSetupViewModel {
|
|||||||
var pairingInProgress: Bool = false
|
var pairingInProgress: Bool = false
|
||||||
|
|
||||||
func load() {
|
func load() {
|
||||||
let env = HermesEnvService().load()
|
let env = HermesEnvService(context: context).load()
|
||||||
enabled = PlatformSetupHelpers.parseEnvBool(env["WHATSAPP_ENABLED"])
|
enabled = PlatformSetupHelpers.parseEnvBool(env["WHATSAPP_ENABLED"])
|
||||||
mode = env["WHATSAPP_MODE"] ?? "bot"
|
mode = env["WHATSAPP_MODE"] ?? "bot"
|
||||||
allowedUsers = env["WHATSAPP_ALLOWED_USERS"] ?? ""
|
allowedUsers = env["WHATSAPP_ALLOWED_USERS"] ?? ""
|
||||||
@@ -40,7 +46,7 @@ final class WhatsAppSetupViewModel {
|
|||||||
allowedUsers = ""
|
allowedUsers = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
let cfg = HermesFileService().loadConfig().whatsapp
|
let cfg = HermesFileService(context: context).loadConfig().whatsapp
|
||||||
unauthorizedDMBehavior = cfg.unauthorizedDMBehavior
|
unauthorizedDMBehavior = cfg.unauthorizedDMBehavior
|
||||||
replyPrefix = cfg.replyPrefix
|
replyPrefix = cfg.replyPrefix
|
||||||
}
|
}
|
||||||
@@ -57,7 +63,7 @@ final class WhatsAppSetupViewModel {
|
|||||||
"whatsapp.unauthorized_dm_behavior": unauthorizedDMBehavior,
|
"whatsapp.unauthorized_dm_behavior": unauthorizedDMBehavior,
|
||||||
"whatsapp.reply_prefix": replyPrefix
|
"whatsapp.reply_prefix": replyPrefix
|
||||||
]
|
]
|
||||||
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: configKV)
|
message = PlatformSetupHelpers.saveForm(context: context, envPairs: envPairs, configKV: configKV)
|
||||||
clearMessageAfterDelay()
|
clearMessageAfterDelay()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +78,7 @@ final class WhatsAppSetupViewModel {
|
|||||||
self?.clearMessageAfterDelay()
|
self?.clearMessageAfterDelay()
|
||||||
}
|
}
|
||||||
terminalController.start(
|
terminalController.start(
|
||||||
executable: HermesPaths.hermesBinary,
|
executable: context.paths.hermesBinary,
|
||||||
arguments: ["whatsapp"]
|
arguments: ["whatsapp"]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,14 @@ import os
|
|||||||
@MainActor
|
@MainActor
|
||||||
final class PlatformsViewModel {
|
final class PlatformsViewModel {
|
||||||
private let logger = Logger(subsystem: "com.scarf", category: "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 gatewayState: GatewayState?
|
||||||
var selected: HermesToolPlatform = KnownPlatforms.cli
|
var selected: HermesToolPlatform = KnownPlatforms.cli
|
||||||
@@ -41,14 +48,14 @@ final class PlatformsViewModel {
|
|||||||
/// until the first YAML edit.
|
/// until the first YAML edit.
|
||||||
func hasConfigBlock(for platform: HermesToolPlatform) -> Bool {
|
func hasConfigBlock(for platform: HermesToolPlatform) -> Bool {
|
||||||
if platform.name == "cli" { return true }
|
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") {
|
for line in yaml.components(separatedBy: "\n") where !line.hasPrefix(" ") && !line.hasPrefix("\t") {
|
||||||
if line.trimmingCharacters(in: .whitespaces) == "\(platform.name):" { return true }
|
if line.trimmingCharacters(in: .whitespaces) == "\(platform.name):" { return true }
|
||||||
}
|
}
|
||||||
// Env-var fallback: any identifying env var for this platform counts
|
// Env-var fallback: any identifying env var for this platform counts
|
||||||
// as "configured". Uses the shared `identifyingEnvVar(for:)` mapping.
|
// as "configured". Uses the shared `identifyingEnvVar(for:)` mapping.
|
||||||
if let key = Self.identifyingEnvVar(for: platform.name) {
|
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 }
|
if let value = env[key], !value.isEmpty { return true }
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct DiscordSetupView: View {
|
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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct EmailSetupView: View {
|
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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct FeishuSetupView: View {
|
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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct HomeAssistantSetupView: View {
|
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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct IMessageSetupView: View {
|
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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct MatrixSetupView: View {
|
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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct MattermostSetupView: View {
|
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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct SignalSetupView: View {
|
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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct SlackSetupView: View {
|
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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct TelegramSetupView: View {
|
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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct WebhookSetupView: View {
|
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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct WhatsAppSetupView: View {
|
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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct PlatformsView: View {
|
struct PlatformsView: View {
|
||||||
@State private var viewModel = PlatformsViewModel()
|
@State private var viewModel: PlatformsViewModel
|
||||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||||
|
|
||||||
|
init(context: ServerContext) {
|
||||||
|
_viewModel = State(initialValue: PlatformsViewModel(context: context))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// HSplitView (not nested NavigationSplitView) because ContentView already
|
// HSplitView (not nested NavigationSplitView) because ContentView already
|
||||||
// hosts the outer NavigationSplitView — nesting them breaks layout on macOS.
|
// hosts the outer NavigationSplitView — nesting them breaks layout on macOS.
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -114,23 +119,25 @@ struct PlatformsView: View {
|
|||||||
|
|
||||||
/// Dispatch to the right per-platform setup view based on the selection.
|
/// 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
|
/// 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
|
@ViewBuilder
|
||||||
private var platformForm: some View {
|
private var platformForm: some View {
|
||||||
|
let ctx = viewModel.context
|
||||||
switch viewModel.selected.name {
|
switch viewModel.selected.name {
|
||||||
case "cli": cliPanel
|
case "cli": cliPanel
|
||||||
case "telegram": TelegramSetupView()
|
case "telegram": TelegramSetupView(context: ctx)
|
||||||
case "discord": DiscordSetupView()
|
case "discord": DiscordSetupView(context: ctx)
|
||||||
case "slack": SlackSetupView()
|
case "slack": SlackSetupView(context: ctx)
|
||||||
case "whatsapp": WhatsAppSetupView()
|
case "whatsapp": WhatsAppSetupView(context: ctx)
|
||||||
case "signal": SignalSetupView()
|
case "signal": SignalSetupView(context: ctx)
|
||||||
case "email": EmailSetupView()
|
case "email": EmailSetupView(context: ctx)
|
||||||
case "matrix": MatrixSetupView()
|
case "matrix": MatrixSetupView(context: ctx)
|
||||||
case "mattermost": MattermostSetupView()
|
case "mattermost": MattermostSetupView(context: ctx)
|
||||||
case "feishu": FeishuSetupView()
|
case "feishu": FeishuSetupView(context: ctx)
|
||||||
case "imessage": IMessageSetupView()
|
case "imessage": IMessageSetupView(context: ctx)
|
||||||
case "homeassistant": HomeAssistantSetupView()
|
case "homeassistant": HomeAssistantSetupView(context: ctx)
|
||||||
case "webhook": WebhookSetupView()
|
case "webhook": WebhookSetupView(context: ctx)
|
||||||
default:
|
default:
|
||||||
SettingsSection(title: viewModel.selected.displayName, icon: KnownPlatforms.icon(for: viewModel.selected.name)) {
|
SettingsSection(title: viewModel.selected.displayName, icon: KnownPlatforms.icon(for: viewModel.selected.name)) {
|
||||||
ReadOnlyRow(label: "Setup", value: "No setup form for this platform yet.")
|
ReadOnlyRow(label: "Setup", value: "No setup form for this platform yet.")
|
||||||
|
|||||||
@@ -13,59 +13,67 @@ struct HermesPlugin: Identifiable, Sendable, Equatable {
|
|||||||
@Observable
|
@Observable
|
||||||
final class PluginsViewModel {
|
final class PluginsViewModel {
|
||||||
private let logger = Logger(subsystem: "com.scarf", category: "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 plugins: [HermesPlugin] = []
|
||||||
var isLoading = false
|
var isLoading = false
|
||||||
var message: String?
|
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
|
/// Source of truth is the `~/.hermes/plugins/` directory. Each plugin is a
|
||||||
/// subdirectory — we read its `plugin.json` (if present) for source/version
|
/// subdirectory — we read its `plugin.json` (if present) for source/version
|
||||||
/// metadata. Parsing `hermes plugins list` box-drawn output is fragile.
|
/// metadata. Parsing `hermes plugins list` box-drawn output is fragile.
|
||||||
func load() {
|
func load() {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
defer { isLoading = false }
|
let dir = pluginsDir
|
||||||
|
let ctx = context
|
||||||
let fm = FileManager.default
|
// listDirectory + (stat × N entries) + (readManifest × N) is a lot
|
||||||
guard let entries = try? fm.contentsOfDirectory(atPath: pluginsDir) else {
|
// of sync transport ops on remote — definitively a beach ball if
|
||||||
plugins = []
|
// run on main. Detach the whole walk.
|
||||||
return
|
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.
|
/// Static form of readManifest used by the detached load task. The
|
||||||
private static func readManifest(path: String) -> (source: String, version: String) {
|
/// instance form delegates to this so both call paths share logic.
|
||||||
let fm = FileManager.default
|
fileprivate static func readManifestStatic(path: String, context: ServerContext) -> (source: String, version: String) {
|
||||||
let jsonPath = path + "/plugin.json"
|
let jsonPath = path + "/plugin.json"
|
||||||
if fm.fileExists(atPath: jsonPath),
|
if let data = context.readData(jsonPath),
|
||||||
let data = try? Data(contentsOf: URL(fileURLWithPath: jsonPath)),
|
|
||||||
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
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 source = (obj["source"] as? String) ?? (obj["repository"] as? String) ?? (obj["url"] as? String) ?? ""
|
||||||
let version = (obj["version"] as? String) ?? ""
|
let version = (obj["version"] as? String) ?? ""
|
||||||
return (source, version)
|
return (source, version)
|
||||||
}
|
}
|
||||||
let yamlPath = path + "/plugin.yaml"
|
let yamlPath = path + "/plugin.yaml"
|
||||||
if fm.fileExists(atPath: yamlPath),
|
if let yaml = context.readText(yamlPath) {
|
||||||
let yaml = try? String(contentsOfFile: yamlPath, encoding: .utf8) {
|
|
||||||
let parsed = HermesFileService.parseNestedYAML(yaml)
|
let parsed = HermesFileService.parseNestedYAML(yaml)
|
||||||
let source = HermesFileService.stripYAMLQuotes(parsed.values["source"] ?? parsed.values["repository"] ?? parsed.values["url"] ?? "")
|
let source = HermesFileService.stripYAMLQuotes(parsed.values["source"] ?? parsed.values["repository"] ?? parsed.values["url"] ?? "")
|
||||||
let version = HermesFileService.stripYAMLQuotes(parsed.values["version"] ?? "")
|
let version = HermesFileService.stripYAMLQuotes(parsed.values["version"] ?? "")
|
||||||
@@ -74,6 +82,10 @@ final class PluginsViewModel {
|
|||||||
return ("", "")
|
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) {
|
func install(_ identifier: String) {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
message = "Installing \(identifier)…"
|
message = "Installing \(identifier)…"
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct PluginsView: View {
|
struct PluginsView: View {
|
||||||
@State private var viewModel = PluginsViewModel()
|
@State private var viewModel: PluginsViewModel
|
||||||
@State private var installIdentifier = ""
|
@State private var installIdentifier = ""
|
||||||
@State private var showInstall = false
|
@State private var showInstall = false
|
||||||
@State private var pendingRemove: HermesPlugin?
|
@State private var pendingRemove: HermesPlugin?
|
||||||
|
|
||||||
|
init(context: ServerContext) {
|
||||||
|
_viewModel = State(initialValue: PluginsViewModel(context: context))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
header
|
header
|
||||||
@@ -19,6 +24,11 @@ struct PluginsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Plugins")
|
.navigationTitle("Plugins")
|
||||||
|
.loadingOverlay(
|
||||||
|
viewModel.isLoading,
|
||||||
|
label: "Loading plugins…",
|
||||||
|
isEmpty: viewModel.plugins.isEmpty
|
||||||
|
)
|
||||||
.onAppear { viewModel.load() }
|
.onAppear { viewModel.load() }
|
||||||
.sheet(isPresented: $showInstall) { installSheet }
|
.sheet(isPresented: $showInstall) { installSheet }
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
|
|||||||
@@ -11,7 +11,14 @@ struct HermesProfile: Identifiable, Sendable, Equatable {
|
|||||||
@Observable
|
@Observable
|
||||||
final class ProfilesViewModel {
|
final class ProfilesViewModel {
|
||||||
private let logger = Logger(subsystem: "com.scarf", category: "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 profiles: [HermesProfile] = []
|
||||||
var activeName: String = "default"
|
var activeName: String = "default"
|
||||||
|
|||||||
@@ -3,13 +3,18 @@ import AppKit
|
|||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
struct ProfilesView: View {
|
struct ProfilesView: View {
|
||||||
@State private var viewModel = ProfilesViewModel()
|
@State private var viewModel: ProfilesViewModel
|
||||||
@State private var selected: HermesProfile?
|
@State private var selected: HermesProfile?
|
||||||
@State private var showCreate = false
|
@State private var showCreate = false
|
||||||
@State private var createName = ""
|
@State private var createName = ""
|
||||||
@State private var createCloneConfig = true
|
@State private var createCloneConfig = true
|
||||||
@State private var createCloneAll = false
|
@State private var createCloneAll = false
|
||||||
@State private var showRename = false
|
@State private var showRename = false
|
||||||
|
|
||||||
|
init(context: ServerContext) {
|
||||||
|
_viewModel = State(initialValue: ProfilesViewModel(context: context))
|
||||||
|
}
|
||||||
|
|
||||||
@State private var renameTarget: HermesProfile?
|
@State private var renameTarget: HermesProfile?
|
||||||
@State private var renameNewName = ""
|
@State private var renameNewName = ""
|
||||||
@State private var pendingDelete: HermesProfile?
|
@State private var pendingDelete: HermesProfile?
|
||||||
|
|||||||
@@ -2,7 +2,14 @@ import Foundation
|
|||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
final class ProjectsViewModel {
|
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 projects: [ProjectEntry] = []
|
||||||
var selectedProject: ProjectEntry?
|
var selectedProject: ProjectEntry?
|
||||||
|
|||||||
@@ -6,10 +6,15 @@ private enum DashboardTab: String, CaseIterable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct ProjectsView: View {
|
struct ProjectsView: View {
|
||||||
@State private var viewModel = ProjectsViewModel()
|
@State private var viewModel: ProjectsViewModel
|
||||||
@Environment(AppCoordinator.self) private var coordinator
|
@Environment(AppCoordinator.self) private var coordinator
|
||||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||||
@State private var showingAddSheet = false
|
@State private var showingAddSheet = false
|
||||||
|
|
||||||
|
init(context: ServerContext) {
|
||||||
|
_viewModel = State(initialValue: ProjectsViewModel(context: context))
|
||||||
|
}
|
||||||
|
|
||||||
@State private var selectedTab: DashboardTab = .dashboard
|
@State private var selectedTab: DashboardTab = .dashboard
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -209,7 +214,10 @@ struct ProjectsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func openInFinder(_ path: String) {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,31 +13,39 @@ struct HermesQuickCommand: Identifiable, Sendable, Equatable {
|
|||||||
@Observable
|
@Observable
|
||||||
final class QuickCommandsViewModel {
|
final class QuickCommandsViewModel {
|
||||||
private let logger = Logger(subsystem: "com.scarf", category: "QuickCommandsViewModel")
|
private let logger = Logger(subsystem: "com.scarf", category: "QuickCommandsViewModel")
|
||||||
|
let context: ServerContext
|
||||||
|
|
||||||
|
init(context: ServerContext = .local) {
|
||||||
|
self.context = context
|
||||||
|
}
|
||||||
|
|
||||||
var commands: [HermesQuickCommand] = []
|
var commands: [HermesQuickCommand] = []
|
||||||
var message: String?
|
var message: String?
|
||||||
|
|
||||||
func load() {
|
func load() {
|
||||||
guard let yaml = try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8) else {
|
let ctx = context
|
||||||
commands = []
|
Task.detached { [weak self] in
|
||||||
return
|
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.<name>.type` + `quick_commands.<name>.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.
|
/// 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
|
/// Removal requires editing config.yaml directly — `hermes config set` has no
|
||||||
/// unset for nested keys. Open the file in the editor for manual removal.
|
/// unset for nested keys. Open the file in the editor for manual removal.
|
||||||
func openConfigForRemoval() {
|
func openConfigForRemoval() {
|
||||||
NSWorkspace.shared.open(URL(fileURLWithPath: HermesPaths.configYAML))
|
context.openInLocalEditor(context.paths.configYAML)
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
||||||
let process = Process()
|
context.runHermes(arguments)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct QuickCommandsView: View {
|
struct QuickCommandsView: View {
|
||||||
@State private var viewModel = QuickCommandsViewModel()
|
@State private var viewModel: QuickCommandsViewModel
|
||||||
@State private var showAddSheet = false
|
@State private var showAddSheet = false
|
||||||
@State private var editTarget: HermesQuickCommand?
|
@State private var editTarget: HermesQuickCommand?
|
||||||
|
|
||||||
|
init(context: ServerContext) {
|
||||||
|
_viewModel = State(initialValue: QuickCommandsViewModel(context: context))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Void, Never>?
|
||||||
|
|
||||||
|
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<Void, TransportError>
|
||||||
|
// 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: "'\\''") + "'"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Content: View>: 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 "<your-host>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,14 @@ struct SessionStoreStats {
|
|||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
final class SessionsViewModel {
|
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 sessions: [HermesSession] = []
|
||||||
var sessionPreviews: [String: String] = [:]
|
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 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
|
let fileSize: String
|
||||||
if let attrs = try? FileManager.default.attributesOfItem(atPath: dbPath),
|
if let stat = context.makeTransport().stat(dbPath) {
|
||||||
let size = attrs[.size] as? Int {
|
let size = Double(stat.size)
|
||||||
if Double(size) >= FileSizeUnit.megabyte {
|
if size >= FileSizeUnit.megabyte {
|
||||||
fileSize = String(format: "%.1f MB", Double(size) / FileSizeUnit.megabyte)
|
fileSize = String(format: "%.1f MB", size / FileSizeUnit.megabyte)
|
||||||
} else {
|
} else {
|
||||||
fileSize = String(format: "%.0f KB", Double(size) / FileSizeUnit.kilobyte)
|
fileSize = String(format: "%.0f KB", size / FileSizeUnit.kilobyte)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fileSize = "unknown"
|
fileSize = "unknown"
|
||||||
@@ -171,20 +178,6 @@ final class SessionsViewModel {
|
|||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
||||||
let process = Process()
|
context.runHermes(arguments)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct SessionsView: View {
|
struct SessionsView: View {
|
||||||
@State private var viewModel = SessionsViewModel()
|
@State private var viewModel: SessionsViewModel
|
||||||
@Environment(AppCoordinator.self) private var coordinator
|
@Environment(AppCoordinator.self) private var coordinator
|
||||||
|
|
||||||
|
init(context: ServerContext) {
|
||||||
|
_viewModel = State(initialValue: SessionsViewModel(context: context))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
if let stats = viewModel.storeStats {
|
if let stats = viewModel.storeStats {
|
||||||
|
|||||||
@@ -6,7 +6,14 @@ import os
|
|||||||
@Observable
|
@Observable
|
||||||
final class SettingsViewModel {
|
final class SettingsViewModel {
|
||||||
private let logger = Logger(subsystem: "com.scarf", category: "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 config = HermesConfig.empty
|
||||||
var gatewayState: GatewayState?
|
var gatewayState: GatewayState?
|
||||||
@@ -20,18 +27,35 @@ final class SettingsViewModel {
|
|||||||
var sttProviders = ["local", "groq", "openai", "mistral"]
|
var sttProviders = ["local", "groq", "openai", "mistral"]
|
||||||
var memoryProviders = ["", "honcho", "openviking", "mem0", "hindsight", "holographic", "retaindb", "byterover", "supermemory"]
|
var memoryProviders = ["", "honcho", "openviking", "mem0", "hindsight", "holographic", "retaindb", "byterover", "supermemory"]
|
||||||
var saveMessage: String?
|
var saveMessage: String?
|
||||||
|
var isLoading = false
|
||||||
|
|
||||||
func load() {
|
func load() {
|
||||||
config = fileService.loadConfig()
|
isLoading = true
|
||||||
gatewayState = fileService.loadGatewayState()
|
let svc = fileService
|
||||||
hermesRunning = fileService.isHermesRunning()
|
let ctx = context
|
||||||
do {
|
let displayName = ctx.displayName
|
||||||
rawConfigYAML = try String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)
|
let log = logger
|
||||||
} catch {
|
// Heavy load: config + gateway state + isRunning + raw YAML are
|
||||||
logger.error("Failed to read config.yaml: \(error.localizedDescription)")
|
// four sync transport calls. On remote each is a blocking ssh
|
||||||
rawConfigYAML = ""
|
// 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 <key> <value>` and reload
|
/// Set a scalar config value via `hermes config set <key> <value>` and reload
|
||||||
@@ -278,7 +302,10 @@ final class SettingsViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func openConfigInEditor() {
|
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] {
|
private func parsePersonalities() -> [String] {
|
||||||
@@ -308,21 +335,6 @@ final class SettingsViewModel {
|
|||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
||||||
let process = Process()
|
context.runHermes(arguments)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ struct ModelPickerSheet: View {
|
|||||||
@State private var customModelID: String = ""
|
@State private var customModelID: String = ""
|
||||||
@State private var customProviderID: 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 {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
|||||||
@@ -5,9 +5,14 @@ import SwiftUI
|
|||||||
/// extracted view file under `Tabs/` — per CLAUDE.md guidance, splitting avoids
|
/// extracted view file under `Tabs/` — per CLAUDE.md guidance, splitting avoids
|
||||||
/// SwiftUI type-checker timeouts and keeps each section testable in isolation.
|
/// SwiftUI type-checker timeouts and keeps each section testable in isolation.
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@State private var viewModel = SettingsViewModel()
|
@State private var viewModel: SettingsViewModel
|
||||||
@State private var selectedTab: SettingsTab = .general
|
@State private var selectedTab: SettingsTab = .general
|
||||||
|
|
||||||
|
init(context: ServerContext) {
|
||||||
|
_viewModel = State(initialValue: SettingsViewModel(context: context))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
enum SettingsTab: String, CaseIterable, Identifiable {
|
enum SettingsTab: String, CaseIterable, Identifiable {
|
||||||
case general = "General"
|
case general = "General"
|
||||||
case display = "Display"
|
case display = "Display"
|
||||||
@@ -58,6 +63,11 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Settings")
|
.navigationTitle("Settings")
|
||||||
|
.loadingOverlay(
|
||||||
|
viewModel.isLoading,
|
||||||
|
label: "Loading settings…",
|
||||||
|
isEmpty: viewModel.rawConfigYAML.isEmpty
|
||||||
|
)
|
||||||
.onAppear { viewModel.load() }
|
.onAppear { viewModel.load() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -142,15 +142,16 @@ struct AdvancedTab: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var pathsSection: some View {
|
private var pathsSection: some View {
|
||||||
SettingsSection(title: "Paths", icon: "folder") {
|
let paths = viewModel.context.paths
|
||||||
PathRow(label: "Hermes Home", path: HermesPaths.home)
|
return SettingsSection(title: "Paths", icon: "folder") {
|
||||||
PathRow(label: "State DB", path: HermesPaths.stateDB)
|
PathRow(label: "Hermes Home", path: paths.home)
|
||||||
PathRow(label: "Config", path: HermesPaths.configYAML)
|
PathRow(label: "State DB", path: paths.stateDB)
|
||||||
PathRow(label: "Memory", path: HermesPaths.memoriesDir)
|
PathRow(label: "Config", path: paths.configYAML)
|
||||||
PathRow(label: "Sessions", path: HermesPaths.sessionsDir)
|
PathRow(label: "Memory", path: paths.memoriesDir)
|
||||||
PathRow(label: "Skills", path: HermesPaths.skillsDir)
|
PathRow(label: "Sessions", path: paths.sessionsDir)
|
||||||
PathRow(label: "Agent Log", path: HermesPaths.agentLog)
|
PathRow(label: "Skills", path: paths.skillsDir)
|
||||||
PathRow(label: "Error Log", path: HermesPaths.errorsLog)
|
PathRow(label: "Agent Log", path: paths.agentLog)
|
||||||
|
PathRow(label: "Error Log", path: paths.errorsLog)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,14 @@ struct HermesSkillUpdate: Identifiable, Sendable, Equatable {
|
|||||||
@Observable
|
@Observable
|
||||||
final class SkillsViewModel {
|
final class SkillsViewModel {
|
||||||
private let logger = Logger(subsystem: "com.scarf", category: "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)
|
// MARK: - Installed skills (existing behavior)
|
||||||
var categories: [HermesSkillCategory] = []
|
var categories: [HermesSkillCategory] = []
|
||||||
@@ -61,8 +68,16 @@ final class SkillsViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func load() {
|
func load() {
|
||||||
categories = fileService.loadSkills()
|
let svc = fileService
|
||||||
currentConfig = fileService.loadConfig()
|
// 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) {
|
func selectSkill(_ skill: HermesSkill) {
|
||||||
@@ -80,7 +95,7 @@ final class SkillsViewModel {
|
|||||||
|
|
||||||
private func computeMissingConfig(for skill: HermesSkill) -> [String] {
|
private func computeMissingConfig(for skill: HermesSkill) -> [String] {
|
||||||
guard !skill.requiredConfig.isEmpty else { return [] }
|
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
|
||||||
}
|
}
|
||||||
return skill.requiredConfig.filter { key in
|
return skill.requiredConfig.filter { key in
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct SkillsView: View {
|
struct SkillsView: View {
|
||||||
@State private var viewModel = SkillsViewModel()
|
@State private var viewModel: SkillsViewModel
|
||||||
@State private var currentTab: Tab = .installed
|
@State private var currentTab: Tab = .installed
|
||||||
|
|
||||||
|
init(context: ServerContext) {
|
||||||
|
_viewModel = State(initialValue: SkillsViewModel(context: context))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
enum Tab: String, CaseIterable, Identifiable {
|
enum Tab: String, CaseIterable, Identifiable {
|
||||||
case installed = "Installed"
|
case installed = "Installed"
|
||||||
case hub = "Browse Hub"
|
case hub = "Browse Hub"
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ enum PlatformConnectivity: Sendable, Equatable {
|
|||||||
@Observable
|
@Observable
|
||||||
final class ToolsViewModel {
|
final class ToolsViewModel {
|
||||||
private let logger = Logger(subsystem: "com.scarf", category: "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 selectedPlatform: HermesToolPlatform = KnownPlatforms.cli
|
||||||
var toolsets: [HermesToolset] = []
|
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.
|
/// - `~/.hermes/config.yaml` top-level keys (`discord:`, `whatsapp:`, etc.) tell us which have been configured.
|
||||||
@MainActor
|
@MainActor
|
||||||
private func loadPlatforms() async {
|
private func loadPlatforms() async {
|
||||||
|
let ctx = context
|
||||||
let yaml: String = await Task.detached {
|
let yaml: String = await Task.detached {
|
||||||
(try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)) ?? ""
|
ctx.readText(ctx.paths.configYAML) ?? ""
|
||||||
}.value
|
}.value
|
||||||
|
|
||||||
let gatewayState: GatewayState? = await Task.detached {
|
let gatewayState: GatewayState? = await Task.detached {
|
||||||
HermesFileService().loadGatewayState()
|
HermesFileService(context: ctx).loadGatewayState()
|
||||||
}.value
|
}.value
|
||||||
|
|
||||||
let configuredNames = Self.parseConfiguredPlatforms(yaml: yaml)
|
let configuredNames = Self.parseConfiguredPlatforms(yaml: yaml)
|
||||||
@@ -168,31 +174,7 @@ final class ToolsViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private nonisolated func runHermes(_ arguments: [String]) async -> (output: String, exitCode: Int32) {
|
private nonisolated func runHermes(_ arguments: [String]) async -> (output: String, exitCode: Int32) {
|
||||||
await Task.detached {
|
let ctx = context
|
||||||
let process = Process()
|
return await Task.detached { ctx.runHermes(arguments) }.value
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ToolsView: View {
|
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 {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user