From 53b4a8da5c1eb6af89840f83f79b9ec3cb744efd Mon Sep 17 00:00:00 2001 From: William Casarin Date: Fri, 8 Aug 2025 13:19:39 -0700 Subject: [PATCH] notedeck app: add clndash a core-lightning dashboard i'm working on feature-gate it behind --clndash Signed-off-by: William Casarin --- Cargo.lock | 37 +++++ Cargo.toml | 4 +- assets/icons/clnlogo.svg | 57 ++++++++ crates/notedeck/src/args.rs | 2 + crates/notedeck/src/options.rs | 3 + crates/notedeck_chrome/Cargo.toml | 1 + crates/notedeck_chrome/src/app.rs | 3 + crates/notedeck_chrome/src/chrome.rs | 80 ++++++----- crates/notedeck_clndash/Cargo.toml | 15 +++ crates/notedeck_clndash/src/lib.rs | 195 +++++++++++++++++++++++++++ crates/notedeck_ui/src/app_images.rs | 4 + 11 files changed, 358 insertions(+), 43 deletions(-) create mode 100644 assets/icons/clnlogo.svg create mode 100644 crates/notedeck_clndash/Cargo.toml create mode 100644 crates/notedeck_clndash/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 29ad0b7..3d5f62d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2336,6 +2336,12 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" + [[package]] name = "hashbrown" version = "0.15.4" @@ -3064,6 +3070,22 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5" +[[package]] +name = "lnsocket" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a88bd51e5bb3753f89b0d3e73baa565064c5a9f5b2aad3ab3f3db5fffb89955" +dependencies = [ + "bitcoin", + "hashbrown 0.13.2", + "hex", + "lightning-types", + "serde", + "serde_json", + "tokio", + "tracing", +] + [[package]] name = "lock_api" version = "0.4.13" @@ -3540,6 +3562,7 @@ dependencies = [ "egui_tabs", "nostrdb", "notedeck", + "notedeck_clndash", "notedeck_columns", "notedeck_dave", "notedeck_notebook", @@ -3559,6 +3582,20 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "notedeck_clndash" +version = "0.6.0" +dependencies = [ + "eframe", + "egui", + "lnsocket", + "notedeck", + "serde", + "serde_json", + "tokio", + "tracing", +] + [[package]] name = "notedeck_columns" version = "0.6.0" diff --git a/Cargo.toml b/Cargo.toml index a036cba..cb7abc8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,8 +8,9 @@ members = [ "crates/notedeck_dave", "crates/notedeck_notebook", "crates/notedeck_ui", + "crates/notedeck_clndash", - "crates/enostr", "crates/tokenator", "crates/notedeck_dave", "crates/notedeck_ui", + "crates/enostr", "crates/tokenator", "crates/notedeck_dave", "crates/notedeck_ui", "crates/notedeck_clndash", ] [workspace.dependencies] @@ -48,6 +49,7 @@ nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", rev = "2b2e5e43c019b #nostrdb = "0.6.1" notedeck = { path = "crates/notedeck" } notedeck_chrome = { path = "crates/notedeck_chrome" } +notedeck_clndash = { path = "crates/notedeck_clndash" } notedeck_columns = { path = "crates/notedeck_columns" } notedeck_dave = { path = "crates/notedeck_dave" } notedeck_notebook = { path = "crates/notedeck_notebook" } diff --git a/assets/icons/clnlogo.svg b/assets/icons/clnlogo.svg new file mode 100644 index 0000000..ab4b18b --- /dev/null +++ b/assets/icons/clnlogo.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + diff --git a/crates/notedeck/src/args.rs b/crates/notedeck/src/args.rs index bd6caed..1f413d5 100644 --- a/crates/notedeck/src/args.rs +++ b/crates/notedeck/src/args.rs @@ -126,6 +126,8 @@ impl Args { res.options.set(NotedeckOptions::RelayDebug, true); } else if arg == "--notebook" { res.options.set(NotedeckOptions::FeatureNotebook, true); + } else if arg == "--clndash" { + res.options.set(NotedeckOptions::FeatureClnDash, true); } else { unrecognized_args.insert(arg.clone()); } diff --git a/crates/notedeck/src/options.rs b/crates/notedeck/src/options.rs index 1c01644..e948745 100644 --- a/crates/notedeck/src/options.rs +++ b/crates/notedeck/src/options.rs @@ -26,6 +26,9 @@ bitflags! { // ===== Feature Flags ====== /// Is notebook enabled? const FeatureNotebook = 1 << 32; + + /// Is clndash enabled? + const FeatureClnDash = 1 << 33; } } diff --git a/crates/notedeck_chrome/Cargo.toml b/crates/notedeck_chrome/Cargo.toml index 78d9901..33a1063 100644 --- a/crates/notedeck_chrome/Cargo.toml +++ b/crates/notedeck_chrome/Cargo.toml @@ -18,6 +18,7 @@ notedeck_columns = { workspace = true } notedeck_ui = { workspace = true } notedeck_dave = { workspace = true } notedeck_notebook = { workspace = true } +notedeck_clndash = { workspace = true } notedeck = { workspace = true } nostrdb = { workspace = true } puffin = { workspace = true, optional = true } diff --git a/crates/notedeck_chrome/src/app.rs b/crates/notedeck_chrome/src/app.rs index d672e6c..1ce9784 100644 --- a/crates/notedeck_chrome/src/app.rs +++ b/crates/notedeck_chrome/src/app.rs @@ -1,4 +1,5 @@ use notedeck::{AppAction, AppContext}; +use notedeck_clndash::ClnDash; use notedeck_columns::Damus; use notedeck_dave::Dave; use notedeck_notebook::Notebook; @@ -8,6 +9,7 @@ pub enum NotedeckApp { Dave(Box), Columns(Box), Notebook(Box), + ClnDash(Box), Other(Box), } @@ -17,6 +19,7 @@ impl notedeck::App for NotedeckApp { NotedeckApp::Dave(dave) => dave.update(ctx, ui), NotedeckApp::Columns(columns) => columns.update(ctx, ui), NotedeckApp::Notebook(notebook) => notebook.update(ctx, ui), + NotedeckApp::ClnDash(clndash) => clndash.update(ctx, ui), NotedeckApp::Other(other) => other.update(ctx, ui), } } diff --git a/crates/notedeck_chrome/src/chrome.rs b/crates/notedeck_chrome/src/chrome.rs index c37e709..f22f707 100644 --- a/crates/notedeck_chrome/src/chrome.rs +++ b/crates/notedeck_chrome/src/chrome.rs @@ -18,7 +18,6 @@ use notedeck_columns::{ Damus, }; use notedeck_dave::{Dave, DaveAvatar}; -use notedeck_notebook::Notebook; use notedeck_ui::{app_images, AnimationHelper, ProfilePic}; use std::collections::HashMap; @@ -198,6 +197,10 @@ impl Chrome { chrome.add_app(NotedeckApp::Notebook(Box::default())); } + if notedeck.has_option(NotedeckOptions::FeatureClnDash) { + chrome.add_app(NotedeckApp::ClnDash(Box::default())); + } + chrome.set_active(0); Ok(chrome) @@ -231,16 +234,6 @@ impl Chrome { None } - fn get_notebook(&mut self) -> Option<&mut Notebook> { - for app in &mut self.apps { - if let NotedeckApp::Notebook(notebook) = app { - return Some(notebook); - } - } - - None - } - fn switch_to_dave(&mut self) { for (i, app) in self.apps.iter().enumerate() { if let NotedeckApp::Dave(_) = app { @@ -249,14 +242,6 @@ impl Chrome { } } - fn switch_to_notebook(&mut self) { - for (i, app) in self.apps.iter().enumerate() { - if let NotedeckApp::Notebook(_) = app { - self.active = i as i32; - } - } - } - fn switch_to_columns(&mut self) { for (i, app) in self.apps.iter().enumerate() { if let NotedeckApp::Columns(_) = app { @@ -498,32 +483,32 @@ impl Chrome { ui.add_space(4.0); ui.add(milestone_name(i18n)); - ui.add_space(16.0); //let dark_mode = ui.ctx().style().visuals.dark_mode; - if columns_button(ui) - .on_hover_cursor(egui::CursorIcon::PointingHand) - .clicked() - { - self.active = 0; - } - ui.add_space(32.0); - if let Some(dave) = self.get_dave() { - let rect = dave_sidebar_rect(ui); - let dave_resp = dave_button(dave.avatar_mut(), ui, rect) - .on_hover_cursor(egui::CursorIcon::PointingHand); - if dave_resp.clicked() { - self.switch_to_dave(); - } - } - //ui.add_space(32.0); + for (i, app) in self.apps.iter_mut().enumerate() { + let r = match app { + NotedeckApp::Columns(_columns_app) => columns_button(ui), - if let Some(_notebook) = self.get_notebook() { - if notebook_button(ui) - .on_hover_cursor(egui::CursorIcon::PointingHand) - .clicked() - { - self.switch_to_notebook(); + NotedeckApp::Dave(dave) => { + ui.add_space(24.0); + let rect = dave_sidebar_rect(ui); + dave_button(dave.avatar_mut(), ui, rect) + } + + NotedeckApp::ClnDash(_clndash) => clndash_button(ui), + + NotedeckApp::Notebook(_notebook) => notebook_button(ui), + + NotedeckApp::Other(_other) => { + // app provides its own button rendering ui? + panic!("TODO: implement other apps") + } + }; + + ui.add_space(4.0); + + if r.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() { + self.active = i as i32; } } } @@ -720,6 +705,17 @@ fn accounts_button(ui: &mut egui::Ui) -> egui::Response { ) } +fn clndash_button(ui: &mut egui::Ui) -> egui::Response { + expanding_button( + "clndash-button", + 24.0, + app_images::cln_image(), + app_images::cln_image(), + ui, + false, + ) +} + fn notebook_button(ui: &mut egui::Ui) -> egui::Response { expanding_button( "notebook-button", diff --git a/crates/notedeck_clndash/Cargo.toml b/crates/notedeck_clndash/Cargo.toml new file mode 100644 index 0000000..1cd481f --- /dev/null +++ b/crates/notedeck_clndash/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "notedeck_clndash" +edition = "2024" +version.workspace = true + +[dependencies] +egui = { workspace = true } +notedeck = { workspace = true } +#notedeck_ui = { workspace = true } +eframe = { workspace = true } +lnsocket = "0.3.0" +tracing = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } diff --git a/crates/notedeck_clndash/src/lib.rs b/crates/notedeck_clndash/src/lib.rs new file mode 100644 index 0000000..1a3b45c --- /dev/null +++ b/crates/notedeck_clndash/src/lib.rs @@ -0,0 +1,195 @@ +use egui::{Color32, Label, RichText}; +use lnsocket::bitcoin::secp256k1::{PublicKey, SecretKey, rand}; +use lnsocket::{CommandoClient, LNSocket}; +use notedeck::{AppAction, AppContext}; +use serde_json::{Value, json}; +use std::str::FromStr; +use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel}; + +#[derive(Default)] +pub struct ClnDash { + initialized: bool, + connection_state: ConnectionState, + get_info: Option, + channel: Option, +} + +impl Default for ConnectionState { + fn default() -> Self { + ConnectionState::Dead("uninitialized".to_string()) + } +} + +struct Channel { + req_tx: UnboundedSender, + event_rx: UnboundedReceiver, +} + +/// Responses from the socket +enum ClnResponse { + GetInfo(Result), +} + +enum ConnectionState { + Dead(String), + Connecting, + Active, +} + +enum Request { + GetInfo, +} + +enum Event { + /// We lost the socket somehow + Ended { + reason: String, + }, + + Connected, + + Response(ClnResponse), +} + +impl notedeck::App for ClnDash { + fn update(&mut self, _ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> Option { + if !self.initialized { + self.connection_state = ConnectionState::Connecting; + self.setup_connection(); + self.initialized = true; + } + + self.process_events(); + + self.show(ui); + + None + } +} + +fn connection_state_ui(ui: &mut egui::Ui, state: &ConnectionState) { + match state { + ConnectionState::Active => { + ui.add(Label::new(RichText::new("Connected").color(Color32::GREEN))); + } + + ConnectionState::Connecting => { + ui.add(Label::new( + RichText::new("Connecting").color(Color32::YELLOW), + )); + } + + ConnectionState::Dead(reason) => { + ui.add(Label::new( + RichText::new(format!("Disconnected: {reason}")).color(Color32::RED), + )); + } + } +} + +impl ClnDash { + fn show(&mut self, ui: &mut egui::Ui) { + egui::Frame::new() + .inner_margin(egui::Margin::same(50)) + .show(ui, |ui| { + connection_state_ui(ui, &self.connection_state); + + if let Some(info) = self.get_info.as_ref() { + get_info_ui(ui, info); + } + }); + } + + fn setup_connection(&mut self) { + let (req_tx, mut req_rx) = unbounded_channel::(); + let (event_tx, event_rx) = unbounded_channel::(); + self.channel = Some(Channel { req_tx, event_rx }); + + tokio::spawn(async move { + let key = SecretKey::new(&mut rand::thread_rng()); + let their_pubkey = PublicKey::from_str( + "03f3c108ccd536b8526841f0a5c58212bb9e6584a1eb493080e7c1cc34f82dad71", + ) + .unwrap(); + + let lnsocket = + match LNSocket::connect_and_init(key, their_pubkey, "ln.damus.io:9735").await { + Err(err) => { + let _ = event_tx.send(Event::Ended { + reason: err.to_string(), + }); + return; + } + + Ok(lnsocket) => { + let _ = event_tx.send(Event::Connected); + lnsocket + } + }; + + let rune = "Vns1Zxvidr4J8pP2ZCg3Wjp2SyGyyf5RHgvFG8L36yM9MzMmbWV0aG9kPWdldGluZm8="; // getinfo only atm + let commando = CommandoClient::spawn(lnsocket, rune); + + loop { + match req_rx.recv().await { + None => { + let _ = event_tx.send(Event::Ended { + reason: "channel dead?".to_string(), + }); + break; + } + + Some(req) => match req { + Request::GetInfo => match commando.call("getinfo", json!({})).await { + Ok(v) => { + let _ = event_tx.send(Event::Response(ClnResponse::GetInfo(Ok(v)))); + } + Err(err) => { + let _ = event_tx.send(Event::Ended { + reason: err.to_string(), + }); + } + }, + }, + } + } + }); + } + + fn process_events(&mut self) { + let Some(channel) = &mut self.channel else { + return; + }; + + while let Ok(event) = channel.event_rx.try_recv() { + match event { + Event::Ended { reason } => { + self.connection_state = ConnectionState::Dead(reason); + } + + Event::Connected => { + self.connection_state = ConnectionState::Active; + let _ = channel.req_tx.send(Request::GetInfo); + } + + Event::Response(resp) => match resp { + ClnResponse::GetInfo(value) => { + let Ok(value) = value else { + return; + }; + + if let Ok(s) = serde_json::to_string_pretty(&value) { + self.get_info = Some(s); + } + } + }, + } + } + } +} + +fn get_info_ui(ui: &mut egui::Ui, info: &str) { + ui.horizontal_wrapped(|ui| { + ui.add(Label::new(info).wrap_mode(egui::TextWrapMode::Wrap)); + }); +} diff --git a/crates/notedeck_ui/src/app_images.rs b/crates/notedeck_ui/src/app_images.rs index 1306bd2..ba2c396 100644 --- a/crates/notedeck_ui/src/app_images.rs +++ b/crates/notedeck_ui/src/app_images.rs @@ -15,6 +15,10 @@ pub fn accounts_image() -> Image<'static> { Image::new(include_image!("../../../assets/icons/accounts.png")) } +pub fn cln_image() -> Image<'static> { + Image::new(include_image!("../../../assets/icons/clnlogo.svg")) +} + pub fn add_column_dark_image() -> Image<'static> { Image::new(include_image!( "../../../assets/icons/add_column_dark_4x.png"