Skip to main content

tsafe_tui/
lib.rs

1pub mod app;
2pub mod clipboard;
3pub mod events;
4pub mod state;
5
6pub use events::feed_events;
7pub mod tui_debug;
8pub mod ui;
9
10use std::io::{self, Write};
11use std::time::{Duration, Instant};
12
13use anyhow::Result;
14use crossterm::{
15    event::{
16        self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers,
17    },
18    execute,
19    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
20};
21use ratatui::{backend::CrosstermBackend, Terminal};
22
23#[cfg(windows)]
24use windows_sys::Win32::System::Console::SetConsoleOutputCP;
25
26use app::{sensitive_string, App, Screen, SensitiveString};
27use tsafe_core::keyring_store;
28use tsafe_core::profile::vault_path;
29use tsafe_core::vault::Vault;
30
31/// Close the TUI after this long with no input (any key, mouse, paste, etc.).
32const IDLE_TIMEOUT: Duration = Duration::from_secs(5 * 60);
33
34/// macOS (and some desktops) cannot show Touch ID / keychain UI while the terminal is in raw mode
35/// and the alternate screen. Suspend the TUI briefly while reading the vault password from the OS store.
36fn suspend_tui_for_os_auth(
37    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
38) -> io::Result<()> {
39    disable_raw_mode()?;
40    execute!(
41        terminal.backend_mut(),
42        LeaveAlternateScreen,
43        DisableMouseCapture
44    )?;
45    terminal.show_cursor()?;
46    let _ = io::stdout().flush();
47    let _ = io::stderr().flush();
48    Ok(())
49}
50
51fn resume_tui_after_os_auth(
52    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
53) -> io::Result<()> {
54    enable_raw_mode()?;
55    execute!(
56        terminal.backend_mut(),
57        EnterAlternateScreen,
58        EnableMouseCapture
59    )?;
60    terminal.hide_cursor()?;
61    Ok(())
62}
63
64/// Launch the full-screen TUI. Blocks until the user quits.
65pub fn run() -> Result<()> {
66    // On Windows, set the console output codepage to UTF-8 (65001) so that
67    // Unicode box-drawing characters and the password mask glyph render
68    // correctly in conhost (old Windows PowerShell / cmd). This is a no-op
69    // on Windows Terminal / pwsh which are already UTF-8.
70    #[cfg(windows)]
71    unsafe {
72        SetConsoleOutputCP(65001);
73    }
74
75    // Install a panic hook that restores the terminal before printing the panic message.
76    // Without this, a panic leaves the terminal in raw/alternate-screen mode.
77    let original_hook = std::panic::take_hook();
78    std::panic::set_hook(Box::new(move |info| {
79        let _ = crossterm::terminal::disable_raw_mode();
80        let _ = crossterm::execute!(
81            io::stdout(),
82            crossterm::terminal::LeaveAlternateScreen,
83            crossterm::event::DisableMouseCapture,
84        );
85        original_hook(info);
86    }));
87
88    tui_debug::announce_if_enabled();
89
90    enable_raw_mode()?;
91    let mut stdout = io::stdout();
92    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
93    let backend = CrosstermBackend::new(io::stdout());
94    let mut terminal = Terminal::new(backend)?;
95
96    let (result, idle_timeout_exit) = run_loop(&mut terminal);
97
98    disable_raw_mode()?;
99    execute!(
100        terminal.backend_mut(),
101        LeaveAlternateScreen,
102        DisableMouseCapture
103    )?;
104    terminal.show_cursor()?;
105
106    if idle_timeout_exit {
107        eprintln!(
108            "tsafe: session ended after 5 minutes with no input (idle timeout). Run `tsafe ui` again to continue."
109        );
110    }
111
112    result
113}
114
115/// Runs the main loop. Returns `(io/terminal result, idle_timeout)` where `idle_timeout` is true
116/// if the loop exited due to [`IDLE_TIMEOUT`].
117fn run_loop(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> (Result<()>, bool) {
118    let mut app = App::new();
119    let mut password_store: Option<SensitiveString> = None;
120    let mut last_input = Instant::now();
121    let mut idle_timeout_exit = false;
122
123    let loop_result = (|| -> Result<()> {
124        loop {
125            app.poll_update_check();
126            app.maybe_expire_clipboard();
127            app.maybe_expire_reveals();
128            app.maybe_expire_undo();
129            terminal.draw(|f| ui::render(f, &mut app))?;
130
131            let tick = Duration::from_millis(100);
132            if !event::poll(tick)? {
133                if last_input.elapsed() >= IDLE_TIMEOUT {
134                    idle_timeout_exit = true;
135                    break;
136                }
137                continue;
138            }
139
140            last_input = Instant::now();
141            let ev = event::read()?;
142
143            if app.screen == Screen::Login && tui_debug::enabled() {
144                if let Event::Key(ref k) = ev {
145                    match k.code {
146                        KeyCode::Char(_) => {}
147                        _ => {
148                            tui_debug::log(format!(
149                                "login key (non-char): kind={:?} code={:?} mods={:?}",
150                                k.kind, k.code, k.modifiers
151                            ));
152                        }
153                    }
154                }
155            }
156
157            if app.screen == Screen::Login {
158                if let Event::Key(key) = &ev {
159                    let blocked_mods = key.modifiers.contains(KeyModifiers::CONTROL)
160                        || key.modifiers.contains(KeyModifiers::ALT);
161                    let enter_submit = key.kind == KeyEventKind::Press
162                        && key.code == KeyCode::Enter
163                        && !blocked_mods;
164                    if enter_submit {
165                        app.pending_keyring_master_password = None;
166                        let empty = app.password_buf.is_empty();
167                        // Touch ID / system auth must run outside raw mode + alternate screen.
168                        // Always suspend here on empty Enter — `try_login` must not call `retrieve_password`
169                        // in TUI mode or the OS prompt often never appears and unlock fails.
170                        if empty {
171                            if let Some(profile) = app.current_profile_name().map(str::to_owned) {
172                                let vp = vault_path(&profile);
173                                // Don't pre-probe with `has_password` here: on non-macOS the
174                                // generic backend has no no-UI existence probe, so it would fire
175                                // a real keychain read in addition to the `retrieve_password`
176                                // below — surfacing as a double prompt. The Ok(None) / Ok(Some)
177                                // log lines below already record whether an entry was present.
178                                tui_debug::log(format!(
179                                    "login empty Enter: profile={profile:?} vault_path={} exists={} is_team_vault={}",
180                                    vp.display(),
181                                    vp.exists(),
182                                    Vault::is_team_vault(&vp),
183                                ));
184                                tui_debug::log("suspend_tui_for_os_auth: begin");
185                                if let Err(e) = suspend_tui_for_os_auth(terminal) {
186                                    tui_debug::log(format!("suspend_tui_for_os_auth: ERR {e}"));
187                                    return Err(e.into());
188                                }
189                                tui_debug::log("suspend_tui_for_os_auth: ok");
190                                tui_debug::log("keyring retrieve_password: begin (Touch ID / system UI may appear)");
191                                let res = keyring_store::retrieve_password(&profile);
192                                match &res {
193                                    Ok(None) => {
194                                        tui_debug::log("keyring retrieve_password: Ok(None) — no credential for this profile name");
195                                    }
196                                    Ok(Some(s)) => {
197                                        tui_debug::log(format!(
198                                            "keyring retrieve_password: Ok(Some) stored_secret_byte_len={}",
199                                            s.len()
200                                        ));
201                                    }
202                                    Err(e) => {
203                                        tui_debug::log(format!(
204                                            "keyring retrieve_password: ERR {e}"
205                                        ));
206                                    }
207                                }
208                                if let Err(e) = resume_tui_after_os_auth(terminal) {
209                                    tui_debug::log(format!("resume_tui_after_os_auth: ERR {e}"));
210                                    return Err(e.into());
211                                }
212                                tui_debug::log("resume_tui_after_os_auth: ok");
213                                match res {
214                                    Ok(Some(pw)) => {
215                                        app.pending_keyring_master_password =
216                                            Some(sensitive_string(pw))
217                                    }
218                                    Ok(None) => {}
219                                    Err(e) => {
220                                        app.login_error = Some(format!(
221                                            "Could not read OS credential store: {e}"
222                                        ));
223                                        terminal.draw(|f| ui::render(f, &mut app))?;
224                                        continue;
225                                    }
226                                }
227                                terminal.draw(|f| ui::render(f, &mut app))?;
228                            } else {
229                                tui_debug::log(
230                                    "login empty Enter: no profile name (profile list empty?)",
231                                );
232                            }
233                        }
234                        let quit = events::handle_event(&mut app, ev, &mut password_store);
235                        app.pending_keyring_master_password = None;
236                        if tui_debug::enabled() {
237                            tui_debug::log(format!(
238                                "after login Enter dispatch: screen={:?} login_error={}",
239                                app.screen,
240                                app.login_error.is_some()
241                            ));
242                            if let Some(ref e) = app.login_error {
243                                let short: String = e.chars().take(220).collect();
244                                tui_debug::log(format!("login_error text: {short}"));
245                            }
246                        }
247                        if app.screen == Screen::Dashboard {
248                            // Typed password or keyring (Touch ID) — see `App::try_login`.
249                            password_store = app.login_session_password.take();
250                        }
251                        if quit || app.screen == Screen::Quitting {
252                            break;
253                        }
254                        continue;
255                    }
256                }
257            }
258
259            let quit = events::handle_event(&mut app, ev, &mut password_store);
260            if quit || app.screen == Screen::Quitting {
261                break;
262            }
263        }
264
265        let _ = password_store.take();
266        Ok(())
267    })();
268
269    (loop_result, idle_timeout_exit)
270}