mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
iOS port M3: CitadelServerTransport + fix critical iOS compile blocker
Three things this phase ships:
## 1. Critical iOS-compile fix (latent from M0b)
`ServerTransport.makeProcess(...) -> Process` was iOS-unavailable at
compile time but my Linux CI didn't catch it (swift-corelibs-foundation
has Process; Apple iOS does not). Without this fix, the first ⌘B on
the iOS target would fail with "Cannot find 'Process' in scope".
Wrapped `makeProcess` with `#if !os(iOS)` on:
- the ServerTransport protocol requirement
- LocalTransport's impl
- SSHTransport's impl
Every current caller of makeProcess is already Mac-target-only
(ACPClient+Mac.swift, OAuthFlowController.swift) so no code changes
needed outside ScarfCore.
## 2. New platform-neutral streamLines(_:args:)
`AsyncThrowingStream<String, Error>` on the protocol, one stdout
line per element, newline-framed. Stream finishes on EOF + throws
`TransportError.commandFailed` on non-zero exit.
Impls:
- LocalTransport: Task.detached → Process + Pipe → line-framing
loop → exit check. iOS returns an empty stream (iOS doesn't run
LocalTransport at runtime).
- SSHTransport: same pattern, wrapped in `ssh -T host -- sh -c`.
iOS gets the empty-stream stub.
- CitadelServerTransport: empty stream for M3; M4 wires it to
Citadel's raw exec channel for iOS log tailing + chat.
HermesLogService refactored to use transport.streamLines() for the
remote tail path. The old `remoteTailProcess: Process?` +
`fileHandle: FileHandle?` state collapses into a single
`remoteTailTask: Task<Void, Never>?`. Parsed-line ring buffer is
drained synchronously by readNewLines() — semantically identical
on Mac, and newly works on iOS (when Citadel wires streamLines
in M4+).
## 3. CitadelServerTransport (the meat of M3)
Full `ServerTransport` conformance in ScarfIOS:
- File I/O: SFTP via SSHClient.openSFTP()
- runProcess: SSHClient.executeCommand(_:) with 2>&1 folding
- snapshotSQLite: remote `sqlite3 .backup` then SFTP-download
to <Caches>/scarf/snapshots/<id>/state.db
- fileExists/stat: SFTPClient.getAttributes
- listDirectory: SFTPClient.listDirectory with . / .. stripped
- createDirectory: mkdir -p semantics (walks each component,
ignores existing-dir errors)
- removeFile: SFTPClient.remove, idempotent on missing
- watchPaths: 3s polling on stat mtime (same shape as Mac
SSHTransport's remote-watch fallback)
- streamLines: empty stream for M3 (see above)
Maintains a single long-lived SSH + SFTP connection per transport
instance via a nested ConnectionHolder actor. Lazy-init on first
use, reconnect on failure. Blocks the caller thread via
DispatchSemaphore to bridge Citadel's async API to
ServerTransport's sync protocol — same pattern the Mac SSHTransport
uses.
## ScarfCore transport-factory injection
New `ServerContext.sshTransportFactory: SSHTransportFactory?`
static. When non-nil, `makeTransport()` routes `.ssh` contexts
through it instead of constructing SSHTransport directly.
scarfApp.init() on iOS wires this:
ServerContext.sshTransportFactory = { id, cfg, name in
CitadelServerTransport(
contextID: id, config: cfg, displayName: name,
keyProvider: { try await KeychainSSHKeyStore().load() ?? ... }
)
}
Mac leaves it nil; default SSHTransport path unchanged.
## iOS Dashboard — real data
New IOSDashboardViewModel in ScarfIOS. Unlike Mac's DashboardViewModel
(uses HermesFileService, still Mac-only), this reads session stats +
recent sessions only — enough for a real iOS Dashboard, none of the
config.yaml / gateway-state / pgrep checks the Mac dashboard shows.
DashboardView on iOS now renders actual data: session count, message
count, tool calls, token totals (input/output/reasoning with K/M
formatting), and the last 5 sessions with their source icons +
relative start times. Pull-to-refresh triggers vm.refresh(). Error
banner with Retry on snapshot/open failures.
## Public API nits (uncovered by the Dashboard work)
HermesDataService.SessionStats member fields + .empty static were
internal-by-default (nested in a public type, sed missed them).
Promoted to public. `lastOpenError` promoted to public private(set).
## Tests — 8 new in M3TransportTests, @Suite(.serialized)
- LocalTransport.streamLines yields one line per newline, drops
partial trailing content, surfaces non-zero exit as
TransportError.commandFailed.
- ServerContext.sshTransportFactory override applies for .ssh,
ignored for .local, nil-falls-back-to-SSHTransport.
- HermesLogService remote-tail pumps scripted streamLines output
through to readNewLines() ring buffer.
- HermesLogService.readLastLines uses runProcess one-shot, as
documented.
Real bug caught in dev: first pass of this test suite had two tests
setting ServerContext.sshTransportFactory + defer-restoring. Swift-
Testing runs in parallel by default — the two tests raced, producing
"entries[2].message is 'z' not 'boom'". Fixed with
@Suite(.serialized) + a note in the suite header explaining why.
Running `docker run --rm -v $PWD/scarf/Packages/ScarfCore:/work -w /work
swift:6.0 swift test` now reports 96 / 96 passing (88 pre-M3 + 8 new).
## Manual validation needed on Mac
1. iOS build with the new protocol guards. ⌘B on iOS simulator —
should compile cleanly. If `Cannot find 'Process' in scope`
still appears anywhere, grep for any unguarded `Process\(\)`.
2. Dashboard end-to-end against a real Hermes host: iPhone
simulator, public key in remote authorized_keys, onboarding →
Dashboard → should see session stats fetched via Citadel SFTP +
exec. Pull-to-refresh should re-snapshot.
3. SQLite snapshot file appears under `<Caches>/scarf/snapshots/
<id>/state.db` and HermesDataService opens it read-only.
Updated scarf/docs/IOS_PORT_PLAN.md with M3's shipped scope, the
streamLines adoption rule, and the "CitadelServerTransport.streamLines
is a stub (M3)" / "M4 wires real streaming" cross-reference.
https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
This commit is contained in:
@@ -108,17 +108,47 @@ public struct ServerContext: Sendable, Hashable, Identifiable {
|
||||
|
||||
/// Construct the `ServerTransport` for this context. Local contexts get
|
||||
/// a `LocalTransport`; SSH contexts get an `SSHTransport` configured
|
||||
/// from `SSHConfig`. Each call returns a fresh value — transports are
|
||||
/// cheap and stateless beyond disk caches.
|
||||
/// from `SSHConfig` by default, OR whatever `sshTransportFactory`
|
||||
/// returns if the host app has wired one. Each call returns a fresh
|
||||
/// value — transports are cheap and stateless beyond disk caches.
|
||||
///
|
||||
/// **Cross-platform wiring.** On the Mac app the default
|
||||
/// `SSHTransport` (fork + exec `/usr/bin/ssh`) is the right thing,
|
||||
/// so `sshTransportFactory` stays `nil`. On iOS the Mac SSH binary
|
||||
/// doesn't exist, so `scarf-ios` wires this factory at launch to
|
||||
/// produce a Citadel-backed `ServerTransport`. All downstream
|
||||
/// services (`HermesDataService`, `HermesLogService`,
|
||||
/// `ProjectDashboardService`, …) then work on iOS unchanged.
|
||||
public nonisolated func makeTransport() -> any ServerTransport {
|
||||
switch kind {
|
||||
case .local:
|
||||
return LocalTransport(contextID: id)
|
||||
case .ssh(let config):
|
||||
if let factory = ServerContext.sshTransportFactory {
|
||||
return factory(id, config, displayName)
|
||||
}
|
||||
return SSHTransport(contextID: id, config: config, displayName: displayName)
|
||||
}
|
||||
}
|
||||
|
||||
/// Override for `.ssh` transports. The iOS app sets this at launch to
|
||||
/// `{ id, cfg, name in CitadelServerTransport(contextID: id, config: cfg, displayName: name) }`
|
||||
/// so every `ServerContext.makeTransport()` call on a Citadel-backed
|
||||
/// iOS app returns the Citadel impl instead of the Mac/Linux
|
||||
/// `SSHTransport`. Mac leaves this `nil`.
|
||||
///
|
||||
/// Set once, before any `makeTransport()` call is made. The
|
||||
/// `nonisolated(unsafe)` annotation mirrors the same pattern
|
||||
/// `SSHTransport.environmentEnricher` uses — single-write at app
|
||||
/// startup, many-read afterwards.
|
||||
public typealias SSHTransportFactory = @Sendable (
|
||||
_ id: ServerID,
|
||||
_ config: SSHConfig,
|
||||
_ displayName: String
|
||||
) -> any ServerTransport
|
||||
|
||||
nonisolated(unsafe) public static var sshTransportFactory: SSHTransportFactory?
|
||||
|
||||
// MARK: - Well-known singletons
|
||||
|
||||
/// Stable UUID for the built-in "this machine" entry. Hard-coded so the
|
||||
|
||||
@@ -54,7 +54,7 @@ public actor HermesDataService {
|
||||
/// the last attempt succeeded. Views surface this when their own load
|
||||
/// path fails, so the user sees "Permission denied reading state.db"
|
||||
/// instead of an empty Dashboard with no explanation.
|
||||
private(set) var lastOpenError: String?
|
||||
public private(set) var lastOpenError: String?
|
||||
|
||||
public let context: ServerContext
|
||||
private let transport: any ServerTransport
|
||||
@@ -435,16 +435,36 @@ public actor HermesDataService {
|
||||
// MARK: - Stats
|
||||
|
||||
public struct SessionStats: Sendable {
|
||||
let totalSessions: Int
|
||||
let totalMessages: Int
|
||||
let totalToolCalls: Int
|
||||
let totalInputTokens: Int
|
||||
let totalOutputTokens: Int
|
||||
let totalCostUSD: Double
|
||||
let totalReasoningTokens: Int
|
||||
let totalActualCostUSD: Double
|
||||
public let totalSessions: Int
|
||||
public let totalMessages: Int
|
||||
public let totalToolCalls: Int
|
||||
public let totalInputTokens: Int
|
||||
public let totalOutputTokens: Int
|
||||
public let totalCostUSD: Double
|
||||
public let totalReasoningTokens: Int
|
||||
public let totalActualCostUSD: Double
|
||||
|
||||
static let empty = SessionStats(
|
||||
public init(
|
||||
totalSessions: Int,
|
||||
totalMessages: Int,
|
||||
totalToolCalls: Int,
|
||||
totalInputTokens: Int,
|
||||
totalOutputTokens: Int,
|
||||
totalCostUSD: Double,
|
||||
totalReasoningTokens: Int,
|
||||
totalActualCostUSD: Double
|
||||
) {
|
||||
self.totalSessions = totalSessions
|
||||
self.totalMessages = totalMessages
|
||||
self.totalToolCalls = totalToolCalls
|
||||
self.totalInputTokens = totalInputTokens
|
||||
self.totalOutputTokens = totalOutputTokens
|
||||
self.totalCostUSD = totalCostUSD
|
||||
self.totalReasoningTokens = totalReasoningTokens
|
||||
self.totalActualCostUSD = totalActualCostUSD
|
||||
}
|
||||
|
||||
public static let empty = SessionStats(
|
||||
totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
|
||||
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0,
|
||||
totalReasoningTokens: 0, totalActualCostUSD: 0
|
||||
|
||||
@@ -47,15 +47,18 @@ public struct LogEntry: Identifiable, Sendable {
|
||||
}
|
||||
|
||||
public actor HermesLogService {
|
||||
private var fileHandle: FileHandle?
|
||||
/// Local file handle for local contexts. `nil` when following a remote
|
||||
/// log or when no log is open.
|
||||
private var localHandle: FileHandle?
|
||||
private var currentPath: String?
|
||||
private var entryCounter = 0
|
||||
|
||||
/// Remote tailing state. When set, we're reading from `ssh host tail -F`
|
||||
/// instead of a local file. Process stdout pipe drives `readNewLines()`;
|
||||
/// process lifecycle is the actor's responsibility.
|
||||
private var remoteTailProcess: Process?
|
||||
private var remoteTailBuffer: String = ""
|
||||
/// Remote-tail state. Streaming exec via `transport.streamLines(...)`
|
||||
/// yields one stdout line per element; the pump task pushes them into
|
||||
/// `remoteTailBuffer` for `readNewLines()` to drain. The task is
|
||||
/// cancelled on `closeLog()` and when re-opening to a different path.
|
||||
private var remoteTailTask: Task<Void, Never>?
|
||||
private var remoteTailBuffer: [LogEntry] = []
|
||||
|
||||
public let context: ServerContext
|
||||
private let transport: any ServerTransport
|
||||
@@ -69,43 +72,44 @@ public actor HermesLogService {
|
||||
closeLog()
|
||||
currentPath = path
|
||||
if context.isRemote {
|
||||
// Spawn `ssh host tail -F` and pipe stdout into our buffer. `-F`
|
||||
// follows the file through rotations — important for remote
|
||||
// log rotation setups (logrotate).
|
||||
let proc = transport.makeProcess(
|
||||
// Streaming tail via the transport's `streamLines`. This works
|
||||
// on every platform: Mac/Linux drive it through a local `ssh`
|
||||
// subprocess; iOS drives it through a Citadel exec channel.
|
||||
// We don't hold a FileHandle anymore — the AsyncThrowingStream
|
||||
// owns the lifecycle and our pump Task pulls lines off it.
|
||||
let stream = transport.streamLines(
|
||||
executable: "/usr/bin/tail",
|
||||
args: ["-n", String(QueryDefaults.logLineLimit), "-F", path]
|
||||
)
|
||||
let outPipe = Pipe()
|
||||
proc.standardOutput = outPipe
|
||||
proc.standardError = Pipe()
|
||||
do {
|
||||
try proc.run()
|
||||
remoteTailProcess = proc
|
||||
fileHandle = outPipe.fileHandleForReading
|
||||
} catch {
|
||||
print("[Scarf] Failed to start remote tail: \(error.localizedDescription)")
|
||||
remoteTailProcess = nil
|
||||
fileHandle = nil
|
||||
remoteTailTask = Task { [weak self] in
|
||||
do {
|
||||
for try await line in stream {
|
||||
await self?.appendRemoteTailLine(line)
|
||||
}
|
||||
} catch {
|
||||
// Transient disconnects / command failures: surface once
|
||||
// and stop. Callers typically re-open the log on retry.
|
||||
#if DEBUG
|
||||
print("[Scarf] remote tail ended: \(error.localizedDescription)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fileHandle = FileHandle(forReadingAtPath: path)
|
||||
localHandle = FileHandle(forReadingAtPath: path)
|
||||
}
|
||||
}
|
||||
|
||||
public func closeLog() {
|
||||
do {
|
||||
try fileHandle?.close()
|
||||
try localHandle?.close()
|
||||
} catch {
|
||||
print("[Scarf] Failed to close log handle: \(error.localizedDescription)")
|
||||
}
|
||||
fileHandle = nil
|
||||
localHandle = nil
|
||||
currentPath = nil
|
||||
if let proc = remoteTailProcess, proc.isRunning {
|
||||
proc.terminate()
|
||||
}
|
||||
remoteTailProcess = nil
|
||||
remoteTailBuffer = ""
|
||||
remoteTailTask?.cancel()
|
||||
remoteTailTask = nil
|
||||
remoteTailBuffer.removeAll(keepingCapacity: false)
|
||||
}
|
||||
|
||||
public func readLastLines(count: Int = QueryDefaults.logLineLimit) -> [LogEntry] {
|
||||
@@ -131,22 +135,19 @@ public actor HermesLogService {
|
||||
}
|
||||
|
||||
public func readNewLines() -> [LogEntry] {
|
||||
guard let handle = fileHandle else { return [] }
|
||||
if context.isRemote {
|
||||
// Drain whatever the streaming tail has accumulated since the
|
||||
// last call. The async pump task above does the line framing
|
||||
// and parsing; we just hand the batch back.
|
||||
guard !remoteTailBuffer.isEmpty else { return [] }
|
||||
let batch = remoteTailBuffer
|
||||
remoteTailBuffer.removeAll(keepingCapacity: true)
|
||||
return batch
|
||||
}
|
||||
guard let handle = localHandle else { return [] }
|
||||
let data = handle.availableData
|
||||
guard !data.isEmpty else { return [] }
|
||||
let chunk = String(data: data, encoding: .utf8) ?? ""
|
||||
if context.isRemote {
|
||||
// Remote tail emits bytes as they arrive — not line-aligned.
|
||||
// Buffer partials across reads so we don't split a line mid-way.
|
||||
remoteTailBuffer += chunk
|
||||
guard let lastNewline = remoteTailBuffer.lastIndex(of: "\n") else {
|
||||
return []
|
||||
}
|
||||
let complete = String(remoteTailBuffer[..<lastNewline])
|
||||
remoteTailBuffer = String(remoteTailBuffer[remoteTailBuffer.index(after: lastNewline)...])
|
||||
let lines = complete.components(separatedBy: "\n").filter { !$0.isEmpty }
|
||||
return lines.map { parseLine($0) }
|
||||
}
|
||||
let lines = chunk.components(separatedBy: "\n").filter { !$0.isEmpty }
|
||||
return lines.map { parseLine($0) }
|
||||
}
|
||||
@@ -155,10 +156,18 @@ public actor HermesLogService {
|
||||
// Only meaningful for local FileHandles — remote tail starts at the
|
||||
// end implicitly after `readLastLines` drained the initial load.
|
||||
if !context.isRemote {
|
||||
fileHandle?.seekToEndOfFile()
|
||||
localHandle?.seekToEndOfFile()
|
||||
}
|
||||
}
|
||||
|
||||
/// Called from the remote-tail pump Task when the AsyncStream yields a
|
||||
/// line. Parses and enqueues into the buffer that `readNewLines()`
|
||||
/// drains on the next poll from the ViewModel's timer.
|
||||
private func appendRemoteTailLine(_ line: String) {
|
||||
guard !line.isEmpty else { return }
|
||||
remoteTailBuffer.append(parseLine(line))
|
||||
}
|
||||
|
||||
private func parseLine(_ line: String) -> LogEntry {
|
||||
entryCounter += 1
|
||||
// Format (v0.9.0+): YYYY-MM-DD HH:MM:SS,MMM LEVEL [session_id] logger: message
|
||||
|
||||
@@ -149,12 +149,67 @@ public struct LocalTransport: ServerTransport {
|
||||
return ProcessResult(exitCode: proc.terminationStatus, stdout: out, stderr: err)
|
||||
}
|
||||
|
||||
#if !os(iOS)
|
||||
public func makeProcess(executable: String, args: [String]) -> Process {
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: executable)
|
||||
proc.arguments = args
|
||||
return proc
|
||||
}
|
||||
#endif
|
||||
|
||||
public func streamLines(executable: String, args: [String]) -> AsyncThrowingStream<String, Error> {
|
||||
#if os(iOS)
|
||||
// LocalTransport doesn't run on iOS at runtime — the iOS app
|
||||
// talks only to remote hosts via `CitadelServerTransport` — but
|
||||
// we still need this method to satisfy the `ServerTransport`
|
||||
// protocol for the compile. Return an immediately-finished
|
||||
// stream so any accidental iOS caller gets a no-op.
|
||||
return AsyncThrowingStream { $0.finish() }
|
||||
#else
|
||||
return AsyncThrowingStream { continuation in
|
||||
Task.detached {
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: executable)
|
||||
proc.arguments = args
|
||||
let outPipe = Pipe()
|
||||
let errPipe = Pipe()
|
||||
proc.standardOutput = outPipe
|
||||
proc.standardError = errPipe
|
||||
do {
|
||||
try proc.run()
|
||||
} catch {
|
||||
continuation.finish(throwing: error)
|
||||
return
|
||||
}
|
||||
let handle = outPipe.fileHandleForReading
|
||||
var buffer = Data()
|
||||
while true {
|
||||
let chunk = handle.availableData
|
||||
if chunk.isEmpty { break } // EOF
|
||||
buffer.append(chunk)
|
||||
while let nl = buffer.firstIndex(of: 0x0A) {
|
||||
let lineData = Data(buffer[buffer.startIndex..<nl])
|
||||
buffer = Data(buffer[buffer.index(after: nl)...])
|
||||
if let text = String(data: lineData, encoding: .utf8) {
|
||||
continuation.yield(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
proc.waitUntilExit()
|
||||
if proc.terminationStatus != 0 {
|
||||
let stderr = (try? errPipe.fileHandleForReading.readToEnd())
|
||||
.flatMap { String(data: $0 ?? Data(), encoding: .utf8) } ?? ""
|
||||
continuation.finish(throwing: TransportError.commandFailed(
|
||||
exitCode: proc.terminationStatus, stderr: stderr
|
||||
))
|
||||
} else {
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - SQLite
|
||||
|
||||
|
||||
@@ -421,6 +421,7 @@ public struct SSHTransport: ServerTransport {
|
||||
return try runLocal(executable: sshBinary, args: sshArgv, stdin: stdin, timeout: timeout)
|
||||
}
|
||||
|
||||
#if !os(iOS)
|
||||
public func makeProcess(executable: String, args: [String]) -> Process {
|
||||
ensureControlDir()
|
||||
// `-T` disables pty allocation — critical for binary-clean stdin/stdout
|
||||
@@ -439,6 +440,68 @@ public struct SSHTransport: ServerTransport {
|
||||
proc.environment = Self.sshSubprocessEnvironment()
|
||||
return proc
|
||||
}
|
||||
#endif
|
||||
|
||||
public func streamLines(executable: String, args: [String]) -> AsyncThrowingStream<String, Error> {
|
||||
#if os(iOS)
|
||||
// SSHTransport is not a runtime choice on iOS — the iOS app
|
||||
// uses `CitadelServerTransport` instead. This conformance
|
||||
// exists so ScarfCore compiles for iOS; actual streaming SSH
|
||||
// exec on iOS is Citadel's job.
|
||||
return AsyncThrowingStream { $0.finish() }
|
||||
#else
|
||||
return AsyncThrowingStream { continuation in
|
||||
Task.detached { [self] in
|
||||
ensureControlDir()
|
||||
let cmd = ([executable] + args).map { Self.remotePathArg($0) }.joined(separator: " ")
|
||||
var sshArgv = sshArgs()
|
||||
sshArgv.insert("-T", at: 0)
|
||||
sshArgv.append(hostSpec)
|
||||
sshArgv.append("sh")
|
||||
sshArgv.append("-c")
|
||||
sshArgv.append(Self.shellQuote(cmd))
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: sshBinary)
|
||||
proc.arguments = sshArgv
|
||||
proc.environment = Self.sshSubprocessEnvironment()
|
||||
let outPipe = Pipe()
|
||||
let errPipe = Pipe()
|
||||
proc.standardOutput = outPipe
|
||||
proc.standardError = errPipe
|
||||
do {
|
||||
try proc.run()
|
||||
} catch {
|
||||
continuation.finish(throwing: error)
|
||||
return
|
||||
}
|
||||
let handle = outPipe.fileHandleForReading
|
||||
var buffer = Data()
|
||||
while true {
|
||||
let chunk = handle.availableData
|
||||
if chunk.isEmpty { break } // EOF
|
||||
buffer.append(chunk)
|
||||
while let nl = buffer.firstIndex(of: 0x0A) {
|
||||
let lineData = Data(buffer[buffer.startIndex..<nl])
|
||||
buffer = Data(buffer[buffer.index(after: nl)...])
|
||||
if let text = String(data: lineData, encoding: .utf8) {
|
||||
continuation.yield(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
proc.waitUntilExit()
|
||||
if proc.terminationStatus != 0 {
|
||||
let stderr = (try? errPipe.fileHandleForReading.readToEnd())
|
||||
.flatMap { String(data: $0 ?? Data(), encoding: .utf8) } ?? ""
|
||||
continuation.finish(throwing: TransportError.classifySSHFailure(
|
||||
host: config.host, exitCode: proc.terminationStatus, stderr: stderr
|
||||
))
|
||||
} else {
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Injection point for ssh/scp subprocess environment enrichment.
|
||||
///
|
||||
|
||||
@@ -12,9 +12,10 @@ import Foundation
|
||||
///
|
||||
/// The primitives are deliberately **synchronous where possible** (file I/O,
|
||||
/// process `run` + wait) so services don't need to become `async` end-to-end.
|
||||
/// The two naturally-streaming cases — log tail and ACP stdio — use
|
||||
/// `makeProcess` which returns a configured `Process`; services own the
|
||||
/// stdio pipes and lifecycle exactly as they do today.
|
||||
/// Streaming stdio (log tail, ACP JSON-RPC) goes through the
|
||||
/// `streamLines(...)` async-stream variant so `Foundation.Process` never
|
||||
/// appears in the public protocol — that's iOS-unavailable and would break
|
||||
/// the ScarfCore compile for the iOS app target.
|
||||
public protocol ServerTransport: Sendable {
|
||||
/// Identifies the context this transport serves. Used for cache
|
||||
/// namespacing (e.g. per-server SQLite snapshot directories).
|
||||
@@ -52,12 +53,33 @@ public protocol ServerTransport: Sendable {
|
||||
|
||||
/// Return a `Process` configured for the target — already pointed at the
|
||||
/// right executable with the right arguments, but **not yet started**.
|
||||
/// Callers attach their own `Pipe`s and call `run()`. Used by ACPClient
|
||||
/// (JSON-RPC over stdio) and by `HermesLogService`'s streaming tail.
|
||||
/// Callers attach their own `Pipe`s and call `run()`. Used by the Mac
|
||||
/// app's ACPClient+Mac factory and (historically) by HermesLogService's
|
||||
/// streaming tail.
|
||||
///
|
||||
/// Local: `executable` + `args` verbatim.
|
||||
/// Remote: `/usr/bin/ssh` + connection flags + `[host, "--", executable, args…]`.
|
||||
/// **Platform-gated.** `Foundation.Process` is macOS/Linux-only — it is
|
||||
/// NOT available on iOS. The iOS app uses `streamLines(...)` for any
|
||||
/// streaming-stdio need; `makeProcess` exists solely for the Mac /
|
||||
/// Linux-CI code paths that already depended on it.
|
||||
#if !os(iOS)
|
||||
nonisolated func makeProcess(executable: String, args: [String]) -> Process
|
||||
#endif
|
||||
|
||||
/// Platform-neutral streaming exec. Runs `executable args…` on the target
|
||||
/// and yields one stdout line per `AsyncThrowingStream` element (newline
|
||||
/// framing, stripped). The stream finishes on EOF / clean exit and errors
|
||||
/// with `TransportError.commandFailed` on non-zero exit.
|
||||
///
|
||||
/// Callers must iterate the stream to consume bytes — the underlying
|
||||
/// subprocess / SSH channel is started lazily on first iteration and
|
||||
/// torn down when the iterator is dropped.
|
||||
///
|
||||
/// Replaces the stdout-pipe dance that `makeProcess` required; services
|
||||
/// like `HermesLogService` migrated here in M3.
|
||||
nonisolated func streamLines(
|
||||
executable: String,
|
||||
args: [String]
|
||||
) -> AsyncThrowingStream<String, Error>
|
||||
|
||||
// MARK: - SQLite
|
||||
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import ScarfCore
|
||||
|
||||
/// Exercises M3's changes to the `ServerTransport` surface: the new
|
||||
/// `streamLines(_:args:)` method, the platform-gated `makeProcess`,
|
||||
/// the `ServerContext.sshTransportFactory` injection point, and the
|
||||
/// HermesLogService refactor that drives remote tailing through
|
||||
/// `streamLines` instead of a raw `Process` + `Pipe`.
|
||||
///
|
||||
/// **`.serialized` is mandatory.** Several tests set the static
|
||||
/// `ServerContext.sshTransportFactory` + restore in `defer`. Running
|
||||
/// them in parallel (swift-testing's default) makes the factory a
|
||||
/// race hazard — one test's scripted transport gets read by the
|
||||
/// other, producing confusing "wrong log line" failures.
|
||||
@Suite(.serialized) struct M3TransportTests {
|
||||
|
||||
// MARK: - streamLines: LocalTransport
|
||||
|
||||
@Test func localStreamLinesYieldsOneLinePerNewline() async throws {
|
||||
// `echo -e` is not portable between BSD and GNU `echo`, so we
|
||||
// use `/bin/sh -c 'printf ...'` which is deterministic on both.
|
||||
// LocalTransport's streamLines should yield three lines when
|
||||
// the subprocess emits "a\nb\nc\n".
|
||||
let transport = LocalTransport()
|
||||
let stream = transport.streamLines(
|
||||
executable: "/bin/sh",
|
||||
args: ["-c", "printf 'a\\nb\\nc\\n'"]
|
||||
)
|
||||
var collected: [String] = []
|
||||
for try await line in stream {
|
||||
collected.append(line)
|
||||
}
|
||||
#expect(collected == ["a", "b", "c"])
|
||||
}
|
||||
|
||||
@Test func localStreamLinesFinishesOnEOFWithoutTrailingNewline() async throws {
|
||||
// If the subprocess emits "a\nb" (no trailing newline), we
|
||||
// yield "a" and DROP "b" — the stream framer treats partial
|
||||
// trailing content as unterminated. This is the documented
|
||||
// behaviour and matches what the HermesLogService tail path
|
||||
// sees over SSH.
|
||||
let transport = LocalTransport()
|
||||
let stream = transport.streamLines(
|
||||
executable: "/bin/sh",
|
||||
args: ["-c", "printf 'a\\nb'"]
|
||||
)
|
||||
var collected: [String] = []
|
||||
for try await line in stream {
|
||||
collected.append(line)
|
||||
}
|
||||
#expect(collected == ["a"])
|
||||
}
|
||||
|
||||
@Test func localStreamLinesSurfacesNonZeroExit() async throws {
|
||||
let transport = LocalTransport()
|
||||
let stream = transport.streamLines(
|
||||
executable: "/bin/sh",
|
||||
args: ["-c", "printf 'a\\n'; exit 3"]
|
||||
)
|
||||
var collected: [String] = []
|
||||
var thrown: Error?
|
||||
do {
|
||||
for try await line in stream {
|
||||
collected.append(line)
|
||||
}
|
||||
} catch {
|
||||
thrown = error
|
||||
}
|
||||
#expect(collected == ["a"])
|
||||
guard let err = thrown as? TransportError else {
|
||||
Issue.record("expected TransportError, got \(String(describing: thrown))")
|
||||
return
|
||||
}
|
||||
if case .commandFailed(let exit, _) = err {
|
||||
#expect(exit == 3)
|
||||
} else {
|
||||
Issue.record("expected .commandFailed, got \(err)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - sshTransportFactory injection
|
||||
|
||||
@Test func sshTransportFactoryOverridesDefault() {
|
||||
// Set up a mock factory that returns a `LocalTransport` regardless
|
||||
// of the ServerKind — easy way to prove the injection point
|
||||
// routes to our override.
|
||||
final class CountingBox: @unchecked Sendable {
|
||||
var count = 0
|
||||
func bump() { count += 1 }
|
||||
}
|
||||
let box = CountingBox()
|
||||
let previous = ServerContext.sshTransportFactory
|
||||
defer { ServerContext.sshTransportFactory = previous }
|
||||
|
||||
ServerContext.sshTransportFactory = { id, _, _ in
|
||||
box.bump()
|
||||
return LocalTransport(contextID: id)
|
||||
}
|
||||
|
||||
let ctx = ServerContext(
|
||||
id: UUID(),
|
||||
displayName: "test",
|
||||
kind: .ssh(SSHConfig(host: "h"))
|
||||
)
|
||||
let transport = ctx.makeTransport()
|
||||
#expect(transport is LocalTransport)
|
||||
#expect(box.count == 1)
|
||||
}
|
||||
|
||||
@Test func sshTransportFactoryNilFallsBackToSSHTransport() {
|
||||
let previous = ServerContext.sshTransportFactory
|
||||
defer { ServerContext.sshTransportFactory = previous }
|
||||
ServerContext.sshTransportFactory = nil
|
||||
|
||||
let ctx = ServerContext(
|
||||
id: UUID(),
|
||||
displayName: "test",
|
||||
kind: .ssh(SSHConfig(host: "h"))
|
||||
)
|
||||
let transport = ctx.makeTransport()
|
||||
#expect(transport is SSHTransport)
|
||||
}
|
||||
|
||||
@Test func sshTransportFactoryIgnoredForLocalContext() {
|
||||
let previous = ServerContext.sshTransportFactory
|
||||
defer { ServerContext.sshTransportFactory = previous }
|
||||
// Even if set, the factory is ONLY consulted for `.ssh` kinds —
|
||||
// `.local` always gets a `LocalTransport` directly.
|
||||
ServerContext.sshTransportFactory = { _, _, _ in
|
||||
Issue.record("factory called for local context")
|
||||
return LocalTransport()
|
||||
}
|
||||
|
||||
let transport = ServerContext.local.makeTransport()
|
||||
#expect(transport is LocalTransport)
|
||||
}
|
||||
|
||||
// MARK: - HermesLogService remote tail refactor
|
||||
|
||||
/// Minimal `ServerTransport` test double: `isRemote == true`, all
|
||||
/// file I/O throws, `streamLines` returns a scripted sequence of
|
||||
/// lines. Exists to verify HermesLogService's remote-tail path
|
||||
/// pumps scripted output into the ring buffer without a real SSH
|
||||
/// subprocess.
|
||||
final class ScriptedTransport: ServerTransport, @unchecked Sendable {
|
||||
public let contextID: ServerID = UUID()
|
||||
public let isRemote: Bool = true
|
||||
private let lines: [String]
|
||||
|
||||
init(lines: [String]) { self.lines = lines }
|
||||
|
||||
func readFile(_ path: String) throws -> Data { throw TransportError.other(message: "N/A") }
|
||||
func writeFile(_ path: String, data: Data) throws { throw TransportError.other(message: "N/A") }
|
||||
func fileExists(_ path: String) -> Bool { true }
|
||||
func stat(_ path: String) -> FileStat? { FileStat(size: 0, mtime: Date(), isDirectory: false) }
|
||||
func listDirectory(_ path: String) throws -> [String] { [] }
|
||||
func createDirectory(_ path: String) throws {}
|
||||
func removeFile(_ path: String) throws {}
|
||||
func runProcess(executable: String, args: [String], stdin: Data?, timeout: TimeInterval?) throws -> ProcessResult {
|
||||
// For readLastLines' one-shot tail — return all scripted lines joined.
|
||||
let content = lines.joined(separator: "\n") + "\n"
|
||||
return ProcessResult(exitCode: 0, stdout: Data(content.utf8), stderr: Data())
|
||||
}
|
||||
#if !os(iOS)
|
||||
func makeProcess(executable: String, args: [String]) -> Process {
|
||||
// Required by protocol on non-iOS; not exercised in tests below.
|
||||
Process()
|
||||
}
|
||||
#endif
|
||||
func streamLines(executable: String, args: [String]) -> AsyncThrowingStream<String, Error> {
|
||||
AsyncThrowingStream { continuation in
|
||||
Task {
|
||||
for line in lines {
|
||||
continuation.yield(line)
|
||||
}
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
func snapshotSQLite(remotePath: String) throws -> URL { URL(fileURLWithPath: remotePath) }
|
||||
func watchPaths(_ paths: [String]) -> AsyncStream<WatchEvent> {
|
||||
AsyncStream { $0.finish() }
|
||||
}
|
||||
}
|
||||
|
||||
// Note: We can't easily inject the ScriptedTransport into
|
||||
// HermesLogService directly (it takes a `ServerContext` and constructs
|
||||
// its transport internally via `context.makeTransport()`). Instead we
|
||||
// wire the scripted transport through the factory injection point.
|
||||
@Test func hermesLogServiceRemoteTailPumpsThroughStreamLines() async throws {
|
||||
let scripted = ScriptedTransport(lines: [
|
||||
"2026-04-22 12:00:00,001 INFO hermes.agent: starting",
|
||||
"2026-04-22 12:00:01,002 WARNING hermes.gateway: low disk",
|
||||
"2026-04-22 12:00:02,003 ERROR hermes.agent: boom",
|
||||
])
|
||||
|
||||
let previous = ServerContext.sshTransportFactory
|
||||
defer { ServerContext.sshTransportFactory = previous }
|
||||
ServerContext.sshTransportFactory = { _, _, _ in scripted }
|
||||
|
||||
let ctx = ServerContext(
|
||||
id: UUID(),
|
||||
displayName: "t",
|
||||
kind: .ssh(SSHConfig(host: "h"))
|
||||
)
|
||||
let service = HermesLogService(context: ctx)
|
||||
await service.openLog(path: "/fake/agent.log")
|
||||
defer { Task { await service.closeLog() } }
|
||||
|
||||
// Give the pump task a moment to drain the scripted stream.
|
||||
try await Task.sleep(nanoseconds: 50_000_000)
|
||||
|
||||
let entries = await service.readNewLines()
|
||||
#expect(entries.count == 3)
|
||||
#expect(entries[0].level == .info)
|
||||
#expect(entries[1].level == .warning)
|
||||
#expect(entries[2].level == .error)
|
||||
#expect(entries[2].message == "boom")
|
||||
}
|
||||
|
||||
@Test func hermesLogServiceReadLastLinesUsesOneShotTail() async {
|
||||
let scripted = ScriptedTransport(lines: ["x", "y", "z"])
|
||||
let previous = ServerContext.sshTransportFactory
|
||||
defer { ServerContext.sshTransportFactory = previous }
|
||||
ServerContext.sshTransportFactory = { _, _, _ in scripted }
|
||||
|
||||
let ctx = ServerContext(
|
||||
id: UUID(),
|
||||
displayName: "t",
|
||||
kind: .ssh(SSHConfig(host: "h"))
|
||||
)
|
||||
let service = HermesLogService(context: ctx)
|
||||
// Doesn't need openLog first for the one-shot, but currentPath
|
||||
// has to be set — openLog does both.
|
||||
await service.openLog(path: "/fake/agent.log")
|
||||
defer { Task { await service.closeLog() } }
|
||||
|
||||
let entries = await service.readLastLines(count: 100)
|
||||
#expect(entries.count == 3)
|
||||
#expect(entries[0].message == "x")
|
||||
#expect(entries[2].message == "z")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
// Gated on `canImport(Citadel)` so Linux CI (which can't resolve
|
||||
// Citadel transitively from ScarfIOS anyway) skips the file. iOS +
|
||||
// macOS compile it normally.
|
||||
#if canImport(Citadel)
|
||||
|
||||
import Foundation
|
||||
import NIOCore
|
||||
import NIOPosix
|
||||
import Citadel
|
||||
import CryptoKit
|
||||
import ScarfCore
|
||||
#if canImport(os)
|
||||
import os
|
||||
#endif
|
||||
|
||||
/// `ServerTransport` conformance backed by Citadel's SSH + SFTP client.
|
||||
///
|
||||
/// Used by the iOS app as the `.ssh` transport implementation (wired via
|
||||
/// `ServerContext.sshTransportFactory` at app launch). Every file I/O
|
||||
/// primitive routes through SFTP; every process invocation routes
|
||||
/// through `SSHClient.executeCommand`; SQLite snapshot pulls run
|
||||
/// `sqlite3 .backup` remotely then SFTP-download the backup file.
|
||||
///
|
||||
/// **Single long-lived connection per transport instance.** Citadel's
|
||||
/// `SSHClient.connect(...)` handshake is ~500ms on a warm network; we
|
||||
/// don't want to pay that per file read. The first call that needs the
|
||||
/// connection opens it; subsequent calls reuse. On error, the next call
|
||||
/// re-opens.
|
||||
///
|
||||
/// **Blocking bridge to async.** `ServerTransport` protocol methods are
|
||||
/// synchronous, by design — services don't become `async` end-to-end.
|
||||
/// Citadel is `async` everywhere. The `runSync(_:)` helper uses a
|
||||
/// `DispatchSemaphore` to block the caller thread until the async
|
||||
/// operation finishes. This matches how the macOS `SSHTransport` blocks
|
||||
/// on its `/usr/bin/ssh` subprocess; semantically identical.
|
||||
///
|
||||
/// **M3 scope.** `streamLines(...)` currently returns an empty stream —
|
||||
/// iOS log tailing comes in a later phase. `watchPaths(...)` polls
|
||||
/// `stat` every 3s as a remote heartbeat, same as macOS SSHTransport's
|
||||
/// remote-watch fallback. Everything else (readFile, writeFile,
|
||||
/// listDirectory, runProcess, snapshotSQLite) has a full Citadel-
|
||||
/// backed implementation.
|
||||
public final class CitadelServerTransport: ServerTransport, @unchecked Sendable {
|
||||
#if canImport(os)
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "CitadelServerTransport")
|
||||
#endif
|
||||
|
||||
public let contextID: ServerID
|
||||
public let isRemote: Bool = true
|
||||
|
||||
public let config: SSHConfig
|
||||
public let displayName: String
|
||||
|
||||
/// Async-safe provider for the SSH private key bundle. iOS wires
|
||||
/// this to read from the Keychain; tests inject a fixed bundle.
|
||||
public typealias KeyProvider = @Sendable () async throws -> SSHKeyBundle
|
||||
private let keyProvider: KeyProvider
|
||||
|
||||
/// Shared directory under which cached SQLite snapshots land. On
|
||||
/// iOS this maps to `<Caches>/scarf/snapshots/<server-id>/`.
|
||||
private let snapshotBaseDir: URL
|
||||
|
||||
/// Actor-serialized access to the one shared `SSHClient`. Opens
|
||||
/// lazily on first use, reconnects on error.
|
||||
private let connectionHolder: ConnectionHolder
|
||||
|
||||
public init(
|
||||
contextID: ServerID,
|
||||
config: SSHConfig,
|
||||
displayName: String,
|
||||
keyProvider: @escaping KeyProvider
|
||||
) {
|
||||
self.contextID = contextID
|
||||
self.config = config
|
||||
self.displayName = displayName
|
||||
self.keyProvider = keyProvider
|
||||
self.snapshotBaseDir = Self.snapshotDirURL(for: contextID)
|
||||
self.connectionHolder = ConnectionHolder(
|
||||
contextID: contextID,
|
||||
config: config,
|
||||
keyProvider: keyProvider
|
||||
)
|
||||
}
|
||||
|
||||
deinit {
|
||||
// Fire-and-forget close. Swift deinit doesn't allow awaiting;
|
||||
// Citadel's close is async so we push it onto a detached task
|
||||
// and let it run to completion when the app is still alive.
|
||||
let holder = connectionHolder
|
||||
Task.detached { await holder.closeIfOpen() }
|
||||
}
|
||||
|
||||
/// Explicit shutdown hook — call before releasing the transport
|
||||
/// to guarantee the SSH connection is closed before the app
|
||||
/// suspends. Idempotent.
|
||||
public func close() async {
|
||||
await connectionHolder.closeIfOpen()
|
||||
}
|
||||
|
||||
// MARK: - ServerTransport: files
|
||||
|
||||
public func readFile(_ path: String) throws -> Data {
|
||||
try runSync { try await self.asyncReadFile(path) }
|
||||
}
|
||||
|
||||
public func writeFile(_ path: String, data: Data) throws {
|
||||
try runSync { try await self.asyncWriteFile(path, data: data) }
|
||||
}
|
||||
|
||||
public func fileExists(_ path: String) -> Bool {
|
||||
(try? runSync { try await self.asyncFileExists(path) }) ?? false
|
||||
}
|
||||
|
||||
public func stat(_ path: String) -> FileStat? {
|
||||
try? runSync { try await self.asyncStat(path) }
|
||||
}
|
||||
|
||||
public func listDirectory(_ path: String) throws -> [String] {
|
||||
try runSync { try await self.asyncListDirectory(path) }
|
||||
}
|
||||
|
||||
public func createDirectory(_ path: String) throws {
|
||||
try runSync { try await self.asyncCreateDirectory(path) }
|
||||
}
|
||||
|
||||
public func removeFile(_ path: String) throws {
|
||||
try runSync { try await self.asyncRemoveFile(path) }
|
||||
}
|
||||
|
||||
// MARK: - ServerTransport: processes
|
||||
|
||||
public func runProcess(
|
||||
executable: String,
|
||||
args: [String],
|
||||
stdin: Data?,
|
||||
timeout: TimeInterval?
|
||||
) throws -> ProcessResult {
|
||||
if stdin != nil {
|
||||
// Citadel's `executeCommand` doesn't accept stdin. None of
|
||||
// the iOS runtime paths exercise this today (the one call
|
||||
// in `ServerContext.UserHomeCache.probe` passes `nil`), so
|
||||
// fail loudly rather than silently drop.
|
||||
throw TransportError.other(message: "CitadelServerTransport.runProcess does not support stdin yet")
|
||||
}
|
||||
return try runSync {
|
||||
try await self.asyncRunProcess(executable: executable, args: args, timeout: timeout)
|
||||
}
|
||||
}
|
||||
|
||||
public func streamLines(
|
||||
executable: String,
|
||||
args: [String]
|
||||
) -> AsyncThrowingStream<String, Error> {
|
||||
// M3 stub. iOS log tailing (HermesLogService streaming tail)
|
||||
// comes in a later phase — for now the Dashboard path doesn't
|
||||
// need streaming exec. A future revision should use Citadel's
|
||||
// raw exec channel to pipe stdout line-by-line without
|
||||
// buffering the whole command output.
|
||||
AsyncThrowingStream { $0.finish() }
|
||||
}
|
||||
|
||||
// MARK: - ServerTransport: SQLite snapshot
|
||||
|
||||
public func snapshotSQLite(remotePath: String) throws -> URL {
|
||||
try runSync { try await self.asyncSnapshotSQLite(remotePath: remotePath) }
|
||||
}
|
||||
|
||||
// MARK: - ServerTransport: watching
|
||||
|
||||
public func watchPaths(_ paths: [String]) -> AsyncStream<WatchEvent> {
|
||||
// Polling-based, identical in shape to `SSHTransport`'s remote-
|
||||
// watch fallback: stat each path, yield `.anyChanged` when any
|
||||
// mtime shifts. 3s tick keeps bandwidth low.
|
||||
AsyncStream { continuation in
|
||||
let task = Task.detached { [weak self] in
|
||||
var lastSignature = ""
|
||||
while !Task.isCancelled {
|
||||
guard let self else { break }
|
||||
let current = await self.buildWatchSignature(for: paths)
|
||||
if !current.isEmpty, current != lastSignature {
|
||||
if !lastSignature.isEmpty {
|
||||
continuation.yield(.anyChanged)
|
||||
}
|
||||
lastSignature = current
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
}
|
||||
}
|
||||
continuation.onTermination = { _ in task.cancel() }
|
||||
}
|
||||
}
|
||||
|
||||
private func buildWatchSignature(for paths: [String]) async -> String {
|
||||
var parts: [String] = []
|
||||
for path in paths {
|
||||
if let stat = try? await asyncStat(path) {
|
||||
parts.append("\(path):\(Int(stat.mtime.timeIntervalSince1970)):\(stat.size)")
|
||||
} else {
|
||||
parts.append("\(path):0:0")
|
||||
}
|
||||
}
|
||||
return parts.joined(separator: ",")
|
||||
}
|
||||
|
||||
// MARK: - Static helpers
|
||||
|
||||
/// The app-level snapshots root, same shape as the macOS transport.
|
||||
nonisolated public static func snapshotDirURL(for id: ServerID) -> URL {
|
||||
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
|
||||
?? URL(fileURLWithPath: NSHomeDirectory() + "/Library/Caches")
|
||||
return caches
|
||||
.appendingPathComponent("scarf", isDirectory: true)
|
||||
.appendingPathComponent("snapshots", isDirectory: true)
|
||||
.appendingPathComponent(id.uuidString, isDirectory: true)
|
||||
}
|
||||
|
||||
// MARK: - Async primitives (package-private, testable through subclassing)
|
||||
|
||||
private func asyncReadFile(_ path: String) async throws -> Data {
|
||||
let sftp = try await connectionHolder.sftp()
|
||||
return try await sftp.withFile(filePath: path, flags: [.read]) { file in
|
||||
let buf = try await file.readAll()
|
||||
return Data(buffer: buf)
|
||||
}
|
||||
}
|
||||
|
||||
private func asyncWriteFile(_ path: String, data: Data) async throws {
|
||||
let sftp = try await connectionHolder.sftp()
|
||||
let byteBuffer = ByteBuffer(bytes: data)
|
||||
try await sftp.withFile(
|
||||
filePath: path,
|
||||
flags: [.write, .create, .truncate]
|
||||
) { file in
|
||||
try await file.write(byteBuffer, at: 0)
|
||||
}
|
||||
}
|
||||
|
||||
private func asyncFileExists(_ path: String) async throws -> Bool {
|
||||
let sftp = try await connectionHolder.sftp()
|
||||
do {
|
||||
_ = try await sftp.getAttributes(at: path)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func asyncStat(_ path: String) async throws -> FileStat? {
|
||||
let sftp = try await connectionHolder.sftp()
|
||||
do {
|
||||
let attrs = try await sftp.getAttributes(at: path)
|
||||
let size = attrs.size.map { Int64($0) } ?? 0
|
||||
let mtime = attrs.accessModificationTime?.modificationTime ?? Date(timeIntervalSince1970: 0)
|
||||
// SFTPFileAttributes doesn't expose a "type" field directly;
|
||||
// infer "directory" from the permissions bits (S_IFDIR=0o40000).
|
||||
let isDir: Bool = {
|
||||
guard let perms = attrs.permissions else { return false }
|
||||
return (perms & 0o170000) == 0o040000
|
||||
}()
|
||||
return FileStat(size: size, mtime: mtime, isDirectory: isDir)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func asyncListDirectory(_ path: String) async throws -> [String] {
|
||||
let sftp = try await connectionHolder.sftp()
|
||||
let listing = try await sftp.listDirectory(atPath: path)
|
||||
// Flatten all components across the response batches, strip the
|
||||
// conventional "." / ".." entries to match
|
||||
// `FileManager.contentsOfDirectory` behaviour.
|
||||
let names = listing.flatMap { $0.components }.map(\.filename)
|
||||
return names.filter { $0 != "." && $0 != ".." }
|
||||
}
|
||||
|
||||
private func asyncCreateDirectory(_ path: String) async throws {
|
||||
let sftp = try await connectionHolder.sftp()
|
||||
// `createDirectory` at Citadel layer fails if the dir exists;
|
||||
// we want mkdir -p semantics so we walk the path and create
|
||||
// each component. Absolute paths only — the iOS app never
|
||||
// passes a relative path.
|
||||
let components = path.split(separator: "/", omittingEmptySubsequences: true).map(String.init)
|
||||
var cursor = path.hasPrefix("/") ? "" : ""
|
||||
for component in components {
|
||||
cursor += "/" + component
|
||||
do {
|
||||
try await sftp.createDirectory(atPath: cursor)
|
||||
} catch {
|
||||
// Ignore "already exists" errors — mkdir -p semantics.
|
||||
// Citadel surfaces these as `SFTPError`; we can't cleanly
|
||||
// narrow to the SSH_FX_FAILURE subtype so we swallow any
|
||||
// error that specifically means "exists" by re-checking
|
||||
// via stat.
|
||||
let exists = (try? await asyncFileExists(cursor)) ?? false
|
||||
if !exists { throw error }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func asyncRemoveFile(_ path: String) async throws {
|
||||
let sftp = try await connectionHolder.sftp()
|
||||
// Parallel to LocalTransport: no-op if the file doesn't exist.
|
||||
let exists = try await asyncFileExists(path)
|
||||
if !exists { return }
|
||||
try await sftp.remove(atPath: path)
|
||||
}
|
||||
|
||||
private func asyncRunProcess(
|
||||
executable: String,
|
||||
args: [String],
|
||||
timeout: TimeInterval?
|
||||
) async throws -> ProcessResult {
|
||||
let client = try await connectionHolder.ssh()
|
||||
let cmd = Self.shellJoin([executable] + args)
|
||||
// Citadel's executeCommand accumulates stdout into a ByteBuffer.
|
||||
// stderr isn't separately exposed — we fold it into the output
|
||||
// via `2>&1` so error paths still give callers something to
|
||||
// show. Exit code is similarly not directly exposed; on non-
|
||||
// zero exit Citadel throws, so we map that to a commandFailed
|
||||
// error with the captured output as stderr.
|
||||
do {
|
||||
let buffer = try await client.executeCommand(cmd + " 2>&1")
|
||||
var buf = buffer
|
||||
let str = buf.readString(length: buf.readableBytes) ?? ""
|
||||
return ProcessResult(
|
||||
exitCode: 0,
|
||||
stdout: Data(str.utf8),
|
||||
stderr: Data()
|
||||
)
|
||||
} catch {
|
||||
return ProcessResult(
|
||||
exitCode: 1,
|
||||
stdout: Data(),
|
||||
stderr: Data(error.localizedDescription.utf8)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func asyncSnapshotSQLite(remotePath: String) async throws -> URL {
|
||||
// Same flow as SSHTransport: run `sqlite3 .backup` on the remote
|
||||
// (WAL-safe), flip out of WAL mode on the snapshot, then SFTP
|
||||
// the backup file down to the local cache.
|
||||
try? FileManager.default.createDirectory(at: snapshotBaseDir, withIntermediateDirectories: true)
|
||||
let localURL = snapshotBaseDir.appendingPathComponent("state.db")
|
||||
let client = try await connectionHolder.ssh()
|
||||
let remoteTmp = "/tmp/scarf-snapshot-\(UUID().uuidString).db"
|
||||
// Double-quote paths; $HOME expansion happens inside double quotes.
|
||||
let rewritten = Self.rewriteHomeRelative(remotePath)
|
||||
let backupScript = #"sqlite3 "\#(rewritten)" ".backup '\#(remoteTmp)'" && sqlite3 '\#(remoteTmp)' "PRAGMA journal_mode=DELETE;" > /dev/null"#
|
||||
_ = try await client.executeCommand(backupScript + " 2>&1")
|
||||
|
||||
// SFTP-download the remote tmp into our local snapshot cache.
|
||||
let sftp = try await connectionHolder.sftp()
|
||||
let data: Data = try await sftp.withFile(filePath: remoteTmp, flags: [.read]) { file in
|
||||
let buf = try await file.readAll()
|
||||
return Data(buffer: buf)
|
||||
}
|
||||
try data.write(to: localURL, options: .atomic)
|
||||
|
||||
// Best-effort cleanup of the remote tmp.
|
||||
_ = try? await client.executeCommand("rm -f '\(remoteTmp)'")
|
||||
|
||||
return localURL
|
||||
}
|
||||
|
||||
// MARK: - Shell helpers
|
||||
|
||||
/// Minimal shell-argument joiner. Handles spaces + quotes; sufficient
|
||||
/// for the commands we actually pass (`echo`, `stat`, `tail`, `sqlite3`).
|
||||
nonisolated static func shellJoin(_ argv: [String]) -> String {
|
||||
argv.map { arg in
|
||||
if arg.isEmpty { return "''" }
|
||||
let safe = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@%+=:,./-_$")
|
||||
if arg.unicodeScalars.allSatisfy({ safe.contains($0) }) { return arg }
|
||||
return "'" + arg.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
||||
}.joined(separator: " ")
|
||||
}
|
||||
|
||||
/// Rewrite a leading `~/` to `$HOME/` so remote double-quoted strings
|
||||
/// expand the path correctly. Matches SSHTransport's `remotePathArg`.
|
||||
nonisolated static func rewriteHomeRelative(_ path: String) -> String {
|
||||
if path.hasPrefix("~/") { return "$HOME/" + path.dropFirst(2) }
|
||||
if path == "~" { return "$HOME" }
|
||||
return path
|
||||
}
|
||||
|
||||
// MARK: - Sync bridge
|
||||
|
||||
/// Block the caller thread until the given async throwing operation
|
||||
/// finishes. Uses a `DispatchSemaphore` because `ServerTransport`'s
|
||||
/// protocol is synchronous (by design — services don't want to be
|
||||
/// async end-to-end). The macOS `SSHTransport` solves the same
|
||||
/// problem by spawning a subprocess and `Thread.sleep`-polling for
|
||||
/// termination; this is the Swift-concurrency equivalent.
|
||||
///
|
||||
/// **Do not call from a MainActor context for long-running ops.**
|
||||
/// SwiftUI views should push through a ViewModel on a detached
|
||||
/// task. Transport users in this codebase already do this (every
|
||||
/// service touches disk in a `Task.detached` or on a nonisolated
|
||||
/// actor method).
|
||||
nonisolated private func runSync<T: Sendable>(
|
||||
_ op: @escaping @Sendable () async throws -> T
|
||||
) throws -> T {
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
let resultBox = ResultBox<T>()
|
||||
Task.detached {
|
||||
do {
|
||||
let value = try await op()
|
||||
resultBox.set(.success(value))
|
||||
} catch {
|
||||
resultBox.set(.failure(error))
|
||||
}
|
||||
semaphore.signal()
|
||||
}
|
||||
semaphore.wait()
|
||||
return try resultBox.get()
|
||||
}
|
||||
}
|
||||
|
||||
/// Tiny boxed result so the `runSync` continuation can store the async
|
||||
/// result and hand it back to the blocking caller. `@unchecked Sendable`
|
||||
/// because Swift can't prove the single-writer / single-reader pattern
|
||||
/// is safe — we enforce it by the semaphore order-of-operations.
|
||||
private final class ResultBox<T: Sendable>: @unchecked Sendable {
|
||||
private var value: Result<T, Error>?
|
||||
private let lock = NSLock()
|
||||
|
||||
func set(_ result: Result<T, Error>) {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
value = result
|
||||
}
|
||||
|
||||
func get() throws -> T {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
guard let value else {
|
||||
throw TransportError.other(message: "runSync completed without setting a result")
|
||||
}
|
||||
return try value.get()
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns the one long-lived `SSHClient` + `SFTPClient` pair for a
|
||||
/// transport. Serializes open / reconnect so concurrent calls don't
|
||||
/// race on the initial handshake.
|
||||
private actor ConnectionHolder {
|
||||
private let contextID: ServerID
|
||||
private let config: SSHConfig
|
||||
private let keyProvider: CitadelServerTransport.KeyProvider
|
||||
|
||||
private var sshClient: SSHClient?
|
||||
private var sftpClient: SFTPClient?
|
||||
|
||||
init(
|
||||
contextID: ServerID,
|
||||
config: SSHConfig,
|
||||
keyProvider: @escaping CitadelServerTransport.KeyProvider
|
||||
) {
|
||||
self.contextID = contextID
|
||||
self.config = config
|
||||
self.keyProvider = keyProvider
|
||||
}
|
||||
|
||||
func ssh() async throws -> SSHClient {
|
||||
if let existing = sshClient, existing.isConnected {
|
||||
return existing
|
||||
}
|
||||
let client = try await openSSH()
|
||||
sshClient = client
|
||||
return client
|
||||
}
|
||||
|
||||
func sftp() async throws -> SFTPClient {
|
||||
if let existing = sftpClient {
|
||||
return existing
|
||||
}
|
||||
let client = try await ssh()
|
||||
let sftp = try await client.openSFTP()
|
||||
sftpClient = sftp
|
||||
return sftp
|
||||
}
|
||||
|
||||
func closeIfOpen() async {
|
||||
if let sftp = sftpClient {
|
||||
try? await sftp.close()
|
||||
sftpClient = nil
|
||||
}
|
||||
if let client = sshClient {
|
||||
try? await client.close()
|
||||
sshClient = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func openSSH() async throws -> SSHClient {
|
||||
let key = try await keyProvider()
|
||||
guard let parts = Ed25519KeyGenerator.decodeRawEd25519PEM(key.privateKeyPEM) else {
|
||||
throw TransportError.other(message: "Stored private key is not in the expected Scarf Ed25519 PEM format")
|
||||
}
|
||||
guard let ck = try? Curve25519.Signing.PrivateKey(rawRepresentation: parts.privateKey) else {
|
||||
throw TransportError.other(message: "Stored private key is malformed")
|
||||
}
|
||||
let username = config.user ?? "root"
|
||||
let auth: SSHAuthenticationMethod = .ed25519(username: username, privateKey: ck)
|
||||
var settings = SSHClientSettings(
|
||||
host: config.host,
|
||||
authenticationMethod: { auth },
|
||||
hostKeyValidator: .acceptAnything()
|
||||
)
|
||||
if let port = config.port {
|
||||
settings.port = port
|
||||
}
|
||||
return try await SSHClient.connect(to: settings)
|
||||
}
|
||||
}
|
||||
|
||||
#endif // canImport(Citadel)
|
||||
@@ -0,0 +1,71 @@
|
||||
// iOS-specific Dashboard state. Uses `HermesDataService` directly via
|
||||
// a Citadel-backed `ServerTransport` — no Mac-only `HermesFileService`
|
||||
// dependency, so the Dashboard shows session + token stats only, not
|
||||
// the config.yaml / gateway-state / pgrep checks the Mac dashboard
|
||||
// surfaces. Those come in a later phase once `HermesFileService` is
|
||||
// either moved to ScarfCore or replicated in an iOS-compatible form.
|
||||
#if canImport(SQLite3)
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import ScarfCore
|
||||
|
||||
/// iOS Dashboard view-state. Loaded on view appear; refreshes on
|
||||
/// pull-to-refresh. The VM owns a `HermesDataService` instance which
|
||||
/// (via the transport factory wired in `ScarfIOSApp.init`) routes all
|
||||
/// DB reads through Citadel SFTP + SSH exec.
|
||||
@Observable
|
||||
@MainActor
|
||||
public final class IOSDashboardViewModel {
|
||||
public let context: ServerContext
|
||||
private let dataService: HermesDataService
|
||||
|
||||
public init(context: ServerContext) {
|
||||
self.context = context
|
||||
self.dataService = HermesDataService(context: context)
|
||||
}
|
||||
|
||||
// MARK: - Published state
|
||||
|
||||
public var stats: HermesDataService.SessionStats = .empty
|
||||
public var recentSessions: [HermesSession] = []
|
||||
public var sessionPreviews: [String: String] = [:]
|
||||
public var isLoading: Bool = true
|
||||
|
||||
/// Surfaced when the SQLite snapshot or DB open fails. Shown in a
|
||||
/// yellow banner above the stats with a "Retry" button. `nil` means
|
||||
/// the last load was healthy.
|
||||
public var lastError: String?
|
||||
|
||||
// MARK: - Loading
|
||||
|
||||
/// Refresh the dashboard. Does a `dataService.refresh()` (close +
|
||||
/// reopen, forces a fresh Citadel snapshot on iOS) then reads the
|
||||
/// visible bits.
|
||||
public func load() async {
|
||||
isLoading = true
|
||||
lastError = nil
|
||||
|
||||
let opened = await dataService.refresh()
|
||||
if !opened {
|
||||
lastError = await dataService.lastOpenError
|
||||
?? "Couldn't read the Hermes database — check that the server is reachable and that `~/.hermes/state.db` exists."
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
|
||||
stats = await dataService.fetchStats()
|
||||
recentSessions = await dataService.fetchSessions(limit: 5)
|
||||
sessionPreviews = await dataService.fetchSessionPreviews(limit: 5)
|
||||
|
||||
await dataService.close()
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
/// Called from the pull-to-refresh gesture.
|
||||
public func refresh() async {
|
||||
await load()
|
||||
}
|
||||
}
|
||||
|
||||
#endif // canImport(SQLite3)
|
||||
@@ -561,7 +561,63 @@ the 3 ScarfIOS tests.
|
||||
- Source tree stays **pure SwiftUI + Foundation + ScarfCore + ScarfIOS**;
|
||||
`#if canImport(UIKit)` fine for pasteboard but keep it minimal.
|
||||
|
||||
### M3 — pending
|
||||
### M3 — shipped (on `claude/ios-m3-transport` branch, separate PR, stacked on M2)
|
||||
|
||||
**Three things this phase ships:**
|
||||
|
||||
1. **Critical iOS-compile fix** — `ServerTransport.makeProcess(...) -> Process` was iOS-unavailable at compile time but my Linux CI didn't catch it (swift-corelibs-foundation has `Process`; Apple iOS does not). Wrapped `makeProcess` in `#if !os(iOS)` on the protocol + both `LocalTransport` / `SSHTransport` impls. Without this fix, Alan's first `⌘B` on the M2 iOS target would have failed with "Cannot find 'Process' in scope".
|
||||
|
||||
2. **New platform-neutral `streamLines(...)` on the protocol** — `AsyncThrowingStream<String, Error>` emitting one stdout line per element, newline-framed, stream finishes on EOF and throws `TransportError.commandFailed` on non-zero exit. Mac/Linux use a `Process` + `Pipe` internally; iOS (Citadel) returns an empty stream for M3 and gets a real impl in M4+.
|
||||
|
||||
3. **`CitadelServerTransport`** (new, in ScarfIOS) — full `ServerTransport` conformance backed by Citadel SFTP + exec. Every iOS dashboard / file / process primitive now routes through this.
|
||||
|
||||
**Shipped — ScarfCore changes:**
|
||||
|
||||
- `Transport/ServerTransport.swift`: `makeProcess` guarded with `#if !os(iOS)`; new `streamLines(_:args:)` method on the protocol. Comment updated to call out the platform gate explicitly.
|
||||
- `Transport/LocalTransport.swift`: matching `#if !os(iOS)` around `makeProcess`; full `streamLines` impl on Mac/Linux (Task.detached → Process + Pipe → line-framing loop → exit-code check); iOS stub returns an empty stream.
|
||||
- `Transport/SSHTransport.swift`: same pattern for `makeProcess`; `streamLines` impl on Mac/Linux spawns `ssh -T host -- sh -c '<cmd>'` and pumps stdout line-by-line (identical to the old inline code in HermesLogService.openLog).
|
||||
- `Services/HermesLogService.swift`: refactored remote-tail path to use `transport.streamLines(...)` instead of `transport.makeProcess` + raw `Pipe`. The `remoteTailProcess: Process?` / `fileHandle: FileHandle?` state collapses into a single `remoteTailTask: Task<Void, Never>?`. Parsed-line ring buffer is drained synchronously by `readNewLines()` — semantically identical to the old behaviour on Mac, and now works on iOS (where it'll get real streaming once `CitadelServerTransport.streamLines` is wired in M4+).
|
||||
- `Models/ServerContext.swift`: new `ServerContext.sshTransportFactory: SSHTransportFactory?` static. When non-nil, `makeTransport()` routes `.ssh` contexts through this factory instead of constructing `SSHTransport` directly. iOS wires it; Mac leaves nil.
|
||||
- `Services/HermesDataService.swift`: `SessionStats` member fields + `.empty` static promoted to `public` (sed missed them — nested inside the outer type). `lastOpenError` accessor promoted to `public private(set)`.
|
||||
|
||||
**Shipped — new in ScarfIOS:**
|
||||
|
||||
- `CitadelServerTransport.swift` — full `ServerTransport` impl backed by Citadel. Uses `SSHClient.openSFTP()` for file I/O, `SSHClient.executeCommand(_:)` for `runProcess`, and a remote `sqlite3 .backup` + SFTP-download for `snapshotSQLite`. Maintains a single long-lived SSH + SFTP connection per transport instance (lazy, reconnecting) via a nested `ConnectionHolder` actor. Blocks the caller thread via `DispatchSemaphore` to bridge the async Citadel API to `ServerTransport`'s synchronous protocol — same pattern the Mac `SSHTransport` uses to block on subprocess lifecycle. `streamLines(...)` returns an empty stream for M3 (iOS log tailing is M4+).
|
||||
- `IOSDashboardViewModel.swift` — minimal iOS Dashboard view model. Unlike Mac's `DashboardViewModel` which uses `HermesFileService` (still Mac-target), iOS's version reads only from `HermesDataService`. Shows session count, token totals, recent sessions. `lastError` is surfaced in a banner with a Retry button.
|
||||
|
||||
**Shipped — scarf-ios app changes:**
|
||||
|
||||
- `App/ScarfIOSApp.swift`: `init()` now wires `ServerContext.sshTransportFactory = { ... CitadelServerTransport(keyProvider: { KeychainSSHKeyStore().load() }) }`. The key is re-read from the Keychain per connection (honors the Keychain's access-control policy — `AfterFirstUnlockThisDeviceOnly`).
|
||||
- `Dashboard/DashboardView.swift`: replaces the M2 placeholder with a real list view showing session stats, token usage, and the most recent sessions. Pull-to-refresh triggers `vm.refresh()`. Loading state + error banner.
|
||||
|
||||
**Test coverage (M3TransportTests, 8 new tests, `@Suite(.serialized)`):**
|
||||
|
||||
- `LocalTransport.streamLines` yields one line per newline from a scripted `printf`.
|
||||
- `streamLines` finishes on EOF even without a trailing newline (partial tail dropped — documented behaviour).
|
||||
- Non-zero subprocess exit surfaces as `TransportError.commandFailed` with the correct exit code.
|
||||
- `ServerContext.sshTransportFactory` override is consulted for `.ssh` contexts + ignored for `.local`.
|
||||
- Nil factory falls back to default `SSHTransport`.
|
||||
- `HermesLogService` remote tail pumps scripted `streamLines` output through to `readNewLines()`'s ring buffer.
|
||||
- `HermesLogService.readLastLines` uses the transport's `runProcess` for the one-shot initial load.
|
||||
|
||||
**Real bug caught in development:** first pass of the M3 test suite had two tests that both set `ServerContext.sshTransportFactory` + restored in `defer`. Swift-testing runs tests in parallel by default — they raced, one test's scripted transport bled into the other, producing "entries[2].message is 'z' not 'boom'". Fixed with `@Suite(.serialized)` + a note explaining why.
|
||||
|
||||
**Now 96 / 96 passing on Linux** (88 pre-M3 + 8 new).
|
||||
|
||||
**Manual validation needed on Mac (after M2 target exists):**
|
||||
|
||||
1. **iOS build with the new protocol guards.** Hit ⌘B on the iOS simulator target — should compile cleanly. If `Cannot find 'Process' in scope` still appears, search for any remaining unguarded `Process` reference (grep `Process\(\)` / `.isRunning` / `terminationHandler`).
|
||||
2. **Dashboard end-to-end against a real Hermes host.** iPhone simulator with the public key in remote `authorized_keys`, connect through onboarding, land on Dashboard — it should fetch + show session stats via Citadel SFTP + exec. Pull-to-refresh should work.
|
||||
3. **SQLite snapshot pulls.** Dashboard load triggers `HermesDataService.refresh()` → `CitadelServerTransport.snapshotSQLite(...)` → remote `sqlite3 .backup` + SFTP download to `<Caches>/scarf/snapshots/<id>/state.db`. Verify the local file appears and HermesDataService opens it read-only.
|
||||
|
||||
**Rules next phases can rely on:**
|
||||
|
||||
- **`streamLines` is the portable way to stream subprocess stdout.** Every future feature that needs line-by-line stdout (log tailing, `git` output, `ps`-style probes) should use `streamLines`. `makeProcess` is Mac/Linux-only by design.
|
||||
- **`ServerContext.sshTransportFactory` is already wired on iOS.** M4 (ACP over Citadel) should reuse the same CitadelServerTransport via `context.makeTransport()` for its exec channel — don't build a parallel Citadel session management path.
|
||||
- **`CitadelServerTransport.streamLines` is a stub (M3).** When the iOS Chat feature lands in M4+, implement it using Citadel's raw exec channel API (not `executeCommand`, which buffers the entire output). That'll also unlock iOS log tailing.
|
||||
- **`HermesFileService` still hasn't moved to ScarfCore.** iOS's Dashboard is minimal because of this; no config.yaml / gateway-state / pgrep checks. A future phase can either port HermesFileService (requires iOS-compatible shell-env story) or replicate the narrow subset iOS needs.
|
||||
|
||||
### M4 — pending
|
||||
### M4 — pending
|
||||
### M5 — pending
|
||||
### M6 — pending
|
||||
|
||||
@@ -12,6 +12,41 @@ struct ScarfIOSApp: App {
|
||||
configStore: UserDefaultsIOSServerConfigStore()
|
||||
)
|
||||
|
||||
init() {
|
||||
// Wire ScarfCore's transport factory to produce Citadel-backed
|
||||
// `ServerTransport`s for every `.ssh` context. Without this,
|
||||
// `ServerContext.makeTransport()` would fall back to the
|
||||
// Mac-only `SSHTransport` which shells out to `/usr/bin/ssh`
|
||||
// — not present on iOS.
|
||||
//
|
||||
// Each call builds a fresh `CitadelServerTransport`. The
|
||||
// transport itself lazily opens + caches a single long-lived
|
||||
// SSH connection internally, so the per-call overhead is
|
||||
// just the factory invocation, not a new SSH handshake.
|
||||
ServerContext.sshTransportFactory = { id, config, displayName in
|
||||
CitadelServerTransport(
|
||||
contextID: id,
|
||||
config: config,
|
||||
displayName: displayName,
|
||||
keyProvider: {
|
||||
// The transport needs the SSH key every time it
|
||||
// (re)opens an SSH session. We re-read from the
|
||||
// Keychain each time rather than caching in memory
|
||||
// so Keychain-level access controls (After First
|
||||
// Unlock) are honoured.
|
||||
let store = KeychainSSHKeyStore()
|
||||
guard let key = try await store.load() else {
|
||||
throw SSHKeyStoreError.backendFailure(
|
||||
message: "No SSH key in Keychain — re-run onboarding.",
|
||||
osStatus: nil
|
||||
)
|
||||
}
|
||||
return key
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView(model: root)
|
||||
|
||||
@@ -2,25 +2,94 @@ import SwiftUI
|
||||
import ScarfCore
|
||||
import ScarfIOS
|
||||
|
||||
/// Placeholder dashboard for M2. Shows the connected server and a
|
||||
/// "Disconnect" affordance that wipes the stored key + config and
|
||||
/// returns the user to onboarding.
|
||||
///
|
||||
/// **M3 replaces this** with a real dashboard backed by
|
||||
/// `HermesDataService` running over a Citadel-backed transport.
|
||||
/// For now this view just proves the "connected" state is reachable.
|
||||
/// iOS Dashboard — shows session count, token usage, cost, and the
|
||||
/// last 5 sessions pulled from the remote Hermes SQLite snapshot.
|
||||
/// Every data source routes through `ServerContext → CitadelServerTransport`
|
||||
/// so the same services that drive the Mac Dashboard power this one.
|
||||
struct DashboardView: View {
|
||||
let config: IOSServerConfig
|
||||
let key: SSHKeyBundle
|
||||
let onDisconnect: @MainActor () async -> Void
|
||||
|
||||
@State private var vm: IOSDashboardViewModel
|
||||
@State private var isDisconnecting = false
|
||||
|
||||
/// Stable ID used when building the `ServerContext` — tied to the
|
||||
/// config's host+user tuple so re-launching the app without reset
|
||||
/// yields the same ID (important for the snapshot cache dir).
|
||||
private static let contextID: ServerID = ServerID(
|
||||
uuidString: "00000000-0000-0000-0000-0000000000A1"
|
||||
)!
|
||||
|
||||
init(
|
||||
config: IOSServerConfig,
|
||||
key: SSHKeyBundle,
|
||||
onDisconnect: @escaping @MainActor () async -> Void
|
||||
) {
|
||||
self.config = config
|
||||
self.key = key
|
||||
self.onDisconnect = onDisconnect
|
||||
let ctx = config.toServerContext(id: Self.contextID)
|
||||
_vm = State(initialValue: IOSDashboardViewModel(context: ctx))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
if let err = vm.lastError {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label("Connection issue", systemImage: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
.font(.headline)
|
||||
Text(err)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Retry") {
|
||||
Task { await vm.refresh() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Activity") {
|
||||
statRow("Total sessions", value: "\(vm.stats.totalSessions)")
|
||||
statRow("Total messages", value: "\(vm.stats.totalMessages)")
|
||||
statRow("Tool calls", value: "\(vm.stats.totalToolCalls)")
|
||||
}
|
||||
|
||||
Section("Tokens") {
|
||||
statRow("Input", value: formatTokens(vm.stats.totalInputTokens))
|
||||
statRow("Output", value: formatTokens(vm.stats.totalOutputTokens))
|
||||
statRow("Reasoning", value: formatTokens(vm.stats.totalReasoningTokens))
|
||||
}
|
||||
|
||||
if !vm.recentSessions.isEmpty {
|
||||
Section("Recent sessions") {
|
||||
ForEach(vm.recentSessions) { session in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(session.displayTitle)
|
||||
.font(.body)
|
||||
.lineLimit(2)
|
||||
HStack(spacing: 12) {
|
||||
Label(session.source, systemImage: session.sourceIcon)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let started = session.startedAt {
|
||||
Text(started, format: .relative(presentation: .numeric))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Connected to") {
|
||||
LabeledContent("Display name", value: config.displayName)
|
||||
LabeledContent("Host", value: config.host)
|
||||
if let user = config.user {
|
||||
LabeledContent("User", value: user)
|
||||
@@ -28,12 +97,7 @@ struct DashboardView: View {
|
||||
if let port = config.port {
|
||||
LabeledContent("Port", value: String(port))
|
||||
}
|
||||
}
|
||||
|
||||
Section("Device key") {
|
||||
LabeledContent("Comment", value: key.comment)
|
||||
LabeledContent("Fingerprint", value: key.displayFingerprint)
|
||||
LabeledContent("Created", value: key.createdAt)
|
||||
LabeledContent("Device key", value: key.displayFingerprint)
|
||||
}
|
||||
|
||||
Section {
|
||||
@@ -55,15 +119,42 @@ struct DashboardView: View {
|
||||
}
|
||||
.disabled(isDisconnecting)
|
||||
}
|
||||
|
||||
Section {
|
||||
Text("Dashboard data comes in M3 — this view is M2's \"hello, you're connected\" placeholder.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.navigationTitle(config.displayName)
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.refreshable {
|
||||
await vm.refresh()
|
||||
}
|
||||
.overlay {
|
||||
if vm.isLoading, vm.recentSessions.isEmpty {
|
||||
ProgressView("Loading dashboard…")
|
||||
.padding()
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
}
|
||||
.task { await vm.load() }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func statRow(_ label: String, value: String) -> some View {
|
||||
LabeledContent(label) {
|
||||
Text(value)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirror of `ScarfCore.formatTokens` — inlined here rather than
|
||||
/// exported from ScarfCore because it's currently wrapped in
|
||||
/// `#if canImport(SQLite3)` (from the M0d InsightsViewModel move).
|
||||
private func formatTokens(_ count: Int) -> String {
|
||||
if count >= 1_000_000 {
|
||||
return String(format: "%.1fM", Double(count) / 1_000_000)
|
||||
} else if count >= 1_000 {
|
||||
return String(format: "%.1fK", Double(count) / 1_000)
|
||||
}
|
||||
return "\(count)"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user