use std::path::{Path, PathBuf};
use std::time::Duration;
use serde::{Deserialize, Serialize};
pub(crate) const AUTOSAVE_MINUTES: [u32; 4] = [1, 2, 5, 10];
pub(crate) const UI_SCALES: [f32; 8] = [0.75, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0];
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Preferences {
pub audio: AudioChoice,
pub autoconnect: bool,
pub record_offset_ms: f64,
pub autosave: bool,
pub autosave_minutes: u32,
pub theme: ThemeChoice,
pub ui_scale: f32,
pub clap_folders: Vec<PathBuf>,
pub lv2_folders: Vec<PathBuf>,
pub vst3_folders: Vec<PathBuf>,
pub bridge_plugins: bool,
pub share_plugin_processes: bool,
pub sandbox_plugins: bool,
pub mcp_http: bool,
pub mcp_http_port: u16,
}
impl Default for Preferences {
fn default() -> Self {
Self {
audio: AudioChoice::Pipewire,
autoconnect: true,
record_offset_ms: 0.0,
autosave: true,
autosave_minutes: 2,
theme: ThemeChoice::System,
ui_scale: 1.0,
clap_folders: Vec::new(),
lv2_folders: Vec::new(),
vst3_folders: Vec::new(),
bridge_plugins: true,
share_plugin_processes: true,
sandbox_plugins: true,
mcp_http: false,
mcp_http_port: crate::mcp_http::DEFAULT_MCP_PORT,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AudioChoice {
Pipewire,
Virtual,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ThemeChoice {
System,
Dark,
Light,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum PluginKind {
Clap,
Lv2,
Vst3,
}
impl Preferences {
pub fn default_path() -> Option<PathBuf> {
let config = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.filter(|p| p.is_absolute())
.or_else(|| std::env::var_os("HOME").map(|h| Path::new(&h).join(".config")))?;
Some(config.join("dawkey").join("preferences.json"))
}
pub fn load(path: &Path) -> Self {
let bytes = match std::fs::read(path) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Self::default(),
Err(e) => {
tracing::warn!("reading {}: {e}", path.display());
return Self::default();
}
};
match serde_json::from_slice::<Self>(&bytes) {
Ok(prefs) => prefs.sanitized(),
Err(e) => {
tracing::warn!("{}: {e}; using the defaults", path.display());
Self::default()
}
}
}
pub fn save(&self, path: &Path) -> std::io::Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let mut json = serde_json::to_vec_pretty(self).expect("preferences serialize");
json.push(b'\n');
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, json)?;
std::fs::rename(tmp, path)
}
fn sanitized(mut self) -> Self {
if !(-1000.0..=1000.0).contains(&self.record_offset_ms) {
self.record_offset_ms = 0.0;
}
self.autosave_minutes = self.autosave_minutes.clamp(1, 60);
if !(0.5..=3.0).contains(&self.ui_scale) {
self.ui_scale = 1.0;
}
if self.mcp_http_port == 0 {
self.mcp_http_port = crate::mcp_http::DEFAULT_MCP_PORT;
}
self
}
pub fn autosave_every(&self) -> Option<Duration> {
self.autosave
.then(|| Duration::from_secs(u64::from(self.autosave_minutes) * 60))
}
pub fn plugin_search(&self) -> dawkey_studio::SearchPath {
dawkey_studio::SearchPath::with_extra(&self.clap_folders, &self.lv2_folders, &self.vst3_folders)
}
pub(crate) fn folders_mut(&mut self, kind: PluginKind) -> &mut Vec<PathBuf> {
match kind {
PluginKind::Clap => &mut self.clap_folders,
PluginKind::Lv2 => &mut self.lv2_folders,
PluginKind::Vst3 => &mut self.vst3_folders,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips_and_falls_back_to_the_defaults() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("dawkey/preferences.json");
assert_eq!(Preferences::load(&path), Preferences::default());
let prefs = Preferences {
audio: AudioChoice::Virtual,
ui_scale: 1.25,
lv2_folders: vec![PathBuf::from("/opt/lv2")],
..Preferences::default()
};
prefs.save(&path).unwrap();
assert_eq!(Preferences::load(&path), prefs);
std::fs::write(&path, br#"{ "ui_scale": 40, "autosave": false }"#).unwrap();
let loaded = Preferences::load(&path);
assert_eq!((loaded.ui_scale, loaded.autosave), (1.0, false));
assert_eq!(loaded.autosave_every(), None);
std::fs::write(&path, b"not json").unwrap();
assert_eq!(Preferences::load(&path), Preferences::default());
}
}