From cef468ae7ebb90c690b9b3da7470ee74b73c5537 Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 27 Apr 2026 11:22:40 -0700 Subject: [PATCH] feat(gui): first-run modal with enrollment + 60s idle watchdog Replace the prep-lane stub with the real Flow C: a fullscreen, undecorated adw::ApplicationWindow that queries the daemon's pending flag for the current user, presents an AdwStatusPage "Get started" button when a flag is set, runs the Phase 8 enrollment dialog, calls ClearPendingFlag on success, and exits. A 60s idle watchdog (5s tick) falls back to gnome-session-quit --logout. EventControllerKey and GestureClick poke the WatchdogState on activity; missing pending flag short-circuits to immediate exit so the autostart cost is one D-Bus call. Co-Authored-By: Claude Opus 4.7 (1M context) --- gui/src/firstrun/mod.rs | 168 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 157 insertions(+), 11 deletions(-) diff --git a/gui/src/firstrun/mod.rs b/gui/src/firstrun/mod.rs index 6166ef3..b6add56 100644 --- a/gui/src/firstrun/mod.rs +++ b/gui/src/firstrun/mod.rs @@ -1,18 +1,164 @@ -//! First-login modal. Phase 10 implements the body of `run`. - -#![allow(dead_code)] // body lands in Phase 10 Task 2. +//! First-login modal. Fullscreen `adw::ApplicationWindow` with a 60s idle +//! watchdog: if the user goes silent we fall back to `gnome-session-quit +//! --logout`. On a present pending flag, we run the Phase 8 enrollment +//! dialog, clear the flag on success, and exit. mod watchdog; -#[allow(unused_imports)] // wired into run() in Phase 10 Task 2. pub(crate) use watchdog::WatchdogState; use crate::app_context::AppContext; +use crate::bus::{current_user, Daemon}; +use crate::error::user_message; +use adw::prelude::*; +use gtk::glib; +use std::cell::RefCell; +use std::rc::Rc; +use std::time::Duration; -/// Entry point for `authforge --first-run`. Phase 10 fills this with the -/// fullscreen modal + watchdog + enroll-then-clear-pending logic. -/// Until then it exits cleanly so a developer who passes `--first-run` -/// doesn't get a stuck process. -pub(crate) fn run(_ctx: AppContext) { - eprintln!("authforge: --first-run mode is a Phase 10 stub; exiting."); - std::process::exit(0); +const WATCHDOG_TIMEOUT: Duration = Duration::from_secs(60); +const WATCHDOG_TICK: Duration = Duration::from_secs(5); + +pub(crate) fn run(ctx: AppContext) { + // Reuse the application from the parent window the activate handler + // already created — avoids re-fetching the default Application. + let app = ctx + .parent_window + .application() + .expect("parent window has its application set in main.rs"); + let app = app + .downcast::() + .expect("authforge uses adw::Application"); + + let win = adw::ApplicationWindow::builder() + .application(&app) + .title("Authentication setup") + .decorated(false) + .fullscreened(true) + .build(); + + let status = adw::StatusPage::builder() + .icon_name("dialog-password-symbolic") + .title("Setting up authentication") + .description("Connecting to the authentication daemon…") + .build(); + win.set_content(Some(&status)); + + // Watchdog state shared across the activity-poke handlers and the timer. + let watchdog = Rc::new(RefCell::new(WatchdogState::new(WATCHDOG_TIMEOUT))); + + install_activity_pokes(&win, watchdog.clone()); + install_watchdog_timer(&win, watchdog); + + // Async startup: GetPendingStatus → render flow. + let win_for_async = win.clone(); + let status_for_async = status.clone(); + glib::MainContext::default().spawn_local(async move { + let daemon = match Daemon::connect().await { + Ok(d) => d, + Err(e) => { + status_for_async.set_description(Some(&user_message(&e))); + // Stay open so the user sees the error; watchdog will + // logout in 60s if they don't take action. + return; + } + }; + let user = current_user(); + let s = match daemon.get_pending_status(&user).await { + Ok(s) => s, + Err(e) => { + status_for_async.set_description(Some(&user_message(&e))); + return; + } + }; + if !s.present { + // Nothing for us to do — exit cleanly. Most session starts + // hit this path; cost is one D-Bus call. + win_for_async.close(); + std::process::exit(0); + } + render_get_started(&win_for_async, &status_for_async, daemon, user); + }); + + win.present(); +} + +fn render_get_started( + win: &adw::ApplicationWindow, + status: &adw::StatusPage, + daemon: Daemon, + user: String, +) { + status.set_title("Welcome"); + status.set_description(Some( + "Enroll a security key to finish setting up your account.", + )); + let go = gtk::Button::builder() + .label("Get started") + .css_classes(["pill", "suggested-action"]) + .halign(gtk::Align::Center) + .build(); + let win_for_click = win.clone(); + let daemon_for_click = daemon.clone(); + let user_for_click = user.clone(); + go.connect_clicked(move |btn| { + // Disable the button so a second click doesn't double-prompt. + btn.set_sensitive(false); + let win_inner = win_for_click.clone(); + let daemon_inner = daemon_for_click.clone(); + let user_inner = user_for_click.clone(); + crate::enroll_dialog::present( + &win_inner.clone().upcast::(), + daemon_inner.clone(), + move || { + // On success: clear the pending flag, then exit. + let win_done = win_inner.clone(); + let daemon_done = daemon_inner.clone(); + let user_done = user_inner.clone(); + glib::MainContext::default().spawn_local(async move { + if let Err(e) = daemon_done.clear_pending_flag(&user_done).await { + // Even if clearing fails, exit; the user enrolled + // a key and the next login will succeed via pam_u2f + // even if pending is still set. + eprintln!("authforge: ClearPendingFlag failed: {}", user_message(&e)); + } + win_done.close(); + std::process::exit(0); + }); + }, + ); + }); + status.set_child(Some(&go)); +} + +fn install_activity_pokes(win: &adw::ApplicationWindow, watchdog: Rc>) { + // Any keypress or click counts as activity. + let key = gtk::EventControllerKey::new(); + let w = watchdog.clone(); + key.connect_key_pressed(move |_, _, _, _| { + w.borrow_mut().poke(); + glib::Propagation::Proceed + }); + win.add_controller(key); + + let click = gtk::GestureClick::new(); + let w = watchdog; + click.connect_pressed(move |_, _, _, _| w.borrow_mut().poke()); + win.add_controller(click); +} + +fn install_watchdog_timer(win: &adw::ApplicationWindow, watchdog: Rc>) { + let win_for_timer = win.clone(); + glib::source::timeout_add_local(WATCHDOG_TICK, move || { + if watchdog.borrow().expired() { + // 60s idle — kick the user back out. gnome-session-quit + // is the documented path for "log this user out cleanly." + let _ = std::process::Command::new("gnome-session-quit") + .arg("--logout") + .arg("--no-prompt") + .spawn(); + win_for_timer.close(); + return glib::ControlFlow::Break; + } + glib::ControlFlow::Continue + }); }