iOS port M0b: extract Transport + ServerContext to ScarfCore

Second of four M0 sub-PRs. Moves the remaining cross-cutting
infrastructure — the ServerTransport protocol and its two implementations
(LocalTransport, SSHTransport), plus ServerContext and its helpers —
into ScarfCore so both Mac and (future) iOS targets share one codebase.

Files moved (5):
  - scarf/Core/Transport/ServerTransport.swift (+ FileStat, ProcessResult, WatchEvent)
  - scarf/Core/Transport/LocalTransport.swift
  - scarf/Core/Transport/SSHTransport.swift
  - scarf/Core/Transport/TransportErrors.swift
  - scarf/Core/Models/ServerContext.swift (+ SSHConfig, ServerKind, ServerID, UserHomeCache)

Split out of ServerContext.swift into a new Mac-target sibling file
scarf/Core/Models/ServerContext+Mac.swift:
  - runHermes(_:timeout:stdin:) — depends on HermesFileService
  - openInLocalEditor(_:) — depends on AppKit.NSWorkspace
These methods can't live in ScarfCore itself because ScarfCore must not
depend on main-target services or AppKit. iOS will provide a sibling
ServerContext+iOS.swift in M2+.

Removed: scarf/Core/Models/HermesPaths+Deprecated.swift.
  Zero callers in-tree; its only justification was that ServerContext
  used to be in the Mac target. With ServerContext in ScarfCore now,
  the deprecated forwarders are both unreachable AND dead code.

Breaking the ScarfCore → main-target circular dep in SSHTransport:
  The old SSHTransport.sshSubprocessEnvironment() called
  HermesFileService.enrichedEnvironment() to harvest SSH_AUTH_SOCK from
  the user's login shell. Replaced with a local #if os(macOS) helper
  SSHTransport.macLoginShellSSHAgent() that probes /bin/zsh for only
  the two SSH agent vars (no PATH/credentials — that's still in
  HermesFileService for ACP subprocess use). Behavior-identical on
  macOS; no-op on iOS/Linux.

Platform guards added in ScarfCore (runtime targets still macOS/iOS):
  - `#if canImport(os)` around os.Logger (definition + every call site,
    except the large Darwin-dependent ensureControlDir block).
  - `#if canImport(Darwin)` around LocalTransport.watchPaths (FSEvents)
    and SSHTransport.ensureControlDir (Darwin.stat/lstat). Linux gets
    a no-op empty stream and a best-effort FileManager.createDirectory
    fallback — neither is exercised at runtime on Linux, only compiled.
  - `#if canImport(SwiftUI)` around ServerContext's EnvironmentKey.
  - `#if canImport(AppKit)` inside the new ServerContext+Mac.swift
    extension.

Bug fixed: M0a's sed transform accidentally added `public` to protocol
requirements in ServerTransport.swift, e.g. `public nonisolated var
contextID: ServerID { get }`. Swift forbids access modifiers on
protocol requirements — stripped.

54 additional consumer files in the Mac target gained `import ScarfCore`.

Test coverage: 18 new tests in ScarfCoreTests/M0bTransportTests.swift.
Runs on Linux via
  docker run --rm -v $PWD/scarf/Packages/ScarfCore:/work -w /work swift:6.0 swift test
Total suite: 34 / 34 passing (M0a's 16 + M0b's 18).

Updated scarf/docs/IOS_PORT_PLAN.md progress log with the shipped M0b
state and the Platform-guard patterns future phases should reuse.

