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>
130 lines
4.5 KiB
Rust
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);
|
|
});
|
|
}
|
|
}
|