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

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"