git.lucas.co / cce-secrets
secrets manager
git clone https://git.lucas.co/cce-secrets.git

commit18f886bed0e2410083a380e0dd15508060b676a4
authorLucas Galante <[email protected]>
date2026-08-10 18:40
cce-secrets: Secret Service browser MVP

Wayland client (cce-ui Application) over org.freedesktop.secrets with
KeePassXC as the daemon: entry list with search + wheel scrolling,
detail pane with attributes, on-demand secret fetch by item path
(reveal toggle / copy with 30 s clipboard auto-clear). D-Bus session
lives on a worker thread (single-thread tokio + secret-service 5,
notifier pattern); locked collections raise the KeePassXC unlock
prompt with an honest status line.

Co-Authored-By: Claude Fable 5 <[email protected]>

 Cargo.toml  |  14 ++
 src/main.rs | 634 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 648 insertions(+)

diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..039af8f
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "cce-secrets"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+cce-ui = { path = "../cce-ui" }
+calloop = "0.13.0"
+wayland-client = { version = "0.31", features = ["system"] }
+tokio = { version = "1", features = ["full"] }
+secret-service = { version = "5", features = ["rt-tokio-crypto-rust"] }
+zbus = "5"
+log = "0.4"
+env_logger = "0.11"
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..8b7305f
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,634 @@
+use secret_service::{EncryptionType, SecretService};
+use wayland_client::QueueHandle;
+
+use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
+use cce_ui::widget::{
+    Button, ElementState, Key, KeyEvent, MouseButton, MouseScrollDelta, NamedKey, TextBox,
+    WidgetHost,
+};
+
+const PAD: f32 = 16.0;
+const LIST_W: f32 = 280.0;
+const ROW_H: f32 = 44.0;
+const STATUS_H: f32 = 30.0;
+const CLIPBOARD_CLEAR_SECS: u64 = 30;
+
+/// One Secret Service item, sans secret: the secret itself is fetched on
+/// demand by object path (reveal/copy) and never held in the list.
+#[derive(Clone, Debug)]
+struct EntryData {
+    path: String,
+    label: String,
+    collection: String,
+    attrs: Vec<(String, String)>,
+}
+
+impl EntryData {
+    /// The dim second line of a list row: a username-ish attribute if present.
+    fn hint(&self) -> Option<&str> {
+        self.attrs
+            .iter()
+            .find(|(k, _)| k.eq_ignore_ascii_case("username") || k.eq_ignore_ascii_case("user"))
+            .or_else(|| self.attrs.first())
+            .map(|(_, v)| v.as_str())
+            .filter(|v| !v.is_empty())
+    }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+enum Purpose {
+    Copy,
+    Reveal,
+}
+
+enum Cmd {
+    Reload,
+    GetSecret { path: String, purpose: Purpose },
+}
+
+#[derive(Clone, Debug)]
+enum AppMessage {
+    Loaded(Vec<EntryData>),
+    Status(String, bool),
+    Revealed { path: String, secret: String },
+    RefreshClicked,
+    RevealClicked,
+    CopyClicked,
+}
+
+// ── Secret Service worker ─────────────────────────────────────────────────
+//
+// The D-Bus session lives on its own thread (single-thread tokio runtime,
+// notifier pattern): the UI sends commands over an mpsc, results come back
+// through the calloop channel into update(). Copied secrets go straight to
+// the clipboard from here — only revealed ones cross to the UI at all.
+
+fn spawn_worker(rx: std::sync::mpsc::Receiver<Cmd>, tx: calloop::channel::Sender<AppMessage>) {
+    std::thread::spawn(move || {
+        let rt = match tokio::runtime::Builder::new_current_thread().enable_all().build() {
+            Ok(rt) => rt,
+            Err(e) => {
+                let _ = tx.send(AppMessage::Status(format!("tokio runtime failed: {e}"), true));
+                return;
+            }
+        };
+        rt.block_on(async move {
+            let ss = match SecretService::connect(EncryptionType::Dh).await {
+                Ok(ss) => ss,
+                Err(e) => {
+                    let _ = tx.send(AppMessage::Status(
+                        format!("Secret Service unavailable: {e} — is KeePassXC running with Secret Service integration enabled?"),
+                        true,
+                    ));
+                    return;
+                }
+            };
+            load_entries(&ss, &tx).await;
+            while let Ok(cmd) = rx.recv() {
+                match cmd {
+                    Cmd::Reload => load_entries(&ss, &tx).await,
+                    Cmd::GetSecret { path, purpose } => fetch_secret(&ss, &tx, path, purpose).await,
+                }
+            }
+        });
+    });
+}
+
+async fn load_entries(ss: &SecretService<'_>, tx: &calloop::channel::Sender<AppMessage>) {
+    let _ = tx.send(AppMessage::Status("Loading entries…".to_string(), false));
+    let collections = match ss.get_all_collections().await {
+        Ok(c) => c,
+        Err(e) => {
+            let _ = tx.send(AppMessage::Status(format!("Listing collections failed: {e}"), true));
+            return;
+        }
+    };
+    let mut entries = Vec::new();
+    for col in &collections {
+        let label = col.get_label().await.unwrap_or_else(|_| "collection".to_string());
+        // Locked collection: unlocking prompts through the daemon (KeePassXC
+        // raises its own dialog and this await blocks until it's answered);
+        // a refused prompt just skips the collection.
+        if col.is_locked().await.unwrap_or(false) {
+            let _ = tx.send(AppMessage::Status(
+                format!("Unlock \"{label}\" in KeePassXC to load its entries…"),
+                false,
+            ));
+            if col.unlock().await.is_err() || col.is_locked().await.unwrap_or(true) {
+                let _ = tx.send(AppMessage::Status(
+                    format!("Collection \"{label}\" stayed locked — Refresh to retry"),
+                    true,
+                ));
+                continue;
+            }
+        }
+        let items = match col.get_all_items().await {
+            Ok(i) => i,
+            Err(e) => {
+                let _ = tx.send(AppMessage::Status(format!("Listing \"{label}\" failed: {e}"), true));
+                continue;
+            }
+        };
+        for item in items {
+            let mut attrs: Vec<(String, String)> = item
+                .get_attributes()
+                .await
+                .unwrap_or_default()
+                .into_iter()
+                .filter(|(k, _)| k != "xdg:schema")
+                .collect();
+            attrs.sort();
+            entries.push(EntryData {
+                path: item.item_path.to_string(),
+                label: item.get_label().await.unwrap_or_default(),
+                collection: label.clone(),
+                attrs,
+            });
+        }
+    }
+    entries.sort_by(|a, b| a.label.to_lowercase().cmp(&b.label.to_lowercase()));
+    let _ = tx.send(AppMessage::Loaded(entries));
+}
+
+async fn fetch_secret(
+    ss: &SecretService<'_>,
+    tx: &calloop::channel::Sender<AppMessage>,
+    path: String,
+    purpose: Purpose,
+) {
+    let status_err = |msg: String| {
+        let _ = tx.send(AppMessage::Status(msg, true));
+    };
+    let opath = match zbus::zvariant::OwnedObjectPath::try_from(path.clone()) {
+        Ok(p) => p,
+        Err(e) => return status_err(format!("Bad item path: {e}")),
+    };
+    let item = match ss.get_item_by_path(opath).await {
+        Ok(i) => i,
+        Err(e) => return status_err(format!("Item lookup failed: {e}")),
+    };
+    let _ = item.ensure_unlocked().await;
+    let bytes = match item.get_secret().await {
+        Ok(b) => b,
+        Err(e) => return status_err(format!("Secret fetch failed: {e}")),
+    };
+    let secret = String::from_utf8_lossy(&bytes).to_string();
+    match purpose {
+        Purpose::Copy => {
+            cce_ui::widget::clipboard::copy_to_clipboard(&secret);
+            let _ = tx.send(AppMessage::Status(
+                format!("Secret copied — clipboard clears in {CLIPBOARD_CLEAR_SECS} s"),
+                false,
+            ));
+            std::thread::spawn(move || {
+                std::thread::sleep(std::time::Duration::from_secs(CLIPBOARD_CLEAR_SECS));
+                // Only clear if the clipboard still holds our secret.
+                if cce_ui::widget::clipboard::read_from_clipboard().as_deref() == Some(secret.as_str()) {
+                    cce_ui::widget::clipboard::copy_to_clipboard("");
+                }
+            });
+        }
+        Purpose::Reveal => {
+            let _ = tx.send(AppMessage::Revealed { path, secret });
+        }
+    }
+}
+
+// ── Application ───────────────────────────────────────────────────────────
+
+struct SecretsApp {
+    search_box: cce_ui::widget::Adapted<TextBox>,
+    refresh_btn: cce_ui::widget::Adapted<Button>,
+    reveal_btn: cce_ui::widget::Adapted<Button>,
+    copy_btn: cce_ui::widget::Adapted<Button>,
+
+    entries: Vec<EntryData>,
+    /// Selected entry's object path (stable across reloads and filtering).
+    selected: Option<String>,
+    /// Revealed (path, secret); cleared on selection change and reload.
+    revealed: Option<(String, String)>,
+
+    scroll_y: f32,
+    /// List viewport (x, y, w, h), refreshed each paint for hit-testing.
+    list_rect: (f32, f32, f32, f32),
+    pointer: (f32, f32),
+    hover_row: Option<usize>,
+
+    status_msg: String,
+    status_is_error: bool,
+
+    cmd_tx: std::sync::mpsc::Sender<Cmd>,
+    cmd_rx: Option<std::sync::mpsc::Receiver<Cmd>>,
+    sender: calloop::channel::Sender<AppMessage>,
+    ui_context: cce_ui::context::UiContext,
+}
+
+impl SecretsApp {
+    /// Indices into `entries` matching the search box, in display order.
+    fn filtered(&self) -> Vec<usize> {
+        let query = self.search_box.text.to_lowercase();
+        (0..self.entries.len())
+            .filter(|&i| {
+                if query.is_empty() {
+                    return true;
+                }
+                let e = &self.entries[i];
+                e.label.to_lowercase().contains(&query)
+                    || e.collection.to_lowercase().contains(&query)
+                    || e.attrs.iter().any(|(_, v)| v.to_lowercase().contains(&query))
+            })
+            .collect()
+    }
+
+    fn selected_entry(&self) -> Option<&EntryData> {
+        let sel = self.selected.as_deref()?;
+        self.entries.iter().find(|e| e.path == sel)
+    }
+
+    fn revealed_secret(&self) -> Option<&str> {
+        let (path, secret) = self.revealed.as_ref()?;
+        (self.selected.as_deref() == Some(path.as_str())).then_some(secret.as_str())
+    }
+
+    fn max_scroll(&self) -> f32 {
+        (self.filtered().len() as f32 * ROW_H - self.list_rect.3).max(0.0)
+    }
+
+    fn row_at(&self, px: f32, py: f32) -> Option<usize> {
+        let (lx, ly, lw, lh) = self.list_rect;
+        if px < lx || px > lx + lw || py < ly || py > ly + lh {
+            return None;
+        }
+        let row = ((py - ly + self.scroll_y) / ROW_H).floor();
+        (row >= 0.0 && (row as usize) < self.filtered().len()).then_some(row as usize)
+    }
+
+    fn widgets_iter(&self) -> Vec<&dyn WidgetHost> {
+        vec![&self.search_box, &self.refresh_btn, &self.reveal_btn, &self.copy_btn]
+    }
+}
+
+fn srgb_u8(linear: [f32; 4]) -> [u8; 3] {
+    let srgb = cce_ui::colors::to_srgb(linear);
+    [
+        (srgb[0] * 255.0) as u8,
+        (srgb[1] * 255.0) as u8,
+        (srgb[2] * 255.0) as u8,
+    ]
+}
+
+impl Application for SecretsApp {
+    type Message = AppMessage;
+
+    fn ui_context(&self) -> Option<&cce_ui::context::UiContext> {
+        Some(&self.ui_context)
+    }
+
+    fn new(_qh: &QueueHandle<EngineState<Self>>, sender: calloop::channel::Sender<Self::Message>) -> Self {
+        let (cmd_tx, cmd_rx) = std::sync::mpsc::channel();
+        Self {
+            search_box: TextBox::new(String::new()).with_placeholder("Search"),
+            refresh_btn: Button::new(0.0, 0.0, 90.0, 28.0).with_label("Refresh"),
+            reveal_btn: Button::new(0.0, 0.0, 90.0, 28.0).with_label("Reveal"),
+            copy_btn: Button::new(0.0, 0.0, 90.0, 28.0).with_label("Copy"),
+            entries: Vec::new(),
+            selected: None,
+            revealed: None,
+            scroll_y: 0.0,
+            list_rect: (PAD, PAD + 38.0, LIST_W, 0.0),
+            pointer: (0.0, 0.0),
+            hover_row: None,
+            status_msg: "Connecting to Secret Service…".to_string(),
+            status_is_error: false,
+            cmd_tx,
+            cmd_rx: Some(cmd_rx),
+            sender,
+            ui_context: cce_ui::context::UiContext::new(),
+        }
+    }
+
+    fn settings(&self) -> WindowSettings {
+        WindowSettings {
+            title: "CCE Secrets".to_string(),
+            app_id: "cce-secrets".to_string(),
+            width: 760,
+            height: 520,
+            fullscreen: false,
+            min_size: Some((560, 380)),
+        }
+    }
+
+    fn register_sources(&mut self, _handle: &calloop::LoopHandle<'_, EngineState<Self>>) {
+        if let Some(rx) = self.cmd_rx.take() {
+            spawn_worker(rx, self.sender.clone());
+        }
+    }
+
+    fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, _exit: &mut bool) {
+        *needs_rebuild = true;
+        match msg {
+            AppMessage::Loaded(entries) => {
+                self.entries = entries;
+                self.revealed = None;
+                if self.selected_entry().is_none() {
+                    self.selected = None;
+                }
+                self.scroll_y = self.scroll_y.clamp(0.0, self.max_scroll());
+                self.status_msg = if self.entries.is_empty() {
+                    "No entries — expose a KeePassXC group via Tools → Settings → Secret Service Integration".to_string()
+                } else {
+                    format!("{} entries", self.entries.len())
+                };
+                self.status_is_error = false;
+            }
+            AppMessage::Status(msg, is_error) => {
+                self.status_msg = msg;
+                self.status_is_error = is_error;
+            }
+            AppMessage::Revealed { path, secret } => {
+                if self.selected.as_deref() == Some(path.as_str()) {
+                    self.revealed = Some((path, secret));
+                }
+            }
+            AppMessage::RefreshClicked => {
+                self.status_msg = "Refreshing…".to_string();
+                self.status_is_error = false;
+                let _ = self.cmd_tx.send(Cmd::Reload);
+            }
+            AppMessage::RevealClicked => {
+                if let Some(sel) = self.selected.clone() {
+                    if self.revealed_secret().is_some() {
+                        self.revealed = None;
+                    } else {
+                        let _ = self.cmd_tx.send(Cmd::GetSecret { path: sel, purpose: Purpose::Reveal });
+                    }
+                }
+            }
+            AppMessage::CopyClicked => {
+                if let Some(sel) = self.selected.clone() {
+                    let _ = self.cmd_tx.send(Cmd::GetSecret { path: sel, purpose: Purpose::Copy });
+                }
+            }
+        }
+    }
+
+    fn tick(&mut self, _dt: f32, _needs_rebuild: &mut bool) {}
+
+    fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<cce_ui::scene::paint::DisplayList> {
+        use cce_ui::scene::layout::Rect;
+        cce_ui::scale::set_scale_factor(scale as f32);
+        let sw = size.width as f32;
+        let sh = size.height as f32;
+
+        // Widget roots re-registered every frame (idempotent; the frame is
+        // assembled by hand, so nothing else registers them).
+        {
+            let (id, ptr) = (self.search_box.id(), self.search_box.as_ptr_mut());
+            self.ui_context.register_widget(id, ptr);
+            let (id, ptr) = (self.refresh_btn.id(), self.refresh_btn.as_ptr_mut());
+            self.ui_context.register_widget(id, ptr);
+            let (id, ptr) = (self.reveal_btn.id(), self.reveal_btn.as_ptr_mut());
+            self.ui_context.register_widget(id, ptr);
+            let (id, ptr) = (self.copy_btn.id(), self.copy_btn.as_ptr_mut());
+            self.ui_context.register_widget(id, ptr);
+        }
+
+        let mut pc = cce_ui::scene::paint::PaintCtx::new();
+        let quad = |pc: &mut cce_ui::scene::paint::PaintCtx, x: f32, y: f32, w: f32, h: f32, c: [f32; 4]| {
+            pc.quad(Rect { x, y, width: w, height: h }, c);
+        };
+
+        quad(&mut pc, 0.0, 0.0, sw, sh, cce_ui::colors::CONTENT_BG);
+
+        // ── Left panel: search + entry list ──
+        self.search_box.set_rect(PAD, PAD, LIST_W, 30.0);
+        self.refresh_btn.set_rect(sw - PAD - 90.0, PAD, 90.0, 28.0);
+
+        let list_y = PAD + 38.0;
+        let list_h = (sh - list_y - STATUS_H - 8.0).max(0.0);
+        self.list_rect = (PAD, list_y, LIST_W, list_h);
+        quad(&mut pc, PAD, list_y, LIST_W, list_h, cce_ui::color::list_bg_color());
+
+        let filtered = self.filtered();
+        let list_bounds = Some([PAD, list_y, PAD + LIST_W, list_y + list_h]);
+        for (row, &ei) in filtered.iter().enumerate() {
+            let ry = list_y + row as f32 * ROW_H - self.scroll_y;
+            if ry + ROW_H < list_y || ry > list_y + list_h {
+                continue;
+            }
+            let entry = &self.entries[ei];
+            let is_selected = self.selected.as_deref() == Some(entry.path.as_str());
+            if is_selected {
+                quad(&mut pc, PAD, ry, LIST_W, ROW_H, [0.10, 0.28, 0.17, 1.0]);
+            } else if self.hover_row == Some(row) {
+                quad(&mut pc, PAD, ry, LIST_W, ROW_H, [1.0, 1.0, 1.0, 0.04]);
+            }
+            pc.text_with(
+                entry.label.clone(),
+                PAD + 12.0,
+                ry + 8.0,
+                12.0,
+                srgb_u8(cce_ui::colors::TEXT_HEADER),
+                None,
+                list_bounds,
+            );
+            if let Some(hint) = entry.hint() {
+                pc.text_with(
+                    hint.to_string(),
+                    PAD + 12.0,
+                    ry + 25.0,
+                    10.0,
+                    srgb_u8(cce_ui::colors::TEXT_DIM),
+                    None,
+                    list_bounds,
+                );
+            }
+        }
+
+        // Scrollbar (wheel-driven; thumb is display-only).
+        let content_h = filtered.len() as f32 * ROW_H;
+        if content_h > list_h {
+            let sb_w = 4.0;
+            let sb_x = PAD + LIST_W - sb_w - 3.0;
+            let visible_ratio = list_h / content_h;
+            let thumb_h = (list_h * visible_ratio).clamp(20.0, list_h);
+            let scroll_ratio = if self.max_scroll() > 0.0 { self.scroll_y / self.max_scroll() } else { 0.0 };
+            let thumb_y = list_y + scroll_ratio * (list_h - thumb_h);
+            quad(&mut pc, sb_x, list_y, sb_w, list_h, cce_ui::color::scrollbar_track_color());
+            quad(&mut pc, sb_x, thumb_y, sb_w, thumb_h, cce_ui::color::scrollbar_thumb_color());
+        }
+
+        // ── Right panel: selected entry detail ──
+        let dx = PAD + LIST_W + 20.0;
+        let dw = (sw - dx - PAD).max(0.0);
+        let detail_bounds = Some([dx, list_y, dx + dw, list_y + list_h]);
+        if let Some(entry) = self.selected_entry().cloned() {
+            pc.text_with(entry.label.clone(), dx, list_y + 4.0, 15.0, srgb_u8(cce_ui::colors::TEXT_HEADER), None, detail_bounds);
+            pc.text_with(entry.collection.clone(), dx, list_y + 26.0, 10.0, srgb_u8(cce_ui::colors::TEXT_DIM), None, detail_bounds);
+
+            let mut ay = list_y + 56.0;
+            for (key, value) in &entry.attrs {
+                pc.text_with(key.clone(), dx, ay, 10.0, srgb_u8(cce_ui::colors::TEXT_DIM), None, detail_bounds);
+                pc.text_with(value.clone(), dx + 120.0, ay, 11.0, srgb_u8(cce_ui::colors::TEXT_FG), None, detail_bounds);
+                ay += 22.0;
+            }
+
+            ay += 8.0;
+            pc.text_with("secret".to_string(), dx, ay, 10.0, srgb_u8(cce_ui::colors::TEXT_DIM), None, detail_bounds);
+            let (secret_text, revealed) = match self.revealed_secret() {
+                Some(s) => (s.to_string(), true),
+                None => ("••••••••••••".to_string(), false),
+            };
+            pc.text_with(secret_text, dx + 120.0, ay, 11.0, srgb_u8(cce_ui::colors::TEXT_FG), None, detail_bounds);
+
+            self.reveal_btn.set_label(if revealed { "Hide" } else { "Reveal" });
+            self.reveal_btn.set_rect(dx, ay + 28.0, 90.0, 28.0);
+            self.copy_btn.set_rect(dx + 100.0, ay + 28.0, 90.0, 28.0);
+        } else {
+            let hint = if self.entries.is_empty() { "" } else { "Select an entry" };
+            pc.text_with(hint.to_string(), dx, list_y + 4.0, 11.0, srgb_u8(cce_ui::colors::TEXT_DIM), None, detail_bounds);
+            // Parked out of reach so stale rects can't swallow clicks.
+            self.reveal_btn.set_rect(-1000.0, -1000.0, 90.0, 28.0);
+            self.copy_btn.set_rect(-1000.0, -1000.0, 90.0, 28.0);
+        }
+
+        // ── Widgets + status line ──
+        for w in &self.widgets_iter() {
+            let (wx, wy, ww, wh) = w.rect();
+            quad(&mut pc, wx, wy, ww, wh, w.color());
+            for (qx, qy, qw, qh, qc) in w.extra_quads() {
+                quad(&mut pc, qx, qy, qw, qh, qc);
+            }
+        }
+        for w in self.widgets_iter() {
+            cce_ui::scene::painter::append_widget_text(&self.ui_context, w, &mut pc);
+        }
+
+        let status_color = if self.status_is_error { [0xee, 0x5c, 0x5c] } else { srgb_u8(cce_ui::colors::TEXT_DIM) };
+        pc.text_with(
+            self.status_msg.clone(),
+            PAD,
+            sh - STATUS_H + 6.0,
+            10.0,
+            status_color,
+            None,
+            Some([PAD, sh - STATUS_H, sw - PAD, sh]),
+        );
+
+        Some(pc.finish())
+    }
+
+    fn display_list_text(&self) -> bool {
+        true
+    }
+
+    fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
+        self.pointer = (pos.x, pos.y);
+        let mv = cce_ui::widget::Event::PointerMove { x: pos.x, y: pos.y, local_x: pos.x, local_y: pos.y };
+        let ctx = &mut self.ui_context;
+        if ctx.propagate_event(&mv, self.search_box.id()) { *needs_rebuild = true; }
+        if ctx.propagate_event(&mv, self.refresh_btn.id()) { *needs_rebuild = true; }
+        if ctx.propagate_event(&mv, self.reveal_btn.id()) { *needs_rebuild = true; }
+        if ctx.propagate_event(&mv, self.copy_btn.id()) { *needs_rebuild = true; }
+
+        let hover = self.row_at(pos.x, pos.y);
+        if hover != self.hover_row {
+            self.hover_row = hover;
+            *needs_rebuild = true;
+        }
+    }
+
+    fn handle_mouse_input(
+        &mut self,
+        button: MouseButton,
+        state: ElementState,
+        pos: LogicalPosition,
+        needs_rebuild: &mut bool,
+    ) -> Option<Self::Message> {
+        let (lx, ly) = (pos.x, pos.y);
+        let ev = cce_ui::widget::Event::MouseButton { button, state, x: lx, y: ly, local_x: lx, local_y: ly };
+
+        if { let root = self.refresh_btn.id(); self.ui_context.propagate_event(&ev, root) } {
+            *needs_rebuild = true;
+        }
+        if self.refresh_btn.take_click() {
+            return Some(AppMessage::RefreshClicked);
+        }
+        if { let root = self.reveal_btn.id(); self.ui_context.propagate_event(&ev, root) } {
+            *needs_rebuild = true;
+        }
+        if self.reveal_btn.take_click() {
+            return Some(AppMessage::RevealClicked);
+        }
+        if { let root = self.copy_btn.id(); self.ui_context.propagate_event(&ev, root) } {
+            *needs_rebuild = true;
+        }
+        if self.copy_btn.take_click() {
+            return Some(AppMessage::CopyClicked);
+        }
+
+        let tb = &mut self.search_box;
+        if state == ElementState::Pressed && !tb.hit_test(lx, ly, &self.ui_context) {
+            tb.unfocus();
+        }
+        if { let root = tb.id(); self.ui_context.propagate_event(&ev, root) } {
+            *needs_rebuild = true;
+        }
+
+        if button == MouseButton::Left && state == ElementState::Pressed {
+            if let Some(row) = self.row_at(lx, ly) {
+                let filtered = self.filtered();
+                let path = self.entries[filtered[row]].path.clone();
+                if self.selected.as_deref() != Some(path.as_str()) {
+                    self.selected = Some(path);
+                    self.revealed = None;
+                    *needs_rebuild = true;
+                }
+            }
+        }
+        None
+    }
+
+    fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool) {
+        let (lx, ly, lw, lh) = self.list_rect;
+        if pos.x < lx || pos.x > lx + lw || pos.y < ly || pos.y > ly + lh {
+            return;
+        }
+        let dy = match delta {
+            MouseScrollDelta::LineDelta(_, y) => -y * 24.0,
+            MouseScrollDelta::PixelDelta(p) => -p.y as f32,
+        };
+        let old = self.scroll_y;
+        self.scroll_y = (self.scroll_y + dy).clamp(0.0, self.max_scroll());
+        if (self.scroll_y - old).abs() > 0.01 {
+            self.hover_row = self.row_at(self.pointer.0, self.pointer.1);
+            *needs_rebuild = true;
+        }
+    }
+
+    fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Self::Message> {
+        if event.state == ElementState::Pressed && !event.repeat {
+            if let Key::Named(NamedKey::Escape) = event.logical_key {
+                if self.search_box.focused(&self.ui_context) {
+                    self.search_box.text.clear();
+                    self.search_box.unfocus();
+                    self.scroll_y = 0.0;
+                    *needs_rebuild = true;
+                    return None;
+                }
+            }
+        }
+        let kev = cce_ui::widget::Event::KeyInput(event.clone());
+        let root = self.search_box.id();
+        if self.ui_context.propagate_event(&kev, root) {
+            self.scroll_y = self.scroll_y.clamp(0.0, self.max_scroll());
+            *needs_rebuild = true;
+        }
+        None
+    }
+}
+
+fn main() {
+    env_logger::init();
+    cce_ui::engine::run::<SecretsApp>();
+}