Files
authforge/docs/plans/2026-04-27-phase-8-gui-keys.md
michael a17f70c4ac 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.
2026-04-27 09:05:58 -07:00

31 KiB

Phase 8: GUI Keys Tab — Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

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 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 = { 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:


Conventions

  • One logical change per commit. Conventional prefixes scoped to gui: feat(gui):, test(gui):, chore(gui):, refactor(gui):.
  • Always commit with --no-gpg-sign.
  • TDD only for error.rs (pure logic). Widget code (keys_page, enroll_dialog, main) ships with manual smoke as the acceptance gate — GUI testing requires a display server and is out of scope for v1.
  • After each commit: cargo fmt --all && cargo clippy -p authforge-gui --all-targets -- -D warnings && cargo build -p authforge-gui. Run cargo test -p authforge-gui whenever an error.rs test is added.
  • User identity is std::env::var("USER").unwrap_or_else(|_| "unknown".into()) — same heuristic the CLI uses. No getpwuid in v1.

Task 1: Add GUI dependencies

Files:

  • Modify: gui/Cargo.toml

Step 1: Append to gui/Cargo.toml [dependencies]:

zbus = { workspace = true }
tokio = { workspace = true }
futures-util = { workspace = true }

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 deps resolve).

Step 3: Commit.

git add gui/Cargo.toml Cargo.lock
git commit --no-gpg-sign -m "chore(gui): add zbus + tokio + futures-util deps for D-Bus client"

Task 2: error.rs — zbus-error → user-message helper (TDD)

Files:

  • Create: gui/src/error.rs
  • Modify: gui/src/main.rs (add mod error;)

Step 1: Add mod error; to the top of gui/src/main.rs (above fn main).

Step 2: Write the failing tests + impl in gui/src/error.rs:

//! 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)]
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,
}

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

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

Step 3: Run cargo test -p authforge-gui error::tests. Expected: 4 PASS.

Step 4: cargo clippy -p authforge-gui --all-targets -- -D warnings. Clean.

Step 5: Commit.

git add gui/src/error.rs gui/src/main.rs
git commit --no-gpg-sign -m "feat(gui): error helper to classify zbus errors and produce user messages"

Task 3: bus.rs — D-Bus client wrapper (glib runtime)

Files:

  • Create: gui/src/bus.rs
  • Modify: gui/src/main.rs (add mod bus;)

Background: Mirror cli/src/bus.rs but on the glib runtime. The GUI only needs list_credentials, enroll_own, remove_own, plus a way to subscribe to the two enrollment signals. Other methods (policy, pending, recovery) are not needed for Phase 8.

Step 1: Add mod bus; to gui/src/main.rs.

Step 2: Create gui/src/bus.rs:

//! D-Bus client for the GUI. zbus is configured with `features = ["glib"]` at
//! the crate level, so all futures returned here run on the GTK main thread
//! via `glib::MainContext::spawn_local`. There is no tokio runtime in this
//! process — do not call `tokio::spawn` from anywhere in the GUI.

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)]
pub(crate) struct Daemon {
    proxy: zbus::Proxy<'static>,
}

impl Daemon {
    pub async fn connect() -> zbus::Result<Self> {
        let conn = zbus::Connection::system().await?;
        // CLI uses the same `new` -> `into_owned()` pattern; mirror it.
        let proxy = zbus::Proxy::new(&conn, BUS_NAME, OBJECT_PATH, IFACE)
            .await?
            .into_owned();
        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 `EnrollmentSucceeded`. The returned `SignalStream` is
    /// alive for as long as the caller holds it; the touch dialog drops it
    /// when it closes. zbus removes the match rule on drop.
    pub async fn subscribe_enrollment_succeeded(
        &self,
    ) -> zbus::Result<zbus::proxy::SignalStream<'static>> {
        self.proxy.receive_signal("EnrollmentSucceeded").await
    }

    pub async fn subscribe_enrollment_failed(
        &self,
    ) -> zbus::Result<zbus::proxy::SignalStream<'static>> {
        self.proxy.receive_signal("EnrollmentFailed").await
    }
}

pub(crate) fn current_user() -> String {
    std::env::var("USER").unwrap_or_else(|_| "unknown".into())
}

Note on Proxy::new(...).into_owned(): if the installed zbus 4 minor doesn't expose into_owned(), swap to whatever the CLI's bus.rs is doing — that file is a known-working reference for this exact API surface in this exact zbus version.

Step 3: cargo build -p authforge-gui. Expected: clean.

Step 4: cargo clippy -p authforge-gui --all-targets -- -D warnings. Clean.

Step 5: Commit.

git add gui/src/bus.rs gui/src/main.rs
git commit --no-gpg-sign -m "feat(gui): D-Bus client wrapper around AuthForge interface (glib runtime)"

Task 4: keys_page.rs — empty state + retry banner

Files:

  • Create: gui/src/keys_page.rs
  • Modify: gui/src/main.rs (add mod keys_page;)

