use crate::error::{Result, VmicError}; use rusqlite::{params, Connection, OptionalExtension}; use std::path::PathBuf; /// Persisted state for one vmic, stored in `~/.config/vmic/vmic.db`. #[derive(Debug, Clone, Default)] pub struct VmicState { pub name: String, pub sink_name: String, pub mid_name: String, pub mix_name: String, pub source_name: String, // PipeWire node ids, used to check liveness against the live graph. pub sink_node_id: Option, pub mix_node_id: Option, // pw-loopback pids, needed alongside the node ids: a client can't // destroy another client's node, so teardown happens by signal instead // (see pw::loopback::terminate_stage). pub sink_pid: Option, pub mix_pid: Option, pub loopback_id: Option, pub volume_pct: Option, // May contain arbitrary multibyte text; SQLite TEXT has no encoding // pitfalls here, unlike PipeWire's JSON dump (see pw::text). pub mic_source: Option, pub mic_volume_pct: Option, // Opt-in: keep the 2-node topology even when a source is mixed in while // the self-monitor loopback is on, accepting that the mix becomes // audible in the loopback instead of paying for a 4-node split. pub loopback_no_mix: bool, } fn db_path() -> PathBuf { if let Ok(dir) = std::env::var("VMIC_STATE_DIR_OVERRIDE") { return PathBuf::from(dir).join("vmic.db"); } dirs::config_dir() .expect("could not resolve config dir") .join("vmic") .join("vmic.db") } pub fn valid_name(name: &str) -> bool { let mut chars = name.chars(); let Some(first) = chars.next() else { return false }; if !first.is_ascii_alphanumeric() { return false; } name.len() <= 64 && name .chars() .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') } pub fn require_valid_name(name: &str) -> Result<()> { if valid_name(name) { Ok(()) } else { Err(VmicError::InvalidName(name.to_string())) } } /// Canonical form of a vmic name, used as the state-table key and for /// deriving PipeWire node names. Names are lowercased so e.g. "Test" and /// "test" can't collide on the underlying (already-lowercased) node names /// while being treated as distinct rows in the state table. pub fn normalize(name: &str) -> String { name.to_lowercase() } /// Opens (or creates) the database and ensures the schema exists. pub fn open_db() -> Result { let path = db_path(); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } let conn = Connection::open(path)?; conn.execute_batch( "CREATE TABLE IF NOT EXISTS vmics ( name TEXT PRIMARY KEY, sink_name TEXT NOT NULL, mid_name TEXT NOT NULL, mix_name TEXT NOT NULL, source_name TEXT NOT NULL, sink_node_id INTEGER, mix_node_id INTEGER, sink_pid INTEGER, mix_pid INTEGER, loopback_id INTEGER, volume_pct INTEGER, mic_source TEXT, mic_volume_pct INTEGER, loopback_no_mix INTEGER NOT NULL DEFAULT 0 );", )?; migrate_add_loopback_no_mix(&conn)?; Ok(conn) } /// `CREATE TABLE IF NOT EXISTS` above only covers fresh databases; an /// existing `vmic.db` from before `loopback_no_mix` existed needs the column /// added explicitly. Safe to call unconditionally - a no-op once the column /// is present either way. fn migrate_add_loopback_no_mix(conn: &Connection) -> Result<()> { let has_column = conn .prepare("PRAGMA table_info(vmics)")? .query_map([], |row| row.get::<_, String>(1))? .filter_map(std::result::Result::ok) .any(|c| c == "loopback_no_mix"); if !has_column { conn.execute( "ALTER TABLE vmics ADD COLUMN loopback_no_mix INTEGER NOT NULL DEFAULT 0", [], )?; } Ok(()) } impl VmicState { fn from_row(row: &rusqlite::Row) -> rusqlite::Result { Ok(Self { name: row.get("name")?, sink_name: row.get("sink_name")?, mid_name: row.get("mid_name")?, mix_name: row.get("mix_name")?, source_name: row.get("source_name")?, sink_node_id: row.get("sink_node_id")?, mix_node_id: row.get("mix_node_id")?, sink_pid: row.get("sink_pid")?, mix_pid: row.get("mix_pid")?, loopback_id: row.get("loopback_id")?, volume_pct: row.get("volume_pct")?, mic_source: row.get("mic_source")?, mic_volume_pct: row.get("mic_volume_pct")?, loopback_no_mix: row.get("loopback_no_mix")?, }) } pub fn load(conn: &Connection, name: &str) -> Result { require_valid_name(name)?; conn.query_row("SELECT * FROM vmics WHERE name = ?1", params![name], Self::from_row) .optional()? .ok_or_else(|| VmicError::NotFound(name.to_string())) } pub fn exists(conn: &Connection, name: &str) -> Result { Ok(conn .query_row("SELECT 1 FROM vmics WHERE name = ?1", params![name], |_| Ok(())) .optional()? .is_some()) } /// Upsert: replaces the row for `self.name` if it exists. pub fn save(&self, conn: &Connection) -> Result<()> { conn.execute( "INSERT INTO vmics ( name, sink_name, mid_name, mix_name, source_name, sink_node_id, mix_node_id, sink_pid, mix_pid, loopback_id, volume_pct, mic_source, mic_volume_pct, loopback_no_mix ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14) ON CONFLICT(name) DO UPDATE SET sink_name = excluded.sink_name, mid_name = excluded.mid_name, mix_name = excluded.mix_name, source_name = excluded.source_name, sink_node_id = excluded.sink_node_id, mix_node_id = excluded.mix_node_id, sink_pid = excluded.sink_pid, mix_pid = excluded.mix_pid, loopback_id = excluded.loopback_id, volume_pct = excluded.volume_pct, mic_source = excluded.mic_source, mic_volume_pct = excluded.mic_volume_pct, loopback_no_mix = excluded.loopback_no_mix", params![ self.name, self.sink_name, self.mid_name, self.mix_name, self.source_name, self.sink_node_id, self.mix_node_id, self.sink_pid, self.mix_pid, self.loopback_id, self.volume_pct, self.mic_source, self.mic_volume_pct, self.loopback_no_mix, ], )?; Ok(()) } pub fn delete(conn: &Connection, name: &str) -> Result<()> { conn.execute("DELETE FROM vmics WHERE name = ?1", params![name])?; Ok(()) } /// Lists every persisted vmic, sorted by name. pub fn list_all(conn: &Connection) -> Result> { let mut stmt = conn.prepare("SELECT * FROM vmics ORDER BY name")?; let rows = stmt.query_map([], Self::from_row)?; let mut out = Vec::new(); for row in rows { out.push(row?); } Ok(out) } }