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:
@@ -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.");
|
||||
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::<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
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user