From a17f70c4ac0fb88a280db93500cb35ddd8aba4b3 Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 27 Apr 2026 09:05:58 -0700 Subject: [PATCH 1/9] docs(plan): fix Phase 8 zbus runtime path (no glib feature exists) zbus 4.4 does not expose a glib feature; my earlier draft was wrong about that. Switch the plan to use the workspace tokio config and install a multi-thread tokio runtime in main.rs, with the runtime guard kept alive for the lifetime of app.run(). glib::MainContext::spawn_local still drives the widget-touching closures; tokio's reactor wakes them. Also: hoist futures-util into the Task 1 dep bundle so Task 6 doesn't need a separate Cargo.toml edit. --- docs/plans/2026-04-27-phase-8-gui-keys.md | 41 ++++++++++++++--------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/docs/plans/2026-04-27-phase-8-gui-keys.md b/docs/plans/2026-04-27-phase-8-gui-keys.md index b3a2b99..a122673 100644 --- a/docs/plans/2026-04-27-phase-8-gui-keys.md +++ b/docs/plans/2026-04-27-phase-8-gui-keys.md @@ -4,12 +4,15 @@ **Goal:** Land a working `authforge` GUI that lists the current user's enrolled FIDO2 credentials, enrolls a new key with a touch-prompt modal driven by the daemon's `EnrollmentSucceeded`/`EnrollmentFailed` signals, removes a credential, and degrades gracefully when the daemon is unreachable. -**Architecture:** Single-window GTK4 + libadwaita app on the user's session, talking to the daemon on the **system bus** at `io.dangerousthings.AuthForge / /io/dangerousthings/AuthForge / io.dangerousthings.AuthForge1`. zbus uses its **glib** integration (`features = ["glib"]`) instead of the workspace tokio config, so all async runs on the GTK main thread via `glib::MainContext::spawn_local`. No tokio dep in the GUI process. +**Architecture:** Single-window GTK4 + libadwaita app on the user's session, talking to the daemon on the **system bus** at `io.dangerousthings.AuthForge / /io/dangerousthings/AuthForge / io.dangerousthings.AuthForge1`. zbus uses the workspace tokio runtime; a multi-thread tokio runtime is installed at app startup and its enter-guard is held for the lifetime of `app.run()`. Closures that touch widgets dispatch async work via `glib::MainContext::spawn_local`, which polls the future on the GTK main thread; tokio's reactor (running on its own worker threads) wakes the future when zbus I/O is ready, and glib re-polls. + +> **Plan note (2026-04-27):** an earlier draft of this plan claimed zbus 4 has a `glib` feature for runtime integration. It does not — zbus 4.4's features are `async-io` (default), `tokio`, `bus-impl`, `chrono`, `p2p`, `time`, `url`, `uuid`, `vsock`. We use `tokio` to match the rest of the workspace. **Tech Stack:** - `gtk4-rs` 0.8 (already pinned in `gui/Cargo.toml`). - `libadwaita` 0.6 (already pinned). -- `zbus = { version = "4", default-features = false, features = ["glib"] }` — **not** `workspace = true`; the workspace dep is tokio-flavored and would conflict. +- `zbus = { workspace = true }` — workspace config is `default-features = false, features = ["tokio", "p2p"]`. +- `tokio = { workspace = true }` — needed for the runtime guard that hosts zbus's tokio executor. - `authforge-common = { path = "../common" }` for the shared `Credential` wire type. **Reference:** @@ -38,18 +41,20 @@ **Step 1:** Append to `gui/Cargo.toml` `[dependencies]`: ```toml -zbus = { version = "4", default-features = false, features = ["glib"] } +zbus = { workspace = true } +tokio = { workspace = true } +futures-util = { workspace = true } ``` -(Do **not** use `workspace = true`. The workspace zbus is tokio-flavored — the GUI needs the glib runtime feature, which is mutually exclusive at the zbus crate level.) +The tokio runtime guard installed in Task 4 (`main.rs`) is what makes zbus's tokio-flavored futures actually run when `glib::MainContext::spawn_local` polls them. `futures-util` shows up here so Task 6's `select!`-style race in `enroll_dialog.rs` doesn't need a follow-up Cargo.toml edit. -**Step 2:** `cargo build -p authforge-gui`. Expected: clean compile (no usages yet, but the dep resolves). +**Step 2:** `cargo build -p authforge-gui`. Expected: clean compile (no usages yet, but the deps resolve). **Step 3:** Commit. ```bash git add gui/Cargo.toml Cargo.lock -git commit --no-gpg-sign -m "chore(gui): add zbus glib feature for D-Bus client" +git commit --no-gpg-sign -m "chore(gui): add zbus + tokio + futures-util deps for D-Bus client" ``` --- @@ -357,6 +362,16 @@ mod keys_page; const APP_ID: &str = "io.dangerousthings.AuthForge"; fn main() -> glib::ExitCode { + // Multi-thread tokio runtime hosts zbus's tokio-flavored futures. The + // `_guard` keeps the runtime context active on this thread so calls like + // `zbus::Connection::system().await` from within `glib::MainContext:: + // spawn_local` closures don't panic with "no current reactor". + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("build tokio runtime"); + let _guard = runtime.enter(); + let app = adw::Application::builder().application_id(APP_ID).build(); app.connect_activate(|app| { let win = adw::ApplicationWindow::builder() @@ -624,13 +639,7 @@ fn show_failure(dialog: &adw::AlertDialog, msg: &str) { } ``` -**Step 3:** Add `futures-util` to `gui/Cargo.toml`: - -```toml -futures-util = { workspace = true } -``` - -**Step 4:** Replace the `start_enroll` body in `gui/src/keys_page.rs`: +**Step 3:** Replace the `start_enroll` body in `gui/src/keys_page.rs`: ```rust async fn start_enroll(self: &Rc) { @@ -648,12 +657,12 @@ futures-util = { workspace = true } } ``` -**Step 5:** `cargo build -p authforge-gui && cargo clippy -p authforge-gui --all-targets -- -D warnings`. Clean. +**Step 4:** `cargo build -p authforge-gui && cargo clippy -p authforge-gui --all-targets -- -D warnings`. Clean. -**Step 6:** Commit. +**Step 5:** Commit. ```bash -git add gui/src/enroll_dialog.rs gui/src/keys_page.rs gui/src/main.rs gui/Cargo.toml Cargo.lock +git add gui/src/enroll_dialog.rs gui/src/keys_page.rs gui/src/main.rs git commit --no-gpg-sign -m "feat(gui): TouchDialog modal racing EnrollOwn call against EnrollmentFailed signal" ``` From 8d6b80276e00ea73e1cc3a2c55c24777c9ee4c49 Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 27 Apr 2026 09:08:02 -0700 Subject: [PATCH 2/9] chore(gui): add zbus + tokio + futures-util deps for D-Bus client --- Cargo.lock | 3 +++ gui/Cargo.toml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 028e853..7620d2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -309,8 +309,11 @@ version = "0.1.0" dependencies = [ "anyhow", "authforge-common", + "futures-util", "gtk4", "libadwaita", + "tokio", + "zbus", ] [[package]] diff --git a/gui/Cargo.toml b/gui/Cargo.toml index 4bb6294..f1bc887 100644 --- a/gui/Cargo.toml +++ b/gui/Cargo.toml @@ -13,3 +13,6 @@ gtk = { package = "gtk4", version = "0.8" } adw = { package = "libadwaita", version = "0.6" } authforge-common = { path = "../common" } anyhow = { workspace = true } +zbus = { workspace = true } +tokio = { workspace = true } +futures-util = { workspace = true } From d7e6d562dd58c4fa62cf096fae09971098fbf4b1 Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 27 Apr 2026 09:11:06 -0700 Subject: [PATCH 3/9] feat(gui): error helper to classify zbus errors and produce user messages --- gui/src/error.rs | 87 ++++++++++++++++++++++++++++++++++++++++++++++++ gui/src/main.rs | 2 ++ 2 files changed, 89 insertions(+) create mode 100644 gui/src/error.rs diff --git a/gui/src/error.rs b/gui/src/error.rs new file mode 100644 index 0000000..f43bb28 --- /dev/null +++ b/gui/src/error.rs @@ -0,0 +1,87 @@ +//! Map zbus errors to short, user-facing strings. The GUI surfaces these in +//! `adw::Toast` and inside the touch-prompt dialog's failure state. +//! +//! Pure logic; testable without a display server. + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(dead_code)] // wired through keys_page.rs / enroll_dialog.rs in Tasks 4-6. +pub(crate) enum ErrorKind { + /// Daemon unreachable / bus dropped — caller should swap to disconnected banner. + Disconnected, + /// polkit denied. Daemon's message is usually fine to show verbatim. + AccessDenied, + /// Anything else — daemon returned a typed `Failed(msg)` or similar. + Other, +} + +#[allow(dead_code)] // wired through keys_page.rs / enroll_dialog.rs in Tasks 4-6. +pub(crate) fn classify(err: &zbus::Error) -> ErrorKind { + match err { + zbus::Error::InputOutput(_) | zbus::Error::Address(_) | zbus::Error::Handshake(_) => { + ErrorKind::Disconnected + } + zbus::Error::FDO(boxed) => match boxed.as_ref() { + zbus::fdo::Error::AccessDenied(_) => ErrorKind::AccessDenied, + zbus::fdo::Error::ServiceUnknown(_) | zbus::fdo::Error::NameHasNoOwner(_) => { + ErrorKind::Disconnected + } + _ => ErrorKind::Other, + }, + _ => ErrorKind::Other, + } +} + +#[allow(dead_code)] // wired through keys_page.rs / enroll_dialog.rs in Tasks 4-6. +pub(crate) fn user_message(err: &zbus::Error) -> String { + match classify(err) { + ErrorKind::Disconnected => { + "Couldn't reach the authentication daemon. Make sure authforge-daemon is running." + .to_string() + } + ErrorKind::AccessDenied => match err { + zbus::Error::FDO(boxed) => match boxed.as_ref() { + zbus::fdo::Error::AccessDenied(msg) => msg.clone(), + _ => "Access denied.".to_string(), + }, + _ => "Access denied.".to_string(), + }, + ErrorKind::Other => err.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn service_unknown_classifies_as_disconnected() { + let e = zbus::Error::FDO(Box::new(zbus::fdo::Error::ServiceUnknown( + "no daemon".to_string(), + ))); + assert_eq!(classify(&e), ErrorKind::Disconnected); + } + + #[test] + fn access_denied_classifies_as_access_denied() { + let e = zbus::Error::FDO(Box::new(zbus::fdo::Error::AccessDenied( + "polkit said no".to_string(), + ))); + assert_eq!(classify(&e), ErrorKind::AccessDenied); + } + + #[test] + fn access_denied_user_message_is_daemon_message_verbatim() { + let e = zbus::Error::FDO(Box::new(zbus::fdo::Error::AccessDenied( + "polkit said no".to_string(), + ))); + assert_eq!(user_message(&e), "polkit said no"); + } + + #[test] + fn disconnected_user_message_mentions_authforge_daemon() { + let e = zbus::Error::FDO(Box::new(zbus::fdo::Error::ServiceUnknown( + "no daemon".to_string(), + ))); + assert!(user_message(&e).contains("authforge-daemon")); + } +} diff --git a/gui/src/main.rs b/gui/src/main.rs index 7aa2497..6e684d1 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -1,6 +1,8 @@ use adw::prelude::*; use gtk::glib; +mod error; + const APP_ID: &str = "io.dangerousthings.AuthForge"; fn main() -> glib::ExitCode { From d07d3469fe6a0ace85262a7fd76f66d5ba2cd34a Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 27 Apr 2026 09:13:14 -0700 Subject: [PATCH 4/9] feat(gui): D-Bus client wrapper around AuthForge interface --- gui/src/bus.rs | 56 +++++++++++++++++++++++++++++++++++++++++++++++++ gui/src/main.rs | 1 + 2 files changed, 57 insertions(+) create mode 100644 gui/src/bus.rs diff --git a/gui/src/bus.rs b/gui/src/bus.rs new file mode 100644 index 0000000..92dfb66 --- /dev/null +++ b/gui/src/bus.rs @@ -0,0 +1,56 @@ +//! D-Bus client for the GUI. zbus runs on the workspace tokio runtime; the +//! runtime guard installed in `main` keeps the runtime context alive for the +//! lifetime of `app.run()`. Closures dispatch async work via +//! `glib::MainContext::spawn_local`, which polls the future on the GTK main +//! thread; tokio's reactor wakes the future when zbus I/O is ready. + +use authforge_common::types::Credential; + +const BUS_NAME: &str = "io.dangerousthings.AuthForge"; +const OBJECT_PATH: &str = "/io/dangerousthings/AuthForge"; +const IFACE: &str = "io.dangerousthings.AuthForge1"; + +#[derive(Clone)] +#[allow(dead_code)] // wired through keys_page.rs / enroll_dialog.rs in Tasks 4-6. +pub(crate) struct Daemon { + proxy: zbus::Proxy<'static>, +} + +#[allow(dead_code)] // wired through keys_page.rs / enroll_dialog.rs in Tasks 4-6. +impl Daemon { + pub async fn connect() -> zbus::Result { + let conn = zbus::Connection::system().await?; + // Mirror the CLI's known-working pattern; `Proxy::new_owned` returns a + // Proxy<'static> owning its connection so `Daemon` can be passed + // around freely. + let proxy = zbus::Proxy::new_owned(conn, BUS_NAME, OBJECT_PATH, IFACE).await?; + Ok(Self { proxy }) + } + + pub async fn list_credentials(&self, user: &str) -> zbus::Result> { + self.proxy.call("ListCredentials", &(user,)).await + } + + pub async fn enroll_own(&self, user: &str, nickname: &str) -> zbus::Result { + self.proxy.call("EnrollOwn", &(user, nickname)).await + } + + pub async fn remove_own(&self, user: &str, cred_id: &str) -> zbus::Result<()> { + self.proxy.call("RemoveOwn", &(user, cred_id)).await + } + + /// Subscribes to `EnrollmentFailed`. The returned stream is alive as long + /// as the caller holds it; the touch dialog drops it when it closes. We + /// don't subscribe to `EnrollmentSucceeded` because the call's return + /// value carries the new `Credential` — the source of truth for success. + pub async fn subscribe_enrollment_failed( + &self, + ) -> zbus::Result> { + self.proxy.receive_signal("EnrollmentFailed").await + } +} + +#[allow(dead_code)] // wired through keys_page.rs in Task 4. +pub(crate) fn current_user() -> String { + std::env::var("USER").unwrap_or_else(|_| "unknown".into()) +} diff --git a/gui/src/main.rs b/gui/src/main.rs index 6e684d1..2f1b8a8 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -1,6 +1,7 @@ use adw::prelude::*; use gtk::glib; +mod bus; mod error; const APP_ID: &str = "io.dangerousthings.AuthForge"; From 5d6b7ceeb0dc72d88a486967d196cdddea7455b4 Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 27 Apr 2026 09:20:31 -0700 Subject: [PATCH 5/9] feat(gui): KeysPage scaffold + tokio runtime in main, gtk::Box layout KeysPage::new spawns the connect-and-render flow on glib::MainContext:: spawn_local. On success it shows a placeholder PreferencesPage (Task 5 fills in the list); on failure it shows StatusPage with a Retry button that re-runs the connect attempt. main.rs installs the multi-thread tokio runtime guard before app.run() so zbus's tokio futures execute correctly when polled by glib. Layout deviation from plan: ToolbarView is libadwaita v1_4-gated; the project sticks with gtk::Box vertical for portability (commit c6a5e94 established this pattern). --- gui/src/keys_page.rs | 79 ++++++++++++++++++++++++++++++++++++++++++++ gui/src/main.rs | 36 ++++++++++++++++---- 2 files changed, 109 insertions(+), 6 deletions(-) create mode 100644 gui/src/keys_page.rs diff --git a/gui/src/keys_page.rs b/gui/src/keys_page.rs new file mode 100644 index 0000000..5e68df2 --- /dev/null +++ b/gui/src/keys_page.rs @@ -0,0 +1,79 @@ +//! "Security keys" preferences page. v1 surface: the current user's enrolled +//! FIDO2 credentials with per-row remove + a single "Enroll a new key" button. +//! Task 4 lands the empty state + retry banner; Task 5 fills in the list. + +use crate::bus::Daemon; +use crate::error::user_message; +use adw::prelude::*; +use gtk::glib; +use std::cell::RefCell; +use std::rc::Rc; + +pub(crate) struct KeysPage { + pub root: adw::Bin, + daemon: Rc>>, + #[allow(dead_code)] // wired through enroll_dialog in Task 6. + parent_window: gtk::Window, +} + +impl KeysPage { + pub fn new(parent_window: gtk::Window) -> Rc { + let page = Rc::new(Self { + root: adw::Bin::new(), + daemon: Rc::new(RefCell::new(None)), + parent_window, + }); + let p = page.clone(); + glib::MainContext::default().spawn_local(async move { + p.try_connect_and_render().await; + }); + page + } + + async fn try_connect_and_render(self: Rc) { + match Daemon::connect().await { + Ok(d) => { + *self.daemon.borrow_mut() = Some(d); + self.render_connected().await; + } + Err(e) => self.render_disconnected(&user_message(&e)), + } + } + + fn render_disconnected(self: &Rc, msg: &str) { + let status = adw::StatusPage::builder() + .icon_name("network-offline-symbolic") + .title("Daemon unavailable") + .description(msg) + .build(); + let retry = gtk::Button::builder() + .label("Retry") + .css_classes(["pill", "suggested-action"]) + .halign(gtk::Align::Center) + .build(); + let me = self.clone(); + retry.connect_clicked(move |_| { + let me = me.clone(); + glib::MainContext::default().spawn_local(async move { + me.try_connect_and_render().await; + }); + }); + status.set_child(Some(&retry)); + self.root.set_child(Some(&status)); + } + + async fn render_connected(self: &Rc) { + // Placeholder until Task 5 fills in the list. + let page = adw::PreferencesPage::new(); + let group = adw::PreferencesGroup::builder() + .title("Security keys") + .description("FIDO2 keys enrolled for your account") + .build(); + let placeholder = adw::ActionRow::builder() + .title("(Task 5 will populate this)") + .build(); + group.add(&placeholder); + page.add(&group); + self.root.set_child(Some(&page)); + } +} diff --git a/gui/src/main.rs b/gui/src/main.rs index 2f1b8a8..c66cbdc 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -3,24 +3,48 @@ use gtk::glib; mod bus; mod error; +mod keys_page; const APP_ID: &str = "io.dangerousthings.AuthForge"; fn main() -> glib::ExitCode { + // Multi-thread tokio runtime hosts zbus's tokio-flavored futures. The + // `_guard` keeps the runtime context active on this thread so calls like + // `zbus::Connection::system().await` from within `glib::MainContext:: + // spawn_local` closures don't panic with "no current reactor". + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("build tokio runtime"); + let _guard = runtime.enter(); + let app = adw::Application::builder().application_id(APP_ID).build(); app.connect_activate(|app| { - let status = adw::StatusPage::builder() - .icon_name("dialog-password-symbolic") - .title("Authentication setup") - .description("Phase 0 placeholder. Real UI in Phase 8.") - .build(); let win = adw::ApplicationWindow::builder() .application(app) .default_width(720) .default_height(540) .title("Authentication") - .content(&status) .build(); + let header = adw::HeaderBar::new(); + let stack = adw::ViewStack::new(); + let switcher = adw::ViewSwitcher::builder() + .stack(&stack) + .policy(adw::ViewSwitcherPolicy::Wide) + .build(); + header.set_title_widget(Some(&switcher)); + + let keys = keys_page::KeysPage::new(win.clone().upcast()); + stack.add_titled(&keys.root, Some("keys"), "Keys"); + + // ToolbarView is libadwaita v1_4-gated; the project sticks with the + // simpler Box layout for portability (see commit c6a5e94). + let layout = gtk::Box::new(gtk::Orientation::Vertical, 0); + layout.append(&header); + layout.append(&stack); + stack.set_vexpand(true); + + win.set_content(Some(&layout)); win.present(); }); app.run() From f11047fbcbbac4881a52accee1eca687eb0c7cc2 Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 27 Apr 2026 09:21:45 -0700 Subject: [PATCH 6/9] feat(gui): list enrolled credentials with per-row remove + enroll button --- gui/src/keys_page.rs | 108 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 101 insertions(+), 7 deletions(-) diff --git a/gui/src/keys_page.rs b/gui/src/keys_page.rs index 5e68df2..35497c1 100644 --- a/gui/src/keys_page.rs +++ b/gui/src/keys_page.rs @@ -1,8 +1,7 @@ //! "Security keys" preferences page. v1 surface: the current user's enrolled //! FIDO2 credentials with per-row remove + a single "Enroll a new key" button. -//! Task 4 lands the empty state + retry banner; Task 5 fills in the list. -use crate::bus::Daemon; +use crate::bus::{current_user, Daemon}; use crate::error::user_message; use adw::prelude::*; use gtk::glib; @@ -63,17 +62,112 @@ impl KeysPage { } async fn render_connected(self: &Rc) { - // Placeholder until Task 5 fills in the list. + let daemon = match self.daemon.borrow().clone() { + Some(d) => d, + None => { + self.render_disconnected("internal: daemon handle missing"); + return; + } + }; + + let user = current_user(); + let creds = match daemon.list_credentials(&user).await { + Ok(v) => v, + Err(e) => { + self.render_disconnected(&user_message(&e)); + return; + } + }; + let page = adw::PreferencesPage::new(); let group = adw::PreferencesGroup::builder() .title("Security keys") - .description("FIDO2 keys enrolled for your account") + .description(if creds.is_empty() { + "No keys enrolled yet. Click below to enroll your first." + } else { + "FIDO2 keys enrolled for your account" + }) .build(); - let placeholder = adw::ActionRow::builder() - .title("(Task 5 will populate this)") + + for c in &creds { + let title = if c.nickname.is_empty() { + c.id.as_str() + } else { + c.nickname.as_str() + }; + let row = adw::ActionRow::builder() + .title(title) + .subtitle(short_id(&c.id)) + .build(); + let remove = gtk::Button::builder() + .icon_name("user-trash-symbolic") + .valign(gtk::Align::Center) + .css_classes(["flat"]) + .tooltip_text("Remove this key") + .build(); + let me = self.clone(); + let cred_id = c.id.clone(); + remove.connect_clicked(move |_| { + let me = me.clone(); + let cred_id = cred_id.clone(); + glib::MainContext::default().spawn_local(async move { + me.remove_credential(&cred_id).await; + }); + }); + row.add_suffix(&remove); + group.add(&row); + } + + let enroll_group = adw::PreferencesGroup::new(); + let enroll = adw::ActionRow::builder() + .title("Enroll a new key") + .activatable(true) .build(); - group.add(&placeholder); + enroll.add_suffix(>k::Image::builder().icon_name("list-add-symbolic").build()); + let me = self.clone(); + enroll.connect_activated(move |_| { + let me = me.clone(); + glib::MainContext::default().spawn_local(async move { + me.start_enroll().await; + }); + }); + enroll_group.add(&enroll); + page.add(&group); + page.add(&enroll_group); self.root.set_child(Some(&page)); } + + async fn remove_credential(self: &Rc, cred_id: &str) { + let daemon = match self.daemon.borrow().clone() { + Some(d) => d, + None => return, + }; + let user = current_user(); + if let Err(e) = daemon.remove_own(&user, cred_id).await { + self.show_toast(&user_message(&e)); + return; + } + self.render_connected().await; + } + + async fn start_enroll(self: &Rc) { + // 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"); + } + + fn show_toast(self: &Rc, msg: &str) { + // ToastOverlay wiring lands in Task 7. Until then write to stderr so + // manual smoke can observe what would be shown. + eprintln!("[gui toast] {msg}"); + } +} + +fn short_id(id: &str) -> String { + if id.len() > 16 { + format!("{}…{}", &id[..8], &id[id.len() - 4..]) + } else { + id.to_string() + } } From 3fbd872a9cca2e9c8b2d2a1ab3004fc98fbf72d2 Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 27 Apr 2026 09:24:50 -0700 Subject: [PATCH 7/9] 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). --- gui/Cargo.toml | 2 +- gui/src/enroll_dialog.rs | 79 ++++++++++++++++++++++++++++++++++++++++ gui/src/keys_page.rs | 15 ++++++-- gui/src/main.rs | 1 + 4 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 gui/src/enroll_dialog.rs diff --git a/gui/Cargo.toml b/gui/Cargo.toml index f1bc887..04f8b0d 100644 --- a/gui/Cargo.toml +++ b/gui/Cargo.toml @@ -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 } diff --git a/gui/src/enroll_dialog.rs b/gui/src/enroll_dialog.rs new file mode 100644 index 0000000..9d46ced --- /dev/null +++ b/gui/src/enroll_dialog.rs @@ -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: >k::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::<>k::Widget>); + // AlertDialog responses can't be removed dynamically; relabel "cancel" + // to "Close" so the surviving button reads correctly. + dialog.set_response_label("cancel", "Close"); +} diff --git a/gui/src/keys_page.rs b/gui/src/keys_page.rs index 35497c1..476c93e 100644 --- a/gui/src/keys_page.rs +++ b/gui/src/keys_page.rs @@ -11,7 +11,6 @@ use std::rc::Rc; pub(crate) struct KeysPage { pub root: adw::Bin, daemon: Rc>>, - #[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) { - // 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, msg: &str) { diff --git a/gui/src/main.rs b/gui/src/main.rs index c66cbdc..6da00f4 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -2,6 +2,7 @@ use adw::prelude::*; use gtk::glib; mod bus; +mod enroll_dialog; mod error; mod keys_page; From 14b473a48aa777600e1feb79a8e3a31e19241592 Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 27 Apr 2026 09:25:50 -0700 Subject: [PATCH 8/9] feat(gui): wire adw::ToastOverlay for error surfacing --- gui/src/keys_page.rs | 9 +++++---- gui/src/main.rs | 6 ++++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/gui/src/keys_page.rs b/gui/src/keys_page.rs index 476c93e..c85300a 100644 --- a/gui/src/keys_page.rs +++ b/gui/src/keys_page.rs @@ -12,14 +12,16 @@ pub(crate) struct KeysPage { pub root: adw::Bin, daemon: Rc>>, parent_window: gtk::Window, + toast_overlay: adw::ToastOverlay, } impl KeysPage { - pub fn new(parent_window: gtk::Window) -> Rc { + pub fn new(parent_window: gtk::Window, toast_overlay: adw::ToastOverlay) -> Rc { let page = Rc::new(Self { root: adw::Bin::new(), daemon: Rc::new(RefCell::new(None)), parent_window, + toast_overlay, }); let p = page.clone(); glib::MainContext::default().spawn_local(async move { @@ -165,9 +167,8 @@ impl KeysPage { } fn show_toast(self: &Rc, msg: &str) { - // ToastOverlay wiring lands in Task 7. Until then write to stderr so - // manual smoke can observe what would be shown. - eprintln!("[gui toast] {msg}"); + let toast = adw::Toast::builder().title(msg).timeout(5).build(); + self.toast_overlay.add_toast(toast); } } diff --git a/gui/src/main.rs b/gui/src/main.rs index 6da00f4..133775e 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -35,7 +35,8 @@ fn main() -> glib::ExitCode { .build(); header.set_title_widget(Some(&switcher)); - let keys = keys_page::KeysPage::new(win.clone().upcast()); + let toast_overlay = adw::ToastOverlay::new(); + let keys = keys_page::KeysPage::new(win.clone().upcast(), toast_overlay.clone()); stack.add_titled(&keys.root, Some("keys"), "Keys"); // ToolbarView is libadwaita v1_4-gated; the project sticks with the @@ -44,8 +45,9 @@ fn main() -> glib::ExitCode { layout.append(&header); layout.append(&stack); stack.set_vexpand(true); + toast_overlay.set_child(Some(&layout)); - win.set_content(Some(&layout)); + win.set_content(Some(&toast_overlay)); win.present(); }); app.run() From 27dec5ab4a42b42751716644c348a7d81ab7e094 Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 27 Apr 2026 09:26:17 -0700 Subject: [PATCH 9/9] docs(gui): smoke-test recipe and CI gate --- gui/TESTING.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 gui/TESTING.md diff --git a/gui/TESTING.md b/gui/TESTING.md new file mode 100644 index 0000000..f2e6ffc --- /dev/null +++ b/gui/TESTING.md @@ -0,0 +1,47 @@ +# Testing the AuthForge GUI + +## Automated gate + +```bash +cargo build -p authforge-gui +cargo clippy -p authforge-gui --all-targets -- -D warnings +cargo test -p authforge-gui # 4 error-classifier tests +``` + +CI runs all three on every push (libgtk-4-dev / libadwaita-1-dev are +installed in the workflow image). + +The GUI requires libadwaita 1.5 (Ubuntu 24.04+) for `adw::AlertDialog` and +the v1_5 feature gate enabled in `gui/Cargo.toml`. Older distros need the +plan's MessageDialog fallback (not implemented in v1). + +## Manual smoke + +Requires a USB FIDO2 device (Yubikey 5, Solo, etc.) and a built daemon. + +```bash +# Terminal 1 — daemon (permissive polkit so you don't get prompted). +AUTHFORGE_POLKIT_BYPASS=1 \ + AUTHFORGE_POLICY_DIR=/tmp/af/policy.d \ + AUTHFORGE_PENDING_DIR=/tmp/af/pending \ + AUTHFORGE_USERDB=/tmp/af/users.db \ + target/release/authforged + +# Terminal 2 — GUI. +target/release/authforge +``` + +Walk-through: + +1. Window opens, Keys tab visible. Empty list with hint text "No keys + enrolled yet." +2. Click "Enroll a new key" → modal opens with spinner + "Touch your + security key now". Touch the device. +3. Modal closes, the list re-renders with one row showing the key's + short id. +4. Click the trash icon on the row → row vanishes immediately. +5. Pull the security key, click Enroll → modal opens, swaps to error + text ("authenticator: no FIDO2 device found") with a Close button. +6. Kill the daemon (`pkill authforged`), click Enroll → toast appears + with the disconnected message; Retry on the page reconnects after + restart.