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:
103
src/commands/wipe.rs
Normal file
103
src/commands/wipe.rs
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user