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:
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"