Files
pixelpass/src/common/config.rs
T
molluskandClaude Opus 4.7 b260d57dc4 feat(gui): system tray with opt-in close-to-tray setting
Add a StatusNotifierItem tray (ksni — pure-Rust over the zbus stack
notify-rust already pulls; only new crate is the pastey macro helper).
The icon reflects host/viewer status via its tooltip and offers
Show / Quit; it runs on its own thread, channel-wired to the egui app.

Add a Settings screen with a persisted toggle 'keep running in the tray
when I close the window' (config.toml [gui] close_to_tray), defaulting
OFF so the close button quits as users expect. When ON, closing hides
to the tray on X11 / minimizes on Wayland (which has no protocol to hide
a toplevel) and keeps any live stream running. If no tray is present the
close behaves normally, so the window can never be stranded.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 05:54:06 -04:00

110 lines
3.8 KiB
Rust

//! 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, Default, 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,
}
/// 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<f64>,
#[serde(default)]
pub measured_at: Option<DateTime<Utc>>,
}
#[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<PathBuf> {
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<Config> {
let path = config_path()?;
match fs::read_to_string(&path) {
Ok(s) => toml::from_str::<Config>(&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(())
}