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>
This commit is contained in:
William Casarin
2025-08-08 13:19:39 -07:00
parent cb72592f4b
commit 53b4a8da5c
11 changed files with 358 additions and 43 deletions

37
Cargo.lock generated
View File

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

View File

@@ -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" }

57
assets/icons/clnlogo.svg Normal file
View File

@@ -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>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -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());
}

View File

@@ -26,6 +26,9 @@ bitflags! {
// ===== Feature Flags ======
/// Is notebook enabled?
const FeatureNotebook = 1 << 32;
/// Is clndash enabled?
const FeatureClnDash = 1 << 33;
}
}

View File

@@ -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 }

View File

@@ -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),
}
}

View File

@@ -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",

View File

@@ -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 }

View File

@@ -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));
});
}

View File

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