Files
authforge/docs/plans/2026-04-27-prep-shell-and-pending.md
michael d20643cde5 docs(plan): coordinator + 3 step-level plans for parallel Phase 9/10/11 fan-out
Ships four documents:
  * 2026-04-27-parallel-fanout-9-10-11.md — coordinator. File-conflict map,
    prep-lane scope rationale, post-prep parallel-safety guarantee, merge
    order rule, and risks.
  * 2026-04-27-prep-shell-and-pending.md — small ~1hr prep lane: extract
    AppContext, add --first-run flag scaffold (with stub firstrun::run),
    add daemon GetPendingStatus D-Bus method + GUI client wrapper, new
    PendingStatus wire type. 6 tasks, single agent.
  * 2026-04-27-phase-10-firstrun.md — first-login flow. Fullscreen modal,
    60s idle watchdog (TDD'd as pure WatchdogState), enrollment via Phase
    8 enroll_dialog, ClearPendingFlag on success, gnome-session-quit on
    idle, autostart .desktop entry, debian install. 6 tasks.
  * 2026-04-27-phase-11-totp.md — TOTP support behind default-on Cargo
    feature. Daemon-side: 160-bit secret + base32 + otpauth URI + atomic
    0600 writes in pam_google_authenticator format. PAM profile renderer
    extension. D-Bus surface. CLI subcommand. GUI tab with QR modal.
    Deviation flagged: TOTP recovery codes reuse the Phase 12 recovery
    flow rather than introducing a parallel hashed-recovery file format.
    9 tasks.

Execution model: prep lane first (single agent ~1hr), then dispatch three
subagents in parallel worktrees for the three follow-on lanes. Per the
conflict map, the lanes are file-disjoint after prep merges; merge order
is any-order.

If all four lanes land cleanly, the roadmap jumps from 13/19 to 17/19
phases code-complete in a single session, leaving Phase 13 (deb finalization),
14 (PPA + smoke), 17 (integration tests), 18 (user docs), and the v1.0
release tag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 10:28:23 -07:00

17 KiB

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. 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). It owns parent_window, toast_overlay, and the Rc<RefCell<Option<Daemon>>>. 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: PendingStore::get already returns Result<Option<PendingFlag>>.
  • 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

// gui/src/app_context.rs
//! Shared per-window handles passed to every page constructor.
//!
//! `daemon` is a `RefCell<Option<...>>` 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<RefCell<Option<Daemon>>>,
}

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<Self> to pub fn new(ctx: AppContext) -> Rc<Self>.
  • 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:

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

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

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

// 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

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

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<T>, so GetPendingStatus returns a struct with an explicit present: bool next to the (possibly-empty) flag.

// 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<Method>,
    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:

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

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

pub async fn get_pending_status(&self, user: &str) -> Result<PendingStatus, StateError> {
    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

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:

async fn get_pending_status(
    &self,
    user: String,
) -> zbus::fdo::Result<authforge_common::types::PendingStatus> {
    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:

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

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:

pub async fn get_pending_status(
    &self,
    user: &str,
) -> zbus::Result<authforge_common::types::PendingStatus> {
    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

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) can dispatch in parallel.