diff --git a/gui/src/keys_page.rs b/gui/src/keys_page.rs new file mode 100644 index 0000000..5e68df2 --- /dev/null +++ b/gui/src/keys_page.rs @@ -0,0 +1,79 @@ +//! "Security keys" preferences page. v1 surface: the current user's enrolled +//! FIDO2 credentials with per-row remove + a single "Enroll a new key" button. +//! Task 4 lands the empty state + retry banner; Task 5 fills in the list. + +use crate::bus::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>>, + #[allow(dead_code)] // wired through enroll_dialog in Task 6. + parent_window: gtk::Window, +} + +impl KeysPage { + pub fn new(parent_window: gtk::Window) -> Rc { + let page = Rc::new(Self { + root: adw::Bin::new(), + daemon: Rc::new(RefCell::new(None)), + parent_window, + }); + let p = page.clone(); + glib::MainContext::default().spawn_local(async move { + p.try_connect_and_render().await; + }); + page + } + + async fn try_connect_and_render(self: Rc) { + match Daemon::connect().await { + Ok(d) => { + *self.daemon.borrow_mut() = Some(d); + self.render_connected().await; + } + Err(e) => self.render_disconnected(&user_message(&e)), + } + } + + fn render_disconnected(self: &Rc, msg: &str) { + let status = adw::StatusPage::builder() + .icon_name("network-offline-symbolic") + .title("Daemon unavailable") + .description(msg) + .build(); + let retry = gtk::Button::builder() + .label("Retry") + .css_classes(["pill", "suggested-action"]) + .halign(gtk::Align::Center) + .build(); + let me = self.clone(); + retry.connect_clicked(move |_| { + let me = me.clone(); + glib::MainContext::default().spawn_local(async move { + me.try_connect_and_render().await; + }); + }); + status.set_child(Some(&retry)); + self.root.set_child(Some(&status)); + } + + async fn render_connected(self: &Rc) { + // Placeholder until Task 5 fills in the list. + let page = adw::PreferencesPage::new(); + let group = adw::PreferencesGroup::builder() + .title("Security keys") + .description("FIDO2 keys enrolled for your account") + .build(); + let placeholder = adw::ActionRow::builder() + .title("(Task 5 will populate this)") + .build(); + group.add(&placeholder); + page.add(&group); + self.root.set_child(Some(&page)); + } +} diff --git a/gui/src/main.rs b/gui/src/main.rs index 2f1b8a8..c66cbdc 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -3,24 +3,48 @@ use gtk::glib; mod bus; mod error; +mod keys_page; const APP_ID: &str = "io.dangerousthings.AuthForge"; fn main() -> glib::ExitCode { + // Multi-thread tokio runtime hosts zbus's tokio-flavored futures. The + // `_guard` keeps the runtime context active on this thread so calls like + // `zbus::Connection::system().await` from within `glib::MainContext:: + // spawn_local` closures don't panic with "no current reactor". + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("build tokio runtime"); + let _guard = runtime.enter(); + let app = adw::Application::builder().application_id(APP_ID).build(); app.connect_activate(|app| { - let status = adw::StatusPage::builder() - .icon_name("dialog-password-symbolic") - .title("Authentication setup") - .description("Phase 0 placeholder. Real UI in Phase 8.") - .build(); let win = adw::ApplicationWindow::builder() .application(app) .default_width(720) .default_height(540) .title("Authentication") - .content(&status) .build(); + 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(win.clone().upcast()); + stack.add_titled(&keys.root, Some("keys"), "Keys"); + + // ToolbarView is libadwaita v1_4-gated; the project sticks with the + // simpler Box layout for portability (see commit c6a5e94). + let layout = gtk::Box::new(gtk::Orientation::Vertical, 0); + layout.append(&header); + layout.append(&stack); + stack.set_vexpand(true); + + win.set_content(Some(&layout)); win.present(); }); app.run()