Merge branch 'feature/phase-10-firstrun'
This commit is contained in:
@@ -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
181
gui/src/firstrun/mod.rs
Normal 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
|
||||
});
|
||||
}
|
||||
55
gui/src/firstrun/watchdog.rs
Normal file
55
gui/src/firstrun/watchdog.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user