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 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
|
||||||
+39
-15
@@ -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
|
||||||
|
|
||||||
+94
-25
@@ -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)
|
||||||
}
|
}
|
||||||
+32
-12
@@ -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
-4
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct ScarfApp: App {
|
struct ScarfApp: App {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user