notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit 53b4a8da5c1eb6af89840f83f79b9ec3cb744efd
parent cb72592f4b5840c3b95a5b58d8f2eaba7503c823
Author: William Casarin <jb55@jb55.com>
Date:   Fri,  8 Aug 2025 13:19:39 -0700

notedeck app: add clndash

a core-lightning dashboard i'm working on

feature-gate it behind --clndash

Signed-off-by: William Casarin <jb55@jb55.com>

Diffstat:
MCargo.lock | 37+++++++++++++++++++++++++++++++++++++
MCargo.toml | 4+++-
Aassets/icons/clnlogo.svg | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck/src/args.rs | 2++
Mcrates/notedeck/src/options.rs | 3+++
Mcrates/notedeck_chrome/Cargo.toml | 1+
Mcrates/notedeck_chrome/src/app.rs | 3+++
Mcrates/notedeck_chrome/src/chrome.rs | 82++++++++++++++++++++++++++++++++++++++-----------------------------------------
Acrates/notedeck_clndash/Cargo.toml | 15+++++++++++++++
Acrates/notedeck_clndash/src/lib.rs | 195+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_ui/src/app_images.rs | 4++++
11 files changed, 359 insertions(+), 44 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -2338,6 +2338,12 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" @@ -3065,6 +3071,22 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3540,6 +3562,7 @@ dependencies = [ "egui_tabs", "nostrdb", "notedeck", + "notedeck_clndash", "notedeck_columns", "notedeck_dave", "notedeck_notebook", @@ -3560,6 +3583,20 @@ dependencies = [ ] [[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" dependencies = [ diff --git 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 @@ -0,0 +1,57 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> + +<svg + width="256mm" + height="256mm" + viewBox="0 0 256 256" + version="1.1" + id="svg1" + inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)" + sodipodi:docname="clnlogo.svg" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns="http://www.w3.org/2000/svg" + xmlns:svg="http://www.w3.org/2000/svg"> + <sodipodi:namedview + id="namedview1" + pagecolor="#ffffff" + bordercolor="#666666" + borderopacity="1.0" + inkscape:showpageshadow="2" + inkscape:pageopacity="0.0" + inkscape:pagecheckerboard="0" + inkscape:deskcolor="#d1d1d1" + inkscape:document-units="mm" + inkscape:zoom="1.078823" + inkscape:cx="396.72867" + inkscape:cy="561.25984" + inkscape:window-width="2020" + inkscape:window-height="1420" + inkscape:window-x="270" + inkscape:window-y="20" + inkscape:window-maximized="1" + inkscape:current-layer="layer1" /> + <defs + id="defs1" /> + <g + inkscape:label="Layer 1" + inkscape:groupmode="layer" + id="layer1" + transform="matrix(1.0800571,0,0,1.0347966,-2.6149197,-3.0116377)" + style="display:inline"> + <g + id="g4" + transform="matrix(0.43515072,0,0,0.43515072,68.289343,9.0200629)"> + <path + class="st1" + d="M 214.6,0 2.2,285.8 246.4,222.3 100.1,222.4 Z" + id="path3" + style="fill:#f0d003" /> + <path + fill="#fffae6" + d="M 31.8,550.7 244.1,264.9 0,328.4 146.3,328.3 Z" + id="path4" /> + </g> + </g> +</svg> diff --git 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 @@ -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 @@ -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 @@ -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<Dave>), Columns(Box<Damus>), Notebook(Box<Notebook>), + ClnDash(Box<ClnDash>), Other(Box<dyn notedeck::App>), } @@ -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 @@ -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); - if let Some(_notebook) = self.get_notebook() { - if notebook_button(ui) - .on_hover_cursor(egui::CursorIcon::PointingHand) - .clicked() - { - self.switch_to_notebook(); + for (i, app) in self.apps.iter_mut().enumerate() { + let r = match app { + NotedeckApp::Columns(_columns_app) => columns_button(ui), + + 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 @@ -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 @@ -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<String>, + channel: Option<Channel>, +} + +impl Default for ConnectionState { + fn default() -> Self { + ConnectionState::Dead("uninitialized".to_string()) + } +} + +struct Channel { + req_tx: UnboundedSender<Request>, + event_rx: UnboundedReceiver<Event>, +} + +/// Responses from the socket +enum ClnResponse { + GetInfo(Result<Value, String>), +} + +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<AppAction> { + 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::<Request>(); + let (event_tx, event_rx) = unbounded_channel::<Event>(); + 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 @@ -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"