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:
2026-08-11 13:28:33 +02:00
parent 76c129a5dc
commit e19f398397
18 changed files with 2151 additions and 1 deletions

103
src/commands/wipe.rs Normal file
View File

@@ -0,0 +1,103 @@
use crate::error::Result;
use crate::pw::{loopback, text, PwGraph};
use crate::state;
use crate::ui;
use std::process::Command;
pub fn run() -> Result<()> {
let graph = PwGraph::connect()?;
let mods_removed = wipe_loopback_modules(&graph)?;
let links_removed = graph.unlink_all_into_prefix("vmic_")?;
let procs_killed = wipe_pw_loopback_processes();
let conn = state::open_db()?;
let rows_removed = conn.execute("DELETE FROM vmics", [])? as u32;
restore_defaults();
if mods_removed + links_removed + procs_killed + rows_removed == 0 {
ui::info("Nothing to wipe: no virtual mics or vmic loopbacks found.");
} else {
ui::ok("Wiped all virtual mics:");
println!(" loopback modules unloaded: {mods_removed}");
println!(" links removed: {links_removed}");
println!(" pw-loopback processes killed: {procs_killed}");
println!(" state rows removed: {rows_removed}");
}
Ok(())
}
/// Unloads every loopback module referencing a vmic_* node, tracked or not.
fn wipe_loopback_modules(_graph: &PwGraph) -> Result<u32> {
let ids = text::list_vmic_loopback_module_ids()?;
let mut removed = 0;
for id in ids {
if loopback::unload_pulse_module(id).is_ok() {
removed += 1;
}
}
Ok(removed)
}
/// Kills every pw-loopback process whose cmdline mentions a vmic_* node,
/// tracked or not. SIGTERM only - no SIGKILL escalation, unlike
/// `terminate_stage`.
fn wipe_pw_loopback_processes() -> u32 {
let mut killed = 0;
let Ok(entries) = std::fs::read_dir("/proc") else {
return 0;
};
for entry in entries.flatten() {
let Ok(pid) = entry.file_name().to_string_lossy().parse::<libc::pid_t>() else {
continue;
};
let comm = std::fs::read_to_string(format!("/proc/{pid}/comm")).unwrap_or_default();
if comm.trim() != "pw-loopback" {
continue;
}
let Ok(cmdline) = std::fs::read(format!("/proc/{pid}/cmdline")) else {
continue;
};
if String::from_utf8_lossy(&cmdline).contains("vmic_")
&& unsafe { libc::kill(pid, libc::SIGTERM) } == 0
{
killed += 1;
}
}
killed
}
/// If the default sink/source pointed at a vmic node that just got wiped,
/// point it back at the first non-vmic device instead of leaving it
/// dangling.
fn restore_defaults() {
if default_output("get-default-sink").is_some_and(|s| s.starts_with("vmic_")) {
if let Some(new_sink) = first_non_vmic("sinks", false) {
let _ = Command::new("pactl").args(["set-default-sink", &new_sink]).status();
ui::info(&format!("Default sink restored to '{new_sink}'."));
}
}
if default_output("get-default-source").is_some_and(|s| s.starts_with("vmic_")) {
if let Some(new_source) = first_non_vmic("sources", true) {
let _ = Command::new("pactl").args(["set-default-source", &new_source]).status();
ui::info(&format!("Default source restored to '{new_source}'."));
}
}
}
fn default_output(subcommand: &str) -> Option<String> {
let out = Command::new("pactl").arg(subcommand).output().ok()?;
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
(!s.is_empty()).then_some(s)
}
fn first_non_vmic(kind: &str, exclude_monitors: bool) -> Option<String> {
let out = Command::new("pactl").args(["list", "short", kind]).output().ok()?;
String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|l| l.split_whitespace().nth(1))
.find(|name| !name.starts_with("vmic_") && !(exclude_monitors && name.ends_with(".monitor")))
.map(str::to_string)
}