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) <noreply@anthropic.com>
This commit is contained in:
michael
2026-04-27 11:22:40 -07:00
parent 3b519578c5
commit cef468ae7e

View File

@@ -1,18 +1,164 @@
//! First-login modal. Phase 10 implements the body of `run`. //! First-login modal. Fullscreen `adw::ApplicationWindow` with a 60s idle
//! watchdog: if the user goes silent we fall back to `gnome-session-quit
#![allow(dead_code)] // body lands in Phase 10 Task 2. //! --logout`. On a present pending flag, we run the Phase 8 enrollment
//! dialog, clear the flag on success, and exit.
mod watchdog; mod watchdog;
#[allow(unused_imports)] // wired into run() in Phase 10 Task 2.
pub(crate) use watchdog::WatchdogState; pub(crate) use watchdog::WatchdogState;
use crate::app_context::AppContext; 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 const WATCHDOG_TIMEOUT: Duration = Duration::from_secs(60);
/// fullscreen modal + watchdog + enroll-then-clear-pending logic. const WATCHDOG_TICK: Duration = Duration::from_secs(5);
/// Until then it exits cleanly so a developer who passes `--first-run`
/// doesn't get a stuck process. pub(crate) fn run(ctx: AppContext) {
pub(crate) fn run(_ctx: AppContext) { // Reuse the application from the parent window the activate handler
eprintln!("authforge: --first-run mode is a Phase 10 stub; exiting."); // already created — avoids re-fetching the default Application.
std::process::exit(0); let app = ctx
.parent_window
.application()
.expect("parent window has its application set in main.rs");
let app = app
.downcast::<adw::Application>()
.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::<gtk::Window>(),
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<RefCell<WatchdogState>>) {
// 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<RefCell<WatchdogState>>) {
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
});
} }