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.
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
glibfeature for runtime integration. It does not — zbus 4.4's features areasync-io(default),tokio,bus-impl,chrono,p2p,time,url,uuid,vsock. We usetokioto match the rest of the workspace.
Tech Stack:
gtk4-rs0.8 (already pinned ingui/Cargo.toml).libadwaita0.6 (already pinned).zbus = { workspace = true }— workspace config isdefault-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 sharedCredentialwire type.
Reference:
- Master plan: 2026-04-26-authforge-implementation.md § Phase 8 entry in Parallel Execution Lanes.
- Design doc: 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.
- Daemon interface (read-only reference): 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. Runcargo test -p authforge-guiwhenever anerror.rstest is added. - User identity is
std::env::var("USER").unwrap_or_else(|_| "unknown".into())— same heuristic the CLI uses. Nogetpwuidin 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(addmod 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(addmod 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 exposeinto_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(addmod 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(
>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<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(addmod enroll_dialog;) - Modify:
gui/src/keys_page.rs— replacestart_enrollstub 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: >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:
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 inadw::ToastOverlay. - Modify:
gui/src/keys_page.rs— accept the overlay handle, replace the stderrshow_toaststub.
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:
- Window opens, Keys tab visible. Empty list with hint text "No keys enrolled yet."
- Click "Enroll a new key" → modal opens with spinner + "Touch your security key now". Touch the device.
- Modal closes, the list re-renders with one row showing the key's short id.
- Click the trash icon on the row → row vanishes immediately.
- Pull the security key, click Enroll → modal opens, swaps to error text ("authenticator: no FIDO2 device found") with a Close button.
- 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-guiclean.cargo clippy -p authforge-gui --all-targets -- -D warningsclean.cargo test -p authforge-gui— 4 PASS (error classifier).cargo fmt --all -- --checkclean.cargo build --workspace --releasesucceeds (the GUI now compiles into the workspace release output).- Manual smoke (deferred to live system + Yubikey): all six steps in
gui/TESTING.mdpass.
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.