# 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:** - Master plan: [2026-04-26-authforge-implementation.md](2026-04-26-authforge-implementation.md) § Phase 8 entry in Parallel Execution Lanes. - Design doc: [2026-04-26-authforge-design.md](2026-04-26-authforge-design.md) — § Trust boundary (GUI is a thin client; daemon owns all writes). - Sibling D-Bus client (mirror this pattern, swap runtime): [cli/src/bus.rs](../../cli/src/bus.rs). - Daemon interface (read-only reference): [daemon/src/dbus.rs](../../daemon/src/dbus.rs). --- ## 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]`: ```toml 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. ```bash 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`: ```rust //! 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. ```bash 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](../../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`: ```rust //! 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 { 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> { 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 `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> { self.proxy.receive_signal("EnrollmentSucceeded").await } pub async fn subscribe_enrollment_failed( &self, ) -> zbus::Result> { 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](../../cli/src/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. ```bash 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`: ```rust //! "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, } 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, }); // 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) { 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)); } } ``` **Step 3:** Modify `gui/src/main.rs` to construct and embed `KeysPage` (replacing the StatusPage placeholder body): ```rust 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. ```bash 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`: ```rust 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 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( >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) { // Implemented in Task 6 (TouchDialog). Stub here so the button compiles. self.show_toast("Enroll: implemented in Task 6"); } fn show_toast(self: &Rc, 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. ```bash 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`: ```rust //! 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: >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"); // 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::<>k::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`: ```rust 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; }); }); } ``` **Step 4:** `cargo build -p authforge-gui && cargo clippy -p authforge-gui --all-targets -- -D warnings`. Clean. **Step 5:** Commit. ```bash 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: ```rust 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: ```rust 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 } // ... unchanged through render_connected ... fn show_toast(self: &Rc, 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. ```bash 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: ```markdown # 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. ```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. ``` **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: ```bash 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](../../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`; 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.