diff --git a/Cargo.lock b/Cargo.lock index 5832b0c5..b72c3163 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2437,6 +2437,8 @@ dependencies = [ "serde_json", "shellexpand", "sled", + "strum 0.28.0", + "strum_macros 0.28.0", "temp-dir", "thiserror 2.0.18", "tokio", @@ -4566,7 +4568,7 @@ dependencies = [ "itertools 0.13.0", "lru", "paste", - "strum", + "strum 0.26.3", "unicode-segmentation", "unicode-truncate", "unicode-width 0.2.0", @@ -5543,9 +5545,15 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "strum_macros", + "strum_macros 0.26.4", ] +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" + [[package]] name = "strum_macros" version = "0.26.4" @@ -5559,6 +5567,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "subtle" version = "2.6.1" diff --git a/Cargo.toml b/Cargo.toml index d967d8c7..2f6c74b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,8 @@ serde = "1.0" serde_json = "1.0" shellexpand = "3.1" sled = "0.34.7" +strum = "0.28.0" +strum_macros = "0.28.0" temp-dir = "0.2" thiserror = "2.0" toml = "1.1" diff --git a/docs/iamb.1 b/docs/iamb.1 index d04ec9b0..6f1acb35 100644 --- a/docs/iamb.1 +++ b/docs/iamb.1 @@ -69,6 +69,27 @@ Mark all rooms as read. View the startup Welcome window. .It Sy ":forget" Remove all left rooms from the internal database. +.Id Sy ":reload" +Reload the config. +.It Sy ":se[t] {options}" +Change config options at runtime. +Use +.Dq {option} +and +.Dq no{option} +for booleans and +.Dq {option}={value} +for other settings. + +.Ss Example 1: Disable notifications +.Bd -literal -offset indent +:set notifications.noenabled +.Ed +.El +.Ss Example 2: Remove a username override +.Bd -literal -offset indent +:set users.@username:example.org.name= +.Ed .El .Sh "E2EE COMMANDS" diff --git a/src/base.rs b/src/base.rs index 55ca8584..ce0791ad 100644 --- a/src/base.rs +++ b/src/base.rs @@ -7,6 +7,8 @@ use std::collections::{BTreeSet, HashMap, HashSet}; use std::convert::TryFrom; use std::fmt::{self, Display}; use std::hash::Hash; +use std::ops::Deref; +use std::path::PathBuf; use std::str::FromStr; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -28,6 +30,7 @@ use serde::{ Serialize, Serializer, }; +use strum::{EnumProperty, VariantArray}; use tokio::sync::Mutex as AsyncMutex; use url::Url; @@ -89,7 +92,12 @@ use modalkit::{ prelude::{CommandType, WordStyle}, }; -use crate::config::ImagePreviewProtocolValues; +use crate::config::{ + ImagePreviewProtocolValues, + ReloadError, + TunablesUpdate, + TunablesUpdateDiscriminants, +}; use crate::message::ImageStatus; use crate::notifications::NotificationHandle; use crate::preview::{source_from_event, spawn_insert_preview}; @@ -296,7 +304,7 @@ impl<'de> Deserialize<'de> for SortColumn { } /// [serde] visitor for deserializing [SortColumn] for rooms and spaces. -struct SortRoomVisitor; +pub(crate) struct SortRoomVisitor; impl Visitor<'_> for SortRoomVisitor { type Value = SortColumn; @@ -349,7 +357,7 @@ impl<'de> Deserialize<'de> for SortColumn { } /// [serde] visitor for deserializing [SortColumn] for users. -struct SortUserVisitor; +pub(crate) struct SortUserVisitor; impl Visitor<'_> for SortUserVisitor { type Value = SortColumn; @@ -506,6 +514,16 @@ pub enum KeysAction { Import(String, String), } +/// An action performed on the application settings. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SettingsAction { + /// Change some settings. + Set(Vec), + + /// Reload the (specified) config file. + Reload(Option), +} + /// An action that the main program loop should. /// /// See [the commands module][super::commands] for where these are usually created. @@ -523,6 +541,9 @@ pub enum IambAction { /// Perform an action on the current space. Space(SpaceAction), + /// Perform an action on the application settings. + Settings(SettingsAction), + /// Open a URL. OpenLink(String), @@ -570,6 +591,12 @@ impl From for IambAction { } } +impl From for IambAction { + fn from(act: SettingsAction) -> Self { + IambAction::Settings(act) + } +} + impl From for IambAction { fn from(act: RoomAction) -> Self { IambAction::Room(act) @@ -593,6 +620,7 @@ impl ApplicationAction for IambAction { IambAction::Room(..) => SequenceStatus::Break, IambAction::OpenLink(..) => SequenceStatus::Break, IambAction::Send(..) => SequenceStatus::Break, + IambAction::Settings(..) => SequenceStatus::Break, IambAction::ToggleScrollbackFocus => SequenceStatus::Break, IambAction::Verify(..) => SequenceStatus::Break, IambAction::VerifyRequest(..) => SequenceStatus::Break, @@ -609,6 +637,7 @@ impl ApplicationAction for IambAction { IambAction::OpenLink(..) => SequenceStatus::Atom, IambAction::Room(..) => SequenceStatus::Atom, IambAction::Send(..) => SequenceStatus::Atom, + IambAction::Settings(..) => SequenceStatus::Atom, IambAction::ToggleScrollbackFocus => SequenceStatus::Atom, IambAction::Verify(..) => SequenceStatus::Atom, IambAction::VerifyRequest(..) => SequenceStatus::Atom, @@ -625,6 +654,7 @@ impl ApplicationAction for IambAction { IambAction::Room(..) => SequenceStatus::Ignore, IambAction::OpenLink(..) => SequenceStatus::Ignore, IambAction::Send(..) => SequenceStatus::Ignore, + IambAction::Settings(..) => SequenceStatus::Ignore, IambAction::ToggleScrollbackFocus => SequenceStatus::Ignore, IambAction::Verify(..) => SequenceStatus::Ignore, IambAction::VerifyRequest(..) => SequenceStatus::Ignore, @@ -640,6 +670,7 @@ impl ApplicationAction for IambAction { IambAction::Room(..) => false, IambAction::Keys(..) => false, IambAction::Send(..) => false, + IambAction::Settings(..) => false, IambAction::OpenLink(..) => false, IambAction::ToggleScrollbackFocus => false, IambAction::Verify(..) => false, @@ -813,6 +844,10 @@ pub enum IambError { /// A failure while trying to show an image preview. #[error("Preview error: {0}")] Preview(String), + + /// Config couldn't be reloaded + #[error("Reload error: {0}")] + ConfigReload(#[from] ReloadError), } impl From for UIError { @@ -2129,7 +2164,7 @@ fn complete_cmdarg( match cmd.name.as_str() { "cancel" | "dms" | "edit" | "redact" | "reply" => vec![], "members" | "rooms" | "spaces" | "welcome" => vec![], - "download" | "keys" | "open" | "upload" => complete_path(text, cursor), + "download" | "keys" | "open" | "upload" | "reload" => complete_path(text, cursor), "react" | "unreact" => complete_emoji(text, cursor, store), "invite" => complete_users(text, cursor, store), @@ -2139,6 +2174,27 @@ fn complete_cmdarg( "vertical" | "horizontal" | "aboveleft" | "belowright" | "tab" => { complete_cmd(desc.arg.text.as_str(), text, cursor, store) }, + "set" => { + // TODO: improve once #520 is merged + let word = text + .get_prefix_word_mut(cursor, &MATRIX_ID_WORD) + .unwrap_or_else(EditRope::empty); + let word = Cow::from(&word); + + TunablesUpdateDiscriminants::VARIANTS + .iter() + .flat_map(|variant| { + let name = <_ as Into<&'static str>>::into(variant).to_lowercase(); + if variant.get_bool("is_bool") == Some(true) { + vec![format!("no{name}"), name] + } else { + vec![name] + } + }) + .filter(|option| option.starts_with(word.deref())) + .map(|option| option.to_string()) + .collect() + }, _ => vec![], } } diff --git a/src/commands.rs b/src/commands.rs index d355aca4..b69383a5 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -27,9 +27,11 @@ use crate::base::{ RoomAction, RoomField, SendAction, + SettingsAction, SpaceAction, VerifyAction, }; +use crate::config::TunablesUpdate; type ProgContext = CommandContext; type ProgResult = CommandResult; @@ -717,6 +719,43 @@ fn iamb_logout(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { return Ok(step); } +fn iamb_set(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { + let args = desc.arg.strings()?; + + let mut updates = vec![]; + + for arg in args { + let (option, value) = if let Some((option, value)) = arg.split_once('=') { + (option.to_string(), Some(value)) + } else { + (arg.clone(), None) + }; + + match TunablesUpdate::new(option, value) { + Ok(update) => updates.push(update), + Err(err) => return Result::Err(CommandError::ParseFailed(format!("{err}: {arg}"))), + } + } + + let iact = IambAction::from(SettingsAction::Set(updates)); + let step = CommandStep::Continue(iact.into(), ctx.context.clone()); + + return Ok(step); +} + +fn iamb_reload(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { + let mut args = desc.arg.strings()?; + + if args.len() > 1 { + return Result::Err(CommandError::InvalidArgument); + } + + let iact = IambAction::from(SettingsAction::Reload(args.pop().map(Into::into))); + let step = CommandStep::Continue(iact.into(), ctx.context.clone()); + + return Ok(step); +} + fn add_iamb_commands(cmds: &mut ProgramCommands) { cmds.add_command(ProgramCommand { name: "cancel".into(), @@ -834,6 +873,16 @@ fn add_iamb_commands(cmds: &mut ProgramCommands) { aliases: vec![], f: iamb_logout, }); + cmds.add_command(ProgramCommand { + name: "set".into(), + aliases: vec!["se".into()], + f: iamb_set, + }); + cmds.add_command(ProgramCommand { + name: "reload".into(), + aliases: vec![], + f: iamb_reload, + }); } /// Initialize the default command state. diff --git a/src/config.rs b/src/config.rs index 39853ecb..44e2915d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,6 +9,7 @@ use std::hash::{Hash, Hasher}; use std::io::{BufReader, BufWriter, Write}; use std::path::{Path, PathBuf}; use std::process; +use std::str::FromStr; use clap::Parser; use matrix_sdk::authentication::matrix::MatrixSession; @@ -17,10 +18,16 @@ use ratatui::style::{Color, Modifier as StyleModifier, Style}; use ratatui::text::Span; use ratatui_image::picker::ProtocolType; use serde::{de::Error as SerdeError, de::Visitor, Deserialize, Deserializer, Serialize}; +use strum_macros::{EnumDiscriminants, EnumProperty, EnumString, IntoStaticStr, VariantArray}; +use tracing::Level; +use tracing_subscriber::fmt::format::{DefaultFields, Format}; +use tracing_subscriber::EnvFilter; use url::Url; use modalkit::{env::vim::VimMode, key::TerminalKey, keybindings::InputKey}; +use crate::base::{SortRoomVisitor, SortUserVisitor}; + use super::base::{ IambError, IambId, @@ -71,6 +78,14 @@ const COLORS: [Color; 13] = [ Color::Yellow, ]; +pub fn parse_env_logger( + directives: &str, +) -> Result { + EnvFilter::builder() + .with_default_directive(Level::WARN.into()) + .parse(directives) +} + pub fn user_color(user: &str) -> Color { let mut hasher = DefaultHasher::new(); user.hash(&mut hasher); @@ -148,6 +163,21 @@ pub enum ConfigError { InvalidJSON(#[from] serde_json::Error), } +#[derive(thiserror::Error, Debug)] +pub enum ReloadError { + #[error(transparent)] + Config(#[from] ConfigError), + + #[error("invalid `log_level`: {0}")] + LogLevel(#[from] tracing_subscriber::filter::ParseError), + + #[error("The current profile is not in the new config file")] + ProfileNotFound, + + #[error("The user_id in the new config is different")] + UserIdChanged, +} + #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub struct Keys(pub Vec, pub String); pub struct KeysVisitor; @@ -342,8 +372,9 @@ where } } -#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)] +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, EnumString)] #[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] pub enum UserDisplayStyle { // The Matrix username for the sender (e.g., "@user:example.com"). #[default] @@ -514,6 +545,334 @@ impl SortOverrides { } } +#[derive(Debug, Clone)] +pub struct LogLevelUpdate { + filter: EnvFilter, + directives: String, +} + +impl LogLevelUpdate { + fn parse(directives: String) -> Result, tracing_subscriber::filter::ParseError> { + let filter = parse_env_logger(&directives)?; + Ok(Box::new(Self { filter, directives })) + } +} + +impl PartialEq for LogLevelUpdate { + fn eq(&self, other: &Self) -> bool { + self.directives == other.directives + } +} +impl Eq for LogLevelUpdate {} + +/// Error returned by [`TunablesUpdate::new`]. +#[derive(thiserror::Error, Debug)] +pub enum TunablesUpdateError { + #[error("Unknown option")] + UnknownOption, + + #[error(transparent)] + LogLevel(#[from] tracing_subscriber::filter::ParseError), + + #[error("This option requires an argument")] + NoArguments, + + #[error(transparent)] + ParseEnum(#[from] strum::ParseError), + + #[error(transparent)] + ParseInt(#[from] std::num::ParseIntError), + + #[error("Invalid user id: {0}")] + UserId(#[from] matrix_sdk::IdParseError), + + #[error("{0}")] + Custom(String), +} + +impl serde::de::Error for TunablesUpdateError { + fn custom(msg: T) -> Self + where + T: fmt::Display, + { + Self::Custom(format!("{msg}")) + } +} + +#[derive(Debug, PartialEq, Eq, Clone, EnumDiscriminants)] +#[strum_discriminants(derive(IntoStaticStr, VariantArray))] +pub enum SortUpdate { + Chats(Vec>), + Dms(Vec>), + Rooms(Vec>), + Spaces(Vec>), + Members(Vec>), +} + +impl SortUpdate { + fn new(option: &str, value: &str) -> Result { + if option == "members" { + let order: Result, TunablesUpdateError> = value + .split(',') + .filter(|v| !v.is_empty()) + .map(|v| SortUserVisitor.visit_str(v)) + .collect(); + return Ok(Self::Members(order?)); + } + + let order: Result, TunablesUpdateError> = value + .split(',') + .filter(|v| !v.is_empty()) + .map(|v| SortRoomVisitor.visit_str(v)) + .collect(); + + Ok(match option { + "chats" => Self::Chats(order?), + "dms" => Self::Dms(order?), + "rooms" => Self::Rooms(order?), + "spaces" => Self::Spaces(order?), + _ => return Err(TunablesUpdateError::UnknownOption), + }) + } +} + +#[derive(Debug, PartialEq, Eq, Clone, EnumDiscriminants)] +#[strum_discriminants(derive(IntoStaticStr, VariantArray))] +pub enum NotificationsUpdate { + Enabled(bool), + Via(NotifyVia), + ShowMessage(bool), + SoundHint(Option), +} + +impl NotificationsUpdate { + fn new(option: &str, value: Option<&str>) -> Result { + let res = match option { + "via" => { + if let Some(value) = value { + let via = NotifyViaVisitor.visit_str::(value)?; + Self::Via(via) + } else { + return Err(TunablesUpdateError::NoArguments); + } + }, + "soundhint" => { + if let Some(value) = value { + if value.is_empty() { + Self::SoundHint(None) + } else { + Self::SoundHint(Some(value.to_owned())) + } + } else { + return Err(TunablesUpdateError::NoArguments); + } + }, + + "enabled" => Self::Enabled(true), + "noenabled" => Self::Enabled(false), + "showmessage" => Self::ShowMessage(true), + "noshowmessage" => Self::ShowMessage(false), + + _ => return Err(TunablesUpdateError::UnknownOption), + }; + + Ok(res) + } +} + +#[derive(Debug, PartialEq, Eq, Clone, EnumDiscriminants)] +#[strum_discriminants(derive(IntoStaticStr, VariantArray))] +pub enum UserDisplayUpdate { + Name(Option), + Color(Option), +} + +impl UserDisplayUpdate { + fn new(option: &str, value: &str) -> Result { + let res = match option { + "name" => { + if value.is_empty() { + Self::Name(None) + } else { + Self::Name(Some(value.to_owned())) + } + }, + "color" => { + if value.is_empty() { + Self::Color(None) + } else { + let color = UserColorVisitor.visit_str::(value)?; + Self::Color(Some(color)) + } + }, + + _ => return Err(TunablesUpdateError::UnknownOption), + }; + + Ok(res) + } +} + +/// A update for the [`TunableValues`] after invoking the `:set` command. +#[derive(Debug, PartialEq, Eq, Clone, EnumDiscriminants)] +#[strum_discriminants(derive(IntoStaticStr, EnumProperty, VariantArray))] +pub enum TunablesUpdate { + // multilevel options + Sort(SortUpdate), + Notifications(NotificationsUpdate), + Users(OwnedUserId, UserDisplayUpdate), + + // value options + LogLevel(Box), + UsernameDisplay(UserDisplayStyle), + OpenCommand(Vec), + ExternalEditFileSuffix(String), + UserGutterWidth(usize), + Tabstop(usize), + + // bool options + #[strum_discriminants(strum(props(is_bool = true)))] + MessageShortcodeDisplay(bool), + #[strum_discriminants(strum(props(is_bool = true)))] + NormalAfterSend(bool), + #[strum_discriminants(strum(props(is_bool = true)))] + ReactionDisplay(bool), + #[strum_discriminants(strum(props(is_bool = true)))] + ReactionShortcodeDisplay(bool), + #[strum_discriminants(strum(props(is_bool = true)))] + ReadReceiptSend(bool), + #[strum_discriminants(strum(props(is_bool = true)))] + ReadReceiptDisplay(bool), + #[strum_discriminants(strum(props(is_bool = true)))] + TypingNoticeSend(bool), + #[strum_discriminants(strum(props(is_bool = true)))] + TypingNoticeDisplay(bool), + #[strum_discriminants(strum(props(is_bool = true)))] + MessageUserColor(bool), + // These may need to be adapted + + // TODO: still save state events if display is `false` + // StateEventDisplay(bool), + + // TODO: this might be complicated with the panic hook + // Mouse(Mouse), + + // TODO: This will be possible/easier after #464 lands + // ImagePreview(Option), +} + +impl TunablesUpdate { + pub fn new(mut option: String, value: Option<&str>) -> Result { + option.retain(|c| c != '_'); + + // multilevel options + if let Some(sort_option) = option.strip_prefix("sort.") { + let Some(value) = value else { + return Err(TunablesUpdateError::NoArguments); + }; + + return Ok(Self::Sort(SortUpdate::new(sort_option, value)?)); + } + if let Some(notification_option) = option.strip_prefix("notifications.") { + return Ok(Self::Notifications(NotificationsUpdate::new(notification_option, value)?)); + } + if let Some(users_option) = option.strip_prefix("users.") { + let Some((user_id, user_option)) = users_option.rsplit_once('.') else { + return Err(TunablesUpdateError::UnknownOption); + }; + + let user_id = OwnedUserId::from_str(user_id)?; + + let Some(value) = value else { + return Err(TunablesUpdateError::NoArguments); + }; + + let update = UserDisplayUpdate::new(user_option, value)?; + + return Ok(Self::Users(user_id, update)); + } + + let res = match option.as_str() { + // value options + "loglevel" => { + if let Some(value) = value { + Self::LogLevel(LogLevelUpdate::parse(value.to_owned())?) + } else { + return Err(TunablesUpdateError::NoArguments); + } + }, + "usernamedisplay" => { + if let Some(value) = value { + let display = UserDisplayStyle::from_str(value)?; + Self::UsernameDisplay(display) + } else { + return Err(TunablesUpdateError::NoArguments); + } + }, + "opencommand" => { + if let Some(value) = value { + // TODO: use command parsing + let args = value + .split(' ') + .filter(|arg| !arg.is_empty()) + .map(str::to_string) + .collect(); + Self::OpenCommand(args) + } else { + return Err(TunablesUpdateError::NoArguments); + } + }, + "externaleditfilesuffix" => { + if let Some(value) = value { + Self::ExternalEditFileSuffix(value.to_string()) + } else { + return Err(TunablesUpdateError::NoArguments); + } + }, + "usergutterwidth" => { + if let Some(value) = value { + let width = usize::from_str(value)?; + Self::UserGutterWidth(width) + } else { + return Err(TunablesUpdateError::NoArguments); + } + }, + "tabstop" => { + if let Some(value) = value { + let tabstop = usize::from_str(value)?; + Self::Tabstop(tabstop) + } else { + return Err(TunablesUpdateError::NoArguments); + } + }, + + // bool options + "messageshortcodedisplay" => Self::MessageShortcodeDisplay(true), + "nomessageshortcodedisplay" => Self::MessageShortcodeDisplay(false), + "normalaftersend" => Self::NormalAfterSend(true), + "nonormalaftersend" => Self::NormalAfterSend(false), + "reactiondisplay" => Self::ReactionDisplay(true), + "noreactiondisplay" => Self::ReactionDisplay(false), + "reactionshortcodedisplay" => Self::ReactionShortcodeDisplay(true), + "noreactionshortcodedisplay" => Self::ReactionShortcodeDisplay(false), + "readreceiptsend" => Self::ReadReceiptSend(true), + "noreadreceiptsend" => Self::ReadReceiptSend(false), + "readreceiptdisplay" => Self::ReadReceiptDisplay(true), + "noreadreceiptdisplay" => Self::ReadReceiptDisplay(false), + "typingnoticesend" => Self::TypingNoticeSend(true), + "notypingnoticesend" => Self::TypingNoticeSend(false), + "typingnoticedisplay" => Self::TypingNoticeDisplay(true), + "notypingnoticedisplay" => Self::TypingNoticeDisplay(false), + "messageusercolor" => Self::MessageUserColor(true), + "nomessageusercolor" => Self::MessageUserColor(false), + + _ => return Err(TunablesUpdateError::UnknownOption), + }; + + Ok(res) + } +} + #[derive(Clone)] pub struct TunableValues { pub log_level: String, @@ -821,6 +1180,33 @@ impl IambConfig { } } +#[derive(Clone)] +pub enum SettingsFile { + Toml(PathBuf), + Json(PathBuf), +} + +impl SettingsFile { + fn display(&self) -> std::path::Display<'_> { + match self { + Self::Toml(path) | Self::Json(path) => path.display(), + } + } +} + +type ReloadHandle = tracing_subscriber::reload::Handle< + EnvFilter, + tracing_subscriber::layer::Layered< + tracing_subscriber::fmt::Layer< + tracing_subscriber::Registry, + DefaultFields, + Format, + tracing_appender::non_blocking::NonBlocking, + >, + tracing_subscriber::Registry, + >, +>; + #[derive(Clone)] pub struct ApplicationSettings { pub layout_json: PathBuf, @@ -834,6 +1220,9 @@ pub struct ApplicationSettings { pub dirs: DirectoryValues, pub layout: Layout, pub macros: Macros, + pub log_level_handle: Option, + /// The file the settings were loaded from. + pub load_file: SettingsFile, } impl ApplicationSettings { @@ -841,7 +1230,7 @@ impl ApplicationSettings { env::var("XDG_CONFIG_HOME").ok().map(PathBuf::from) } - pub fn load(cli: Iamb) -> Result> { + pub fn load(cli: Iamb) -> Result { let mut config_dir = cli .config_directory .or_else(Self::get_xdg_config_home) @@ -858,10 +1247,10 @@ impl ApplicationSettings { let config_json = config_dir.join("config.json"); let config_toml = config_dir.join("config.toml"); - let config = if config_toml.is_file() { - IambConfig::load_toml(config_toml.as_path())? + let (config, load_file) = if config_toml.is_file() { + (IambConfig::load_toml(config_toml.as_path())?, SettingsFile::Toml(config_toml)) } else if config_json.is_file() { - IambConfig::load_json(config_json.as_path())? + (IambConfig::load_json(config_json.as_path())?, SettingsFile::Json(config_json)) } else { usage!( "Please create a configuration file at {}\n\n\ @@ -886,7 +1275,7 @@ impl ApplicationSettings { usage!( "No configured profile with the name {:?} in {}", profile, - config_json.display() + load_file.display() ); }) } else if profiles.len() == 1 { @@ -973,11 +1362,147 @@ impl ApplicationSettings { dirs, layout, macros, + log_level_handle: None, + load_file, }; Ok(settings) } + pub fn reload(&mut self, path: Option) -> Result<(), ReloadError> { + let load_file = path.unwrap_or_else(|| self.load_file.clone()); + + let config = match &load_file { + SettingsFile::Toml(path) => IambConfig::load_toml(path.as_path())?, + SettingsFile::Json(path) => IambConfig::load_json(path.as_path())?, + }; + + let IambConfig { mut profiles, dirs, settings: global, .. } = config; + + // TODO: validate profiles? + + let mut profile = + profiles.remove(&self.profile_name).ok_or(ReloadError::ProfileNotFound)?; + + if profile.user_id != self.profile.user_id { + return Err(ReloadError::UserIdChanged); + } + + // TODO: update macros + + let tunables = global.unwrap_or_default(); + let tunables = profile.settings.take().unwrap_or_default().merge(tunables); + let tunables = tunables.values(); + + let dirs = dirs.unwrap_or_default(); + let dirs = profile.dirs.take().unwrap_or_default().merge(dirs); + let dirs = dirs.values(); + + // update values + self.tunables = tunables; + self.profile = profile; + self.load_file = load_file; + self.dirs.downloads = dirs.downloads; + + // apply changes that need more setup + + self.update(TunablesUpdate::LogLevel(LogLevelUpdate::parse( + self.tunables.log_level.to_owned(), + )?)); + + Ok(()) + } + + /// Update [`self.tunables`](`Self::tunables`) with `new`. + /// This will make sure that the updated value is applied. + pub fn update(&mut self, update: TunablesUpdate) { + match update { + TunablesUpdate::LogLevel(update) => { + if let Some(handle) = &mut self.log_level_handle { + handle + .reload(update.filter) + .expect("cannot update appending tracing logger"); + self.tunables.log_level = update.directives; + } + }, + TunablesUpdate::Sort(sort_update) => { + match sort_update { + SortUpdate::Chats(order) => self.tunables.sort.chats = order, + SortUpdate::Dms(order) => self.tunables.sort.dms = order, + SortUpdate::Rooms(order) => self.tunables.sort.rooms = order, + SortUpdate::Spaces(order) => self.tunables.sort.spaces = order, + SortUpdate::Members(order) => self.tunables.sort.members = order, + } + }, + TunablesUpdate::Notifications(notify_update) => { + match notify_update { + NotificationsUpdate::Enabled(value) => { + self.tunables.notifications.enabled = value + }, + NotificationsUpdate::Via(value) => self.tunables.notifications.via = value, + NotificationsUpdate::ShowMessage(value) => { + self.tunables.notifications.show_message = value + }, + NotificationsUpdate::SoundHint(value) => { + self.tunables.notifications.sound_hint = value + }, + } + }, + TunablesUpdate::Users(user_id, user_update) => { + let user = self.tunables.users.entry(user_id).or_default(); + + match user_update { + UserDisplayUpdate::Name(name) => user.name = name, + UserDisplayUpdate::Color(color) => user.color = color, + } + }, + TunablesUpdate::OpenCommand(open_command) => { + if open_command.is_empty() { + self.tunables.open_command = None; + } else { + self.tunables.open_command = Some(open_command); + } + }, + TunablesUpdate::UsernameDisplay(username_display) => { + self.tunables.username_display = username_display + }, + TunablesUpdate::ExternalEditFileSuffix(external_edit_file_suffix) => { + self.tunables.external_edit_file_suffix = external_edit_file_suffix + }, + TunablesUpdate::UserGutterWidth(user_gutter_width) => { + self.tunables.user_gutter_width = user_gutter_width + }, + TunablesUpdate::Tabstop(tabstop) => self.tunables.tabstop = tabstop, + TunablesUpdate::MessageShortcodeDisplay(message_shortcode_display) => { + self.tunables.message_shortcode_display = message_shortcode_display + }, + TunablesUpdate::NormalAfterSend(normal_after_send) => { + self.tunables.normal_after_send = normal_after_send + }, + TunablesUpdate::ReactionDisplay(reaction_display) => { + self.tunables.reaction_display = reaction_display + }, + TunablesUpdate::ReactionShortcodeDisplay(reaction_shortcode_display) => { + self.tunables.reaction_shortcode_display = reaction_shortcode_display + }, + TunablesUpdate::ReadReceiptSend(read_receipt_send) => { + self.tunables.read_receipt_send = read_receipt_send + }, + TunablesUpdate::ReadReceiptDisplay(read_receipt_display) => { + self.tunables.read_receipt_display = read_receipt_display + }, + TunablesUpdate::TypingNoticeSend(typing_notice_send) => { + self.tunables.typing_notice_send = typing_notice_send + }, + TunablesUpdate::TypingNoticeDisplay(typing_notice_display) => { + self.tunables.typing_notice_display = typing_notice_display + }, + TunablesUpdate::MessageUserColor(message_user_color) => { + self.tunables.message_user_color = message_user_color + }, + } + } + pub fn read_session(&self, path: impl AsRef) -> Result { let file = File::open(path)?; let reader = BufReader::new(file); diff --git a/src/main.rs b/src/main.rs index db2b4ab8..804dc306 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,7 +36,6 @@ use rand::distr::Alphanumeric; use rand::RngExt as _; use temp_dir::TempDir; use tokio::sync::Mutex as AsyncMutex; -use tracing::Level; use tracing_subscriber::{EnvFilter, FmtSubscriber}; use modalkit::crossterm::{ @@ -86,6 +85,9 @@ mod worker; #[cfg(test)] mod tests; +use crate::base::SettingsAction; +use crate::config::parse_env_logger; +use crate::config::SettingsFile; use crate::{ base::{ AsyncProgramStore, @@ -630,6 +632,11 @@ impl Application { return Err(IambError::InvalidUserId(user_id).into()); } }, + + IambAction::Settings(act) => { + self.settings_command(act, store)?; + None + }, }; Ok(info) @@ -676,6 +683,30 @@ impl Application { } } + fn settings_command( + &mut self, + action: SettingsAction, + store: &mut ProgramStore, + ) -> IambResult<()> { + match action { + SettingsAction::Set(tunables_updates) => { + for update in tunables_updates { + store.application.settings.update(update); + } + Ok(()) + }, + SettingsAction::Reload(path) => { + let path = match path { + None => None, + Some(path) if path.ends_with(".json") => Some(SettingsFile::Json(path)), + Some(path) => Some(SettingsFile::Toml(path)), + }; + + Ok(store.application.settings.reload(path).map_err(IambError::from)?) + }, + } + } + async fn keys_command( &mut self, action: KeysAction, @@ -1080,7 +1111,9 @@ async fn run(settings: ApplicationSettings) -> IambResult<()> { Ok(()) } -fn setup_logging(settings: &ApplicationSettings) -> tracing_appender::non_blocking::WorkerGuard { +fn setup_logging( + settings: &mut ApplicationSettings, +) -> tracing_appender::non_blocking::WorkerGuard { let log_prefix = format!("iamb-log-{}", settings.profile_name); let log_dir = settings.dirs.logs.as_path(); let max_log_files = settings.tunables.max_log_files; @@ -1091,27 +1124,27 @@ fn setup_logging(settings: &ApplicationSettings) -> tracing_appender::non_blocki .filename_prefix(log_prefix) .max_log_files(max_log_files) .build(log_dir) - .expect("can build appending tracing logger"); + .expect("cannot build appending tracing logger"); let (appender, guard) = tracing_appender::non_blocking(appender); - let filter = if let Ok(dirs) = std::env::var(EnvFilter::DEFAULT_ENV) { - EnvFilter::builder() - .with_default_directive(Level::WARN.into()) - .parse(dirs) + let filter = if let Ok(directives) = std::env::var(EnvFilter::DEFAULT_ENV) { + parse_env_logger(&directives) .map_err(|err| format!("Unable to parse {}: {err}", EnvFilter::DEFAULT_ENV)) .unwrap_or_else(print_exit) } else { - EnvFilter::builder() - .with_default_directive(Level::WARN.into()) - .parse(log_level) + parse_env_logger(log_level) .map_err(|err| format!("Unable to parse `log_level`: {err}")) .unwrap_or_else(print_exit) }; - let subscriber = FmtSubscriber::builder() + let builder = FmtSubscriber::builder() .with_writer(appender) .with_env_filter(filter) - .finish(); + .with_filter_reloading(); + + settings.log_level_handle = Some(builder.reload_handle()); + + let subscriber = builder.finish(); tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed"); @@ -1123,7 +1156,7 @@ fn main() -> IambResult<()> { let iamb = Iamb::parse(); // Load configuration and set up the Matrix SDK. - let settings = ApplicationSettings::load(iamb).unwrap_or_else(print_exit); + let mut settings = ApplicationSettings::load(iamb).unwrap_or_else(print_exit); // Set umask on Unix platforms so that tokens, keys, etc. are only readable by the user. #[cfg(unix)] @@ -1131,7 +1164,7 @@ fn main() -> IambResult<()> { libc::umask(0o077); }; - let guard = setup_logging(&settings); + let guard = setup_logging(&mut settings); let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() diff --git a/src/notifications.rs b/src/notifications.rs index d175e0c1..f7cac874 100644 --- a/src/notifications.rs +++ b/src/notifications.rs @@ -16,10 +16,7 @@ use matrix_sdk::{ }; use unicode_segmentation::UnicodeSegmentation; -use crate::{ - base::{AsyncProgramStore, IambError, IambResult, ProgramStore}, - config::{ApplicationSettings, NotifyVia}, -}; +use crate::base::{AsyncProgramStore, IambError, IambResult, ProgramStore}; const IAMB_XDG_NAME: &str = match option_env!("IAMB_XDG_NAME") { None => "iamb", @@ -41,17 +38,7 @@ impl Drop for NotificationHandle { } } -pub async fn register_notifications( - client: &Client, - settings: &ApplicationSettings, - store: &AsyncProgramStore, -) { - if !settings.tunables.notifications.enabled { - return; - } - let notify_via = settings.tunables.notifications.via; - let show_message = settings.tunables.notifications.show_message; - let sound_hint = settings.tunables.notifications.sound_hint.clone(); +pub async fn register_notifications(client: &Client, store: &AsyncProgramStore) { let server_settings = client.notification_settings().await; let Some(startup_ts) = MilliSecondsSinceUnixEpoch::from_system_time(SystemTime::now()) else { return; @@ -62,7 +49,6 @@ pub async fn register_notifications( .register_notification_handler(move |notification, room: MatrixRoom, client: Client| { let store = store.clone(); let server_settings = server_settings.clone(); - let sound_hint = sound_hint.clone(); async move { let mode = global_or_room_mode(&server_settings, &room).await; if mode == RoomNotificationMode::Mute { @@ -76,7 +62,7 @@ pub async fn register_notifications( let room_id = room.room_id().to_owned(); match notification.event { RawAnySyncOrStrippedTimelineEvent::Sync(e) => { - match parse_full_notification(e, room, show_message).await { + match parse_full_notification(e, room).await { Ok((summary, body, server_ts)) => { if server_ts < startup_ts { return; @@ -86,15 +72,14 @@ pub async fn register_notifications( return; } - send_notification( - ¬ify_via, - &summary, - body.as_deref(), - room_id, - &store, - sound_hint.as_deref(), - ) - .await; + let mut locked = store.lock().await; + + if !locked.application.settings.tunables.notifications.enabled { + return; + } + + send_notification(&summary, body.as_deref(), room_id, &mut locked) + .await; }, Err(err) => { tracing::error!("Failed to extract notification data: {err}") @@ -112,30 +97,27 @@ pub async fn register_notifications( } async fn send_notification( - via: &NotifyVia, summary: &str, body: Option<&str>, room_id: OwnedRoomId, - store: &AsyncProgramStore, - sound_hint: Option<&str>, + store: &mut ProgramStore, ) { #[cfg(feature = "desktop")] - if via.desktop { - send_notification_desktop(summary, body, room_id, store, sound_hint).await; + if store.application.settings.tunables.notifications.via.desktop { + send_notification_desktop(summary, body, room_id, store).await; } #[cfg(not(feature = "desktop"))] { let _ = (summary, body, IAMB_XDG_NAME); } - if via.bell { + if store.application.settings.tunables.notifications.via.bell { send_notification_bell(store).await; } } -async fn send_notification_bell(store: &AsyncProgramStore) { - let mut locked = store.lock().await; - locked.application.ring_bell = true; +async fn send_notification_bell(store: &mut ProgramStore) { + store.application.ring_bell = true; } #[cfg(feature = "desktop")] @@ -144,8 +126,7 @@ async fn send_notification_desktop( summary: &str, body: Option<&str>, room_id: OwnedRoomId, - _store: &AsyncProgramStore, - sound_hint: Option<&str>, + store: &mut ProgramStore, ) { let mut desktop_notification = notify_rust::Notification::new(); desktop_notification @@ -154,15 +135,17 @@ async fn send_notification_desktop( .icon(IAMB_XDG_NAME) .action("default", "default"); - if let Some(sound_hint) = sound_hint { + if let Some(sound_hint) = &store.application.settings.tunables.notifications.sound_hint { desktop_notification.sound_name(sound_hint); } #[cfg(all(unix, not(target_os = "macos")))] desktop_notification.urgency(notify_rust::Urgency::Normal); - if let Some(body) = body { - desktop_notification.body(body); + if store.application.settings.tunables.notifications.show_message { + if let Some(body) = body { + desktop_notification.body(body); + } } #[cfg(all(unix, not(target_os = "macos")))] @@ -174,9 +157,7 @@ async fn send_notification_desktop( Err(err) => tracing::error!("Failed to send notification: {err}"), Ok(handle) => { #[cfg(all(unix, not(target_os = "macos")))] - _store - .lock() - .await + store .application .open_notifications .entry(room_id) @@ -243,7 +224,6 @@ async fn is_visible_room(store: &AsyncProgramStore, room_id: &RoomId) -> bool { pub async fn parse_full_notification( event: Raw, room: MatrixRoom, - show_body: bool, ) -> IambResult<(String, Option, MilliSecondsSinceUnixEpoch)> { let event = event.deserialize().map_err(IambError::from)?; @@ -268,11 +248,7 @@ pub async fn parse_full_notification( sender_name.to_string() }; - let body = if show_body { - event_notification_body(&event, sender_name).map(truncate) - } else { - None - }; + let body = event_notification_body(&event, sender_name).map(truncate); return Ok((summary, body, server_ts)); } diff --git a/src/tests.rs b/src/tests.rs index 9213d247..6efa1673 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -30,6 +30,7 @@ use crate::{ Notifications, NotifyVia, ProfileConfig, + SettingsFile, SortOverrides, TunableValues, UserColor, @@ -228,6 +229,8 @@ pub fn mock_settings() -> ApplicationSettings { dirs: mock_dirs(), layout: Default::default(), macros: HashMap::default(), + log_level_handle: None, + load_file: SettingsFile::Toml("/dev/null".into()), } } diff --git a/src/worker.rs b/src/worker.rs index 01fd4055..56e22265 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -1249,7 +1249,6 @@ impl ClientWorker { self.load_handle = tokio::spawn({ let client = self.client.clone(); - let settings = self.settings.clone(); async move { while !client.is_active() { @@ -1259,7 +1258,7 @@ impl ClientWorker { let load = load_older_forever(&client, &store); let rcpt = send_receipts_forever(&client, &store); let room = refresh_rooms_forever(&client, &store); - let notifications = register_notifications(&client, &settings, &store); + let notifications = register_notifications(&client, &store); let ((), (), (), ()) = tokio::join!(load, rcpt, room, notifications); } })