diff --git a/STARTUP_CONFIG.md b/STARTUP_CONFIG.md new file mode 100644 index 0000000..9637d8a --- /dev/null +++ b/STARTUP_CONFIG.md @@ -0,0 +1,53 @@ +# Startup Configuration + +Notedeck can be configured to automatically connect to a relay and load your account on startup using a configuration file. + +## Configuration File Location + +Create a file named `startup_config.json` in: +- **Linux**: `~/.local/share/notedeck/settings/startup_config.json` +- **macOS**: `~/Library/Application Support/notedeck/settings/startup_config.json` +- **Windows**: `%APPDATA%\notedeck\settings\startup_config.json` + +## Configuration Format + +The configuration file is a JSON file with the following format: + +```json +{ + "relay": "wss://relay.damus.io", + "nsec": "nsec1your_private_key_here" +} +``` + +### Fields + +- **`relay`** (optional): The WebSocket URL of the relay you want to connect to + - Example: `"wss://relay.damus.io"` + - If not specified, the application will use default relays + +- **`nsec`** (optional): Your Nostr private key in nsec format + - Example: `"nsec1..."` + - This will be used to automatically create your account on startup + - **Keep this file secure!** Your nsec is your private key and should never be shared + +## Example Configuration + +See `startup_config.json.example` in the root directory for a template. + +## Security Notes + +- **Never share your nsec** with anyone +- **Keep your startup_config.json file secure** with appropriate file permissions +- **Back up your nsec** in a safe location +- On Linux/macOS, you can set secure permissions with: + ```bash + chmod 600 ~/.local/share/notedeck/settings/startup_config.json + ``` + +## Notes + +- Both fields are optional - you can specify just the relay, just the nsec, or both +- If the configuration file doesn't exist, the application will start with default settings +- The startup configuration is loaded once during application startup +- Changes to the file require restarting the application to take effect diff --git a/crates/notedeck_columns/src/app.rs b/crates/notedeck_columns/src/app.rs index db97b6e..441339b 100644 --- a/crates/notedeck_columns/src/app.rs +++ b/crates/notedeck_columns/src/app.rs @@ -747,6 +747,45 @@ impl Damus { ); } + // Load startup config (if exists) + if let Some(startup_config) = crate::storage::load_startup_config(app_context.path) { + info!("StartupConfig: loaded from disk"); + + // Add account from nsec if provided + if let Some(nsec) = &startup_config.nsec { + use enostr::SecretKey; + use std::str::FromStr; + + match SecretKey::from_str(nsec.as_str()) { + Ok(secret_key) => { + let keypair = enostr::Keypair::from_secret(secret_key); + info!("StartupConfig: Adding account from nsec: {}", keypair.pubkey); + if let Some(resp) = app_context.accounts.add_account(keypair) { + let txn = nostrdb::Transaction::new(app_context.ndb).expect("txn"); + resp.unk_id_action.process_action( + app_context.unknown_ids, + app_context.ndb, + &txn, + ); + } + } + Err(e) => { + error!("StartupConfig: Failed to parse nsec: {}", e); + } + } + } + + // Add relay from startup config if provided + if let Some(relay_url) = &startup_config.relay { + let wakeup = || {}; // Wakeup closure for relay events + if let Err(e) = app_context.pool.add_url(relay_url.clone(), wakeup) { + error!("StartupConfig: Failed to add relay {}: {}", relay_url, e); + } else { + info!("StartupConfig: Added relay from config: {}", relay_url); + } + } + } + // Load or create relay config let relay_config = if let Some(relay_config) = crate::storage::load_relay_config( app_context.path, @@ -1176,12 +1215,8 @@ fn timelines_view( // Desktop Side Panel strip.cell(|ui| { let rect = ui.available_rect_before_wrap(); - let side_panel = DesktopSidePanel::new( - ctx.accounts.get_selected_account(), - &app.decks_cache, - ctx.i18n, - ) - .show(ui); + let side_panel = + DesktopSidePanel::new(ctx.accounts.get_selected_account()).show(ui); if let Some(side_panel) = side_panel { if side_panel.response.clicked() || side_panel.response.secondary_clicked() { @@ -1320,12 +1355,8 @@ fn timelines_view( // Desktop Side Panel strip.cell(|ui| { let rect = ui.available_rect_before_wrap(); - let side_panel = DesktopSidePanel::new( - ctx.accounts.get_selected_account(), - &app.decks_cache, - ctx.i18n, - ) - .show(ui); + let side_panel = + DesktopSidePanel::new(ctx.accounts.get_selected_account()).show(ui); if let Some(side_panel) = side_panel { if side_panel.response.clicked() || side_panel.response.secondary_clicked() { diff --git a/crates/notedeck_columns/src/app_style.rs b/crates/notedeck_columns/src/app_style.rs index 7e9626b..5e46615 100644 --- a/crates/notedeck_columns/src/app_style.rs +++ b/crates/notedeck_columns/src/app_style.rs @@ -2,8 +2,6 @@ use egui::{FontFamily, FontId}; use notedeck::fonts::NamedFontFamily; -pub static DECK_ICON_SIZE: f32 = 24.0; - pub fn deck_icon_font_sized(size: f32) -> FontId { egui::FontId::new(size, emoji_font_family()) } diff --git a/crates/notedeck_columns/src/lib.rs b/crates/notedeck_columns/src/lib.rs index 794d1e1..af83e5a 100644 --- a/crates/notedeck_columns/src/lib.rs +++ b/crates/notedeck_columns/src/lib.rs @@ -14,6 +14,7 @@ mod deck_state; mod decks; mod draft; pub mod relay_config; +pub mod startup_config; mod key_parsing; pub mod login_manager; mod media_upload; diff --git a/crates/notedeck_columns/src/startup_config.rs b/crates/notedeck_columns/src/startup_config.rs new file mode 100644 index 0000000..54f8834 --- /dev/null +++ b/crates/notedeck_columns/src/startup_config.rs @@ -0,0 +1,58 @@ +use serde::{Deserialize, Serialize}; + +/// Startup configuration for initial relay and account setup +/// This file should be manually created by the user in ~/.local/share/notedeck/settings/startup_config.json +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StartupConfig { + /// Single relay URL to connect to (e.g., "wss://relay.damus.io") + pub relay: Option, + + /// Private key in nsec format (e.g., "nsec1...") + /// This will be used to create the user's account + pub nsec: Option, +} + +impl StartupConfig { + pub fn new() -> Self { + Self { + relay: None, + nsec: None, + } + } + + pub fn with_relay(mut self, relay: String) -> Self { + self.relay = Some(relay); + self + } + + pub fn with_nsec(mut self, nsec: String) -> Self { + self.nsec = Some(nsec); + self + } + + pub fn is_configured(&self) -> bool { + self.relay.is_some() || self.nsec.is_some() + } +} + +impl Default for StartupConfig { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_startup_config() { + let config = StartupConfig::new() + .with_relay("wss://relay.damus.io".to_string()) + .with_nsec("nsec1test".to_string()); + + assert!(config.is_configured()); + assert_eq!(config.relay, Some("wss://relay.damus.io".to_string())); + assert_eq!(config.nsec, Some("nsec1test".to_string())); + } +} diff --git a/crates/notedeck_columns/src/storage/mod.rs b/crates/notedeck_columns/src/storage/mod.rs index b81cabc..f6acc3a 100644 --- a/crates/notedeck_columns/src/storage/mod.rs +++ b/crates/notedeck_columns/src/storage/mod.rs @@ -1,7 +1,9 @@ mod channels; mod decks; mod relay_config; +mod startup_config; pub use channels::{load_channels_cache, save_channels_cache, CHANNELS_CACHE_FILE}; pub use decks::{load_decks_cache, save_decks_cache, DECKS_CACHE_FILE}; pub use relay_config::{load_relay_config, save_relay_config, RELAY_CONFIG_FILE}; +pub use startup_config::{load_startup_config, save_startup_config, STARTUP_CONFIG_FILE}; diff --git a/crates/notedeck_columns/src/storage/startup_config.rs b/crates/notedeck_columns/src/storage/startup_config.rs new file mode 100644 index 0000000..0e78f00 --- /dev/null +++ b/crates/notedeck_columns/src/storage/startup_config.rs @@ -0,0 +1,60 @@ +use notedeck::{storage, DataPath, DataPathType, Directory}; +use tracing::{debug, error, info}; + +use crate::startup_config::StartupConfig; + +pub static STARTUP_CONFIG_FILE: &str = "startup_config.json"; + +/// Load startup configuration from disk +/// Returns None if file doesn't exist or can't be parsed +pub fn load_startup_config(path: &DataPath) -> Option { + let data_path = path.path(DataPathType::Setting); + + let config_str = match Directory::new(data_path).get_file(STARTUP_CONFIG_FILE.to_owned()) { + Ok(s) => s, + Err(e) => { + debug!( + "No startup config file found at {}: {}. This is normal for first-time setup.", + STARTUP_CONFIG_FILE, e + ); + return None; + } + }; + + match serde_json::from_str::(&config_str) { + Ok(config) => { + info!("Loaded startup configuration from {}", STARTUP_CONFIG_FILE); + Some(config) + } + Err(e) => { + error!("Could not parse startup config: {}", e); + None + } + } +} + +/// Save startup configuration to disk (optional - mainly for reference) +pub fn save_startup_config(path: &DataPath, config: &StartupConfig) { + let serialized_config = match serde_json::to_string_pretty(config) { + Ok(s) => s, + Err(e) => { + error!("Could not serialize startup config: {}", e); + return; + } + }; + + let data_path = path.path(DataPathType::Setting); + + if let Err(e) = storage::write_file( + &data_path, + STARTUP_CONFIG_FILE.to_string(), + &serialized_config, + ) { + error!( + "Could not write startup config to file {}: {}", + STARTUP_CONFIG_FILE, e + ); + } else { + debug!("Successfully wrote startup config to {}", STARTUP_CONFIG_FILE); + } +} diff --git a/crates/notedeck_columns/src/ui/side_panel.rs b/crates/notedeck_columns/src/ui/side_panel.rs index 817a379..db63bc4 100644 --- a/crates/notedeck_columns/src/ui/side_panel.rs +++ b/crates/notedeck_columns/src/ui/side_panel.rs @@ -1,33 +1,22 @@ -use egui::{ - vec2, CursorIcon, InnerResponse, Layout, Margin, RichText, ScrollArea, Separator, Stroke, - Widget, -}; -use tracing::{error, info}; +use egui::{vec2, CursorIcon, InnerResponse, Layout, Margin, Separator, Stroke, Widget}; +use tracing::info; use crate::{ - accounts::AccountsRoute, - app::{get_active_columns_mut, get_decks_mut}, - app_style::DECK_ICON_SIZE, - decks::{DecksAction, DecksCache}, - nav::SwitchingAction, - route::Route, + accounts::AccountsRoute, app::get_active_columns_mut, decks::DecksCache, + nav::SwitchingAction, route::Route, }; -use notedeck::{tr, Accounts, Localization, UserAccount}; +use notedeck::{Accounts, Localization, UserAccount}; use notedeck_ui::{ anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, app_images, colors, View, }; -use super::configure_deck::deck_icon; - pub static SIDE_PANEL_WIDTH: f32 = 68.0; static ICON_WIDTH: f32 = 40.0; pub struct DesktopSidePanel<'a> { selected_account: &'a UserAccount, - decks_cache: &'a DecksCache, - i18n: &'a mut Localization, } impl View for DesktopSidePanel<'_> { @@ -42,9 +31,6 @@ pub enum SidePanelAction { ComposeNote, Search, ExpandSidePanel, - NewDeck, - SwitchDeck(usize), - EditDeck(usize), Wallet, Account, // Use existing Account instead of UserAccount Settings, @@ -62,16 +48,8 @@ impl SidePanelResponse { } impl<'a> DesktopSidePanel<'a> { - pub fn new( - selected_account: &'a UserAccount, - decks_cache: &'a DecksCache, - i18n: &'a mut Localization, - ) -> Self { - Self { - selected_account, - decks_cache, - i18n, - } + pub fn new(selected_account: &'a UserAccount) -> Self { + Self { selected_account } } pub fn show(&mut self, ui: &mut egui::Ui) -> Option { @@ -124,28 +102,6 @@ impl<'a> DesktopSidePanel<'a> { let search_resp = ui.add(search_button()); let column_resp = ui.add(add_column_button()); - ui.add(Separator::default().horizontal().spacing(8.0).shrink(4.0)); - - ui.add_space(8.0); - ui.add(egui::Label::new( - RichText::new(tr!( - self.i18n, - "DECKS", - "Label for decks section in side panel" - )) - .size(11.0) - .color(ui.visuals().noninteractive().fg_stroke.color), - )); - ui.add_space(8.0); - let add_deck_resp = ui.add(add_deck_button(self.i18n)); - - let decks_inner = ScrollArea::vertical() - .max_height(ui.available_height() - (3.0 * (ICON_WIDTH + 12.0))) - .show(ui, |ui| { - show_decks(ui, self.decks_cache, self.selected_account) - }) - .inner; - /* if expand_resp.clicked() { Some(InnerResponse::new( @@ -174,27 +130,6 @@ impl<'a> DesktopSidePanel<'a> { Some(InnerResponse::new(SidePanelAction::Search, search_resp)) } else if column_resp.clicked() { Some(InnerResponse::new(SidePanelAction::Columns, column_resp)) - } else if add_deck_resp.clicked() { - Some(InnerResponse::new(SidePanelAction::NewDeck, add_deck_resp)) - } else if decks_inner.response.secondary_clicked() { - info!("decks inner secondary click"); - if let Some(clicked_index) = decks_inner.inner { - Some(InnerResponse::new( - SidePanelAction::EditDeck(clicked_index), - decks_inner.response, - )) - } else { - None - } - } else if decks_inner.response.clicked() { - if let Some(clicked_index) = decks_inner.inner { - Some(InnerResponse::new( - SidePanelAction::SwitchDeck(clicked_index), - decks_inner.response, - )) - } else { - None - } } else { None } @@ -217,7 +152,7 @@ impl<'a> DesktopSidePanel<'a> { i18n: &mut Localization, ) -> Option { let router = get_active_columns_mut(i18n, accounts, decks_cache).get_selected_router(); - let mut switching_response = None; + let switching_response = None; match action { SidePanelAction::Account => { if router @@ -273,38 +208,6 @@ impl<'a> DesktopSidePanel<'a> { // TODO info!("Clicked expand side panel button"); } - SidePanelAction::NewDeck => { - if router.routes().iter().any(|r| r == &Route::NewDeck) { - router.go_back(); - } else { - router.route_to(Route::NewDeck); - } - } - SidePanelAction::SwitchDeck(index) => { - switching_response = Some(crate::nav::SwitchingAction::Decks(DecksAction::Switch( - index, - ))) - } - SidePanelAction::EditDeck(index) => { - if router.routes().iter().any(|r| r == &Route::EditDeck(index)) { - router.go_back(); - } else { - switching_response = Some(crate::nav::SwitchingAction::Decks( - DecksAction::Switch(index), - )); - if let Some(edit_deck) = get_decks_mut(i18n, accounts, decks_cache) - .decks_mut() - .get_mut(index) - { - edit_deck - .columns_mut() - .get_selected_router() - .route_to(Route::EditDeck(index)); - } else { - error!("Cannot push EditDeck route to index {}", index); - } - } - } SidePanelAction::Wallet => 's: { if router .routes() @@ -399,71 +302,6 @@ pub fn search_button() -> impl Widget { search_button_impl(colors::MID_GRAY, 1.5) } -// TODO: convert to responsive button when expanded side panel impl is finished - -fn add_deck_button<'a>(i18n: &'a mut Localization) -> impl Widget + 'a { - |ui: &mut egui::Ui| -> egui::Response { - let img_size = 40.0; - - let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget - let img = app_images::new_deck_image().max_width(img_size); - - let helper = AnimationHelper::new(ui, "new-deck-icon", vec2(max_size, max_size)); - - let cur_img_size = helper.scale_1d_pos(img_size); - img.paint_at( - ui, - helper - .get_animation_rect() - .shrink((max_size - cur_img_size) / 2.0), - ); - - helper - .take_animation_response() - .on_hover_cursor(CursorIcon::PointingHand) - .on_hover_text(tr!( - i18n, - "Add new deck", - "Tooltip text for adding a new deck button" - )) - } -} - -fn show_decks<'a>( - ui: &mut egui::Ui, - decks_cache: &'a DecksCache, - selected_account: &'a UserAccount, -) -> InnerResponse> { - let show_decks_id = ui.id().with("show-decks"); - let account_id = selected_account.key.pubkey; - let (cur_decks, account_id) = ( - decks_cache.decks(&account_id), - show_decks_id.with(account_id), - ); - let active_index = cur_decks.active_index(); - - let (_, mut resp) = ui.allocate_exact_size(vec2(0.0, 0.0), egui::Sense::click()); - let mut clicked_index = None; - for (index, deck) in cur_decks.decks().iter().enumerate() { - let highlight = index == active_index; - let deck_icon_resp = ui - .add(deck_icon( - account_id.with(index), - Some(deck.icon), - DECK_ICON_SIZE, - 40.0, - highlight, - )) - .on_hover_text_at_pointer(&deck.name) - .on_hover_cursor(CursorIcon::PointingHand); - if deck_icon_resp.clicked() || deck_icon_resp.secondary_clicked() { - clicked_index = Some(index); - } - resp = resp.union(deck_icon_resp); - } - InnerResponse::new(clicked_index, resp) -} - fn user_account_button(user_account: &UserAccount, dark_mode: bool) -> impl Widget + '_ { move |ui: &mut egui::Ui| -> egui::Response { let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; diff --git a/startup_config.json.example b/startup_config.json.example new file mode 100644 index 0000000..8b5c18f --- /dev/null +++ b/startup_config.json.example @@ -0,0 +1,4 @@ +{ + "relay": "wss://relay.damus.io", + "nsec": "nsec1your_private_key_here" +}