use std::env; use std::fs; use std::io; use std::path::{Path, PathBuf}; use pipeview_client::config::SessionConfig; use serde::{Deserialize, Serialize}; use tracing::warn; const GUI_STATE_FILE_NAME: &str = "gui-state.json"; #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct SessionLogConfig { #[serde(default)] pub enabled: bool, #[serde(default)] pub file_path: String, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct PersistedGuiState { #[serde(default)] pub sessions: Vec, #[serde(default)] pub sessions_log: Vec, #[serde(default)] pub active: usize, #[serde(default = "default_true")] pub show_timestamp: bool, #[serde(default = "default_true")] pub show_direction: bool, #[serde(default = "default_true")] pub show_pipeline: bool, } pub fn load_gui_state() -> PersistedGuiState { match load_gui_state_from_path(&gui_state_path()) { Ok(state) => state, Err(err) if err.kind() == io::ErrorKind::NotFound => PersistedGuiState::default(), Err(err) => { warn!(error = %err, "Failed to load persisted GUI state"); PersistedGuiState::default() } } } pub fn save_gui_state(state: &PersistedGuiState) { if let Err(err) = save_gui_state_to_path(state, &gui_state_path()) { warn!(error = %err, "Failed to persist GUI state"); } } fn gui_state_path() -> PathBuf { gui_state_path_for_os_and_env(env::consts::OS, |key| env::var(key).ok()) } /// Returns the pipeview configuration directory (the parent of gui-state.json). pub fn config_dir() -> PathBuf { gui_state_path() .parent() .map(Path::to_path_buf) .unwrap_or_else(|| PathBuf::from(".")) } fn gui_state_path_for_os_and_env(os: &str, get_env: impl Fn(&str) -> Option) -> PathBuf { match os { "windows" => { if let Some(path) = get_env("APPDATA").filter(|path| !path.trim().is_empty()) { return PathBuf::from(path) .join("pipeview") .join(GUI_STATE_FILE_NAME); } if let Some(path) = get_env("LOCALAPPDATA").filter(|path| !path.trim().is_empty()) { return PathBuf::from(path) .join("pipeview") .join(GUI_STATE_FILE_NAME); } } "macos" => { if let Some(home) = get_env("HOME").filter(|path| !path.trim().is_empty()) { return PathBuf::from(home) .join("Library") .join("Application Support") .join("pipeview") .join(GUI_STATE_FILE_NAME); } } _ => { if let Some(path) = get_env("XDG_CONFIG_HOME").filter(|path| !path.trim().is_empty()) { return PathBuf::from(path) .join("pipeview") .join(GUI_STATE_FILE_NAME); } if let Some(home) = get_env("HOME").filter(|path| !path.trim().is_empty()) { return PathBuf::from(home) .join(".config") .join("pipeview") .join(GUI_STATE_FILE_NAME); } } } PathBuf::from(GUI_STATE_FILE_NAME) } fn load_gui_state_from_path(path: &Path) -> io::Result { let text = fs::read_to_string(path)?; serde_json::from_str(&text).map_err(io::Error::other) } fn save_gui_state_to_path(state: &PersistedGuiState, path: &Path) -> io::Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let json = serde_json::to_string_pretty(state).map_err(io::Error::other)?; fs::write(path, json) } const fn default_true() -> bool { true } #[cfg(test)] mod tests { use super::*; use pipeview_core::transport::TransportConfig; use std::time::{SystemTime, UNIX_EPOCH}; #[test] fn windows_gui_state_path_uses_appdata() { let path = gui_state_path_for_os_and_env("windows", |key| match key { "APPDATA" => Some(String::from(r"C:\Users\Test\AppData\Roaming")), _ => None, }); assert_eq!( path, PathBuf::from(r"C:\Users\Test\AppData\Roaming") .join("pipeview") .join(GUI_STATE_FILE_NAME) ); } #[test] fn gui_state_roundtrip() { let unique = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); let dir = env::temp_dir().join(format!("pipeview-gui-state-{unique}")); let path = dir.join(GUI_STATE_FILE_NAME); let state = PersistedGuiState { sessions: vec![SessionConfig { transport: TransportConfig::Tcp { addr: String::from("127.0.0.1:9000"), }, ..SessionConfig::default() }], sessions_log: vec![SessionLogConfig::default()], active: 0, show_timestamp: false, show_direction: true, show_pipeline: false, }; save_gui_state_to_path(&state, &path).unwrap(); let loaded = load_gui_state_from_path(&path).unwrap(); assert_eq!(loaded.sessions.len(), 1); assert!(!loaded.show_timestamp); assert!(loaded.show_direction); assert!(!loaded.show_pipeline); let _ = fs::remove_file(&path); let _ = fs::remove_dir_all(&dir); } }