Merge branch 'feature/phase-8-gui-keys'

Phase 8: GUI Keys tab. Lists current user's enrolled FIDO2 credentials,
enrolls a new key with a touch-prompt modal driven by the daemon's
EnrollmentFailed signal (success comes from the call return), removes
credentials per row, degrades gracefully to a Retry banner when the
daemon is unreachable, and surfaces transient errors via adw::Toast.

8 tasks, 4 unit tests (error classifier).
This commit is contained in:
michael
2026-04-27 09:32:37 -07:00
9 changed files with 518 additions and 23 deletions

3
Cargo.lock generated
View File

@@ -309,8 +309,11 @@ version = "0.1.0"
dependencies = [
"anyhow",
"authforge-common",
"futures-util",
"gtk4",
"libadwaita",
"tokio",
"zbus",
]
[[package]]

View File

@@ -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<Self>) {
@@ -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"
```

View File

@@ -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 }

47
gui/TESTING.md Normal file
View File

@@ -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.

56
gui/src/bus.rs Normal file
View File

@@ -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<Self> {
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<Vec<Credential>> {
self.proxy.call("ListCredentials", &(user,)).await
}
pub async fn enroll_own(&self, user: &str, nickname: &str) -> zbus::Result<Credential> {
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<zbus::proxy::SignalStream<'static>> {
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())
}

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");
}

87
gui/src/error.rs Normal file
View File

@@ -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"));
}
}

181
gui/src/keys_page.rs Normal file
View File

@@ -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<RefCell<Option<Daemon>>>,
parent_window: gtk::Window,
toast_overlay: adw::ToastOverlay,
}
impl KeysPage {
pub fn new(parent_window: gtk::Window, toast_overlay: adw::ToastOverlay) -> Rc<Self> {
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<Self>) {
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<Self>, 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<Self>) {
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(&gtk::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<Self>, 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<Self>) {
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) {
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()
}
}

View File

@@ -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()