From a927d966309ca6e1619a00ab9b2dd110fc3ecae8 Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 27 Apr 2026 10:42:11 -0700 Subject: [PATCH 1/7] refactor(gui): extract AppContext for shared page handles KeysPage::new now takes a single AppContext that owns parent_window, toast_overlay, and the shared daemon Rc>>. Phases 9, 10, 11, and 12 GUI tab will all consume this struct. Co-Authored-By: Claude Opus 4.7 (1M context) --- gui/src/app_context.rs | 27 +++++++++++++++++++++++++++ gui/src/keys_page.rs | 24 ++++++++++-------------- gui/src/main.rs | 4 +++- 3 files changed, 40 insertions(+), 15 deletions(-) create mode 100644 gui/src/app_context.rs diff --git a/gui/src/app_context.rs b/gui/src/app_context.rs new file mode 100644 index 0000000..6a13868 --- /dev/null +++ b/gui/src/app_context.rs @@ -0,0 +1,27 @@ +//! Shared per-window handles passed to every page constructor. +//! +//! `daemon` is a `RefCell>` because the connect-on-startup attempt +//! may fail (pages render their disconnected banner); a successful Retry +//! repopulates this same cell, and every page sees the new value via the Rc +//! clone chain. + +use crate::bus::Daemon; +use std::cell::RefCell; +use std::rc::Rc; + +#[derive(Clone)] +pub(crate) struct AppContext { + pub parent_window: gtk::Window, + pub toast_overlay: adw::ToastOverlay, + pub daemon: Rc>>, +} + +impl AppContext { + pub fn new(parent_window: gtk::Window, toast_overlay: adw::ToastOverlay) -> Self { + Self { + parent_window, + toast_overlay, + daemon: Rc::new(RefCell::new(None)), + } + } +} diff --git a/gui/src/keys_page.rs b/gui/src/keys_page.rs index c85300a..353a2b5 100644 --- a/gui/src/keys_page.rs +++ b/gui/src/keys_page.rs @@ -1,27 +1,23 @@ //! "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::app_context::AppContext; use crate::bus::{current_user, Daemon}; use crate::error::user_message; use adw::prelude::*; use gtk::glib; -use std::cell::RefCell; use std::rc::Rc; pub(crate) struct KeysPage { pub root: adw::Bin, - daemon: Rc>>, - parent_window: gtk::Window, - toast_overlay: adw::ToastOverlay, + ctx: AppContext, } impl KeysPage { - pub fn new(parent_window: gtk::Window, toast_overlay: adw::ToastOverlay) -> Rc { + pub fn new(ctx: AppContext) -> Rc { let page = Rc::new(Self { root: adw::Bin::new(), - daemon: Rc::new(RefCell::new(None)), - parent_window, - toast_overlay, + ctx, }); let p = page.clone(); glib::MainContext::default().spawn_local(async move { @@ -33,7 +29,7 @@ impl KeysPage { async fn try_connect_and_render(self: Rc) { match Daemon::connect().await { Ok(d) => { - *self.daemon.borrow_mut() = Some(d); + *self.ctx.daemon.borrow_mut() = Some(d); self.render_connected().await; } Err(e) => self.render_disconnected(&user_message(&e)), @@ -63,7 +59,7 @@ impl KeysPage { } async fn render_connected(self: &Rc) { - let daemon = match self.daemon.borrow().clone() { + let daemon = match self.ctx.daemon.borrow().clone() { Some(d) => d, None => { self.render_disconnected("internal: daemon handle missing"); @@ -140,7 +136,7 @@ impl KeysPage { } async fn remove_credential(self: &Rc, cred_id: &str) { - let daemon = match self.daemon.borrow().clone() { + let daemon = match self.ctx.daemon.borrow().clone() { Some(d) => d, None => return, }; @@ -153,12 +149,12 @@ impl KeysPage { } async fn start_enroll(self: &Rc) { - let daemon = match self.daemon.borrow().clone() { + let daemon = match self.ctx.daemon.borrow().clone() { Some(d) => d, None => return, }; let me = self.clone(); - crate::enroll_dialog::present(&self.parent_window, daemon, move || { + crate::enroll_dialog::present(&self.ctx.parent_window, daemon, move || { let me = me.clone(); glib::MainContext::default().spawn_local(async move { me.render_connected().await; @@ -168,7 +164,7 @@ impl KeysPage { fn show_toast(self: &Rc, msg: &str) { let toast = adw::Toast::builder().title(msg).timeout(5).build(); - self.toast_overlay.add_toast(toast); + self.ctx.toast_overlay.add_toast(toast); } } diff --git a/gui/src/main.rs b/gui/src/main.rs index 133775e..9d0634e 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -1,6 +1,7 @@ use adw::prelude::*; use gtk::glib; +mod app_context; mod bus; mod enroll_dialog; mod error; @@ -36,7 +37,8 @@ fn main() -> glib::ExitCode { header.set_title_widget(Some(&switcher)); let toast_overlay = adw::ToastOverlay::new(); - let keys = keys_page::KeysPage::new(win.clone().upcast(), toast_overlay.clone()); + let ctx = app_context::AppContext::new(win.clone().upcast(), toast_overlay.clone()); + let keys = keys_page::KeysPage::new(ctx.clone()); stack.add_titled(&keys.root, Some("keys"), "Keys"); // ToolbarView is libadwaita v1_4-gated; the project sticks with the From 8d19be3e58cf401e0a97acc7c799e6d0ace95cf6 Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 27 Apr 2026 10:45:52 -0700 Subject: [PATCH 2/7] feat(gui): --first-run flag + firstrun::run stub for Phase 10 argv is read manually before adw::Application sees it (GTK's option parser rejects unknown long flags), then the filtered list is passed to app.run_with_args. Stub exits cleanly with a stderr breadcrumb so a developer who passes --first-run before Phase 10 lands doesn't get a stuck process. Co-Authored-By: Claude Opus 4.7 (1M context) --- gui/src/firstrun.rs | 14 ++++++++++++++ gui/src/main.rs | 26 ++++++++++++++++++++++---- 2 files changed, 36 insertions(+), 4 deletions(-) create mode 100644 gui/src/firstrun.rs diff --git a/gui/src/firstrun.rs b/gui/src/firstrun.rs new file mode 100644 index 0000000..970c361 --- /dev/null +++ b/gui/src/firstrun.rs @@ -0,0 +1,14 @@ +//! First-login modal. Phase 10 implements the body of `run`. + +#![allow(dead_code)] // body lands in Phase 10. + +use crate::app_context::AppContext; + +/// Entry point for `authforge --first-run`. Phase 10 fills this with the +/// fullscreen modal + watchdog + enroll-then-clear-pending logic. +/// Until then it exits cleanly so a developer who passes `--first-run` +/// doesn't get a stuck process. +pub(crate) fn run(_ctx: AppContext) { + eprintln!("authforge: --first-run mode is a Phase 10 stub; exiting."); + std::process::exit(0); +} diff --git a/gui/src/main.rs b/gui/src/main.rs index 9d0634e..fc5f9da 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -5,6 +5,7 @@ mod app_context; mod bus; mod enroll_dialog; mod error; +mod firstrun; mod keys_page; const APP_ID: &str = "io.dangerousthings.AuthForge"; @@ -20,14 +21,33 @@ fn main() -> glib::ExitCode { .expect("build tokio runtime"); let _guard = runtime.enter(); + // Strip `--first-run` from argv before GTK's own option parser sees it; + // GTK rejects unknown long options. Phase 10 fills the body of + // firstrun::run; the prep lane only wires the entry point. + let raw_args: Vec = std::env::args().collect(); + let first_run = raw_args.iter().any(|a| a == "--first-run"); + let gtk_args: Vec = raw_args + .iter() + .filter(|a| a.as_str() != "--first-run") + .cloned() + .collect(); + let app = adw::Application::builder().application_id(APP_ID).build(); - app.connect_activate(|app| { + app.connect_activate(move |app| { let win = adw::ApplicationWindow::builder() .application(app) .default_width(720) .default_height(540) .title("Authentication") .build(); + let toast_overlay = adw::ToastOverlay::new(); + let ctx = app_context::AppContext::new(win.clone().upcast(), toast_overlay.clone()); + + if first_run { + firstrun::run(ctx); + return; + } + let header = adw::HeaderBar::new(); let stack = adw::ViewStack::new(); let switcher = adw::ViewSwitcher::builder() @@ -36,8 +56,6 @@ fn main() -> glib::ExitCode { .build(); header.set_title_widget(Some(&switcher)); - let toast_overlay = adw::ToastOverlay::new(); - let ctx = app_context::AppContext::new(win.clone().upcast(), toast_overlay.clone()); let keys = keys_page::KeysPage::new(ctx.clone()); stack.add_titled(&keys.root, Some("keys"), "Keys"); @@ -52,5 +70,5 @@ fn main() -> glib::ExitCode { win.set_content(Some(&toast_overlay)); win.present(); }); - app.run() + app.run_with_args(>k_args) } From e1542d6d1f676b4fa153e3b7beafea734015e9f0 Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 27 Apr 2026 10:47:01 -0700 Subject: [PATCH 3/7] feat(common): PendingStatus wire type + Default impl on PendingFlag PendingStatus carries an explicit 'present' bool next to a PendingFlag because zvariant doesn't support Option. Two new serde tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- common/src/types.rs | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/common/src/types.rs b/common/src/types.rs index bb4cd0a..9e86f31 100644 --- a/common/src/types.rs +++ b/common/src/types.rs @@ -36,7 +36,7 @@ pub struct Credential { pub created_unix: u64, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, zvariant::Type)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, zvariant::Type)] pub struct PendingFlag { pub required_methods: Vec, pub created_unix: u64, @@ -46,6 +46,13 @@ pub struct PendingFlag { pub re_enroll: bool, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, zvariant::Type)] +pub struct PendingStatus { + pub present: bool, + /// Contents are the default when `!present`; consumer must check `present` first. + pub flag: PendingFlag, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, zvariant::Type)] pub struct Violation { pub user: String, @@ -119,4 +126,30 @@ mod tests { let back: Credential = serde_json::from_str(&json).unwrap(); assert_eq!(c, back); } + + #[test] + fn pending_status_serde() { + let s = PendingStatus { + present: true, + flag: PendingFlag { + required_methods: vec![Method::Fido2], + created_unix: 1_700_000_000, + deadline_unix: 0, + re_enroll: false, + }, + }; + let bytes = serde_json::to_vec(&s).unwrap(); + let back: PendingStatus = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(s, back); + } + + #[test] + fn pending_status_default_when_absent() { + let s = PendingStatus { + present: false, + flag: PendingFlag::default(), + }; + assert!(!s.present); + assert!(s.flag.required_methods.is_empty()); + } } From 86e3ffe8d588a403f8ae2d41b89d4df31506a2d0 Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 27 Apr 2026 10:49:53 -0700 Subject: [PATCH 4/7] feat(daemon): AppState::get_pending_status returns wire-friendly bool+flag has_pending stays #[cfg(test)] because production GUI reads via the new get_pending_status (one round-trip carries both presence and flag); production PAM module reads the file directly. Method gated with #[allow(dead_code)] until Task 5 wires D-Bus. Co-Authored-By: Claude Opus 4.7 (1M context) --- daemon/src/state.rs | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/daemon/src/state.rs b/daemon/src/state.rs index 8aeaa87..ca317a8 100644 --- a/daemon/src/state.rs +++ b/daemon/src/state.rs @@ -7,7 +7,9 @@ use crate::storage::policy::PolicyStore; use crate::storage::recovery::{RecoveryEntry, RecoveryStore, RecoveryStoreError}; use crate::storage::userdb::{UserDb, UserDbError}; use authforge_common::policy::{Policy, PolicyError}; -use authforge_common::types::{Credential, Method, PendingFlag, PolicyApplyResult, Transport}; +use authforge_common::types::{ + Credential, Method, PendingFlag, PendingStatus, PolicyApplyResult, Transport, +}; use std::collections::HashSet; use std::path::PathBuf; use std::sync::Arc; @@ -249,12 +251,24 @@ impl AppState { } /// Tests assert pending flag round-trips through SetPendingFlag / - /// ClearPendingFlag via this read accessor. Production readers (PAM - /// module, GUI status) read the on-disk file directly in later phases. + /// ClearPendingFlag via this read accessor. Production readers go + /// through `get_pending_status` (GUI) or read the on-disk file + /// directly (PAM module). #[cfg(test)] pub async fn has_pending(&self, user: &str) -> Result { Ok(self.pending.get(user)?.is_some()) } + + #[allow(dead_code)] // wired through D-Bus in Task 5. + pub async fn get_pending_status(&self, user: &str) -> Result { + match self.pending.get(user)? { + Some(flag) => Ok(PendingStatus { present: true, flag }), + None => Ok(PendingStatus { + present: false, + flag: PendingFlag::default(), + }), + } + } } #[cfg(test)] From 1976971bb5531edf02d4195ecd9425acee74e5b7 Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 27 Apr 2026 10:51:25 -0700 Subject: [PATCH 5/7] feat(daemon): GetPendingStatus D-Bus method + 2 integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the #[allow(dead_code)] on AppState::get_pending_status — it's now called from the D-Bus dispatch. Two new p2p tests cover the absent and the round-trip cases. Co-Authored-By: Claude Opus 4.7 (1M context) --- daemon/src/dbus.rs | 40 +++++++++++++++++++++++++++++++++++++++- daemon/src/state.rs | 1 - 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/daemon/src/dbus.rs b/daemon/src/dbus.rs index 0bc8bb3..2368559 100644 --- a/daemon/src/dbus.rs +++ b/daemon/src/dbus.rs @@ -1,7 +1,9 @@ use crate::polkit::Polkit; use crate::state::AppState; use authforge_common::policy::Policy; -use authforge_common::types::{Credential, PendingFlag, PolicyApplyResult, RecoveryCodeSummary}; +use authforge_common::types::{ + Credential, PendingFlag, PendingStatus, PolicyApplyResult, RecoveryCodeSummary, +}; use std::sync::Arc; pub struct AuthForge { @@ -129,6 +131,14 @@ impl AuthForge { Ok(()) } + async fn get_pending_status(&self, user: String) -> zbus::fdo::Result { + // No polkit gate — pure read; daemon's pattern is gates on writes only. + self.state + .get_pending_status(&user) + .await + .map_err(|e| zbus::fdo::Error::Failed(e.to_string())) + } + async fn generate_recovery_code(&self, user: String) -> zbus::fdo::Result { self.authz("io.dangerousthings.AuthForge.generate-recovery") .await?; @@ -387,6 +397,34 @@ central_path = "{}" assert!(!state.has_pending("alice").await.unwrap()); } + #[tokio::test] + async fn pending_status_is_absent_for_fresh_user() { + let (_srv, client, _state, _tmp) = p2p_pair().await; + let p = proxy(&client).await; + let s: PendingStatus = p.call("GetPendingStatus", &("alice",)).await.unwrap(); + assert!(!s.present); + assert!(s.flag.required_methods.is_empty()); + } + + #[tokio::test] + async fn pending_status_round_trips_set_flag() { + let (_srv, client, _state, _tmp) = p2p_pair().await; + let p = proxy(&client).await; + let flag = PendingFlag { + required_methods: vec![Method::Fido2], + created_unix: 100, + deadline_unix: 200, + re_enroll: true, + }; + let _: () = p + .call("SetPendingFlag", &("alice", flag.clone())) + .await + .unwrap(); + let s: PendingStatus = p.call("GetPendingStatus", &("alice",)).await.unwrap(); + assert!(s.present); + assert_eq!(s.flag, flag); + } + #[tokio::test] async fn generate_recovery_code_returns_8_digits() { let (_srv, client, _state, _tmp) = p2p_pair().await; diff --git a/daemon/src/state.rs b/daemon/src/state.rs index ca317a8..3cb2e4c 100644 --- a/daemon/src/state.rs +++ b/daemon/src/state.rs @@ -259,7 +259,6 @@ impl AppState { Ok(self.pending.get(user)?.is_some()) } - #[allow(dead_code)] // wired through D-Bus in Task 5. pub async fn get_pending_status(&self, user: &str) -> Result { match self.pending.get(user)? { Some(flag) => Ok(PendingStatus { present: true, flag }), From b8e8368a2565ba48a7de07f6878a77a342158be5 Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 27 Apr 2026 10:52:14 -0700 Subject: [PATCH 6/7] feat(gui): bus::Daemon::{get_pending_status,clear_pending_flag} client wrappers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clear_pending_flag is added alongside get_pending_status because Phase 10 needs both — its first-login modal checks status on startup and clears the flag on successful enrollment. Bundling here keeps the Phase 10 lane free of bus.rs edits. Co-Authored-By: Claude Opus 4.7 (1M context) --- gui/src/bus.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/gui/src/bus.rs b/gui/src/bus.rs index 92dfb66..a0580f5 100644 --- a/gui/src/bus.rs +++ b/gui/src/bus.rs @@ -4,7 +4,7 @@ //! `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; +use authforge_common::types::{Credential, PendingStatus}; const BUS_NAME: &str = "io.dangerousthings.AuthForge"; const OBJECT_PATH: &str = "/io/dangerousthings/AuthForge"; @@ -48,6 +48,14 @@ impl Daemon { ) -> zbus::Result> { self.proxy.receive_signal("EnrollmentFailed").await } + + pub async fn get_pending_status(&self, user: &str) -> zbus::Result { + self.proxy.call("GetPendingStatus", &(user,)).await + } + + pub async fn clear_pending_flag(&self, user: &str) -> zbus::Result<()> { + self.proxy.call("ClearPendingFlag", &(user,)).await + } } #[allow(dead_code)] // wired through keys_page.rs in Task 4. From 2ed95d21daa4e3bfb05635c636f616b17e8d3fcf Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 27 Apr 2026 10:55:51 -0700 Subject: [PATCH 7/7] style(daemon): rustfmt collapse get_pending_status match arm Co-Authored-By: Claude Opus 4.7 (1M context) --- daemon/src/state.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/daemon/src/state.rs b/daemon/src/state.rs index 3cb2e4c..2691d6f 100644 --- a/daemon/src/state.rs +++ b/daemon/src/state.rs @@ -261,7 +261,10 @@ impl AppState { pub async fn get_pending_status(&self, user: &str) -> Result { match self.pending.get(user)? { - Some(flag) => Ok(PendingStatus { present: true, flag }), + Some(flag) => Ok(PendingStatus { + present: true, + flag, + }), None => Ok(PendingStatus { present: false, flag: PendingFlag::default(),