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>
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:
AppContextis the small shared-handle struct foreseen by Phase 8's Task 7 risk note (2026-04-27-phase-8-gui-keys.md:812). It ownsparent_window,toast_overlay, and theRc<RefCell<Option<Daemon>>>.KeysPage::newswitches to taking it.--first-runis a clap-ish flag ingui/src/main.rs. Theconnect_activatebody branches: when set, it callsfirstrun::run(ctx); otherwise it builds the normal ViewStack-style window. Phase 10 fillsfirstrun::run()with the real modal + watchdog.GetPendingStatus(user)is a new D-Bus method that returns aPendingStatus { 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_commonfor the newPendingStatuswire type.
Reference:
- Phase 8 plan + commits (post-merge
4ec6911): contracts the prep lane builds on. - daemon/src/storage/pending.rs:
PendingStore::getalready returnsResult<Option<PendingFlag>>. - daemon/src/state.rs:
has_pendingexists 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(addmod 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>topub fn new(ctx: AppContext) -> Rc<Self>. - Replace the
parent_windowandtoast_overlayanddaemonfields with a singlectx: AppContext. - Replace all
self.parent_windowwithself.ctx.parent_window.clone(),self.toast_overlaywithself.ctx.toast_overlay.clone(), andself.daemonwithself.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 --releaseclean.cargo clippy --workspace --all-targets -- -D warningsclean.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 -- --checkclean.target/debug/authforge --first-runexits 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.