9e839ca452
Build the friends feature on top of the phase-2 control plane: you can now befriend someone you've connected with and manage a contacts list. - common/friends.rs: a persisted FriendStore in its own friends.toml (kept out of config.toml so a headless --reconfigure can't clobber it, same as identity.key). Friends are keyed by stable control EndpointId; state is PendingOutgoing / PendingIncoming / Accepted. The handshake transitions (on_friend_request → mutual-match detection, on_friend_ accept) are pure and unit-tested. - gui/code.rs: the bootstrap. The GUI host wraps its share code as `pixelpassF1:<control-id>.<ticket>` so a viewer learns the host's stable id; unwrap is lenient, so a bare/CLI ticket still works (no friend offer). The video/streaming path is untouched. - presence service gains an outbound path (unbounded channel → per-msg send tasks) and exposes our control id for wrapping codes. - gui wiring: on connect, the viewer announces itself to the host with a Hello (carrying our display name); the host replies once, so both ends learn each other and an "Add friend" offer appears on the running host/view screens. Incoming requests/accepts/declines fold into the store with desktop notifications. New Friends screen (accept/decline/ remove, edit your display name, see your id) reachable from the menu, which shows a pending-request count. New [gui] display_name setting, seeded from $USER. Verified: friends store + handshake transitions covered by unit tests (7); code wrap/unwrap round-trips (4); the control loopback still passes; the live GUI starts clean with the presence endpoint online. fmt + clippy clean on both features; 41 gui + 8 headless tests pass. The full two-party UX (connect → mutual add → persisted) wants a cross-machine manual check, as usual. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
148 lines
5.0 KiB
Rust
148 lines
5.0 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, 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<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(())
|
|
}
|