Background: KeysPage is the only visible content in v1. It's an adw::PreferencesPage with one PreferencesGroup. When the daemon is unreachable, swap the group out for a StatusPage with a Retry button. We rebuild the page contents in place (no widget recycling tricks) — Phase 8 doesn't need to optimize redraw cost.

Step 1: Add mod keys_page; to gui/src/main.rs.

Step 2: Create gui/src/keys_page.rs:

//! "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,
}

impl KeysPage {
    pub fn new(parent_window: gtk::Window) -> Rc<Self> {
        let page = Rc::new(Self {
            root: adw::Bin::new(),
            daemon: Rc::new(RefCell::new(None)),
            parent_window,
        });
        // Connect attempt fires on construction; if it fails we render the
        // disconnected banner. If it succeeds we render the populated page.
        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>) {
        // 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));
    }
}

Step 3: Modify gui/src/main.rs to construct and embed KeysPage (replacing the StatusPage placeholder body):

use adw::prelude::*;
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 win = adw::ApplicationWindow::builder()
            .application(app)
            .default_width(720)
            .default_height(540)
            .title("Authentication")
            .build();
        let header = adw::HeaderBar::new();
        let stack = adw::ViewStack::new();
        let switcher = adw::ViewSwitcher::builder()
            .stack(&stack)
            .policy(adw::ViewSwitcherPolicy::Wide)
            .build();
        let toolbar = adw::ToolbarView::new();
        toolbar.add_top_bar(&header);
        header.set_title_widget(Some(&switcher));

        let keys = keys_page::KeysPage::new(win.clone().upcast());
        stack.add_titled_with_icon(&keys.root, Some("keys"), "Keys", "dialog-password-symbolic");

        toolbar.set_content(Some(&stack));
        win.set_content(Some(&toolbar));
        win.present();
    });
    app.run()
}

Step 4: cargo build -p authforge-gui && cargo clippy -p authforge-gui --all-targets -- -D warnings. Clean.

Step 5: Commit.

git add gui/src/keys_page.rs gui/src/main.rs
git commit --no-gpg-sign -m "feat(gui): KeysPage scaffold with disconnected-state retry banner"

Task 5: keys_page.rs — populate list, refresh, remove

Files:

  • Modify: gui/src/keys_page.rs

Background: Replace the placeholder row inside render_connected with a real loop over daemon.list_credentials(user).await, a gtk::Button with destructive-action class on each row, and a single "Enroll a new key" ActionRow at the bottom. Empty list shows a PreferencesGroup description hint instead of zero rows.

Step 1: Replace the body of render_connected in gui/src/keys_page.rs:

    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 row = adw::ActionRow::builder()
                .title(if c.nickname.is_empty() { c.id.as_str() } else { c.nickname.as_str() })
                .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);
        }

        // Enroll button as its own row at the bottom.
        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>) {
        // Implemented in Task 6 (TouchDialog). Stub here so the button compiles.
        self.show_toast("Enroll: implemented in Task 6");
    }

    fn show_toast(self: &Rc<Self>, msg: &str) {
        // ToastOverlay wiring is added in Task 7 alongside the dialog. Until
        // then, write to stderr so manual smoke can see 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()
    }
}

Step 2: cargo build -p authforge-gui && cargo clippy -p authforge-gui --all-targets -- -D warnings. Clean.

Step 3: Commit.

git add gui/src/keys_page.rs
git commit --no-gpg-sign -m "feat(gui): list enrolled credentials with per-row remove + enroll button"

Task 6: enroll_dialog.rs — TouchDialog with select! over call & signal

Files:

  • Create: gui/src/enroll_dialog.rs
  • Modify: gui/src/main.rs (add mod enroll_dialog;)
  • Modify: gui/src/keys_page.rs — replace start_enroll stub with the real call.

Background: Modal adw::AlertDialog. On open: spinner + "Touch your security key now" + Cancel button. The dialog spawns ONE glib task that races the enroll_own call against the EnrollmentFailed signal stream — the call return is the source of truth for success (carries the Credential), so we don't need to race against EnrollmentSucceeded. On Ok(_) close & refresh; on Err(_) swap the dialog body to an error message + Close. Cancel just closes the dialog (no CancelEnrollment D-Bus method exists yet; daemon enrollment runs to completion or times out on its own).

Step 1: Add mod enroll_dialog; to gui/src/main.rs.

Step 2: Create gui/src/enroll_dialog.rs:

//! Modal touch-prompt dialog. Lifetime is one enrollment attempt.

use crate::bus::{current_user, Daemon};
use crate::error::user_message;
use adw::prelude::*;
use futures_util::StreamExt;
use gtk::glib;