https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
This commit is contained in:
Claude
2026-04-22 22:06:36 +00:00
parent f6f31cabe4
commit 0fd2ceb9fc
68 changed files with 645 additions and 188 deletions
@@ -1,65 +1,90 @@
import Foundation import Foundation
import ScarfCore #if canImport(SwiftUI)
import SwiftUI import SwiftUI
import AppKit #endif
/// Stable identifier for a server entry in the user's registry. Backed by /// 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 /// `UUID` so it round-trips through `servers.json` and SwiftUI window-state
/// restoration without collisions. /// restoration without collisions.
typealias ServerID = UUID public typealias ServerID = UUID
/// Connection parameters for a remote Hermes installation reached over SSH. /// Connection parameters for a remote Hermes installation reached over SSH.
/// All fields are optional except `host` unset values defer to the user's /// All fields are optional except `host` unset values defer to the user's
/// `~/.ssh/config` and the OpenSSH defaults. /// `~/.ssh/config` and the OpenSSH defaults.
struct SSHConfig: Sendable, Hashable, Codable { public struct SSHConfig: Sendable, Hashable, Codable {
/// Hostname or `~/.ssh/config` alias. /// Hostname or `~/.ssh/config` alias.
var host: String public var host: String
/// Remote username. `nil` defer to `~/.ssh/config` or the local user. /// Remote username. `nil` defer to `~/.ssh/config` or the local user.
var user: String? public var user: String?
/// TCP port. `nil` 22 (or whatever `~/.ssh/config` says). /// TCP port. `nil` 22 (or whatever `~/.ssh/config` says).
var port: Int? public var port: Int?
/// Absolute path to a private key. `nil` defer to ssh-agent / /// Absolute path to a private key. `nil` defer to ssh-agent /
/// `~/.ssh/config` identity files. /// `~/.ssh/config` identity files.
var identityFile: String? public var identityFile: String?
/// Override for the remote `$HOME/.hermes` directory. `nil` uses /// Override for the remote `$HOME/.hermes` directory. `nil` uses
/// `HermesPathSet.defaultRemoteHome` (`~/.hermes`, shell-expanded on the /// `HermesPathSet.defaultRemoteHome` (`~/.hermes`, shell-expanded on the
/// remote side). /// remote side).
var remoteHome: String? public var remoteHome: String?
/// Resolved remote path to the `hermes` binary. Populated by /// Resolved remote path to the `hermes` binary. Populated by
/// `SSHTransport` after the first `command -v hermes` probe; cached here /// `SSHTransport` after the first `command -v hermes` probe; cached here
/// so subsequent calls skip the round trip. /// so subsequent calls skip the round trip.
var hermesBinaryHint: String? public var hermesBinaryHint: String?
public init(
host: String,
user: String? = nil,
port: Int? = nil,
identityFile: String? = nil,
remoteHome: String? = nil,
hermesBinaryHint: String? = nil
) {
self.host = host
self.user = user
self.port = port
self.identityFile = identityFile
self.remoteHome = remoteHome
self.hermesBinaryHint = hermesBinaryHint
}
} }
/// Distinguishes a local installation (the user's own `~/.hermes`) from a /// Distinguishes a local installation (the user's own `~/.hermes`) from a
/// remote one reached over SSH. Service behavior is identical in shape but /// remote one reached over SSH. Service behavior is identical in shape but
/// dispatches to different I/O primitives in Phase 2. /// dispatches to different I/O primitives in Phase 2.
enum ServerKind: Sendable, Hashable, Codable { public enum ServerKind: Sendable, Hashable, Codable {
case local case local
case ssh(SSHConfig) case ssh(SSHConfig)
} }
/// The per-server value that flows through `.environment` and gets handed to /// The per-server value that flows through `.environment` and gets handed to
/// every service and ViewModel in Phase 1. One `ServerContext` corresponds to /// every service and ViewModel. One `ServerContext` corresponds to one
/// one Hermes installation; multi-window scenes in Phase 3 will construct /// Hermes installation; multi-window scenes construct one per window.
/// one per window.
/// ///
/// **Why every member is `nonisolated`.** This file imports `AppKit` /// **Why every member is `nonisolated`.** Sibling extension methods in the
/// Mac app target (`ServerContext+Mac.swift`) touch `AppKit`
/// (`NSWorkspace.shared.open` in `openInLocalEditor`), which under Swift 6's /// (`NSWorkspace.shared.open` in `openInLocalEditor`), which under Swift 6's
/// upcoming default-isolation rules pulls the whole struct to `@MainActor`. /// default-isolation rules pulls the whole struct to `@MainActor`.
/// `ServerContext` is a plain `Sendable` value accessing `.local`, `.paths`, /// `ServerContext` is a plain `Sendable` value accessing `.local`, `.paths`,
/// `.isRemote`, or `makeTransport()` from a background actor must not trap /// `.isRemote`, or `makeTransport()` from a background actor must not trap
/// the caller into hopping MainActor. `nonisolated` on each member keeps /// the caller into hopping MainActor. `nonisolated` on each member keeps
/// them callable from any context; the one MainActor-dependent method /// them callable from any context.
/// (`openInLocalEditor`) lives in the extension below. public struct ServerContext: Sendable, Hashable, Identifiable {
struct ServerContext: Sendable, Hashable, Identifiable { public let id: ServerID
let id: ServerID public var displayName: String
var displayName: String public var kind: ServerKind
var kind: ServerKind
public init(
id: ServerID,
displayName: String,
kind: ServerKind
) {
self.id = id
self.displayName = displayName
self.kind = kind
}
/// Path layout for this server. Cheap all path components are computed /// Path layout for this server. Cheap all path components are computed
/// on demand from `home`, no I/O. /// on demand from `home`, no I/O.
nonisolated var paths: HermesPathSet { public nonisolated var paths: HermesPathSet {
switch kind { switch kind {
case .local: case .local:
return HermesPathSet( return HermesPathSet(
@@ -76,7 +101,7 @@ struct ServerContext: Sendable, Hashable, Identifiable {
} }
} }
nonisolated var isRemote: Bool { public nonisolated var isRemote: Bool {
if case .ssh = kind { return true } if case .ssh = kind { return true }
return false return false
} }
@@ -85,7 +110,7 @@ struct ServerContext: Sendable, Hashable, Identifiable {
/// a `LocalTransport`; SSH contexts get an `SSHTransport` configured /// a `LocalTransport`; SSH contexts get an `SSHTransport` configured
/// from `SSHConfig`. Each call returns a fresh value transports are /// from `SSHConfig`. Each call returns a fresh value transports are
/// cheap and stateless beyond disk caches. /// cheap and stateless beyond disk caches.
nonisolated func makeTransport() -> any ServerTransport { public nonisolated func makeTransport() -> any ServerTransport {
switch kind { switch kind {
case .local: case .local:
return LocalTransport(contextID: id) return LocalTransport(contextID: id)
@@ -104,7 +129,7 @@ struct ServerContext: Sendable, Hashable, Identifiable {
/// The default "this machine" context. Used everywhere in Phase 0/1 and /// The default "this machine" context. Used everywhere in Phase 0/1 and
/// remains the fallback when no remote server is selected. /// remains the fallback when no remote server is selected.
nonisolated static let local = ServerContext( public nonisolated static let local = ServerContext(
id: localID, id: localID,
displayName: "Local", displayName: "Local",
kind: .local kind: .local
@@ -155,13 +180,13 @@ extension ServerContext {
/// Local: `NSHomeDirectory()`. Remote: probed `$HOME` over SSH, cached. /// Local: `NSHomeDirectory()`. Remote: probed `$HOME` over SSH, cached.
/// Use this not `NSHomeDirectory()` whenever you're passing a `cwd` /// Use this not `NSHomeDirectory()` whenever you're passing a `cwd`
/// or user path to a process that runs on the target host. /// or user path to a process that runs on the target host.
func resolvedUserHome() async -> String { public func resolvedUserHome() async -> String {
await UserHomeCache.shared.resolve(for: self) await UserHomeCache.shared.resolve(for: self)
} }
/// Called when a server is removed from the registry, so the process-wide /// Called when a server is removed from the registry, so the process-wide
/// caches keyed by `ServerID` don't hold stale entries forever. /// caches keyed by `ServerID` don't hold stale entries forever.
static func invalidateCaches(for contextID: ServerID) async { public static func invalidateCaches(for contextID: ServerID) async {
await UserHomeCache.shared.invalidate(contextID: contextID) await UserHomeCache.shared.invalidate(contextID: contextID)
} }
} }
@@ -179,20 +204,20 @@ extension ServerContext {
extension ServerContext { extension ServerContext {
/// Read a UTF-8 text file. `nil` on any error (missing, transport down, /// Read a UTF-8 text file. `nil` on any error (missing, transport down,
/// invalid encoding). /// invalid encoding).
nonisolated func readText(_ path: String) -> String? { public nonisolated func readText(_ path: String) -> String? {
guard let data = try? makeTransport().readFile(path) else { return nil } guard let data = try? makeTransport().readFile(path) else { return nil }
return String(data: data, encoding: .utf8) return String(data: data, encoding: .utf8)
} }
/// Read raw bytes. `nil` on any error. /// Read raw bytes. `nil` on any error.
nonisolated func readData(_ path: String) -> Data? { public nonisolated func readData(_ path: String) -> Data? {
try? makeTransport().readFile(path) try? makeTransport().readFile(path)
} }
/// Atomic write. Returns `true` on success, `false` on any error /// Atomic write. Returns `true` on success, `false` on any error
/// (caller is expected to surface failures via UI when relevant). /// (caller is expected to surface failures via UI when relevant).
@discardableResult @discardableResult
nonisolated func writeText(_ path: String, content: String) -> Bool { public nonisolated func writeText(_ path: String, content: String) -> Bool {
guard let data = content.data(using: .utf8) else { return false } guard let data = content.data(using: .utf8) else { return false }
do { do {
try makeTransport().writeFile(path, data: data) try makeTransport().writeFile(path, data: data)
@@ -203,37 +228,14 @@ extension ServerContext {
} }
/// Existence check. Local: `FileManager`. Remote: `ssh test -e`. /// Existence check. Local: `FileManager`. Remote: `ssh test -e`.
nonisolated func fileExists(_ path: String) -> Bool { public nonisolated func fileExists(_ path: String) -> Bool {
makeTransport().fileExists(path) makeTransport().fileExists(path)
} }
/// File modification timestamp, or `nil` if the file doesn't exist. /// File modification timestamp, or `nil` if the file doesn't exist.
nonisolated func modificationDate(_ path: String) -> Date? { public nonisolated func modificationDate(_ path: String) -> Date? {
makeTransport().stat(path)?.mtime 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
nonisolated 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 // MARK: - SwiftUI environment plumbing
@@ -242,13 +244,19 @@ extension ServerContext {
/// requires an `@Observable` class) doesn't accept it directly. We expose it /// requires an `@Observable` class) doesn't accept it directly. We expose it
/// through a custom `EnvironmentKey` views read it with /// through a custom `EnvironmentKey` views read it with
/// `@Environment(\.serverContext) private var serverContext`. /// `@Environment(\.serverContext) private var serverContext`.
///
/// Guarded on `canImport(SwiftUI)` so ScarfCore still compiles on Linux
/// (swift-corelibs-foundation has no SwiftUI). Apple platforms the real
/// runtime targets compile the SwiftUI plumbing unchanged.
#if canImport(SwiftUI)
private struct ServerContextEnvironmentKey: EnvironmentKey { private struct ServerContextEnvironmentKey: EnvironmentKey {
static let defaultValue: ServerContext = .local static let defaultValue: ServerContext = .local
} }
extension EnvironmentValues { extension EnvironmentValues {
var serverContext: ServerContext { public var serverContext: ServerContext {
get { self[ServerContextEnvironmentKey.self] } get { self[ServerContextEnvironmentKey.self] }
set { self[ServerContextEnvironmentKey.self] = newValue } set { self[ServerContextEnvironmentKey.self] = newValue }
} }
} }
#endif
@@ -1,22 +1,33 @@
import Foundation import Foundation
#if canImport(os)
import os import os
#endif
/// `ServerTransport` over the local filesystem. Thin wrapper around /// `ServerTransport` over the local filesystem. Thin wrapper around
/// `FileManager`, `Process`, and `DispatchSourceFileSystemObject` the APIs /// `FileManager`, `Process`, and `DispatchSourceFileSystemObject` the APIs
/// services were already using before Phase 2. /// services were already using before Phase 2.
struct LocalTransport: ServerTransport { ///
/// **Platform note.** All Hermes code paths that actually construct a
/// `LocalTransport` run on macOS (iOS uses `SSHTransport` exclusively). The
/// `#if canImport(Darwin)` guards below exist only so ScarfCore still
/// compiles on Linux for `swift test` CI on Linux, file-watching is a
/// no-op stream and the subprocess spawn still works via Foundation's
/// `Process`.
public struct LocalTransport: ServerTransport {
#if canImport(os)
nonisolated private static let logger = Logger(subsystem: "com.scarf", category: "LocalTransport") nonisolated private static let logger = Logger(subsystem: "com.scarf", category: "LocalTransport")
#endif
let contextID: ServerID public let contextID: ServerID
let isRemote: Bool = false public let isRemote: Bool = false
nonisolated init(contextID: ServerID = ServerContext.local.id) { public nonisolated init(contextID: ServerID = ServerContext.local.id) {
self.contextID = contextID self.contextID = contextID
} }
// MARK: - Files // MARK: - Files
func readFile(_ path: String) throws -> Data { public func readFile(_ path: String) throws -> Data {
do { do {
return try Data(contentsOf: URL(fileURLWithPath: path)) return try Data(contentsOf: URL(fileURLWithPath: path))
} catch { } catch {
@@ -24,7 +35,7 @@ struct LocalTransport: ServerTransport {
} }
} }
func writeFile(_ path: String, data: Data) throws { public func writeFile(_ path: String, data: Data) throws {
let tmp = path + ".scarf.tmp" let tmp = path + ".scarf.tmp"
do { do {
try data.write(to: URL(fileURLWithPath: tmp)) try data.write(to: URL(fileURLWithPath: tmp))
@@ -54,11 +65,11 @@ struct LocalTransport: ServerTransport {
} }
} }
func fileExists(_ path: String) -> Bool { public func fileExists(_ path: String) -> Bool {
FileManager.default.fileExists(atPath: path) FileManager.default.fileExists(atPath: path)
} }
func stat(_ path: String) -> FileStat? { public func stat(_ path: String) -> FileStat? {
guard let attrs = try? FileManager.default.attributesOfItem(atPath: path) else { guard let attrs = try? FileManager.default.attributesOfItem(atPath: path) else {
return nil return nil
} }
@@ -68,7 +79,7 @@ struct LocalTransport: ServerTransport {
return FileStat(size: size, mtime: mtime, isDirectory: isDir) return FileStat(size: size, mtime: mtime, isDirectory: isDir)
} }
func listDirectory(_ path: String) throws -> [String] { public func listDirectory(_ path: String) throws -> [String] {
do { do {
return try FileManager.default.contentsOfDirectory(atPath: path) return try FileManager.default.contentsOfDirectory(atPath: path)
} catch { } catch {
@@ -76,7 +87,7 @@ struct LocalTransport: ServerTransport {
} }
} }
func createDirectory(_ path: String) throws { public func createDirectory(_ path: String) throws {
do { do {
try FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true) try FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true)
} catch { } catch {
@@ -84,7 +95,7 @@ struct LocalTransport: ServerTransport {
} }
} }
func removeFile(_ path: String) throws { public func removeFile(_ path: String) throws {
guard FileManager.default.fileExists(atPath: path) else { return } guard FileManager.default.fileExists(atPath: path) else { return }
do { do {
try FileManager.default.removeItem(atPath: path) try FileManager.default.removeItem(atPath: path)
@@ -95,7 +106,7 @@ struct LocalTransport: ServerTransport {
// MARK: - Processes // MARK: - Processes
func runProcess(executable: String, args: [String], stdin: Data?, timeout: TimeInterval?) throws -> ProcessResult { public func runProcess(executable: String, args: [String], stdin: Data?, timeout: TimeInterval?) throws -> ProcessResult {
let proc = Process() let proc = Process()
proc.executableURL = URL(fileURLWithPath: executable) proc.executableURL = URL(fileURLWithPath: executable)
proc.arguments = args proc.arguments = args
@@ -138,7 +149,7 @@ struct LocalTransport: ServerTransport {
return ProcessResult(exitCode: proc.terminationStatus, stdout: out, stderr: err) return ProcessResult(exitCode: proc.terminationStatus, stdout: out, stderr: err)
} }
func makeProcess(executable: String, args: [String]) -> Process { public func makeProcess(executable: String, args: [String]) -> Process {
let proc = Process() let proc = Process()
proc.executableURL = URL(fileURLWithPath: executable) proc.executableURL = URL(fileURLWithPath: executable)
proc.arguments = args proc.arguments = args
@@ -147,14 +158,15 @@ struct LocalTransport: ServerTransport {
// MARK: - SQLite // MARK: - SQLite
func snapshotSQLite(remotePath: String) throws -> URL { public func snapshotSQLite(remotePath: String) throws -> URL {
// Local case: no copy needed. Services open the path directly. // Local case: no copy needed. Services open the path directly.
URL(fileURLWithPath: remotePath) URL(fileURLWithPath: remotePath)
} }
// MARK: - Watching // MARK: - Watching
func watchPaths(_ paths: [String]) -> AsyncStream<WatchEvent> { #if canImport(Darwin)
public func watchPaths(_ paths: [String]) -> AsyncStream<WatchEvent> {
AsyncStream { continuation in AsyncStream { continuation in
// Build the source list immutably, then hand a value-typed copy // Build the source list immutably, then hand a value-typed copy
// to onTermination. Swift 6's concurrent-capture rule rejects a // to onTermination. Swift 6's concurrent-capture rule rejects a
@@ -178,6 +190,18 @@ struct LocalTransport: ServerTransport {
} }
} }
} }
#else
/// Linux stub: no FSEvents, no inotify wiring for now. Returns an empty
/// stream so callers that `for await _ in transport.watchPaths(...)`
/// simply never tick. Real Linux deployment would switch this to an
/// inotify implementation, but Linux is a CI-only target for us, not a
/// runtime target the stub suffices.
public func watchPaths(_ paths: [String]) -> AsyncStream<WatchEvent> {
AsyncStream { continuation in
continuation.finish()
}
}
#endif
// MARK: - Helpers // MARK: - Helpers
@@ -1,5 +1,7 @@
import Foundation import Foundation
#if canImport(os)
import os import os
#endif
/// `ServerTransport` that reaches a remote Hermes installation through the /// `ServerTransport` that reaches a remote Hermes installation through the
/// system `ssh`, `scp`, and `sftp` binaries. /// system `ssh`, `scp`, and `sftp` binaries.
@@ -15,16 +17,18 @@ import os
/// calls reuse the same TCP/crypto session at ~5ms each. We point the /// calls reuse the same TCP/crypto session at ~5ms each. We point the
/// control socket at `~/Library/Caches/scarf/ssh/%C` so multiple Scarf /// control socket at `~/Library/Caches/scarf/ssh/%C` so multiple Scarf
/// windows pointed at the same host share one session cleanly. /// windows pointed at the same host share one session cleanly.
struct SSHTransport: ServerTransport { public struct SSHTransport: ServerTransport {
#if canImport(os)
nonisolated private static let logger = Logger(subsystem: "com.scarf", category: "SSHTransport") nonisolated private static let logger = Logger(subsystem: "com.scarf", category: "SSHTransport")
#endif
let contextID: ServerID public let contextID: ServerID
let isRemote: Bool = true public let isRemote: Bool = true
let config: SSHConfig public let config: SSHConfig
let displayName: String public let displayName: String
nonisolated init(contextID: ServerID, config: SSHConfig, displayName: String) { public nonisolated init(contextID: ServerID, config: SSHConfig, displayName: String) {
self.contextID = contextID self.contextID = contextID
self.config = config self.config = config
self.displayName = displayName self.displayName = displayName
@@ -57,14 +61,14 @@ struct SSHTransport: ServerTransport {
/// Unix domain socket limit. The Caches path /// Unix domain socket limit. The Caches path
/// (~/Library/Caches/scarf/ssh/%C) can exceed this limit when the /// (~/Library/Caches/scarf/ssh/%C) can exceed this limit when the
/// username is long, causing ssh to exit 255. /// username is long, causing ssh to exit 255.
nonisolated static func controlDirPath() -> String { public nonisolated static func controlDirPath() -> String {
return "/tmp/scarf-ssh-\(getuid())" return "/tmp/scarf-ssh-\(getuid())"
} }
/// Snapshot cache directory for a given server. Stable per-ID so repeated /// Snapshot cache directory for a given server. Stable per-ID so repeated
/// connections to the same server share the cache, and so cleanup can /// connections to the same server share the cache, and so cleanup can
/// find it from the ID alone. /// find it from the ID alone.
nonisolated static func snapshotDirPath(for contextID: ServerID) -> String { public nonisolated static func snapshotDirPath(for contextID: ServerID) -> String {
let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?.path let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?.path
?? NSHomeDirectory() + "/Library/Caches" ?? NSHomeDirectory() + "/Library/Caches"
return base + "/scarf/snapshots/\(contextID.uuidString)" return base + "/scarf/snapshots/\(contextID.uuidString)"
@@ -72,7 +76,7 @@ struct SSHTransport: ServerTransport {
/// Root of the snapshot cache (all servers). Used by the app-launch sweep /// Root of the snapshot cache (all servers). Used by the app-launch sweep
/// that prunes dirs whose UUID no longer appears in the registry. /// that prunes dirs whose UUID no longer appears in the registry.
nonisolated static func snapshotRootPath() -> String { public nonisolated static func snapshotRootPath() -> String {
let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?.path let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?.path
?? NSHomeDirectory() + "/Library/Caches" ?? NSHomeDirectory() + "/Library/Caches"
return base + "/scarf/snapshots" return base + "/scarf/snapshots"
@@ -80,7 +84,7 @@ struct SSHTransport: ServerTransport {
/// Remove the snapshot directory for a server (no-op if absent). Called /// Remove the snapshot directory for a server (no-op if absent). Called
/// on `removeServer` and on app-launch for orphaned dirs. /// on `removeServer` and on app-launch for orphaned dirs.
static func pruneSnapshotCache(for contextID: ServerID) { public static func pruneSnapshotCache(for contextID: ServerID) {
let dir = snapshotDirPath(for: contextID) let dir = snapshotDirPath(for: contextID)
try? FileManager.default.removeItem(atPath: dir) try? FileManager.default.removeItem(atPath: dir)
} }
@@ -88,7 +92,7 @@ struct SSHTransport: ServerTransport {
/// Walk the snapshot root and delete any directory whose UUID isn't in /// Walk the snapshot root and delete any directory whose UUID isn't in
/// `keep`. Called once at app launch so snapshots from servers the user /// `keep`. Called once at app launch so snapshots from servers the user
/// removed while the app was closed don't linger. /// removed while the app was closed don't linger.
static func sweepOrphanSnapshots(keeping keep: Set<ServerID>) { public static func sweepOrphanSnapshots(keeping keep: Set<ServerID>) {
let root = snapshotRootPath() let root = snapshotRootPath()
guard let entries = try? FileManager.default.contentsOfDirectory(atPath: root) else { return } guard let entries = try? FileManager.default.contentsOfDirectory(atPath: root) else { return }
for name in entries { for name in entries {
@@ -107,7 +111,7 @@ struct SSHTransport: ServerTransport {
/// Wiping these on launch keeps `/tmp/scarf-ssh-<uid>/` from accumulating /// Wiping these on launch keeps `/tmp/scarf-ssh-<uid>/` from accumulating
/// indefinitely until reboot, while leaving any concurrent Scarf /// indefinitely until reboot, while leaving any concurrent Scarf
/// instance's live sockets (always <600s old) untouched. /// instance's live sockets (always <600s old) untouched.
static func sweepStaleControlSockets(staleAfter: TimeInterval = 1800) { public static func sweepStaleControlSockets(staleAfter: TimeInterval = 1800) {
let root = controlDirPath() let root = controlDirPath()
guard let entries = try? FileManager.default.contentsOfDirectory(atPath: root) else { return } guard let entries = try? FileManager.default.contentsOfDirectory(atPath: root) else { return }
let cutoff = Date().addingTimeInterval(-staleAfter) let cutoff = Date().addingTimeInterval(-staleAfter)
@@ -127,7 +131,7 @@ struct SSHTransport: ServerTransport {
/// master is currently running, `ssh -O exit` exits non-zero we ignore /// master is currently running, `ssh -O exit` exits non-zero we ignore
/// the exit code because the desired end state (no master) is reached /// the exit code because the desired end state (no master) is reached
/// either way. /// either way.
func closeControlMaster() { public func closeControlMaster() {
ensureControlDir() ensureControlDir()
let args = sshArgs(extra: ["-O", "exit", hostSpec]) let args = sshArgs(extra: ["-O", "exit", hostSpec])
_ = try? runLocal(executable: sshBinary, args: args, stdin: nil, timeout: 10) _ = try? runLocal(executable: sshBinary, args: args, stdin: nil, timeout: 10)
@@ -177,6 +181,7 @@ struct SSHTransport: ServerTransport {
/// follow symlinks) and `lstat` to verify ownership when the entry /// follow symlinks) and `lstat` to verify ownership when the entry
/// already exists. /// already exists.
nonisolated private func ensureControlDir() { nonisolated private func ensureControlDir() {
#if canImport(Darwin)
let path = controlDir let path = controlDir
let mkResult = path.withCString { mkdir($0, 0o700) } let mkResult = path.withCString { mkdir($0, 0o700) }
@@ -206,6 +211,12 @@ struct SSHTransport: ServerTransport {
Self.logger.warning("ControlDir \(path, privacy: .public) had mode \(String(st.st_mode & 0o777, radix: 8), privacy: .public), repairing to 700") Self.logger.warning("ControlDir \(path, privacy: .public) had mode \(String(st.st_mode & 0o777, radix: 8), privacy: .public), repairing to 700")
_ = path.withCString { chmod($0, 0o700) } _ = path.withCString { chmod($0, 0o700) }
} }
#else
// Linux (CI-only) stub: SSH isn't exercised at runtime on Linux, so
// we don't need a real ControlMaster setup. A best-effort mkdir is
// enough for any tests that poke at `controlDir`.
try? FileManager.default.createDirectory(atPath: controlDir, withIntermediateDirectories: true)
#endif
} }
/// Shell-quote a single argument for remote execution. The remote shell /// Shell-quote a single argument for remote execution. The remote shell
@@ -262,7 +273,7 @@ struct SSHTransport: ServerTransport {
// MARK: - Files // MARK: - Files
func readFile(_ path: String) throws -> Data { public func readFile(_ path: String) throws -> Data {
// `cat` is the simplest portable "give me file bytes" command; we // `cat` is the simplest portable "give me file bytes" command; we
// don't need scp's progress machinery for typical config/memory // don't need scp's progress machinery for typical config/memory
// files (<1 MB each). // files (<1 MB each).
@@ -280,7 +291,7 @@ struct SSHTransport: ServerTransport {
return result.stdout return result.stdout
} }
func writeFile(_ path: String, data: Data) throws { public func writeFile(_ path: String, data: Data) throws {
// Atomic pattern: // Atomic pattern:
// 1. scp to `<path>.scarf.tmp` on the remote // 1. scp to `<path>.scarf.tmp` on the remote
// 2. ssh `mv <tmp> <path>` atomic on POSIX within the same FS // 2. ssh `mv <tmp> <path>` atomic on POSIX within the same FS
@@ -329,14 +340,14 @@ struct SSHTransport: ServerTransport {
} }
} }
func fileExists(_ path: String) -> Bool { public func fileExists(_ path: String) -> Bool {
guard let result = try? runRemoteShell("test -e \(Self.remotePathArg(path))") else { guard let result = try? runRemoteShell("test -e \(Self.remotePathArg(path))") else {
return false return false
} }
return result.exitCode == 0 return result.exitCode == 0
} }
func stat(_ path: String) -> FileStat? { public func stat(_ path: String) -> FileStat? {
// macOS and Linux `stat` differ in flags. `stat -f` is macOS's BSD // 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 // form; `stat -c` is GNU/Linux. We try the GNU form first (typical
// remote target) and fall back to BSD. The format strings use // remote target) and fall back to BSD. The format strings use
@@ -366,7 +377,7 @@ struct SSHTransport: ServerTransport {
return FileStat(size: size, mtime: Date(timeIntervalSince1970: mtimeSecs), isDirectory: isDir) return FileStat(size: size, mtime: Date(timeIntervalSince1970: mtimeSecs), isDirectory: isDir)
} }
func listDirectory(_ path: String) throws -> [String] { public func listDirectory(_ path: String) throws -> [String] {
// `ls -A` lists all entries (incl. dotfiles) except `.`/`..`, one per // `ls -A` lists all entries (incl. dotfiles) except `.`/`..`, one per
// line. Sort order matches local FileManager.contentsOfDirectory. // line. Sort order matches local FileManager.contentsOfDirectory.
let result = try runRemoteShell("ls -A \(Self.remotePathArg(path))") let result = try runRemoteShell("ls -A \(Self.remotePathArg(path))")
@@ -381,14 +392,14 @@ struct SSHTransport: ServerTransport {
.map(String.init) .map(String.init)
} }
func createDirectory(_ path: String) throws { public func createDirectory(_ path: String) throws {
let result = try runRemoteShell("mkdir -p \(Self.remotePathArg(path))") let result = try runRemoteShell("mkdir -p \(Self.remotePathArg(path))")
if result.exitCode != 0 { if result.exitCode != 0 {
throw TransportError.classifySSHFailure(host: config.host, exitCode: result.exitCode, stderr: result.stderrString) throw TransportError.classifySSHFailure(host: config.host, exitCode: result.exitCode, stderr: result.stderrString)
} }
} }
func removeFile(_ path: String) throws { public func removeFile(_ path: String) throws {
let result = try runRemoteShell("rm -f \(Self.remotePathArg(path))") let result = try runRemoteShell("rm -f \(Self.remotePathArg(path))")
if result.exitCode != 0 { if result.exitCode != 0 {
throw TransportError.classifySSHFailure(host: config.host, exitCode: result.exitCode, stderr: result.stderrString) throw TransportError.classifySSHFailure(host: config.host, exitCode: result.exitCode, stderr: result.stderrString)
@@ -397,7 +408,7 @@ struct SSHTransport: ServerTransport {
// MARK: - Processes // MARK: - Processes
func runProcess(executable: String, args: [String], stdin: Data?, timeout: TimeInterval?) throws -> ProcessResult { public func runProcess(executable: String, args: [String], stdin: Data?, timeout: TimeInterval?) throws -> ProcessResult {
// Wrap in `sh -c '<exe> <arg> <arg>'` with `~/`-rewritten paths so // Wrap in `sh -c '<exe> <arg> <arg>'` with `~/`-rewritten paths so
// home-relative args expand on the remote. The executable might be // home-relative args expand on the remote. The executable might be
// `~/.local/bin/hermes` or just `hermes`; either survives. // `~/.local/bin/hermes` or just `hermes`; either survives.
@@ -410,7 +421,7 @@ struct SSHTransport: ServerTransport {
return try runLocal(executable: sshBinary, args: sshArgv, stdin: stdin, timeout: timeout) return try runLocal(executable: sshBinary, args: sshArgv, stdin: stdin, timeout: timeout)
} }
func makeProcess(executable: String, args: [String]) -> Process { public func makeProcess(executable: String, args: [String]) -> Process {
ensureControlDir() ensureControlDir()
// `-T` disables pty allocation critical for binary-clean stdin/stdout // `-T` disables pty allocation critical for binary-clean stdin/stdout
// (ACP JSON-RPC, log tail bytes). Same sh -c wrapping as runProcess // (ACP JSON-RPC, log tail bytes). Same sh -c wrapping as runProcess
@@ -433,20 +444,76 @@ struct SSHTransport: ServerTransport {
/// SSH_AUTH_SOCK / SSH_AGENT_PID harvested from the user's login shell. /// SSH_AUTH_SOCK / SSH_AGENT_PID harvested from the user's login shell.
/// Without this, GUI-launched Scarf can't reach 1Password / Secretive / /// Without this, GUI-launched Scarf can't reach 1Password / Secretive /
/// `ssh-add`'d keys that the user's terminal sees fine. /// `ssh-add`'d keys that the user's terminal sees fine.
///
/// **macOS-only enrichment.** On iOS there's no user login shell SSH
/// agent is provided by the app itself (Citadel) in M4, not by a
/// `ssh-add`'d key loaded via `.zshrc`. On Linux CI there's no SSH
/// invocation actually happening, just compilation checks. Both cases
/// fall back to `ProcessInfo.processInfo.environment` verbatim.
nonisolated private static func sshSubprocessEnvironment() -> [String: String] { nonisolated private static func sshSubprocessEnvironment() -> [String: String] {
var env = ProcessInfo.processInfo.environment var env = ProcessInfo.processInfo.environment
let shellEnv = HermesFileService.enrichedEnvironment() #if os(macOS)
let shellEnv = Self.macLoginShellSSHAgent()
for key in ["SSH_AUTH_SOCK", "SSH_AGENT_PID"] { for key in ["SSH_AUTH_SOCK", "SSH_AGENT_PID"] {
if env[key] == nil, let value = shellEnv[key], !value.isEmpty { if env[key] == nil, let value = shellEnv[key], !value.isEmpty {
env[key] = value env[key] = value
} }
} }
#endif
return env return env
} }
/// macOS-only: probe `/bin/zsh -l -c` for `SSH_AUTH_SOCK` and
/// `SSH_AGENT_PID`. GUI-launched apps don't inherit the user's shell
/// env, so without this, `ssh` spawned from Scarf can't reach the
/// ssh-agent and authentication fails with "Permission denied"
/// (exit 255) even though terminal ssh works fine.
///
/// Scoped down from the broader `HermesFileService.enrichedEnvironment()`
/// we only need two vars here, no PATH/credentials harvesting so
/// SSHTransport can live in `ScarfCore` without a main-target
/// dependency. Cached after first probe for the process lifetime.
#if os(macOS)
nonisolated private static let macLoginShellSSHAgentCache: [String: String] = {
let pipe = Pipe()
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/bin/zsh")
proc.arguments = ["-l", "-c", #"printf '%s\0%s\0%s\0%s\0' "SSH_AUTH_SOCK" "$SSH_AUTH_SOCK" "SSH_AGENT_PID" "$SSH_AGENT_PID""#]
proc.standardOutput = pipe
proc.standardError = Pipe()
do {
try proc.run()
} catch {
return [:]
}
// Bounded wait so a broken login shell doesn't hang app launch.
let deadline = Date().addingTimeInterval(3.0)
while proc.isRunning && Date() < deadline {
Thread.sleep(forTimeInterval: 0.05)
}
if proc.isRunning {
proc.terminate()
return [:]
}
let data = (try? pipe.fileHandleForReading.readToEnd()) ?? Data()
let parts = data.split(separator: 0).map { String(data: Data($0), encoding: .utf8) ?? "" }
var out: [String: String] = [:]
var i = 0
while i + 1 < parts.count {
out[parts[i]] = parts[i + 1]
i += 2
}
return out
}()
nonisolated private static func macLoginShellSSHAgent() -> [String: String] {
macLoginShellSSHAgentCache
}
#endif
// MARK: - SQLite snapshot // MARK: - SQLite snapshot
func snapshotSQLite(remotePath: String) throws -> URL { public func snapshotSQLite(remotePath: String) throws -> URL {
try? FileManager.default.createDirectory(atPath: snapshotDir, withIntermediateDirectories: true) try? FileManager.default.createDirectory(atPath: snapshotDir, withIntermediateDirectories: true)
let localPath = snapshotDir + "/state.db" let localPath = snapshotDir + "/state.db"
// `.backup` is WAL-safe: sqlite takes a consistent snapshot without // `.backup` is WAL-safe: sqlite takes a consistent snapshot without
@@ -501,7 +568,7 @@ struct SSHTransport: ServerTransport {
// MARK: - Watching // MARK: - Watching
func watchPaths(_ paths: [String]) -> AsyncStream<WatchEvent> { public func watchPaths(_ paths: [String]) -> AsyncStream<WatchEvent> {
// Polling: call `stat -c %Y` on all paths every 3s and yield a single // Polling: call `stat -c %Y` on all paths every 3s and yield a single
// `.anyChanged` when any mtime changed vs. the prior tick. ControlMaster // `.anyChanged` when any mtime changed vs. the prior tick. ControlMaster
// makes each stat ~5ms so the cost is bounded. // makes each stat ~5ms so the cost is bounded.
@@ -526,7 +593,9 @@ struct SSHTransport: ServerTransport {
} }
} catch { } catch {
// Transient failure (connection drop) skip this tick. // Transient failure (connection drop) skip this tick.
#if canImport(os)
Self.logger.debug("watchPaths poll failed: \(String(describing: error))") Self.logger.debug("watchPaths poll failed: \(String(describing: error))")
#endif
} }
try? await Task.sleep(nanoseconds: 3_000_000_000) try? await Task.sleep(nanoseconds: 3_000_000_000)
} }
@@ -15,7 +15,7 @@ import Foundation
/// The two naturally-streaming cases log tail and ACP stdio use /// The two naturally-streaming cases log tail and ACP stdio use
/// `makeProcess` which returns a configured `Process`; services own the /// `makeProcess` which returns a configured `Process`; services own the
/// stdio pipes and lifecycle exactly as they do today. /// stdio pipes and lifecycle exactly as they do today.
protocol ServerTransport: Sendable { public protocol ServerTransport: Sendable {
/// Identifies the context this transport serves. Used for cache /// Identifies the context this transport serves. Used for cache
/// namespacing (e.g. per-server SQLite snapshot directories). /// namespacing (e.g. per-server SQLite snapshot directories).
nonisolated var contextID: ServerID { get } nonisolated var contextID: ServerID { get }
@@ -77,23 +77,43 @@ protocol ServerTransport: Sendable {
/// Stat-style file metadata. `nil` (return value) means the file does not /// Stat-style file metadata. `nil` (return value) means the file does not
/// exist or couldn't be queried. /// exist or couldn't be queried.
struct FileStat: Sendable, Hashable { public struct FileStat: Sendable, Hashable {
let size: Int64 public let size: Int64
let mtime: Date public let mtime: Date
let isDirectory: Bool public let isDirectory: Bool
public init(
size: Int64,
mtime: Date,
isDirectory: Bool
) {
self.size = size
self.mtime = mtime
self.isDirectory = isDirectory
}
} }
/// Result of a one-shot process invocation. /// Result of a one-shot process invocation.
struct ProcessResult: Sendable { public struct ProcessResult: Sendable {
let exitCode: Int32 public let exitCode: Int32
let stdout: Data public let stdout: Data
let stderr: Data public let stderr: Data
nonisolated var stdoutString: String { String(data: stdout, encoding: .utf8) ?? "" }
nonisolated var stderrString: String { String(data: stderr, encoding: .utf8) ?? "" } public init(
exitCode: Int32,
stdout: Data,
stderr: Data
) {
self.exitCode = exitCode
self.stdout = stdout
self.stderr = stderr
}
public nonisolated var stdoutString: String { String(data: stdout, encoding: .utf8) ?? "" }
public nonisolated var stderrString: String { String(data: stderr, encoding: .utf8) ?? "" }
} }
enum WatchEvent: Sendable { public enum WatchEvent: Sendable {
/// Any path in the watched set changed; implementations may coalesce /// Any path in the watched set changed; implementations may coalesce
/// rapid changes into one event. Consumers should treat this as "refresh /// rapid changes into one event. Consumers should treat this as "refresh
/// whatever you were displaying" rather than expecting fine-grained /// whatever you were displaying" rather than expecting fine-grained
@@ -4,7 +4,7 @@ import Foundation
/// distinguishes these so user-visible messages can be specific /// distinguishes these so user-visible messages can be specific
/// ("authentication failed" vs. "command failed") without having to grep /// ("authentication failed" vs. "command failed") without having to grep
/// stderr strings. /// stderr strings.
enum TransportError: LocalizedError { public enum TransportError: LocalizedError {
/// `ssh`/`scp` could not reach the host or hit a protocol-level issue /// `ssh`/`scp` could not reach the host or hit a protocol-level issue
/// (name resolution, connection refused, route error). /// (name resolution, connection refused, route error).
case hostUnreachable(host: String, stderr: String) case hostUnreachable(host: String, stderr: String)
@@ -26,7 +26,7 @@ enum TransportError: LocalizedError {
/// for a bug report. /// for a bug report.
case other(message: String) case other(message: String)
var errorDescription: String? { public var errorDescription: String? {
switch self { switch self {
case .hostUnreachable(let host, _): case .hostUnreachable(let host, _):
return "Can't reach \(host). Check the hostname, network, and SSH config." return "Can't reach \(host). Check the hostname, network, and SSH config."
@@ -50,7 +50,7 @@ enum TransportError: LocalizedError {
/// Full stderr (if any) for display in a disclosure view. Empty string /// Full stderr (if any) for display in a disclosure view. Empty string
/// when there's no additional detail worth showing. /// when there's no additional detail worth showing.
var diagnosticStderr: String { public var diagnosticStderr: String {
switch self { switch self {
case .hostUnreachable(_, let s), case .hostUnreachable(_, let s),
.authenticationFailed(_, let s), .authenticationFailed(_, let s),
@@ -66,7 +66,7 @@ enum TransportError: LocalizedError {
/// into a specific `TransportError`. Used by `SSHTransport` after a /// into a specific `TransportError`. Used by `SSHTransport` after a
/// non-zero exit. Defaults to `.commandFailed` when no known marker /// non-zero exit. Defaults to `.commandFailed` when no known marker
/// matches. /// matches.
static func classifySSHFailure(host: String, exitCode: Int32, stderr: String) -> TransportError { public static func classifySSHFailure(host: String, exitCode: Int32, stderr: String) -> TransportError {
let s = stderr.lowercased() let s = stderr.lowercased()
if s.contains("permission denied") || s.contains("authentication failed") if s.contains("permission denied") || s.contains("authentication failed")
|| s.contains("publickey") && s.contains("denied") { || s.contains("publickey") && s.contains("denied") {
@@ -0,0 +1,232 @@
import Testing
import Foundation
@testable import ScarfCore
/// Exercises the Transport types + ServerContext that moved in M0b. Same
/// contract as the M0a tests: if any `public init` drifted away from its
/// stored properties, this suite fails fast on Linux CI before a reviewer
/// has to build on a Mac.
@Suite struct M0bTransportTests {
@Test func sshConfigMemberwiseAndDefaults() {
// Only `host` is required; all other params default to nil.
let minimal = SSHConfig(host: "home.local")
#expect(minimal.host == "home.local")
#expect(minimal.user == nil)
#expect(minimal.port == nil)
#expect(minimal.identityFile == nil)
#expect(minimal.remoteHome == nil)
#expect(minimal.hermesBinaryHint == nil)
let full = SSHConfig(
host: "h",
user: "u",
port: 2222,
identityFile: "/k",
remoteHome: "/opt/hermes",
hermesBinaryHint: "/usr/local/bin/hermes"
)
#expect(full.user == "u")
#expect(full.port == 2222)
#expect(full.remoteHome == "/opt/hermes")
}
@Test func sshConfigCodableRoundTrip() throws {
let src = SSHConfig(host: "h", user: "u", port: 22, identityFile: nil, remoteHome: nil, hermesBinaryHint: nil)
let data = try JSONEncoder().encode(src)
let dec = try JSONDecoder().decode(SSHConfig.self, from: data)
#expect(dec == src)
}
@Test func serverKindCases() {
let local = ServerKind.local
let ssh = ServerKind.ssh(SSHConfig(host: "h"))
#expect(local != ssh)
if case .local = local { } else { Issue.record("expected .local") }
if case .ssh(let cfg) = ssh { #expect(cfg.host == "h") } else { Issue.record("expected .ssh") }
}
@Test func serverContextLocalIsStable() {
// The static .local has a hard-coded UUID so window-state restoration
// across launches resolves. Pin that invariant.
#expect(ServerContext.local.id == UUID(uuidString: "00000000-0000-0000-0000-000000000001"))
#expect(ServerContext.local.displayName == "Local")
#expect(ServerContext.local.isRemote == false)
}
@Test func serverContextPathsLocalVsRemote() {
let local = ServerContext.local
#expect(local.paths.isRemote == false)
// Local home picks up $HOME or NSHomeDirectory(), then appends /.hermes.
#expect(local.paths.home.hasSuffix("/.hermes"))
let remote = ServerContext(
id: UUID(),
displayName: "remote",
kind: .ssh(SSHConfig(host: "h", remoteHome: "/opt/hermes"))
)
#expect(remote.isRemote == true)
#expect(remote.paths.home == "/opt/hermes")
// Default remote home when SSHConfig.remoteHome is nil:
let remoteDefault = ServerContext(
id: UUID(),
displayName: "r2",
kind: .ssh(SSHConfig(host: "h"))
)
#expect(remoteDefault.paths.home == "~/.hermes")
}
@Test func serverContextMakeTransportDispatches() {
let local = ServerContext.local.makeTransport()
#expect(local is LocalTransport)
#expect(local.isRemote == false)
#expect(local.contextID == ServerContext.local.id)
let remoteCtx = ServerContext(
id: UUID(),
displayName: "r",
kind: .ssh(SSHConfig(host: "h"))
)
let remote = remoteCtx.makeTransport()
#expect(remote is SSHTransport)
#expect(remote.isRemote == true)
#expect(remote.contextID == remoteCtx.id)
}
@Test func fileStatMemberwise() {
let s = FileStat(size: 123, mtime: Date(timeIntervalSince1970: 100), isDirectory: false)
#expect(s.size == 123)
#expect(s.mtime == Date(timeIntervalSince1970: 100))
#expect(s.isDirectory == false)
}
@Test func processResultMemberwiseAndStringAccessors() {
let r = ProcessResult(exitCode: 0, stdout: Data("hello\n".utf8), stderr: Data("warn\n".utf8))
#expect(r.exitCode == 0)
#expect(r.stdoutString == "hello\n")
#expect(r.stderrString == "warn\n")
// Non-UTF8 bytes should still return an (empty) String, never crash.
let weird = ProcessResult(exitCode: 1, stdout: Data([0xff, 0xfe]), stderr: Data())
#expect(weird.exitCode == 1)
#expect(weird.stdoutString == "")
}
@Test func watchEventHasOnlyAnyChanged() {
// We rely on .anyChanged as the single coalesced signal. A future
// addition of fine-grained cases would break consumers that pattern-
// match exhaustively; this test guards against that.
let e = WatchEvent.anyChanged
switch e {
case .anyChanged: break
}
}
@Test func localTransportConstructsWithDefaultID() {
let t = LocalTransport()
#expect(t.isRemote == false)
#expect(t.contextID == ServerContext.local.id)
let explicit = LocalTransport(contextID: UUID())
#expect(explicit.contextID != ServerContext.local.id)
}
@Test func sshTransportStaticPathsAreStable() {
// controlDirPath() is used by Mac tests (`ControlPathTests`) to check
// the macOS 104-byte sun_path limit. Pin the format here so the
// per-uid suffix never drifts away.
let dir = SSHTransport.controlDirPath()
#expect(dir.hasPrefix("/tmp/scarf-ssh-"))
let id = UUID()
let snapshot = SSHTransport.snapshotDirPath(for: id)
#expect(snapshot.contains(id.uuidString))
#expect(snapshot.hasSuffix("/scarf/snapshots/\(id.uuidString)"))
let root = SSHTransport.snapshotRootPath()
#expect(root.hasSuffix("/scarf/snapshots"))
}
@Test func sshTransportConstructsWithConfig() {
let cfg = SSHConfig(host: "box.local", user: "alan")
let t = SSHTransport(contextID: UUID(), config: cfg, displayName: "Home")
#expect(t.isRemote == true)
#expect(t.config.host == "box.local")
#expect(t.displayName == "Home")
}
@Test func transportErrorDescriptionsAreUserFacing() {
#expect(TransportError.hostUnreachable(host: "h", stderr: "").errorDescription?.contains("h") == true)
#expect(TransportError.authenticationFailed(host: "h", stderr: "").errorDescription?.contains("authentication") == true)
#expect(TransportError.hostKeyMismatch(host: "h", stderr: "").errorDescription?.contains("Host key") == true)
#expect(TransportError.commandFailed(exitCode: 7, stderr: "no such file").errorDescription?.contains("7") == true)
#expect(TransportError.fileIO(path: "/p", underlying: "boom").errorDescription?.contains("/p") == true)
#expect(TransportError.timeout(seconds: 10, partialStdout: Data()).errorDescription?.contains("10") == true)
#expect(TransportError.other(message: "x").errorDescription == "x")
}
@Test func transportErrorClassifierHandlesKnownStderrPatterns() {
let auth = TransportError.classifySSHFailure(
host: "h", exitCode: 255,
stderr: "Permission denied (publickey).")
if case .authenticationFailed = auth {} else { Issue.record("expected authFailed") }
let mismatch = TransportError.classifySSHFailure(
host: "h", exitCode: 255,
stderr: "Host key verification failed.")
if case .hostKeyMismatch = mismatch {} else { Issue.record("expected hostKeyMismatch") }
let unreach = TransportError.classifySSHFailure(
host: "h", exitCode: 255,
stderr: "ssh: connect to host h port 22: Connection refused")
if case .hostUnreachable = unreach {} else { Issue.record("expected hostUnreachable") }
let generic = TransportError.classifySSHFailure(
host: "h", exitCode: 1, stderr: "random failure")
if case .commandFailed = generic {} else { Issue.record("expected commandFailed") }
}
@Test func transportErrorDiagnosticStderr() {
#expect(TransportError.hostUnreachable(host: "h", stderr: "detail").diagnosticStderr == "detail")
#expect(TransportError.timeout(seconds: 1, partialStdout: Data()).diagnosticStderr == "")
#expect(TransportError.other(message: "x").diagnosticStderr == "")
}
@Test func serverContextCachesInvalidation() async {
// Seed the process-wide home-cache for a made-up server, then invalidate.
// The .local path doesn't hit the cache (isRemote == false), so we use a
// remote context its .resolvedUserHome() would do an SSH probe, which
// we can't run here. We just assert the invalidate API is callable.
let ctxID = UUID()
await ServerContext.invalidateCaches(for: ctxID)
}
@Test func localTransportFileRoundTrip() throws {
let tmp = FileManager.default.temporaryDirectory.appendingPathComponent("scarftest-\(UUID().uuidString).txt")
defer { try? FileManager.default.removeItem(at: tmp) }
let transport = LocalTransport()
let content = Data("hello scarf\n".utf8)
try transport.writeFile(tmp.path, data: content)
#expect(transport.fileExists(tmp.path))
let read = try transport.readFile(tmp.path)
#expect(read == content)
let stat = transport.stat(tmp.path)
#expect(stat != nil)
#expect(stat?.size == Int64(content.count))
#expect(stat?.isDirectory == false)
try transport.removeFile(tmp.path)
#expect(!transport.fileExists(tmp.path))
// Re-remove is a no-op, not a throw.
try transport.removeFile(tmp.path)
}
@Test func localTransportSnapshotSQLiteReturnsPathUnchanged() throws {
let transport = LocalTransport()
let url = try transport.snapshotSQLite(remotePath: "/tmp/some/state.db")
#expect(url.path == "/tmp/some/state.db")
}
}
+79 -1
View File
@@ -224,7 +224,85 @@ $PWD/scarf/Packages/ScarfCore:/work -w /work swift:6.0 swift test`.
`nonisolated` to new ScarfCore APIs pre-emptively; match the `nonisolated` to new ScarfCore APIs pre-emptively; match the
surrounding conventions. surrounding conventions.
### M0b — pending ### M0b — shipped
**Shipped:**
- 4 Transport files moved to `Packages/ScarfCore/Sources/ScarfCore/Transport/`:
`ServerTransport.swift`, `LocalTransport.swift`, `SSHTransport.swift`,
`TransportErrors.swift`.
- `ServerContext.swift` moved to `Packages/ScarfCore/Sources/ScarfCore/Models/`.
The `runHermes(_:timeout:stdin:)` and `openInLocalEditor(_:)` extension
methods — the only two that depend on main-target `HermesFileService` or
on AppKit's `NSWorkspace` — are split out into a new main-target file
`scarf/Core/Models/ServerContext+Mac.swift`.
- `HermesFileService.enrichedEnvironment()` reference inside
`SSHTransport.sshSubprocessEnvironment()` replaced with a local
`#if os(macOS)` helper `macLoginShellSSHAgent()` that does a narrow
`zsh -l -c` probe for only `SSH_AUTH_SOCK` / `SSH_AGENT_PID` (instead
of the broader PATH + credentials harvest that still lives in
`HermesFileService`). This breaks the Mac-target dependency from
ScarfCore. Behavior-identical on macOS; a no-op on iOS (where the SSH
agent comes from Citadel in M4, not the user's shell) and on Linux CI.
- `HermesPaths+Deprecated.swift` deleted. Its only justification was that
`ServerContext` was in the Mac target; with `ServerContext` in ScarfCore
now, the deprecated forwarders are both unreachable AND unused (zero
callers). Good riddance.
- Added `import ScarfCore` to 54 more consumer files that reference
Transport types or `ServerContext` but weren't already importing
ScarfCore from M0a. `scarfTests/scarfTests.swift` also gets the import
— its `ControlPathTests` now hits the public `SSHTransport` via
ScarfCore.
**Platform guards applied in ScarfCore:**
- `#if canImport(os)` — Apple's `os.Logger` (`import os` + every call
site). Linux gets silent logging. **Exception:** the large block in
`SSHTransport.ensureControlDir()` uses `Darwin.stat` / `lstat` / `mkdir`
/ `chmod` alongside its Logger calls — the whole method body is wrapped
in `#if canImport(Darwin)` with a simple `FileManager.createDirectory`
fallback for Linux (stubbed because SSH isn't exercised at runtime on
Linux anyway).
- `#if canImport(Darwin)``Darwin.open`/`Darwin.close` + FSEvents-based
`DispatchSourceFileSystemObject` in `LocalTransport.watchPaths`. Linux
gets a no-op empty stream.
- `#if canImport(SwiftUI)``EnvironmentKey` / `EnvironmentValues`
plumbing in `ServerContext.swift`.
- `#if canImport(AppKit)` — only in the split-out
`ServerContext+Mac.swift`, where `NSWorkspace.shared.open` lives. iOS
will provide its own equivalent (`UIApplication.open(_:)`) when the
target lands in M2.
**Bug fixed while moving:** the sed transform in M0a accidentally promoted
`protocol ServerTransport` requirements to `public nonisolated var contextID ...`.
Protocol requirements inherit the protocol's access level and **must
not** carry an explicit modifier — that's a Swift compile error. Fixed
in this PR's ServerTransport.swift.
**Test coverage (`M0bTransportTests`):** 18 new tests that construct
`SSHConfig` with and without defaults, round-trip it through Codable,
verify `ServerKind` pattern-matching, pin `ServerContext.local`'s
hard-coded UUID, assert local-vs-remote path derivation, verify
`makeTransport()` dispatches to the right impl, exercise `FileStat` /
`ProcessResult` / `WatchEvent` / `TransportError` shapes + error-classifier
stderr patterns, and round-trip an actual local file through
`LocalTransport` (write → read → stat → remove).
**Rules next phases can rely on:**
- `ServerContext` is the canonical multi-server entry point. Any new
service added in M0c or later takes a `ServerContext` in its init.
- `ServerContext+Mac.swift` is the pattern for Mac-only methods on
ScarfCore types. iOS will have a sibling `ServerContext+iOS.swift`
when the iOS target lands. Keep platform-specific methods out of
ScarfCore itself and in these sibling files.
- Logger pattern: `#if canImport(os) ... #endif` around each call site.
If there are 3+ sites in one method, consider wrapping the whole method
body in `#if canImport(Darwin)` with a Linux-safe fallback.
- SSH env enrichment is now self-contained in `SSHTransport.swift`. When
iOS's Citadel-based transport lands (M4), it will provide its own env
story — the existing macOS helper stays untouched.
### M0c — pending ### M0c — pending
### M0d — pending ### M0d — pending
### M1 — pending ### M1 — pending
@@ -1,74 +0,0 @@
import Foundation
import ScarfCore
/// 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>`.
///
/// **Staying behind in the Mac target**: this enum references
/// `ServerContext.local`, which currently lives in the Mac target (not yet
/// extracted to `ScarfCore` that move is part of M0b). Once `ServerContext`
/// moves, this file can be deleted or moved alongside it. Until then, leaving
/// it here keeps the Mac build behavior unchanged.
enum HermesPaths: Sendable {
@available(*, deprecated, message: "use ServerContext.paths.home")
nonisolated static var home: String { ServerContext.local.paths.home }
@available(*, deprecated, message: "use ServerContext.paths.stateDB")
nonisolated static var stateDB: String { ServerContext.local.paths.stateDB }
@available(*, deprecated, message: "use ServerContext.paths.configYAML")
nonisolated static var configYAML: String { ServerContext.local.paths.configYAML }
@available(*, deprecated, message: "use ServerContext.paths.memoriesDir")
nonisolated static var memoriesDir: String { ServerContext.local.paths.memoriesDir }
@available(*, deprecated, message: "use ServerContext.paths.memoryMD")
nonisolated static var memoryMD: String { ServerContext.local.paths.memoryMD }
@available(*, deprecated, message: "use ServerContext.paths.userMD")
nonisolated static var userMD: String { ServerContext.local.paths.userMD }
@available(*, deprecated, message: "use ServerContext.paths.sessionsDir")
nonisolated static var sessionsDir: String { ServerContext.local.paths.sessionsDir }
@available(*, deprecated, message: "use ServerContext.paths.cronJobsJSON")
nonisolated static var cronJobsJSON: String { ServerContext.local.paths.cronJobsJSON }
@available(*, deprecated, message: "use ServerContext.paths.cronOutputDir")
nonisolated static var cronOutputDir: String { ServerContext.local.paths.cronOutputDir }
@available(*, deprecated, message: "use ServerContext.paths.gatewayStateJSON")
nonisolated static var gatewayStateJSON: String { ServerContext.local.paths.gatewayStateJSON }
@available(*, deprecated, message: "use ServerContext.paths.skillsDir")
nonisolated static var skillsDir: String { ServerContext.local.paths.skillsDir }
@available(*, deprecated, message: "use ServerContext.paths.errorsLog")
nonisolated static var errorsLog: String { ServerContext.local.paths.errorsLog }
@available(*, deprecated, message: "use ServerContext.paths.agentLog")
nonisolated static var agentLog: String { ServerContext.local.paths.agentLog }
@available(*, deprecated, message: "use ServerContext.paths.gatewayLog")
nonisolated static var gatewayLog: String { ServerContext.local.paths.gatewayLog }
@available(*, deprecated, message: "use ServerContext.paths.scarfDir")
nonisolated static var scarfDir: String { ServerContext.local.paths.scarfDir }
@available(*, deprecated, message: "use ServerContext.paths.projectsRegistry")
nonisolated static var projectsRegistry: String { ServerContext.local.paths.projectsRegistry }
@available(*, deprecated, message: "use ServerContext.paths.mcpTokensDir")
nonisolated static var mcpTokensDir: String { ServerContext.local.paths.mcpTokensDir }
@available(*, deprecated, message: "use HermesPathSet.hermesBinaryCandidates")
nonisolated static var hermesBinaryCandidates: [String] {
HermesPathSet.hermesBinaryCandidates
}
@available(*, deprecated, message: "use ServerContext.paths.hermesBinary")
nonisolated static var hermesBinary: String { ServerContext.local.paths.hermesBinary }
}
@@ -0,0 +1,41 @@
import Foundation
import ScarfCore
#if canImport(AppKit)
import AppKit
#endif
/// `ServerContext` extensions that depend on main-target services
/// (`HermesFileService`) or macOS-only frameworks (`AppKit.NSWorkspace`).
///
/// These stay in the Mac app target so `ScarfCore` itself has no dependency
/// on AppKit or on the Mac app's services. The iOS target will provide its
/// own equivalents (or skip these features entirely) via its own
/// `ServerContext+iOS.swift` when it lands in M2+.
extension ServerContext {
/// 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
nonisolated 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 }
#if canImport(AppKit)
NSWorkspace.shared.open(URL(fileURLWithPath: path))
return true
#else
return false
#endif
}
}
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
import os import os
/// Persisted entry for a user-added server. `ServerContext` itself is a value /// Persisted entry for a user-added server. `ServerContext` itself is a value
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
import os import os
/// Read/write `~/.hermes/.env` while preserving comments, blank lines, and the /// Read/write `~/.hermes/.env` while preserving comments, blank lines, and the
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
@Observable @Observable
final class HermesFileWatcher { final class HermesFileWatcher {
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
import os import os
/// A single model from the models.dev catalog shipped with hermes. /// A single model from the models.dev catalog shipped with hermes.
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
import AppKit import AppKit
import os import os
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
import AppKit import AppKit
import os import os
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct CredentialPoolsView: View { struct CredentialPoolsView: View {
@State private var viewModel: CredentialPoolsViewModel @State private var viewModel: CredentialPoolsViewModel
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
struct HealthCheck: Identifiable { struct HealthCheck: Identifiable {
let id = UUID() let id = UUID()
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct HealthView: View { struct HealthView: View {
@State private var viewModel: HealthViewModel @State private var viewModel: HealthViewModel
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
@Observable @Observable
final class LogsViewModel { final class LogsViewModel {
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct LogsView: View { struct LogsView: View {
@State private var viewModel: LogsViewModel @State private var viewModel: LogsViewModel
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
@Observable @Observable
final class MemoryViewModel { final class MemoryViewModel {
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct MemoryView: View { struct MemoryView: View {
@State private var viewModel: MemoryViewModel @State private var viewModel: MemoryViewModel
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
import AppKit import AppKit
import os import os
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct PersonalitiesView: View { struct PersonalitiesView: View {
@State private var viewModel: PersonalitiesViewModel @State private var viewModel: PersonalitiesViewModel
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
/// Email setup. IMAP/SMTP with app passwords no OAuth. /// Email setup. IMAP/SMTP with app passwords no OAuth.
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/email /// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/email
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
/// Feishu/Lark setup. Choose domain (feishu = China, lark = international). /// Feishu/Lark setup. Choose domain (feishu = China, lark = international).
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/feishu /// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/feishu
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
import AppKit import AppKit
/// Home Assistant setup. Long-lived access token in `.env`, scalar filters via /// Home Assistant setup. Long-lived access token in `.env`, scalar filters via
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
/// iMessage via BlueBubbles. Requires a BlueBubbles Server running on a Mac /// iMessage via BlueBubbles. Requires a BlueBubbles Server running on a Mac
/// that's always on, with an Apple ID signed into Messages.app. /// that's always on, with an Apple ID signed into Messages.app.
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
/// Matrix setup. Supports both access-token and password auth. No SSO. /// Matrix setup. Supports both access-token and password auth. No SSO.
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/matrix /// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/matrix
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
/// Mattermost setup. Server URL + personal access token (or bot token). /// Mattermost setup. Server URL + personal access token (or bot token).
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/mattermost /// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/mattermost
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
import AppKit import AppKit
import os import os
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
/// Signal setup. Users must install `signal-cli` externally (needs Java), link /// Signal setup. Users must install `signal-cli` externally (needs Java), link
/// their account via `signal-cli link -n ...`, and run a daemon on an HTTP port /// their account via `signal-cli link -n ...`, and run a daemon on an HTTP port
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
/// Slack setup. Requires two tokens (bot + app-level for Socket Mode). /// Slack setup. Requires two tokens (bot + app-level for Socket Mode).
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/slack /// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/slack
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
import os import os
/// Telegram platform setup. Credentials live in `.env` (`TELEGRAM_*`); mention / /// Telegram platform setup. Credentials live in `.env` (`TELEGRAM_*`); mention /
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
/// Webhook platform setup. Just the global enable/port/secret per-subscription /// Webhook platform setup. Just the global enable/port/secret per-subscription
/// routes live in the Webhooks sidebar feature. /// routes live in the Webhooks sidebar feature.
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
/// WhatsApp setup. Unlike other platforms, pairing requires scanning a QR code /// WhatsApp setup. Unlike other platforms, pairing requires scanning a QR code
/// via the `hermes whatsapp` CLI wizard we expose that as an embedded /// via the `hermes whatsapp` CLI wizard we expose that as an embedded
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct DiscordSetupView: View { struct DiscordSetupView: View {
@State private var viewModel: DiscordSetupViewModel @State private var viewModel: DiscordSetupViewModel
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct EmailSetupView: View { struct EmailSetupView: View {
@State private var viewModel: EmailSetupViewModel @State private var viewModel: EmailSetupViewModel
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct FeishuSetupView: View { struct FeishuSetupView: View {
@State private var viewModel: FeishuSetupViewModel @State private var viewModel: FeishuSetupViewModel
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct HomeAssistantSetupView: View { struct HomeAssistantSetupView: View {
@State private var viewModel: HomeAssistantSetupViewModel @State private var viewModel: HomeAssistantSetupViewModel
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct IMessageSetupView: View { struct IMessageSetupView: View {
@State private var viewModel: IMessageSetupViewModel @State private var viewModel: IMessageSetupViewModel
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct MatrixSetupView: View { struct MatrixSetupView: View {
@State private var viewModel: MatrixSetupViewModel @State private var viewModel: MatrixSetupViewModel
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct MattermostSetupView: View { struct MattermostSetupView: View {
@State private var viewModel: MattermostSetupViewModel @State private var viewModel: MattermostSetupViewModel
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct SignalSetupView: View { struct SignalSetupView: View {
@State private var viewModel: SignalSetupViewModel @State private var viewModel: SignalSetupViewModel
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct SlackSetupView: View { struct SlackSetupView: View {
@State private var viewModel: SlackSetupViewModel @State private var viewModel: SlackSetupViewModel
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct TelegramSetupView: View { struct TelegramSetupView: View {
@State private var viewModel: TelegramSetupViewModel @State private var viewModel: TelegramSetupViewModel
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct WebhookSetupView: View { struct WebhookSetupView: View {
@State private var viewModel: WebhookSetupViewModel @State private var viewModel: WebhookSetupViewModel
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct WhatsAppSetupView: View { struct WhatsAppSetupView: View {
@State private var viewModel: WhatsAppSetupViewModel @State private var viewModel: WhatsAppSetupViewModel
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
import os import os
struct HermesPlugin: Identifiable, Sendable, Equatable { struct HermesPlugin: Identifiable, Sendable, Equatable {
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct PluginsView: View { struct PluginsView: View {
@State private var viewModel: PluginsViewModel @State private var viewModel: PluginsViewModel
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
import os import os
struct HermesProfile: Identifiable, Sendable, Equatable { struct HermesProfile: Identifiable, Sendable, Equatable {
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
import AppKit import AppKit
import UniformTypeIdentifiers import UniformTypeIdentifiers
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
import AppKit import AppKit
import os import os
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct QuickCommandsView: View { struct QuickCommandsView: View {
@State private var viewModel: QuickCommandsViewModel @State private var viewModel: QuickCommandsViewModel
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
import AppKit import AppKit
/// Drives the Add Server sheet. Exposed state maps 1:1 to form fields, plus /// Drives the Add Server sheet. Exposed state maps 1:1 to form fields, plus
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
import os import os
/// Tracks connection health for the current window's server. Remote contexts /// Tracks connection health for the current window's server. Remote contexts
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
import os import os
/// Runs a fixed check-list against a remote server and reports per-probe /// Runs a fixed check-list against a remote server and reports per-probe
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
/// Sheet for adding a new remote server. Collects SSH connection details, /// Sheet for adding a new remote server. Collects SSH connection details,
/// runs a "Test Connection" probe, and on save hands the persisted /// runs a "Test Connection" probe, and on save hands the persisted
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
/// List of registered remote servers with add/remove actions. Rendered as a /// List of registered remote servers with add/remove actions. Rendered as a
/// popover from the toolbar switcher. /// popover from the toolbar switcher.
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
/// Shown when a window is restored after the user removed the server it /// 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 /// was bound to. Lets them open Local or any remaining registered server
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
import AppKit import AppKit
/// Per-server diagnostics sheet. Shown from Manage Servers and from the /// Per-server diagnostics sheet. Shown from Manage Servers and from the
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
/// Toolbar control that shows the current window's server and exposes a /// Toolbar control that shows the current window's server and exposes a
/// menu for opening *other* servers in additional windows. Multi-window is /// menu for opening *other* servers in additional windows. Multi-window is
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
/// Settings is now organized into tabs because the full Hermes config surface is far /// Settings is now organized into tabs because the full Hermes config surface is far
/// too large for a single scrolling form (~70 config fields). Each tab has its own /// too large for a single scrolling form (~70 config fields). Each tab has its own
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct SkillsView: View { struct SkillsView: View {
@State private var viewModel: SkillsViewModel @State private var viewModel: SkillsViewModel
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
import os import os
struct HermesWebhook: Identifiable, Sendable, Equatable { struct HermesWebhook: Identifiable, Sendable, Equatable {
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
import AppKit import AppKit
struct WebhooksView: View { struct WebhooksView: View {
+1
View File
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
@main @main
struct ScarfApp: App { struct ScarfApp: App {
+1
View File
@@ -7,6 +7,7 @@
import Testing import Testing
import Darwin import Darwin
import ScarfCore
@testable import scarf @testable import scarf
@Suite struct ControlPathTests { @Suite struct ControlPathTests {