diff --git a/Cargo.lock b/Cargo.lock index 6feceaf..bb24a36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -223,6 +223,7 @@ dependencies = [ "ansi-to-tui", "clap", "image", + "libc", "notify", "pulldown-cmark", "ratatui 0.30.0", diff --git a/Cargo.toml b/Cargo.toml index 36e079f..0e446f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,3 +18,4 @@ walkdir = "2.5" ureq = "2.12" ratatui-image = { version = "10.0", default-features = false } image = "0.25" +libc = "0.2" diff --git a/src/app.rs b/src/app.rs index d6928d2..457a1a4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -161,6 +161,10 @@ pub enum ShutdownReason { UserQuit, /// A SIGHUP or SIGTERM was received. Signal, + /// No user input for the configured idle timeout duration. + IdleTimeout, + /// Parent process (sshd) died — SSH session is gone. + ParentDied, } // ── HistoryEntry ────────────────────────────────────────────────────────────── @@ -277,6 +281,13 @@ pub struct App { image_records: Vec, /// Cache of loaded images keyed by source string. image_cache: HashMap, + + // ── Session lifecycle ────────────────────────────────────────────────── + /// Timestamp of the last keyboard or mouse input from the user. + last_input_at: Instant, + /// PID of the parent process at startup (typically sshd). + /// If getppid() changes, the parent died and the session is orphaned. + initial_ppid: u32, } impl App { @@ -352,6 +363,8 @@ impl App { picker, image_records, image_cache: HashMap::new(), + last_input_at: Instant::now(), + initial_ppid: unsafe { libc::getppid() } as u32, } } @@ -399,9 +412,11 @@ impl App { if event::poll(Duration::from_millis(250))? { match event::read()? { Event::Key(key) => { + self.last_input_at = Instant::now(); self.handle_key(key); } Event::Mouse(mouse) => { + self.last_input_at = Instant::now(); self.handle_mouse(mouse); } Event::Resize(w, _h) => { @@ -411,6 +426,20 @@ impl App { } } + // 3.1 Check idle timeout (0 = disabled) + if self.config.idle_timeout_minutes > 0 { + let timeout = Duration::from_secs(self.config.idle_timeout_minutes * 60); + if self.last_input_at.elapsed() >= timeout { + return Ok(ShutdownReason::IdleTimeout); + } + } + + // 3.2 Check if parent process (sshd) died — session is orphaned + let current_ppid = unsafe { libc::getppid() } as u32; + if current_ppid != self.initial_ppid { + return Ok(ShutdownReason::ParentDied); + } + // 3a. Check for filesystem events (non-blocking) if let Some(ref mut watcher) = self.file_watcher { loop { diff --git a/src/config.rs b/src/config.rs index 778e9c6..62806df 100644 --- a/src/config.rs +++ b/src/config.rs @@ -30,6 +30,12 @@ pub struct Config { /// terminals where protocol detection fails or produces garbled output. #[serde(default)] pub force_halfblocks: bool, + + /// Idle session timeout in minutes. When no keyboard or mouse input is + /// received for this duration, the session exits automatically. + /// Set to 0 to disable (default). + #[serde(default)] + pub idle_timeout_minutes: u64, } fn default_vault_path() -> PathBuf { @@ -58,6 +64,7 @@ impl Default for Config { max_remote_document_size: default_max_remote_document_size(), max_remote_image_size: default_max_remote_image_size(), force_halfblocks: false, + idle_timeout_minutes: 0, } } } diff --git a/src/main.rs b/src/main.rs index aaf1c1f..e7441a2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -175,6 +175,14 @@ fn main() { // SIGHUP or SIGTERM — SSH disconnect or graceful OS shutdown. // Exit silently: there may be nobody on the other end to see a message. } + Ok(app::ShutdownReason::IdleTimeout) => { + // Idle timeout — user may still be watching, show a brief message. + println!("\r\nSession timed out due to inactivity.\r"); + } + Ok(app::ShutdownReason::ParentDied) => { + // Parent process (sshd) died — SSH session is gone. + // Exit silently: there is nobody on the other end. + } Err(e) => { // I/O error from the event loop if e.kind() == std::io::ErrorKind::BrokenPipe {