mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
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:
+65
-57
@@ -1,65 +1,90 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
#if canImport(SwiftUI)
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
/// 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
|
||||
public 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 {
|
||||
public struct SSHConfig: Sendable, Hashable, Codable {
|
||||
/// Hostname or `~/.ssh/config` alias.
|
||||
var host: String
|
||||
public var host: String
|
||||
/// 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).
|
||||
var port: Int?
|
||||
public var port: Int?
|
||||
/// Absolute path to a private key. `nil` → defer to ssh-agent /
|
||||
/// `~/.ssh/config` identity files.
|
||||
var identityFile: String?
|
||||
public var identityFile: String?
|
||||
/// Override for the remote `$HOME/.hermes` directory. `nil` uses
|
||||
/// `HermesPathSet.defaultRemoteHome` (`~/.hermes`, shell-expanded on the
|
||||
/// remote side).
|
||||
var remoteHome: String?
|
||||
public 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?
|
||||
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
|
||||
/// 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 {
|
||||
public 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.
|
||||
/// every service and ViewModel. One `ServerContext` corresponds to one
|
||||
/// Hermes installation; multi-window scenes construct 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
|
||||
/// 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`,
|
||||
/// `.isRemote`, or `makeTransport()` from a background actor must not trap
|
||||
/// the caller into hopping MainActor. `nonisolated` on each member keeps
|
||||
/// them callable from any context; the one MainActor-dependent method
|
||||
/// (`openInLocalEditor`) lives in the extension below.
|
||||
struct ServerContext: Sendable, Hashable, Identifiable {
|
||||
let id: ServerID
|
||||
var displayName: String
|
||||
var kind: ServerKind
|
||||
/// them callable from any context.
|
||||
public struct ServerContext: Sendable, Hashable, Identifiable {
|
||||
public let id: ServerID
|
||||
public var displayName: String
|
||||
public 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
|
||||
/// on demand from `home`, no I/O.
|
||||
nonisolated var paths: HermesPathSet {
|
||||
public nonisolated var paths: HermesPathSet {
|
||||
switch kind {
|
||||
case .local:
|
||||
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 }
|
||||
return false
|
||||
}
|
||||
@@ -85,7 +110,7 @@ struct ServerContext: Sendable, Hashable, Identifiable {
|
||||
/// a `LocalTransport`; SSH contexts get an `SSHTransport` configured
|
||||
/// from `SSHConfig`. Each call returns a fresh value — transports are
|
||||
/// cheap and stateless beyond disk caches.
|
||||
nonisolated func makeTransport() -> any ServerTransport {
|
||||
public nonisolated func makeTransport() -> any ServerTransport {
|
||||
switch kind {
|
||||
case .local:
|
||||
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
|
||||
/// remains the fallback when no remote server is selected.
|
||||
nonisolated static let local = ServerContext(
|
||||
public nonisolated static let local = ServerContext(
|
||||
id: localID,
|
||||
displayName: "Local",
|
||||
kind: .local
|
||||
@@ -155,13 +180,13 @@ extension ServerContext {
|
||||
/// Local: `NSHomeDirectory()`. Remote: probed `$HOME` over SSH, cached.
|
||||
/// Use this — not `NSHomeDirectory()` — whenever you're passing a `cwd`
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// Called when a server is removed from the registry, so the process-wide
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
@@ -179,20 +204,20 @@ extension ServerContext {
|
||||
extension ServerContext {
|
||||
/// Read a UTF-8 text file. `nil` on any error (missing, transport down,
|
||||
/// 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 }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
/// Read raw bytes. `nil` on any error.
|
||||
nonisolated func readData(_ path: String) -> Data? {
|
||||
public nonisolated 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
|
||||
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 }
|
||||
do {
|
||||
try makeTransport().writeFile(path, data: data)
|
||||
@@ -203,37 +228,14 @@ extension ServerContext {
|
||||
}
|
||||
|
||||
/// Existence check. Local: `FileManager`. Remote: `ssh test -e`.
|
||||
nonisolated func fileExists(_ path: String) -> Bool {
|
||||
public nonisolated func fileExists(_ path: String) -> Bool {
|
||||
makeTransport().fileExists(path)
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -242,13 +244,19 @@ extension ServerContext {
|
||||
/// 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`.
|
||||
///
|
||||
/// 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 {
|
||||
static let defaultValue: ServerContext = .local
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var serverContext: ServerContext {
|
||||
public var serverContext: ServerContext {
|
||||
get { self[ServerContextEnvironmentKey.self] }
|
||||
set { self[ServerContextEnvironmentKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
+39
-15
@@ -1,22 +1,33 @@
|
||||
import Foundation
|
||||
#if canImport(os)
|
||||
import os
|
||||
#endif
|
||||
|
||||
/// `ServerTransport` over the local filesystem. Thin wrapper around
|
||||
/// `FileManager`, `Process`, and `DispatchSourceFileSystemObject` — the APIs
|
||||
/// 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")
|
||||
#endif
|
||||
|
||||
let contextID: ServerID
|
||||
let isRemote: Bool = false
|
||||
public let contextID: ServerID
|
||||
public let isRemote: Bool = false
|
||||
|
||||
nonisolated init(contextID: ServerID = ServerContext.local.id) {
|
||||
public nonisolated init(contextID: ServerID = ServerContext.local.id) {
|
||||
self.contextID = contextID
|
||||
}
|
||||
|
||||
// MARK: - Files
|
||||
|
||||
func readFile(_ path: String) throws -> Data {
|
||||
public func readFile(_ path: String) throws -> Data {
|
||||
do {
|
||||
return try Data(contentsOf: URL(fileURLWithPath: path))
|
||||
} 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"
|
||||
do {
|
||||
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)
|
||||
}
|
||||
|
||||
func stat(_ path: String) -> FileStat? {
|
||||
public func stat(_ path: String) -> FileStat? {
|
||||
guard let attrs = try? FileManager.default.attributesOfItem(atPath: path) else {
|
||||
return nil
|
||||
}
|
||||
@@ -68,7 +79,7 @@ struct LocalTransport: ServerTransport {
|
||||
return FileStat(size: size, mtime: mtime, isDirectory: isDir)
|
||||
}
|
||||
|
||||
func listDirectory(_ path: String) throws -> [String] {
|
||||
public func listDirectory(_ path: String) throws -> [String] {
|
||||
do {
|
||||
return try FileManager.default.contentsOfDirectory(atPath: path)
|
||||
} catch {
|
||||
@@ -76,7 +87,7 @@ struct LocalTransport: ServerTransport {
|
||||
}
|
||||
}
|
||||
|
||||
func createDirectory(_ path: String) throws {
|
||||
public func createDirectory(_ path: String) throws {
|
||||
do {
|
||||
try FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true)
|
||||
} 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 }
|
||||
do {
|
||||
try FileManager.default.removeItem(atPath: path)
|
||||
@@ -95,7 +106,7 @@ struct LocalTransport: ServerTransport {
|
||||
|
||||
// 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()
|
||||
proc.executableURL = URL(fileURLWithPath: executable)
|
||||
proc.arguments = args
|
||||
@@ -138,7 +149,7 @@ struct LocalTransport: ServerTransport {
|
||||
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()
|
||||
proc.executableURL = URL(fileURLWithPath: executable)
|
||||
proc.arguments = args
|
||||
@@ -147,14 +158,15 @@ struct LocalTransport: ServerTransport {
|
||||
|
||||
// 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.
|
||||
URL(fileURLWithPath: remotePath)
|
||||
}
|
||||
|
||||
// MARK: - Watching
|
||||
|
||||
func watchPaths(_ paths: [String]) -> AsyncStream<WatchEvent> {
|
||||
#if canImport(Darwin)
|
||||
public func watchPaths(_ paths: [String]) -> AsyncStream<WatchEvent> {
|
||||
AsyncStream { continuation in
|
||||
// Build the source list immutably, then hand a value-typed copy
|
||||
// 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
|
||||
|
||||
+94
-25
@@ -1,5 +1,7 @@
|
||||
import Foundation
|
||||
#if canImport(os)
|
||||
import os
|
||||
#endif
|
||||
|
||||
/// `ServerTransport` that reaches a remote Hermes installation through the
|
||||
/// 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
|
||||
/// control socket at `~/Library/Caches/scarf/ssh/%C` so multiple Scarf
|
||||
/// 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")
|
||||
#endif
|
||||
|
||||
let contextID: ServerID
|
||||
let isRemote: Bool = true
|
||||
public let contextID: ServerID
|
||||
public let isRemote: Bool = true
|
||||
|
||||
let config: SSHConfig
|
||||
let displayName: String
|
||||
public let config: SSHConfig
|
||||
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.config = config
|
||||
self.displayName = displayName
|
||||
@@ -57,14 +61,14 @@ struct SSHTransport: ServerTransport {
|
||||
/// Unix domain socket limit. The Caches path
|
||||
/// (~/Library/Caches/scarf/ssh/%C) can exceed this limit when the
|
||||
/// username is long, causing ssh to exit 255.
|
||||
nonisolated static func controlDirPath() -> String {
|
||||
public nonisolated static func controlDirPath() -> String {
|
||||
return "/tmp/scarf-ssh-\(getuid())"
|
||||
}
|
||||
|
||||
/// Snapshot cache directory for a given server. Stable per-ID so repeated
|
||||
/// connections to the same server share the cache, and so cleanup can
|
||||
/// 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
|
||||
?? NSHomeDirectory() + "/Library/Caches"
|
||||
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
|
||||
/// 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
|
||||
?? NSHomeDirectory() + "/Library/Caches"
|
||||
return base + "/scarf/snapshots"
|
||||
@@ -80,7 +84,7 @@ struct SSHTransport: ServerTransport {
|
||||
|
||||
/// Remove the snapshot directory for a server (no-op if absent). Called
|
||||
/// 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)
|
||||
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
|
||||
/// `keep`. Called once at app launch so snapshots from servers the user
|
||||
/// 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()
|
||||
guard let entries = try? FileManager.default.contentsOfDirectory(atPath: root) else { return }
|
||||
for name in entries {
|
||||
@@ -107,7 +111,7 @@ struct SSHTransport: ServerTransport {
|
||||
/// Wiping these on launch keeps `/tmp/scarf-ssh-<uid>/` from accumulating
|
||||
/// indefinitely until reboot, while leaving any concurrent Scarf
|
||||
/// instance's live sockets (always <600s old) untouched.
|
||||
static func sweepStaleControlSockets(staleAfter: TimeInterval = 1800) {
|
||||
public static func sweepStaleControlSockets(staleAfter: TimeInterval = 1800) {
|
||||
let root = controlDirPath()
|
||||
guard let entries = try? FileManager.default.contentsOfDirectory(atPath: root) else { return }
|
||||
let cutoff = Date().addingTimeInterval(-staleAfter)
|
||||
@@ -127,7 +131,7 @@ struct SSHTransport: ServerTransport {
|
||||
/// master is currently running, `ssh -O exit` exits non-zero — we ignore
|
||||
/// the exit code because the desired end state (no master) is reached
|
||||
/// either way.
|
||||
func closeControlMaster() {
|
||||
public func closeControlMaster() {
|
||||
ensureControlDir()
|
||||
let args = sshArgs(extra: ["-O", "exit", hostSpec])
|
||||
_ = 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
|
||||
/// already exists.
|
||||
nonisolated private func ensureControlDir() {
|
||||
#if canImport(Darwin)
|
||||
let path = controlDir
|
||||
|
||||
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")
|
||||
_ = 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
|
||||
@@ -262,7 +273,7 @@ struct SSHTransport: ServerTransport {
|
||||
|
||||
// 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
|
||||
// don't need scp's progress machinery for typical config/memory
|
||||
// files (<1 MB each).
|
||||
@@ -280,7 +291,7 @@ struct SSHTransport: ServerTransport {
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
func writeFile(_ path: String, data: Data) throws {
|
||||
public 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
|
||||
@@ -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 {
|
||||
return false
|
||||
}
|
||||
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
|
||||
// form; `stat -c` is GNU/Linux. We try the GNU form first (typical
|
||||
// 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)
|
||||
}
|
||||
|
||||
func listDirectory(_ path: String) throws -> [String] {
|
||||
public 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))")
|
||||
@@ -381,14 +392,14 @@ struct SSHTransport: ServerTransport {
|
||||
.map(String.init)
|
||||
}
|
||||
|
||||
func createDirectory(_ path: String) throws {
|
||||
public 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 {
|
||||
public 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)
|
||||
@@ -397,7 +408,7 @@ struct SSHTransport: ServerTransport {
|
||||
|
||||
// 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
|
||||
// home-relative args expand on the remote. The executable might be
|
||||
// `~/.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)
|
||||
}
|
||||
|
||||
func makeProcess(executable: String, args: [String]) -> Process {
|
||||
public 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
|
||||
@@ -433,20 +444,76 @@ struct SSHTransport: ServerTransport {
|
||||
/// 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.
|
||||
///
|
||||
/// **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] {
|
||||
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"] {
|
||||
if env[key] == nil, let value = shellEnv[key], !value.isEmpty {
|
||||
env[key] = value
|
||||
}
|
||||
}
|
||||
#endif
|
||||
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
|
||||
|
||||
func snapshotSQLite(remotePath: String) throws -> URL {
|
||||
public 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
|
||||
@@ -501,7 +568,7 @@ struct SSHTransport: ServerTransport {
|
||||
|
||||
// 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
|
||||
// `.anyChanged` when any mtime changed vs. the prior tick. ControlMaster
|
||||
// makes each stat ~5ms so the cost is bounded.
|
||||
@@ -526,7 +593,9 @@ struct SSHTransport: ServerTransport {
|
||||
}
|
||||
} catch {
|
||||
// Transient failure (connection drop) — skip this tick.
|
||||
#if canImport(os)
|
||||
Self.logger.debug("watchPaths poll failed: \(String(describing: error))")
|
||||
#endif
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
}
|
||||
+32
-12
@@ -15,7 +15,7 @@ import Foundation
|
||||
/// 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 {
|
||||
public protocol ServerTransport: Sendable {
|
||||
/// Identifies the context this transport serves. Used for cache
|
||||
/// namespacing (e.g. per-server SQLite snapshot directories).
|
||||
nonisolated var contextID: ServerID { get }
|
||||
@@ -77,23 +77,43 @@ protocol ServerTransport: Sendable {
|
||||
|
||||
/// 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
|
||||
public struct FileStat: Sendable, Hashable {
|
||||
public let size: Int64
|
||||
public let mtime: Date
|
||||
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.
|
||||
struct ProcessResult: Sendable {
|
||||
let exitCode: Int32
|
||||
let stdout: Data
|
||||
let stderr: Data
|
||||
public struct ProcessResult: Sendable {
|
||||
public let exitCode: Int32
|
||||
public let stdout: 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
|
||||
/// rapid changes into one event. Consumers should treat this as "refresh
|
||||
/// whatever you were displaying" rather than expecting fine-grained
|
||||
+4
-4
@@ -4,7 +4,7 @@ import Foundation
|
||||
/// distinguishes these so user-visible messages can be specific
|
||||
/// ("authentication failed" vs. "command failed") without having to grep
|
||||
/// stderr strings.
|
||||
enum TransportError: LocalizedError {
|
||||
public 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)
|
||||
@@ -26,7 +26,7 @@ enum TransportError: LocalizedError {
|
||||
/// for a bug report.
|
||||
case other(message: String)
|
||||
|
||||
var errorDescription: String? {
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .hostUnreachable(let host, _):
|
||||
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
|
||||
/// when there's no additional detail worth showing.
|
||||
var diagnosticStderr: String {
|
||||
public var diagnosticStderr: String {
|
||||
switch self {
|
||||
case .hostUnreachable(_, let s),
|
||||
.authenticationFailed(_, let s),
|
||||
@@ -66,7 +66,7 @@ enum TransportError: LocalizedError {
|
||||
/// 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 {
|
||||
public 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") {
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
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
|
||||
### M0d — 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 ScarfCore
|
||||
import os
|
||||
|
||||
/// Persisted entry for a user-added server. `ServerContext` itself is a value
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import os
|
||||
|
||||
/// Read/write `~/.hermes/.env` while preserving comments, blank lines, and the
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
|
||||
@Observable
|
||||
final class HermesFileWatcher {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import os
|
||||
|
||||
/// A single model from the models.dev catalog shipped with hermes.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import AppKit
|
||||
import os
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import AppKit
|
||||
import os
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct CredentialPoolsView: View {
|
||||
@State private var viewModel: CredentialPoolsViewModel
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
|
||||
struct HealthCheck: Identifiable {
|
||||
let id = UUID()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct HealthView: View {
|
||||
@State private var viewModel: HealthViewModel
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
|
||||
@Observable
|
||||
final class LogsViewModel {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct LogsView: View {
|
||||
@State private var viewModel: LogsViewModel
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
|
||||
@Observable
|
||||
final class MemoryViewModel {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct MemoryView: View {
|
||||
@State private var viewModel: MemoryViewModel
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import AppKit
|
||||
import os
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct PersonalitiesView: View {
|
||||
@State private var viewModel: PersonalitiesViewModel
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
|
||||
/// Email setup. IMAP/SMTP with app passwords — no OAuth.
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/email
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
|
||||
/// Feishu/Lark setup. Choose domain (feishu = China, lark = international).
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/feishu
|
||||
|
||||
+1
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import AppKit
|
||||
|
||||
/// Home Assistant setup. Long-lived access token in `.env`, scalar filters via
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
|
||||
/// iMessage via BlueBubbles. Requires a BlueBubbles Server running on a Mac
|
||||
/// that's always on, with an Apple ID signed into Messages.app.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
|
||||
/// Matrix setup. Supports both access-token and password auth. No SSO.
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/matrix
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
|
||||
/// Mattermost setup. Server URL + personal access token (or bot token).
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/mattermost
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import AppKit
|
||||
import os
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
|
||||
/// Slack setup. Requires two tokens (bot + app-level for Socket Mode).
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/slack
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import os
|
||||
|
||||
/// Telegram platform setup. Credentials live in `.env` (`TELEGRAM_*`); mention /
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
|
||||
/// Webhook platform setup. Just the global enable/port/secret — per-subscription
|
||||
/// routes live in the Webhooks sidebar feature.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
|
||||
/// WhatsApp setup. Unlike other platforms, pairing requires scanning a QR code
|
||||
/// via the `hermes whatsapp` CLI wizard — we expose that as an embedded
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct DiscordSetupView: View {
|
||||
@State private var viewModel: DiscordSetupViewModel
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct EmailSetupView: View {
|
||||
@State private var viewModel: EmailSetupViewModel
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct FeishuSetupView: View {
|
||||
@State private var viewModel: FeishuSetupViewModel
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct HomeAssistantSetupView: View {
|
||||
@State private var viewModel: HomeAssistantSetupViewModel
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct IMessageSetupView: View {
|
||||
@State private var viewModel: IMessageSetupViewModel
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct MatrixSetupView: View {
|
||||
@State private var viewModel: MatrixSetupViewModel
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct MattermostSetupView: View {
|
||||
@State private var viewModel: MattermostSetupViewModel
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct SignalSetupView: View {
|
||||
@State private var viewModel: SignalSetupViewModel
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct SlackSetupView: View {
|
||||
@State private var viewModel: SlackSetupViewModel
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct TelegramSetupView: View {
|
||||
@State private var viewModel: TelegramSetupViewModel
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct WebhookSetupView: View {
|
||||
@State private var viewModel: WebhookSetupViewModel
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct WhatsAppSetupView: View {
|
||||
@State private var viewModel: WhatsAppSetupViewModel
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import os
|
||||
|
||||
struct HermesPlugin: Identifiable, Sendable, Equatable {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct PluginsView: View {
|
||||
@State private var viewModel: PluginsViewModel
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import os
|
||||
|
||||
struct HermesProfile: Identifiable, Sendable, Equatable {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
import AppKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import AppKit
|
||||
import os
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct QuickCommandsView: View {
|
||||
@State private var viewModel: QuickCommandsViewModel
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import AppKit
|
||||
|
||||
/// Drives the Add Server sheet. Exposed state maps 1:1 to form fields, plus
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import os
|
||||
|
||||
/// Tracks connection health for the current window's server. Remote contexts
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import os
|
||||
|
||||
/// Runs a fixed check-list against a remote server and reports per-probe
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// Sheet for adding a new remote server. Collects SSH connection details,
|
||||
/// runs a "Test Connection" probe, and — on save — hands the persisted
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// List of registered remote servers with add/remove actions. Rendered as a
|
||||
/// popover from the toolbar switcher.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
import AppKit
|
||||
|
||||
/// Per-server diagnostics sheet. Shown from Manage Servers and from the
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// Toolbar control that shows the current window's server and exposes a
|
||||
/// menu for opening *other* servers in additional windows. Multi-window is
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct SkillsView: View {
|
||||
@State private var viewModel: SkillsViewModel
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import os
|
||||
|
||||
struct HermesWebhook: Identifiable, Sendable, Equatable {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
import AppKit
|
||||
|
||||
struct WebhooksView: View {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
@main
|
||||
struct ScarfApp: App {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import Testing
|
||||
import Darwin
|
||||
import ScarfCore
|
||||
@testable import scarf
|
||||
|
||||
@Suite struct ControlPathTests {
|
||||
|
||||
Reference in New Issue
Block a user