Introduce initial implementation of the vmic CLI with PipeWire integration
- Added `Cargo.toml` to define dependencies and project metadata. - Implemented core CLI functionality for managing virtual microphones (`create`, `edit`, `delete`, `list`, `route`, and `wipe` commands) using `clap`. - Integrated `rusqlite` for persistent state storage and `pipewire` to manage PipeWire nodes. - Ensured graceful handling of feedback loops and system defaults during virtual mic creation and deletion. - Added error handling via `thiserror` for cleaner error definitions.
This commit is contained in:
187
src/state.rs
Normal file
187
src/state.rs
Normal file
@@ -0,0 +1,187 @@
|
||||
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<u32>,
|
||||
pub mix_node_id: Option<u32>,
|
||||
|
||||
// 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<i64>,
|
||||
pub mix_pid: Option<i64>,
|
||||
|
||||
pub loopback_id: Option<u32>,
|
||||
pub volume_pct: Option<u8>,
|
||||
|
||||
// May contain arbitrary multibyte text; SQLite TEXT has no encoding
|
||||
// pitfalls here, unlike PipeWire's JSON dump (see pw::text).
|
||||
pub mic_source: Option<String>,
|
||||
pub mic_volume_pct: Option<u8>,
|
||||
}
|
||||
|
||||
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<Connection> {
|
||||
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
|
||||
);",
|
||||
)?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
impl VmicState {
|
||||
fn from_row(row: &rusqlite::Row) -> rusqlite::Result<Self> {
|
||||
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")?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load(conn: &Connection, name: &str) -> Result<Self> {
|
||||
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<bool> {
|
||||
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
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
|
||||
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",
|
||||
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,
|
||||
],
|
||||
)?;
|
||||
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<Vec<Self>> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user