use std::path::PathBuf;
use std::sync::OnceLock;
use anyhow::Result;
use tracing::{info, warn};
use crate::fs_utils::write_private_file;
use crate::ui::icons::{self, Icons};
pub const APP_NAME: &str = "gosuto";
static PROFILE: OnceLock<Option<String>> = OnceLock::new();
pub fn init_profile(profile: Option<String>) {
PROFILE
.set(profile)
.expect("init_profile must only be called once");
}
pub fn active_profile() -> Option<&'static str> {
PROFILE.get_or_init(|| None).as_deref()
}
fn app_dir_name() -> String {
match active_profile() {
Some(name) => format!("{APP_NAME}-{name}"),
None => APP_NAME.to_owned(),
}
}
pub const TICK_RATE_MS: u64 = 250;
pub const RENDER_RATE_MS: u64 = 50;
#[derive(Debug, Default, serde::Deserialize, serde::Serialize)]
pub struct GosutoConfig {
#[serde(default)]
pub network: NetworkConfig,
#[serde(default)]
pub effects: EffectsConfig,
#[serde(default)]
pub audio: AudioConfig,
#[serde(default)]
pub ui: UiConfig,
}
impl GosutoConfig {
pub fn icons(&self) -> &'static Icons {
icons::icons(self.ui.use_nerd_fonts)
}
}
#[derive(Debug, Default, serde::Deserialize, serde::Serialize)]
pub struct UiConfig {
#[serde(default)]
pub use_nerd_fonts: bool,
}
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
pub struct AudioConfig {
pub input_device: Option<String>,
pub output_device: Option<String>,
#[serde(default = "default_volume")]
pub input_volume: f32,
#[serde(default = "default_volume")]
pub output_volume: f32,
#[serde(default)]
pub voice_activity: bool,
#[serde(default = "default_sensitivity")]
pub sensitivity: f32,
#[serde(default)]
pub push_to_talk: bool,
#[serde(default)]
pub push_to_talk_key: Option<String>,
#[serde(default = "default_true")]
pub e2ee: bool,
#[serde(default = "default_vad_hold_ms")]
pub vad_hold_ms: u64,
}
impl Default for AudioConfig {
fn default() -> Self {
Self {
input_device: None,
output_device: None,
input_volume: 1.0,
output_volume: 1.0,
voice_activity: false,
sensitivity: 0.15,
push_to_talk: false,
push_to_talk_key: None,
e2ee: true,
vad_hold_ms: 300,
}
}
}
fn default_volume() -> f32 {
1.0
}
fn default_sensitivity() -> f32 {
0.15
}
fn default_vad_hold_ms() -> u64 {
300
}
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct EffectsConfig {
#[serde(default = "default_true")]
pub rain: bool,
#[serde(default = "default_true")]
pub glitch: bool,
}
fn default_true() -> bool {
true
}
impl Default for EffectsConfig {
fn default() -> Self {
Self {
rain: true,
glitch: true,
}
}
}
#[derive(Debug, Default, serde::Deserialize, serde::Serialize)]
pub struct NetworkConfig {
#[serde(default)]
pub accept_invalid_certs: bool,
}
pub fn config_dir() -> Result<PathBuf> {
let dir = dirs::config_dir()
.ok_or_else(|| anyhow::anyhow!("Could not determine config directory"))?
.join(app_dir_name());
Ok(dir)
}
pub fn load_config() -> GosutoConfig {
let path = match config_dir() {
Ok(dir) => dir.join("config.toml"),
Err(_) => return GosutoConfig::default(),
};
match std::fs::read_to_string(&path) {
Ok(contents) => match toml::from_str(&contents) {
Ok(config) => {
info!("Loaded config from {}", path.display());
config
}
Err(e) => {
warn!("Failed to parse config at {}: {}", path.display(), e);
GosutoConfig::default()
}
},
Err(_) => {
let config = GosutoConfig::default();
if let Some(parent) = path.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
warn!("Could not create config dir {}: {}", parent.display(), e);
return config;
}
match toml::to_string_pretty(&config) {
Ok(contents) => {
if let Err(e) = write_private_file(&path, &contents) {
warn!(
"Could not write default config to {}: {}",
path.display(),
e
);
} else {
info!("Created default config at {}", path.display());
}
}
Err(e) => {
warn!("Could not serialize default config: {}", e);
}
}
config
}
}
}
pub fn save_config(config: &GosutoConfig) {
let path = match config_dir() {
Ok(dir) => dir.join("config.toml"),
Err(_) => return,
};
if let Some(parent) = path.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
warn!("Could not create config dir {}: {}", parent.display(), e);
return;
}
match toml::to_string_pretty(config) {
Ok(contents) => {
if let Err(e) = write_private_file(&path, &contents) {
warn!("Could not write config to {}: {}", path.display(), e);
}
}
Err(e) => warn!("Could not serialize config: {}", e),
}
}
pub fn data_dir() -> Result<PathBuf> {
let dir = dirs::data_local_dir()
.ok_or_else(|| anyhow::anyhow!("Could not determine local data directory"))?
.join(app_dir_name());
std::fs::create_dir_all(&dir)?;
Ok(dir)
}
pub fn session_path() -> Result<PathBuf> {
Ok(data_dir()?.join("session.json"))
}
fn extract_hostname(homeserver: &str) -> String {
url::Url::parse(homeserver)
.ok()
.and_then(|u| u.host_str().map(|h| h.to_string()))
.unwrap_or_else(|| homeserver.replace(['/', ':', '\\'], "_"))
}
pub fn store_path_for_homeserver(homeserver: &str) -> Result<PathBuf> {
let path = data_dir()?.join("store").join(extract_hostname(homeserver));
std::fs::create_dir_all(&path)?;
Ok(path)
}
pub fn store_path_for_homeserver_unchecked(homeserver: &str) -> Result<PathBuf> {
Ok(data_dir()?.join("store").join(extract_hostname(homeserver)))
}
pub fn log_path() -> Result<PathBuf> {
let path = data_dir()?.join("logs");
std::fs::create_dir_all(&path)?;
Ok(path)
}
pub fn cleanup_old_logs(path: &std::path::Path, max_days: u64) {
let cutoff =
std::time::SystemTime::now() - std::time::Duration::from_secs(max_days * 24 * 60 * 60);
let entries = match std::fs::read_dir(path) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let Ok(meta) = entry.metadata() else {
continue;
};
if meta.is_file()
&& let Ok(modified) = meta.modified()
&& modified < cutoff
&& let Err(e) = std::fs::remove_file(entry.path())
{
warn!(
"Failed to remove old log file {}: {}",
entry.path().display(),
e
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn store_path_extracts_hostname_from_url() {
let path = store_path_for_homeserver("https://matrix.org").unwrap();
assert_eq!(path.file_name().unwrap(), "matrix.org");
}
#[test]
fn store_path_strips_port_from_url() {
let path = store_path_for_homeserver("https://matrix.org:8448").unwrap();
assert_eq!(path.file_name().unwrap(), "matrix.org");
}
#[test]
fn store_path_sanitizes_non_url_input() {
let path = store_path_for_homeserver("not://valid").unwrap();
let name = path.file_name().unwrap().to_str().unwrap();
assert!(!name.contains('/'));
assert!(!name.contains('\\'));
}
#[test]
fn store_path_unchecked_matches_checked() {
let checked = store_path_for_homeserver("https://example.com").unwrap();
let unchecked = store_path_for_homeserver_unchecked("https://example.com").unwrap();
assert_eq!(checked, unchecked);
}
#[test]
fn default_config_values() {
let config = GosutoConfig::default();
assert!(!config.network.accept_invalid_certs);
assert!(config.effects.rain);
assert!(config.effects.glitch);
assert!(!config.ui.use_nerd_fonts);
}
#[test]
fn audio_default_values() {
let audio = AudioConfig::default();
assert_eq!(audio.input_volume, 1.0);
assert_eq!(audio.output_volume, 1.0);
assert!(!audio.voice_activity);
assert_eq!(audio.sensitivity, 0.15);
assert!(!audio.push_to_talk);
assert!(audio.push_to_talk_key.is_none());
assert!(audio.input_device.is_none());
assert!(audio.output_device.is_none());
assert!(audio.e2ee);
assert_eq!(audio.vad_hold_ms, 300);
}
#[test]
fn config_roundtrip_toml() {
let config = GosutoConfig::default();
let serialized = toml::to_string_pretty(&config).unwrap();
let deserialized: GosutoConfig = toml::from_str(&serialized).unwrap();
assert_eq!(deserialized.effects.rain, config.effects.rain);
assert_eq!(deserialized.effects.glitch, config.effects.glitch);
assert_eq!(
deserialized.network.accept_invalid_certs,
config.network.accept_invalid_certs
);
assert_eq!(deserialized.audio.input_volume, config.audio.input_volume);
assert_eq!(deserialized.audio.output_volume, config.audio.output_volume);
assert_eq!(deserialized.ui.use_nerd_fonts, config.ui.use_nerd_fonts);
}
#[test]
fn ui_config_nerd_fonts_roundtrip() {
let toml_str = r#"
[ui]
use_nerd_fonts = true
"#;
let config: GosutoConfig = toml::from_str(toml_str).unwrap();
assert!(config.ui.use_nerd_fonts);
assert_eq!(config.icons().room, crate::ui::icons::NERD.room);
}
#[test]
fn icons_default_returns_unicode() {
let config = GosutoConfig::default();
assert_eq!(config.icons().room, crate::ui::icons::UNICODE.room);
}
#[test]
fn effects_default_enabled() {
let effects = EffectsConfig::default();
assert!(effects.rain);
assert!(effects.glitch);
}
}