Merge branch 'feature/phase-10-firstrun'

This commit is contained in:
michael
2026-04-27 12:05:18 -07:00
7 changed files with 317 additions and 15 deletions

View File

@@ -1,14 +0,0 @@
//! First-login modal. Phase 10 implements the body of `run`.
#![allow(dead_code)] // body lands in Phase 10.
use crate::app_context::AppContext;
/// 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);
}

181
gui/src/firstrun/mod.rs Normal file
View File

@@ -0,0 +1,181 @@
//! 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;
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;
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) => {
render_unavailable(&win_for_async, &status_for_async, &user_message(&e));
return;
}
};
let user = current_user();
let s = match daemon.get_pending_status(&user).await {
Ok(s) => s,
Err(e) => {
render_unavailable(&win_for_async, &status_for_async, &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 render_unavailable(win: &adw::ApplicationWindow, status: &adw::StatusPage, message: &str) {
// Daemon-connect or GetPendingStatus failure: surface the error and give
// the user a Skip button so they aren't stuck waiting 60s for the
// watchdog to log them out.
status.set_title("Setup unavailable");
status.set_description(Some(message));
let skip = gtk::Button::builder()
.label("Skip")
.css_classes(["pill"])
.halign(gtk::Align::Center)
.build();
let win_skip = win.clone();
skip.connect_clicked(move |_| {
win_skip.close();
std::process::exit(0);
});
status.set_child(Some(&skip));
}
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
});
}

View File

@@ -0,0 +1,55 @@
//! Tracks the last-activity timestamp; the GTK timer queries this each tick
//! to decide whether 60s of idle has elapsed.
use std::time::{Duration, Instant};
pub(crate) struct WatchdogState {
last_activity: Instant,
timeout: Duration,
}
impl WatchdogState {
pub fn new(timeout: Duration) -> Self {
Self {
last_activity: Instant::now(),
timeout,
}
}
pub fn poke(&mut self) {
self.last_activity = Instant::now();
}
/// True iff `timeout` has passed since the last poke. Once expired,
/// stays expired until the caller drops + recreates.
pub fn expired(&self) -> bool {
self.last_activity.elapsed() >= self.timeout
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fresh_state_is_not_expired() {
let w = WatchdogState::new(Duration::from_secs(60));
assert!(!w.expired());
}
#[test]
fn zero_timeout_is_immediately_expired() {
let w = WatchdogState::new(Duration::from_millis(0));
std::thread::sleep(Duration::from_millis(2));
assert!(w.expired());
}
#[test]
fn poke_resets_the_clock() {
let mut w = WatchdogState::new(Duration::from_millis(100));
std::thread::sleep(Duration::from_millis(150));
assert!(w.expired());
w.poke();
assert!(!w.expired());
}
}