tsafe-tui 1.0.10

Terminal UI for tsafe secret vault — full-screen browser with keyboard navigation, history viewer, quick-unlock
Documentation
pub mod app;
pub mod clipboard;
pub mod events;
pub mod state;

pub use events::feed_events;
pub mod tui_debug;
pub mod ui;

use std::io::{self, Write};
use std::time::{Duration, Instant};

use anyhow::Result;
use crossterm::{
    event::{
        self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers,
    },
    execute,
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};

#[cfg(windows)]
use windows_sys::Win32::System::Console::SetConsoleOutputCP;

use app::{sensitive_string, App, Screen, SensitiveString};
use tsafe_core::keyring_store;
use tsafe_core::profile::vault_path;
use tsafe_core::vault::Vault;

/// Close the TUI after this long with no input (any key, mouse, paste, etc.).
const IDLE_TIMEOUT: Duration = Duration::from_secs(5 * 60);

/// macOS (and some desktops) cannot show Touch ID / keychain UI while the terminal is in raw mode
/// and the alternate screen. Suspend the TUI briefly while reading the vault password from the OS store.
fn suspend_tui_for_os_auth(
    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> io::Result<()> {
    disable_raw_mode()?;
    execute!(
        terminal.backend_mut(),
        LeaveAlternateScreen,
        DisableMouseCapture
    )?;
    terminal.show_cursor()?;
    let _ = io::stdout().flush();
    let _ = io::stderr().flush();
    Ok(())
}

fn resume_tui_after_os_auth(
    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> io::Result<()> {
    enable_raw_mode()?;
    execute!(
        terminal.backend_mut(),
        EnterAlternateScreen,
        EnableMouseCapture
    )?;
    terminal.hide_cursor()?;
    Ok(())
}

/// Launch the full-screen TUI. Blocks until the user quits.
pub fn run() -> Result<()> {
    // On Windows, set the console output codepage to UTF-8 (65001) so that
    // Unicode box-drawing characters and the password mask glyph render
    // correctly in conhost (old Windows PowerShell / cmd). This is a no-op
    // on Windows Terminal / pwsh which are already UTF-8.
    #[cfg(windows)]
    unsafe {
        SetConsoleOutputCP(65001);
    }

    // Install a panic hook that restores the terminal before printing the panic message.
    // Without this, a panic leaves the terminal in raw/alternate-screen mode.
    let original_hook = std::panic::take_hook();
    std::panic::set_hook(Box::new(move |info| {
        let _ = crossterm::terminal::disable_raw_mode();
        let _ = crossterm::execute!(
            io::stdout(),
            crossterm::terminal::LeaveAlternateScreen,
            crossterm::event::DisableMouseCapture,
        );
        original_hook(info);
    }));

    tui_debug::announce_if_enabled();

    enable_raw_mode()?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
    let backend = CrosstermBackend::new(io::stdout());
    let mut terminal = Terminal::new(backend)?;

    let (result, idle_timeout_exit) = run_loop(&mut terminal);

    disable_raw_mode()?;
    execute!(
        terminal.backend_mut(),
        LeaveAlternateScreen,
        DisableMouseCapture
    )?;
    terminal.show_cursor()?;

    if idle_timeout_exit {
        eprintln!(
            "tsafe: session ended after 5 minutes with no input (idle timeout). Run `tsafe ui` again to continue."
        );
    }

    result
}

/// Runs the main loop. Returns `(io/terminal result, idle_timeout)` where `idle_timeout` is true
/// if the loop exited due to [`IDLE_TIMEOUT`].
fn run_loop(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> (Result<()>, bool) {
    let mut app = App::new();
    let mut password_store: Option<SensitiveString> = None;
    let mut last_input = Instant::now();
    let mut idle_timeout_exit = false;

    let loop_result = (|| -> Result<()> {
        loop {
            app.poll_update_check();
            app.maybe_expire_clipboard();
            app.maybe_expire_reveals();
            app.maybe_expire_undo();
            terminal.draw(|f| ui::render(f, &mut app))?;

            let tick = Duration::from_millis(100);
            if !event::poll(tick)? {
                if last_input.elapsed() >= IDLE_TIMEOUT {
                    idle_timeout_exit = true;
                    break;
                }
                continue;
            }

            last_input = Instant::now();
            let ev = event::read()?;

            if app.screen == Screen::Login && tui_debug::enabled() {
                if let Event::Key(ref k) = ev {
                    match k.code {
                        KeyCode::Char(_) => {}
                        _ => {
                            tui_debug::log(format!(
                                "login key (non-char): kind={:?} code={:?} mods={:?}",
                                k.kind, k.code, k.modifiers
                            ));
                        }
                    }
                }
            }

            if app.screen == Screen::Login {
                if let Event::Key(key) = &ev {
                    let blocked_mods = key.modifiers.contains(KeyModifiers::CONTROL)
                        || key.modifiers.contains(KeyModifiers::ALT);
                    let enter_submit = key.kind == KeyEventKind::Press
                        && key.code == KeyCode::Enter
                        && !blocked_mods;
                    if enter_submit {
                        app.pending_keyring_master_password = None;
                        let empty = app.password_buf.is_empty();
                        // Touch ID / system auth must run outside raw mode + alternate screen.
                        // Always suspend here on empty Enter — `try_login` must not call `retrieve_password`
                        // in TUI mode or the OS prompt often never appears and unlock fails.
                        if empty {
                            if let Some(profile) = app.current_profile_name().map(str::to_owned) {
                                let vp = vault_path(&profile);
                                // Don't pre-probe with `has_password` here: on non-macOS the
                                // generic backend has no no-UI existence probe, so it would fire
                                // a real keychain read in addition to the `retrieve_password`
                                // below — surfacing as a double prompt. The Ok(None) / Ok(Some)
                                // log lines below already record whether an entry was present.
                                tui_debug::log(format!(
                                    "login empty Enter: profile={profile:?} vault_path={} exists={} is_team_vault={}",
                                    vp.display(),
                                    vp.exists(),
                                    Vault::is_team_vault(&vp),
                                ));
                                tui_debug::log("suspend_tui_for_os_auth: begin");
                                if let Err(e) = suspend_tui_for_os_auth(terminal) {
                                    tui_debug::log(format!("suspend_tui_for_os_auth: ERR {e}"));
                                    return Err(e.into());
                                }
                                tui_debug::log("suspend_tui_for_os_auth: ok");
                                tui_debug::log("keyring retrieve_password: begin (Touch ID / system UI may appear)");
                                let res = keyring_store::retrieve_password(&profile);
                                match &res {
                                    Ok(None) => {
                                        tui_debug::log("keyring retrieve_password: Ok(None) — no credential for this profile name");
                                    }
                                    Ok(Some(s)) => {
                                        tui_debug::log(format!(
                                            "keyring retrieve_password: Ok(Some) stored_secret_byte_len={}",
                                            s.len()
                                        ));
                                    }
                                    Err(e) => {
                                        tui_debug::log(format!(
                                            "keyring retrieve_password: ERR {e}"
                                        ));
                                    }
                                }
                                if let Err(e) = resume_tui_after_os_auth(terminal) {
                                    tui_debug::log(format!("resume_tui_after_os_auth: ERR {e}"));
                                    return Err(e.into());
                                }
                                tui_debug::log("resume_tui_after_os_auth: ok");
                                match res {
                                    Ok(Some(pw)) => {
                                        app.pending_keyring_master_password =
                                            Some(sensitive_string(pw))
                                    }
                                    Ok(None) => {}
                                    Err(e) => {
                                        app.login_error = Some(format!(
                                            "Could not read OS credential store: {e}"
                                        ));
                                        terminal.draw(|f| ui::render(f, &mut app))?;
                                        continue;
                                    }
                                }
                                terminal.draw(|f| ui::render(f, &mut app))?;
                            } else {
                                tui_debug::log(
                                    "login empty Enter: no profile name (profile list empty?)",
                                );
                            }
                        }
                        let quit = events::handle_event(&mut app, ev, &mut password_store);
                        app.pending_keyring_master_password = None;
                        if tui_debug::enabled() {
                            tui_debug::log(format!(
                                "after login Enter dispatch: screen={:?} login_error={}",
                                app.screen,
                                app.login_error.is_some()
                            ));
                            if let Some(ref e) = app.login_error {
                                let short: String = e.chars().take(220).collect();
                                tui_debug::log(format!("login_error text: {short}"));
                            }
                        }
                        if app.screen == Screen::Dashboard {
                            // Typed password or keyring (Touch ID) — see `App::try_login`.
                            password_store = app.login_session_password.take();
                        }
                        if quit || app.screen == Screen::Quitting {
                            break;
                        }
                        continue;
                    }
                }
            }

            let quit = events::handle_event(&mut app, ev, &mut password_store);
            if quit || app.screen == Screen::Quitting {
                break;
            }
        }

        let _ = password_store.take();
        Ok(())
    })();

    (loop_result, idle_timeout_exit)
}