wraith/src-tauri/src/ssh/cwd.rs
Vantz Stockwell 737491d3f0 feat: Phase 2 complete — SSH terminal + frontend UI
Rust SSH service: russh async client, DashMap session registry,
TOFU host key verification, CWD tracking via separate exec channel
(never touches terminal stream), base64 event emission for terminal
I/O. 52/52 tests passing.

Vue 3 frontend: ported from Wails v3 to Tauri v2 — useTerminal
composable with streaming TextDecoder + rAF batching, session store
with multi-connection support, connection store/tree, sidebar, tab
bar, status bar, keyboard shortcuts. All Wails imports replaced
with Tauri API equivalents.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:28:18 -04:00

130 lines
4.5 KiB
Rust

//! CWD tracking via a SEPARATE SSH exec channel.
//!
//! This module opens an independent exec channel on the same SSH connection and
//! periodically runs `pwd` to determine the remote shell's current working
//! directory. The terminal data stream is NEVER touched — CWD is tracked
//! entirely out of band.
//!
//! When the CWD changes, an `ssh:cwd:{session_id}` event is emitted to the
//! frontend.
use std::sync::Arc;
use log::{debug, error, warn};
use russh::client::Handle;
use russh::ChannelMsg;
use tauri::{AppHandle, Emitter};
use tokio::sync::watch;
use tokio::sync::Mutex as TokioMutex;
use crate::ssh::session::SshClient;
/// Tracks the current working directory of a remote shell by periodically
/// running `pwd` over a separate exec channel.
pub struct CwdTracker {
_sender: watch::Sender<String>,
pub receiver: watch::Receiver<String>,
}
impl CwdTracker {
/// Create a new CWD tracker with an empty initial directory.
pub fn new() -> Self {
let (sender, receiver) = watch::channel(String::new());
Self {
_sender: sender,
receiver,
}
}
/// Spawn a background tokio task that polls `pwd` every 2 seconds on a
/// separate exec channel.
///
/// The task runs until the SSH connection is closed or the channel cannot
/// be opened. CWD changes are emitted as `ssh:cwd:{session_id}` events.
pub fn start(
&self,
handle: Arc<TokioMutex<Handle<SshClient>>>,
app_handle: AppHandle,
session_id: String,
) {
let sender = self._sender.clone();
tokio::spawn(async move {
// Brief initial delay to let the shell start up.
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
let mut previous_cwd = String::new();
loop {
// Open a fresh exec channel for each `pwd` invocation.
// Some SSH servers do not allow multiple exec requests on a
// single channel, so we open a new one each time.
let result = {
let handle_guard = handle.lock().await;
handle_guard.channel_open_session().await
};
let mut exec_channel = match result {
Ok(ch) => ch,
Err(e) => {
debug!(
"CWD tracker for session {} could not open exec channel: {} — stopping",
session_id, e
);
break;
}
};
// Execute `pwd` on the exec channel.
if let Err(e) = exec_channel.exec(true, "pwd").await {
warn!(
"CWD tracker for session {}: failed to exec pwd: {}",
session_id, e
);
break;
}
// Read the output.
let mut output = String::new();
loop {
match exec_channel.wait().await {
Some(ChannelMsg::Data { ref data }) => {
if let Ok(text) = std::str::from_utf8(data.as_ref()) {
output.push_str(text);
}
}
Some(ChannelMsg::Eof) | Some(ChannelMsg::Close) | None => break,
Some(ChannelMsg::ExitStatus { .. }) => {
// pwd exited — we may still get data, keep reading
}
_ => {}
}
}
let cwd = output.trim().to_string();
if !cwd.is_empty() && cwd != previous_cwd {
previous_cwd = cwd.clone();
// Update the watch channel.
let _ = sender.send(cwd.clone());
// Emit event to frontend.
let event_name = format!("ssh:cwd:{}", session_id);
if let Err(e) = app_handle.emit(&event_name, &cwd) {
error!(
"CWD tracker for session {}: failed to emit event: {}",
session_id, e
);
}
}
// Wait 2 seconds before the next poll.
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
}
debug!("CWD tracker for session {} stopped", session_id);
});
}
}