feat(gui): TouchDialog modal racing EnrollOwn against EnrollmentFailed signal

Adds enroll_dialog::present which opens an adw::AlertDialog with a spinner
and races the EnrollOwn call against the EnrollmentFailed signal stream:
- Call returns Ok(_cred) -> close dialog, fire on_success refresh callback.
- Call returns Err -> swap dialog body to the user-message form.
- Signal arrives first -> swap to the signal payload (faster than waiting
  for the call's typed Err to traverse the bus).

KeysPage::start_enroll wires the activated row to present(), passing the
parent window for modal anchoring and a refresh closure as on_success.

Plan deviation: AlertDialog is libadwaita v1_5-gated, so gui/Cargo.toml
enables that feature. Targets Ubuntu 24.04+ (libadwaita 1.5).
This commit is contained in:
michael
2026-04-27 09:24:50 -07:00
parent f11047fbcb
commit 3fbd872a9c
4 changed files with 92 additions and 5 deletions

View File

@@ -10,7 +10,7 @@ path = "src/main.rs"
[dependencies]
gtk = { package = "gtk4", version = "0.8" }
adw = { package = "libadwaita", version = "0.6" }
adw = { package = "libadwaita", version = "0.6", features = ["v1_5"] }
authforge-common = { path = "../common" }
anyhow = { workspace = true }
zbus = { workspace = true }

79
gui/src/enroll_dialog.rs Normal file
View File

@@ -0,0 +1,79 @@
//! Modal touch-prompt dialog. Lifetime is one enrollment attempt.
//!
//! Races the EnrollOwn call against the EnrollmentFailed signal — the call
//! return is the source of truth for success (carries the new Credential),
//! the signal lets us surface a useful failure string fast.
use crate::bus::{current_user, Daemon};
use crate::error::user_message;
use adw::prelude::*;
use futures_util::StreamExt;
use gtk::glib;
/// Present the modal and run an EnrollOwn against the daemon. `on_success`
/// fires after a successful enrollment so the caller can refresh its list.
pub(crate) fn present(parent: &gtk::Window, daemon: Daemon, on_success: impl Fn() + 'static) {
let dialog = adw::AlertDialog::builder()
.heading("Touch your security key")
.body("Touch the metal contact on your security key to enroll it.")
.build();
dialog.add_response("cancel", "Cancel");
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
let spinner = gtk::Spinner::builder()
.spinning(true)
.height_request(48)
.width_request(48)
.halign(gtk::Align::Center)
.build();
dialog.set_extra_child(Some(&spinner));
dialog.present(parent);
let dialog_clone = dialog.clone();
glib::MainContext::default().spawn_local(async move {
let user = current_user();
// Subscribe BEFORE the call so a fast EnrollmentFailed isn't missed.
let mut failed = match daemon.subscribe_enrollment_failed().await {
Ok(s) => s,
Err(e) => {
show_failure(&dialog_clone, &user_message(&e));
return;
}
};
let call_fut = daemon.enroll_own(&user, "Security Key");
futures_util::pin_mut!(call_fut);
let outcome = futures_util::future::select(call_fut, failed.next()).await;
match outcome {
futures_util::future::Either::Left((Ok(_cred), _)) => {
dialog_clone.close();
on_success();
}
futures_util::future::Either::Left((Err(e), _)) => {
show_failure(&dialog_clone, &user_message(&e));
}
futures_util::future::Either::Right((Some(msg), _)) => {
let reason: String = msg
.body()
.deserialize()
.unwrap_or_else(|_| "enrollment failed".into());
show_failure(&dialog_clone, &reason);
}
futures_util::future::Either::Right((None, _)) => {
show_failure(&dialog_clone, "signal stream closed unexpectedly");
}
}
});
}
fn show_failure(dialog: &adw::AlertDialog, msg: &str) {
dialog.set_heading(Some("Enrollment failed"));
dialog.set_body(msg);
dialog.set_extra_child(None::<&gtk::Widget>);
// AlertDialog responses can't be removed dynamically; relabel "cancel"
// to "Close" so the surviving button reads correctly.
dialog.set_response_label("cancel", "Close");
}

View File

@@ -11,7 +11,6 @@ use std::rc::Rc;
pub(crate) struct KeysPage {
pub root: adw::Bin,
daemon: Rc<RefCell<Option<Daemon>>>,
#[allow(dead_code)] // wired through enroll_dialog in Task 6.
parent_window: gtk::Window,
}
@@ -152,9 +151,17 @@ impl KeysPage {
}
async fn start_enroll(self: &Rc<Self>) {
// Stub until Task 6 lands the TouchDialog. Until then, surface the
// intent so manual smoke can see the click was registered.
self.show_toast("Enroll: implemented in Task 6");
let daemon = match self.daemon.borrow().clone() {
Some(d) => d,
None => return,
};
let me = self.clone();
crate::enroll_dialog::present(&self.parent_window, daemon, move || {
let me = me.clone();
glib::MainContext::default().spawn_local(async move {
me.render_connected().await;
});
});
}
fn show_toast(self: &Rc<Self>, msg: &str) {

View File

@@ -2,6 +2,7 @@ use adw::prelude::*;
use gtk::glib;
mod bus;
mod enroll_dialog;
mod error;
mod keys_page;