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>
488 lines
17 KiB
Markdown
488 lines
17 KiB
Markdown
# 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<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](../../daemon/src/storage/pending.rs): `PendingStore::get` already returns `Result<Option<PendingFlag>>`.
|
|
- [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<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:
|
|
|
|
```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<T>`, 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<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`:
|
|
|
|
```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<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**
|
|
|
|
```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<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:
|
|
|
|
```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<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**
|
|
|
|
```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.
|