/// Present the touch-prompt modal and run an EnrollOwn against the daemon.
/// Returns when the user dismisses the dialog (success, failure, or cancel).
/// `on_success` fires after a successful enrollment so the caller can refresh.
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");

    // Spinner inside the dialog body. AlertDialog accepts an extra child via
    // set_extra_child.
    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(Some(parent));

    let dialog_clone = dialog.clone();
    glib::MainContext::default().spawn_local(async move {
        let user = current_user();
        // Subscribe BEFORE the call so we don't miss a fast EnrollmentFailed.
        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);
    // Drop the spinner; rebuild responses with a single "Close".
    dialog.set_extra_child(None::<&gtk::Widget>);
    // AlertDialog buttons aren't dynamically removable; the original "cancel"
    // response stays but its label is now "Close".
    dialog.set_response_label("cancel", "Close");
}

Step 3: Replace the start_enroll body in gui/src/keys_page.rs:

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

Step 4: cargo build -p authforge-gui && cargo clippy -p authforge-gui --all-targets -- -D warnings. Clean.

Step 5: Commit.

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"

Task 7: ToastOverlay for error surfacing

Files:

  • Modify: gui/src/main.rs — wrap content in adw::ToastOverlay.
  • Modify: gui/src/keys_page.rs — accept the overlay handle, replace the stderr show_toast stub.

Background: show_toast currently writes to stderr (Task 5 placeholder). Wire it through adw::ToastOverlay, which is the standard libadwaita pattern for ephemeral notifications.

Step 1: Modify gui/src/main.rs — wrap the stack in a ToastOverlay and pass it down:

        let toast_overlay = adw::ToastOverlay::new();
        let keys = keys_page::KeysPage::new(win.clone().upcast(), toast_overlay.clone());
        stack.add_titled_with_icon(&keys.root, Some("keys"), "Keys", "dialog-password-symbolic");
        toast_overlay.set_child(Some(&stack));
        toolbar.set_content(Some(&toast_overlay));

Step 2: Modify gui/src/keys_page.rs — store the overlay and use it:

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
    }
    // ... unchanged through render_connected ...

    fn show_toast(self: &Rc<Self>, msg: &str) {
        let toast = adw::Toast::builder().title(msg).timeout(5).build();
        self.toast_overlay.add_toast(toast);
    }
}

Step 3: cargo build -p authforge-gui && cargo clippy -p authforge-gui --all-targets -- -D warnings. Clean.

Step 4: Commit.

git add gui/src/keys_page.rs gui/src/main.rs
git commit --no-gpg-sign -m "feat(gui): wire adw::ToastOverlay for error surfacing"

Task 8: Acceptance gate doc + manual smoke recipe

Files:

  • Create: gui/TESTING.md

Step 1: Create gui/TESTING.md with the recipe a maintainer follows on a Yubikey-equipped Ubuntu box:

# 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).

Manual smoke

Requires a USB FIDO2 device (Yubikey 5, Solo, etc.) and a built daemon.

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

**Step 2:** Commit.

```bash
git add gui/TESTING.md
git commit --no-gpg-sign -m "docs(gui): smoke-test recipe and CI gate"

Phase 8 Acceptance Gate

Verify before tagging:

  • cargo build -p authforge-gui clean.
  • cargo clippy -p authforge-gui --all-targets -- -D warnings clean.
  • cargo test -p authforge-gui — 4 PASS (error classifier).
  • cargo fmt --all -- --check clean.
  • cargo build --workspace --release succeeds (the GUI now compiles into the workspace release output).
  • Manual smoke (deferred to live system + Yubikey): all six steps in gui/TESTING.md pass.

Tag the milestone:

git tag -a v0.4.0-gui-keys -m "Phase 8: GUI Keys tab"

Risks / known unknowns

Risk Mitigation
zbus 4 API drift: Proxy::new(...).into_owned() may not be the API in the installed minor. Mirror whatever pattern cli/src/bus.rs uses — that file is committed and known to work in this exact zbus version.
adw::AlertDialog button label can't be changed after present in some libadwaita 0.6 patches. Falls back to leaving the button labeled "Cancel" in the failure state; user-visible body text still conveys the failure. Acceptable until libadwaita 0.7 lets us swap responses dynamically.
glib::MainContext::spawn_local on a non-Send future in a closure that captures Rc — borrow checker may push back at the RefCell boundaries. Each spawned task takes a clone() of the Rc<KeysPage>; the inner daemon is Clone (just an Arc inside zbus's Connection), so we clone it out of the RefCell before the await point.
ToastOverlay wiring requires passing the overlay handle through three constructors. Acceptable for v1; Phase 9 will introduce a small AppContext struct to carry shared handles when the Policy tab joins.
EnrollOwn blocks for the duration of touch (could be 30s+). The select! against EnrollmentFailed correctly handles fast failure, but a slow success keeps the dialog spinning. Intended behavior — spinner explicitly tells the user to act. No timeout in v1; daemon-side timeout (ctap library default ~30s) governs.

Execution Handoff

Plan complete. Single lane, single agent. Suggested execution: open a fresh session in a worktree using superpowers:using-git-worktrees, then drive task-by-task with superpowers:executing-plans. Or stay in this session and run subagent-driven per task.