//! Persistent user-level config at `~/.config/pixelpass/config.toml`. //! //! It tracks the bandwidth pre-flight result and the GUI's preferences. //! Further settings can hang off the same file under their own `[section]`. use anyhow::{Context, Result}; use chrono::{DateTime, Utc}; use directories::ProjectDirs; use serde::{Deserialize, Serialize}; use std::fs; use std::io::Write; use std::path::PathBuf; #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Config { #[serde(default)] pub bandwidth: BandwidthEntry, #[serde(default)] pub gui: GuiSettings, } /// Preferences for the `pixelpass --gui` front-end. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GuiSettings { /// When true, the window's close button hides the app to the system tray /// (keeping any live stream running) instead of quitting. Defaults to /// false — closing quits, which is what people expect. #[serde(default)] pub close_to_tray: bool, /// When true, the host screen renders a QR-code panel for the ticket. /// Defaults to true; the toggle exists for users who prefer the plain /// text-only host screen. #[serde(default = "default_true")] pub show_qr: bool, /// Name of the active GUI colour theme (a built-in, or a user file in /// `~/.config/pixelpass/themes/`). Defaults to the built-in Default Dark. #[serde(default = "default_theme")] pub theme: String, /// The display name shown to friends (in requests and shared codes). /// Seeded from the login name; editable in Settings. #[serde(default = "default_display_name")] pub display_name: String, } impl Default for GuiSettings { fn default() -> Self { Self { close_to_tray: false, show_qr: true, theme: default_theme(), display_name: default_display_name(), } } } fn default_true() -> bool { true } fn default_theme() -> String { "Default Dark".to_string() } /// Seed the friends display name from the login name, falling back to a /// generic label when `$USER` isn't set. fn default_display_name() -> String { std::env::var("USER") .ok() .filter(|s| !s.trim().is_empty()) .unwrap_or_else(|| "PixelPass user".to_string()) } /// Result of the first-run upstream measurement. /// /// `status = "unmeasured"` means we've never asked the user — show the /// first-run dialog. `"measured"` means we have a number. `"skipped"` /// means the user opted out (sticky — don't ask again). `"failed"` /// means the last attempt errored and we should ask the user on next /// interactive launch whether to retry. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct BandwidthEntry { #[serde(default = "default_status")] pub status: BandwidthStatus, #[serde(default)] pub upstream_mbps: Option, #[serde(default)] pub measured_at: Option>, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] #[derive(Default)] pub enum BandwidthStatus { #[default] Unmeasured, Measured, Skipped, Failed, } fn default_status() -> BandwidthStatus { BandwidthStatus::Unmeasured } /// Returns `~/.config/pixelpass/config.toml` (or the XDG equivalent on other /// platforms). The parent directory is created lazily by [`save`]. pub fn config_path() -> Result { let dirs = ProjectDirs::from("", "", "pixelpass") .context("could not locate a config directory for pixelpass")?; Ok(dirs.config_dir().join("config.toml")) } /// Returns the loaded config, or a `Default` instance if the file doesn't /// exist yet. Bubble up parse errors so we don't silently overwrite a /// hand-edited config the user is debugging. pub fn load() -> Result { let path = config_path()?; match fs::read_to_string(&path) { Ok(s) => toml::from_str::(&s) .with_context(|| format!("failed to parse {}", path.display())), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Config::default()), Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())), } } /// Atomic write via tempfile-in-same-dir + rename. pub fn save(cfg: &Config) -> Result<()> { let path = config_path()?; let parent = path .parent() .context("config path has no parent directory")?; fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?; let serialized = toml::to_string_pretty(cfg).context("failed to serialize config to TOML")?; let tmp = parent.join(format!(".config.toml.tmp.{}", std::process::id())); { let mut f = fs::File::create(&tmp) .with_context(|| format!("failed to create {}", tmp.display()))?; f.write_all(serialized.as_bytes()) .with_context(|| format!("failed to write {}", tmp.display()))?; f.sync_all().ok(); } fs::rename(&tmp, &path) .with_context(|| format!("failed to rename {} -> {}", tmp.display(), path.display()))?; Ok(()) }