# Prep Lane: GUI shell scaffolding + pending-flag query — Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. > > Coordinator: [2026-04-27-parallel-fanout-9-10-11.md](2026-04-27-parallel-fanout-9-10-11.md). This lane lands the cross-cutting infrastructure for Phases 9, 10, 11, and the Phase 12 GUI tab so the three follow-on lanes can run truly concurrent in worktrees. **Goal:** Land `AppContext`, the `--first-run` flag scaffold (with a stub `firstrun::run()` function), and the daemon's `GetPendingStatus` D-Bus method (plus the matching GUI client wrapper) — without doing any of the actual phase work. After this lane merges, Phases 9, 10, 11 are file-disjoint at the level of `main.rs`, `bus.rs`, and `daemon/src/{dbus,state}.rs`. **Architecture:** - `AppContext` is the small shared-handle struct foreseen by Phase 8's Task 7 risk note ([2026-04-27-phase-8-gui-keys.md:812](2026-04-27-phase-8-gui-keys.md#L812)). It owns `parent_window`, `toast_overlay`, and the `Rc>>`. `KeysPage::new` switches to taking it. - `--first-run` is a clap-ish flag in `gui/src/main.rs`. The `connect_activate` body branches: when set, it calls `firstrun::run(ctx)`; otherwise it builds the normal ViewStack-style window. Phase 10 fills `firstrun::run()` with the real modal + watchdog. - `GetPendingStatus(user)` is a new D-Bus method that returns a `PendingStatus { present: bool, flag: PendingFlag }` wire type. One round-trip carries both "is there a pending flag" and "what's in it." Phase 10's first-login modal uses this on startup to decide whether to show or exit. **Tech Stack:** - `gtk4-rs` + `libadwaita` (already in tree post-Phase 8). - `zbus` (workspace tokio config — same as Phase 8 lands with). - `authforge_common` for the new `PendingStatus` wire type. **Reference:** - Phase 8 plan + commits (post-merge `4ec6911`): contracts the prep lane builds on. - [daemon/src/storage/pending.rs](../../daemon/src/storage/pending.rs): `PendingStore::get` already returns `Result>`. - [daemon/src/state.rs](../../daemon/src/state.rs): `has_pending` exists at line 229 but is `#[cfg(test)]`-gated; this plan promotes it. --- ## Conventions - One logical change per commit. Conventional prefixes: `chore:`, `refactor(gui):`, `feat(gui):`, `feat(daemon):`, `test:`. - Always commit with `--no-gpg-sign`. - TDD only for the wire-type round-trip test in `common`. Widget refactor (Task 1) is a mechanical move; D-Bus method gets a p2p integration test alongside the existing recovery tests. - After every commit: `cargo fmt --all && cargo clippy --workspace --all-targets -- -D warnings && cargo build --workspace`. --- ## Task 1: `AppContext` struct + `KeysPage` migration **Files:** - Create: `gui/src/app_context.rs` - Modify: `gui/src/main.rs` (add `mod app_context;`, construct + pass) - Modify: `gui/src/keys_page.rs` (constructor + field changes) **Step 1: Create the struct** ```rust // gui/src/app_context.rs //! 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)), } } } ``` **Step 2: Migrate `KeysPage`** In `gui/src/keys_page.rs`: - Change `pub fn new(parent_window: gtk::Window, toast_overlay: adw::ToastOverlay) -> Rc` to `pub fn new(ctx: AppContext) -> Rc`. - Replace the `parent_window` and `toast_overlay` and `daemon` fields with a single `ctx: AppContext`. - Replace all `self.parent_window` with `self.ctx.parent_window.clone()`, `self.toast_overlay` with `self.ctx.toast_overlay.clone()`, and `self.daemon` with `self.ctx.daemon.clone()` (or borrow as needed). - Add `use crate::app_context::AppContext;` at the top. **Step 3: Update `main.rs`** Replace the existing `KeysPage::new(...)` site with the AppContext flow: ```rust mod app_context; mod bus; mod enroll_dialog; mod error; mod keys_page; // inside connect_activate: 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"); // ... rest unchanged ... ``` **Step 4: Build + clippy + test** Run: `cargo build -p authforge-gui && cargo clippy -p authforge-gui --all-targets -- -D warnings && cargo test -p authforge-gui` Expected: Phase 8's 4 error-classifier tests still PASS; build clean. **Step 5: Commit** ```bash git add gui/src/app_context.rs gui/src/keys_page.rs gui/src/main.rs git commit --no-gpg-sign -m "refactor(gui): extract AppContext for shared page handles" ``` --- ## Task 2: `--first-run` flag + stub `firstrun::run()` **Files:** - Create: `gui/src/firstrun.rs` - Modify: `gui/src/main.rs` (parse flag, branch) **Background:** Phase 10 needs a place to put its fullscreen modal logic that doesn't conflict with the ViewStack work landing in Phases 9 + 11. Establish the entry point now; Phase 10 fills the body. **Step 1: Stub the firstrun module** ```rust // gui/src/firstrun.rs //! 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); } ``` **Step 2: Wire the flag into `main.rs`** GTK uses `gio::ApplicationFlags::HANDLES_COMMAND_LINE` and a `connect_command_line` handler to receive argv. The minimum-disruption pattern: parse `std::env::args` directly before constructing `adw::Application`, set a static / closure-captured bool, and branch on it inside `connect_activate`. ```rust // gui/src/main.rs use adw::prelude::*; use gtk::glib; mod app_context; mod bus; mod enroll_dialog; mod error; mod firstrun; mod keys_page; const APP_ID: &str = "io.dangerousthings.AuthForge"; fn main() -> glib::ExitCode { let runtime = tokio::runtime::Builder::new_multi_thread() .enable_all() .build() .expect("build tokio runtime"); let _guard = runtime.enter(); let first_run = std::env::args().any(|a| a == "--first-run"); let app = adw::Application::builder().application_id(APP_ID).build(); 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; } // Normal mode: header + ViewStack with the Keys tab. Phases 9 and 11 // append more tabs here. let header = adw::HeaderBar::new(); let stack = adw::ViewStack::new(); let switcher = adw::ViewSwitcher::builder() .stack(&stack) .policy(adw::ViewSwitcherPolicy::Wide) .build(); header.set_title_widget(Some(&switcher)); let keys = keys_page::KeysPage::new(ctx.clone()); stack.add_titled(&keys.root, Some("keys"), "Keys"); let layout = gtk::Box::new(gtk::Orientation::Vertical, 0); layout.append(&header); layout.append(&stack); stack.set_vexpand(true); toast_overlay.set_child(Some(&layout)); win.set_content(Some(&toast_overlay)); win.present(); }); app.run() } ``` **Step 3: Build + clippy + smoke** ```bash cargo build -p authforge-gui cargo clippy -p authforge-gui --all-targets -- -D warnings target/debug/authforge --first-run # prints "...stub; exiting." and returns 0 ``` (Manual smoke is a one-liner; CI doesn't have a display server but the stub doesn't try to open a window.) **Step 4: Commit** ```bash git add gui/src/firstrun.rs gui/src/main.rs git commit --no-gpg-sign -m "feat(gui): --first-run flag + firstrun::run stub for Phase 10" ``` --- ## Task 3: `PendingStatus` wire type **Files:** - Modify: `common/src/types.rs` **Step 1: Add the wire type and a Default impl on `PendingFlag`** zvariant doesn't carry `Option`, so `GetPendingStatus` returns a struct with an explicit `present: bool` next to the (possibly-empty) `flag`. ```rust // at the top of common/src/types.rs, ensure PendingFlag derives Default. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, zvariant::Type)] pub struct PendingFlag { pub required_methods: Vec, pub created_unix: u64, pub deadline_unix: u64, pub re_enroll: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, zvariant::Type)] pub struct PendingStatus { pub present: bool, pub flag: PendingFlag, // contents are the default when !present; consumer must check present first } ``` (If `PendingFlag` already has `Default`, leave the existing derive — adding `Default` to the existing derive list is the only change.) **Step 2: Add a serde round-trip test** Append to the existing `mod tests` in `common/src/types.rs`: ```rust #[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()); } ``` **Step 3: Run the tests** Run: `cargo test -p authforge-common` Expected: previous count + 2 PASS. **Step 4: Commit** ```bash git add common/src/types.rs git commit --no-gpg-sign -m "feat(common): PendingStatus wire type + Default impl on PendingFlag" ``` --- ## Task 4: Daemon — promote `has_pending`, add `get_pending_status` **Files:** - Modify: `daemon/src/state.rs` **Step 1: Promote `has_pending` out of `#[cfg(test)]`** At [daemon/src/state.rs:225-231](../../daemon/src/state.rs#L225-L231), remove the `#[cfg(test)]` gate and the doc comment about test-only use. The function stays the same body. **Step 2: Add `get_pending_status`** Append to the `impl AppState` block, near `clear_pending`: ```rust pub async fn get_pending_status(&self, user: &str) -> Result { use authforge_common::types::PendingStatus; match self.pending.get(user)? { Some(flag) => Ok(PendingStatus { present: true, flag }), None => Ok(PendingStatus { present: false, flag: PendingFlag::default(), }), } } ``` Add the `PendingStatus` import to the existing `use authforge_common::types::{...}` line. **Step 3: Run the daemon test suite** Run: `cargo test -p authforge-daemon` Expected: previous count, 0 failures (the new method has no tests yet — Task 5 wires it through D-Bus and adds tests there). **Step 4: Commit** ```bash git add daemon/src/state.rs git commit --no-gpg-sign -m "feat(daemon): AppState::get_pending_status returns wire-friendly bool+flag" ``` --- ## Task 5: D-Bus `GetPendingStatus` + integration test **Files:** - Modify: `daemon/src/dbus.rs` **Step 1: Add the D-Bus method** Inside `impl AuthForge` (the `#[zbus::interface]` block), near `clear_pending_flag`: ```rust async fn get_pending_status( &self, user: String, ) -> zbus::fdo::Result { self.state .get_pending_status(&user) .await .map_err(|e| zbus::fdo::Error::Failed(e.to_string())) } ``` No polkit gate — this is a pure read; the daemon's existing pattern is gates on writes only. Add `PendingStatus` to the `use authforge_common::types::{...}` import line at the top. **Step 2: Add integration tests** Append to the test module in `daemon/src/dbus.rs`, near `set_and_clear_pending_flag` at line 374: ```rust #[tokio::test] async fn pending_status_is_absent_for_fresh_user() { use authforge_common::types::PendingStatus; 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() { use authforge_common::types::{PendingFlag, PendingStatus}; 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); } ``` **Step 3: Run the daemon test suite** Run: `cargo test -p authforge-daemon` Expected: previous count + 2 PASS. **Step 4: Clippy + fmt** Run: `cargo fmt --all && cargo clippy --workspace --all-targets -- -D warnings` Expected: clean. **Step 5: Commit** ```bash git add daemon/src/dbus.rs git commit --no-gpg-sign -m "feat(daemon): GetPendingStatus D-Bus method + 2 integration tests" ``` --- ## Task 6: GUI client — `Daemon::get_pending_status` **Files:** - Modify: `gui/src/bus.rs` **Step 1: Add the wrapper** Append to the `impl Daemon` block: ```rust pub async fn get_pending_status( &self, user: &str, ) -> zbus::Result { self.proxy.call("GetPendingStatus", &(user,)).await } ``` Add `PendingStatus` to the `use authforge_common::types::{...}` import line if it isn't already drawn through the workspace path. **Step 2: Build + clippy** Run: `cargo build -p authforge-gui && cargo clippy -p authforge-gui --all-targets -- -D warnings` Expected: clean. **Step 3: Commit** ```bash git add gui/src/bus.rs git commit --no-gpg-sign -m "feat(gui): bus::Daemon::get_pending_status client wrapper" ``` --- ## Prep Lane Acceptance Gate Verify before merging back to main: - [ ] `cargo build --workspace --release` clean. - [ ] `cargo clippy --workspace --all-targets -- -D warnings` clean. - [ ] `cargo test --workspace` — daemon test count up by 2 (the new D-Bus tests); common test count up by 2 (the new wire-type tests); gui test count unchanged (4 from Phase 8). - [ ] `cargo fmt --all -- --check` clean. - [ ] `target/debug/authforge --first-run` exits 0 with the stub message (no display server needed). Commit count target: **6 commits** (one per task). Merge with `--no-ff` so the lane is visible in `git log`. --- ## Risks / known unknowns | Risk | Mitigation | |---|---| | Phase 9 plan's Task 1 already creates `AppContext` with the same shape — duplication. | Phase 9's Task 1 becomes a no-op once prep merges; Phase 9 plan's Task 1 step says "if AppContext already exists from prep, skip and proceed to Task 2." (Add a one-line note to the Phase 9 plan in this lane's final commit.) | | `PendingFlag` already has a hand-written `Default` impl somewhere. | Check via `grep -rn "impl Default for PendingFlag" common/`. If yes, skip Task 3 Step 1's first sub-step. | | `--first-run` flag conflicts with future GTK/clap-style argument parsing. | This is `std::env::args().any(...)` — the lightest possible parse. If future work introduces clap, the prep lane's flag-detection becomes a one-line move. | | `connect_activate` closure now captures `first_run` via `move` — Rust borrow checker may push back on closure-trait selection (Fn vs FnOnce). | `bool` is `Copy`; the move is a copy. Will compile clean — flagged for completeness. | --- ## 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`. Do NOT dispatch subagents for this lane — the work is too small to be worth orchestration overhead. After this lane merges to `main`, the three follow-on lanes (per the [coordinator doc](2026-04-27-parallel-fanout-9-10-11.md)) can dispatch in parallel.