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
31const IDLE_TIMEOUT: Duration = Duration::from_secs(5 * 60);
33
34fn 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
64pub fn run() -> Result<()> {
66 #[cfg(windows)]
71 unsafe {
72 SetConsoleOutputCP(65001);
73 }
74
75 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
115fn 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 if empty {
171 if let Some(profile) = app.current_profile_name().map(str::to_owned) {
172 let vp = vault_path(&profile);
173 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 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}