gui: interactive 2FA onboarding and live stats tick

Onboarding now works on a fresh install with no saved session. The login
does a single initiate_session like the CLI: the two factor callback fires
only when the account actually needs a code, emits bridge://need-totp so the
dashboard reveals the code field, and blocks until submit_totp delivers it.
One auth either way, so it no longer trips Tuta's rate limit the way the old
two step flow did.

First run also gets an email field (the start command bootstraps a config
from the address entered on the dashboard instead of erroring out).

Fixes the dashboard showing zero mails and frozen uptime: stats were purely
event driven, so once the store went quiet after the initial sync no further
snapshot was pushed and uptime stopped climbing. stream_stats now also emits
on a one second tick, which advances uptime and recovers any pulse the UI
missed while the start lock was held through the 2FA wait.
This commit is contained in:
Anthony
2026-06-13 15:35:20 +02:00
parent 836ca6f345
commit 99e511cbbf
5 changed files with 161 additions and 84 deletions
+48 -8
View File
@@ -40,11 +40,17 @@ pub async fn has_saved_session() -> Result<bool, String> {
Ok(tuta::has_saved_session(&cfg.email))
}
/// Holds the sender side of the channel the 2FA callback blocks on. `submit_totp`
/// pushes the code here while a login is waiting for it. Kept separate from
/// `BridgeState` so submitting the code never contends with the start lock.
pub struct TotpState(pub std::sync::Mutex<Option<std::sync::mpsc::Sender<u32>>>);
#[tauri::command]
pub async fn start_bridge(
email: Option<String>,
password: Option<String>,
totp: Option<String>,
app: AppHandle,
totp_state: State<'_, TotpState>,
state: State<'_, BridgeState>,
) -> Result<(), String> {
let mut cfg = match config::load_config() {
@@ -68,14 +74,48 @@ pub async fn start_bridge(
config::ensure_bridge_password(&mut cfg)
.map_err(|e| format!("Bridge password setup failed: {e}"))?;
// If the user supplied a 2FA code, hand the login a callback that returns
// it. Without 2FA on the account this is simply never invoked.
let totp_cb = totp
.and_then(|c| c.trim().parse::<u32>().ok())
.map(|code| tuta::TwoFactorCallback::Totp(Box::new(move || Ok(code))));
// Interactive 2FA, like the CLI: a single login. The callback fires only if
// the account actually needs a code; it tells the UI to show the field
// (`bridge://need-totp`) and blocks until `submit_totp` delivers the code.
// One `initiate_session` either way, so it never trips Tuta's auth rate limit.
let (tx, rx) = std::sync::mpsc::channel::<u32>();
*totp_state.0.lock().unwrap() = Some(tx);
let rx = std::sync::Mutex::new(rx);
let app_for_cb = app.clone();
let totp_cb = tuta::TwoFactorCallback::Totp(Box::new(move || {
let _ = app_for_cb.emit("bridge://need-totp", ());
rx.lock()
.unwrap()
.recv_timeout(std::time::Duration::from_secs(120))
.map_err(|_| {
Box::<dyn std::error::Error + Send + Sync>::from(
"Two-factor code was not entered in time",
)
})
}));
let mut handle = state.lock().await;
handle.start(cfg, password, totp_cb).await
let result = {
let mut handle = state.lock().await;
handle.start(cfg, password, Some(totp_cb)).await
};
*totp_state.0.lock().unwrap() = None;
result
}
/// Deliver the 2FA code to a login currently waiting on it (see `start_bridge`).
#[tauri::command]
pub async fn submit_totp(code: String, totp_state: State<'_, TotpState>) -> Result<(), String> {
let parsed: u32 = code
.trim()
.parse()
.map_err(|_| "Two-factor code must be digits".to_string())?;
let tx = totp_state.0.lock().unwrap().clone();
match tx {
Some(tx) => tx
.send(parsed)
.map_err(|_| "No sign-in is waiting for a code".to_string()),
None => Err("No sign-in is waiting for a code".to_string()),
}
}
#[tauri::command]
+17 -5
View File
@@ -24,11 +24,13 @@ fn main() {
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.manage(shared as BridgeState)
.manage(commands::TotpState(std::sync::Mutex::new(None)))
.invoke_handler(tauri::generate_handler![
commands::get_config,
commands::save_config,
commands::has_saved_session,
commands::start_bridge,
commands::submit_totp,
commands::stop_bridge,
commands::get_status,
commands::get_stats,
@@ -104,12 +106,22 @@ async fn stream_stats(
}
emit_snapshot(&app, &state).await;
// A 1s tick alongside the dirty pulses. Pulses give instant updates on
// state changes (ws transitions, new mail); the tick keeps the time-based
// uptime climbing and self-heals any pulse the UI missed during startup
// (e.g. while the lock was held through an interactive 2FA wait).
let mut tick = tokio::time::interval(std::time::Duration::from_secs(1));
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
match rx.recv().await {
Ok(()) | Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
emit_snapshot(&app, &state).await;
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
tokio::select! {
r = rx.recv() => match r {
Ok(()) | Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
emit_snapshot(&app, &state).await;
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
},
_ = tick.tick() => emit_snapshot(&app, &state).await,
}
}
}