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/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" ``` diff --git a/gui/Cargo.toml b/gui/Cargo.toml index 4bb6294..04f8b0d 100644 --- a/gui/Cargo.toml +++ b/gui/Cargo.toml @@ -10,6 +10,9 @@ 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 } +tokio = { workspace = true } +futures-util = { workspace = true } 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. 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/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/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/keys_page.rs b/gui/src/keys_page.rs new file mode 100644 index 0000000..c85300a --- /dev/null +++ b/gui/src/keys_page.rs @@ -0,0 +1,181 @@ +//! "Security keys" preferences page. v1 surface: the current user's enrolled +//! FIDO2 credentials with per-row remove + a single "Enroll a new key" button. + +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; + +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, 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 { + 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) { + 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(if creds.is_empty() { + "No keys enrolled yet. Click below to enroll your first." + } else { + "FIDO2 keys enrolled for your account" + }) + .build(); + + 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(); + 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) { + 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) { + let toast = adw::Toast::builder().title(msg).timeout(5).build(); + self.toast_overlay.add_toast(toast); + } +} + +fn short_id(id: &str) -> String { + if id.len() > 16 { + format!("{}…{}", &id[..8], &id[id.len() - 4..]) + } else { + id.to_string() + } +} diff --git a/gui/src/main.rs b/gui/src/main.rs index 7aa2497..133775e 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -1,23 +1,53 @@ use adw::prelude::*; use gtk::glib; +mod bus; +mod enroll_dialog; +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 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 + // 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); + toast_overlay.set_child(Some(&layout)); + + win.set_content(Some(&toast_overlay)); win.present(); }); app.run()