Refactor to Cargo Workspace (#129)

* Refactor project into a Cargo workspace with distinct packages

- Created a root `Cargo.toml` defining a workspace.
- Moved `src/types` and `src/utils` into a new `pwsp-lib` crate for shared logic.
- Split binaries into their own crates: `pwsp-daemon`, `pwsp-cli`, and `pwsp-gui`.
- Shifted all dependencies into `[workspace.dependencies]` for centralized version management.
- Updated import paths across all crates (e.g. from `pwsp::` to `pwsp_lib::`).
- Updated build scripts, GitHub actions, Flatpak manifest, and AUR PKGBUILD to support the new workspace structure.
- Ensured no core application logic was altered.

Co-authored-by: arabianq <55220741+arabianq@users.noreply.github.com>

* Fix cargo-deb build process in GitHub actions for workspace architecture

Co-authored-by: arabianq <55220741+arabianq@users.noreply.github.com>

* Fix cargo-deb asset discovery by using exact target/release paths

Co-authored-by: arabianq <55220741+arabianq@users.noreply.github.com>

* refactor deps in Cargo.toml files

* fix incorrect assets path

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
This commit is contained in:
Tarasov Aleksandr
2026-06-02 21:12:44 +03:00
committed by GitHub
parent 6c59137639
commit 0476329798
47 changed files with 224 additions and 87 deletions
+209
View File
@@ -0,0 +1,209 @@
use crate::types::{commands::*, socket::Request};
use std::path::PathBuf;
pub fn parse_command(request: &Request) -> Option<Box<dyn Executable + Send>> {
let id = request.args.get("id").and_then(|s| s.parse::<u32>().ok());
match request.name.as_str() {
"ping" => Some(Box::new(PingCommand {})),
"kill" => Some(Box::new(KillCommand {})),
"pause" => Some(Box::new(PauseCommand { id })),
"resume" => Some(Box::new(ResumeCommand { id })),
"toggle_pause" => Some(Box::new(TogglePauseCommand { id })),
"stop" => Some(Box::new(StopCommand { id })),
"is_paused" => Some(Box::new(IsPausedCommand {})),
"get_state" => Some(Box::new(GetStateCommand {})),
"get_volume" => Some(Box::new(GetVolumeCommand { id })),
"set_volume" => {
let volume = request
.args
.get("volume")
.unwrap_or(&String::new())
.parse::<f32>()
.ok();
Some(Box::new(SetVolumeCommand { volume, id }))
}
"get_position" => Some(Box::new(GetPositionCommand { id })),
"seek" => {
let position = request
.args
.get("position")
.unwrap_or(&String::new())
.parse::<f32>()
.ok();
Some(Box::new(SeekCommand { position, id }))
}
"get_duration" => Some(Box::new(GetDurationCommand { id })),
"play" => {
let file_path = request
.args
.get("file_path")
.unwrap_or(&String::new())
.parse::<PathBuf>()
.ok();
let concurrent = request
.args
.get("concurrent")
.unwrap_or(&String::new())
.parse::<bool>()
.ok();
Some(Box::new(PlayCommand {
file_path,
concurrent,
}))
}
"get_tracks" => Some(Box::new(GetTracksCommand {})),
"get_input" => Some(Box::new(GetCurrentInputCommand {})),
"get_inputs" => Some(Box::new(GetAllInputsCommand {})),
"set_input" => {
let name = Some(request.args.get("input_name").unwrap_or(&String::new())).cloned();
Some(Box::new(SetCurrentInputCommand { name }))
}
"set_loop" => {
let enabled = request
.args
.get("enabled")
.unwrap_or(&String::new())
.parse::<bool>()
.ok();
Some(Box::new(SetLoopCommand { enabled, id }))
}
"toggle_loop" => Some(Box::new(ToggleLoopCommand { id })),
"get_daemon_version" => Some(Box::new(GetDaemonVersionCommand {})),
"get_full_state" => Some(Box::new(GetFullStateCommand {})),
"get_hotkeys" => Some(Box::new(GetHotkeysCommand {})),
"set_hotkey" => {
let slot = request.args.get("slot").cloned();
let file_path = request
.args
.get("file_path")
.and_then(|s| s.parse::<PathBuf>().ok());
Some(Box::new(SetHotkeyCommand { slot, file_path }))
}
"set_hotkey_key" => {
let slot = request.args.get("slot").cloned();
let key_chord = request.args.get("key_chord").cloned();
Some(Box::new(SetHotkeyKeyCommand { slot, key_chord }))
}
"clear_hotkey" => {
let slot = request.args.get("slot").cloned();
Some(Box::new(ClearHotkeyCommand { slot }))
}
"play_hotkey" => {
let slot = request.args.get("slot").cloned();
Some(Box::new(PlayHotkeyCommand { slot }))
}
"set_hotkey_action" => {
let slot = request.args.get("slot").cloned();
let action = request
.args
.get("action")
.and_then(|s| serde_json::from_str::<Request>(s).ok());
Some(Box::new(SetHotkeyActionCommand { slot, action }))
}
"clear_hotkey_key" => {
let slot = request.args.get("slot").cloned();
Some(Box::new(ClearHotkeyKeyCommand { slot }))
}
"set_hotkey_action_and_key" => {
let slot = request.args.get("slot").cloned();
let action = request
.args
.get("action")
.and_then(|s| serde_json::from_str::<Request>(s).ok());
let key_chord = request.args.get("key_chord").cloned();
Some(Box::new(SetHotkeyActionAndKeyCommand {
slot,
action,
key_chord,
}))
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::socket::Request;
use std::collections::HashMap;
#[test]
fn test_parse_set_volume_valid() {
let mut args = HashMap::new();
args.insert("volume".to_string(), "0.5".to_string());
args.insert("id".to_string(), "1".to_string());
let request = Request {
name: "set_volume".to_string(),
args,
};
let cmd = parse_command(&request);
assert!(cmd.is_some());
}
#[test]
fn test_parse_set_volume_missing_volume() {
let mut args = HashMap::new();
args.insert("id".to_string(), "1".to_string());
let request = Request {
name: "set_volume".to_string(),
args,
};
let cmd = parse_command(&request);
assert!(cmd.is_some());
}
#[test]
fn test_parse_set_volume_invalid_volume() {
let mut args = HashMap::new();
args.insert("volume".to_string(), "not-a-float".to_string());
let request = Request {
name: "set_volume".to_string(),
args,
};
let cmd = parse_command(&request);
assert!(cmd.is_some());
}
#[test]
fn test_parse_set_volume_missing_id() {
let mut args = HashMap::new();
args.insert("volume".to_string(), "0.5".to_string());
let request = Request {
name: "set_volume".to_string(),
args,
};
let cmd = parse_command(&request);
assert!(cmd.is_some());
}
#[test]
fn test_parse_set_volume_invalid_id() {
let mut args = HashMap::new();
args.insert("id".to_string(), "not-an-int".to_string());
args.insert("volume".to_string(), "0.5".to_string());
let request = Request {
name: "set_volume".to_string(),
args,
};
let cmd = parse_command(&request);
assert!(cmd.is_some());
}
#[test]
fn test_parse_set_volume_empty_args() {
let request = Request {
name: "set_volume".to_string(),
args: HashMap::new(),
};
let cmd = parse_command(&request);
assert!(cmd.is_some());
}
}
+7
View File
@@ -0,0 +1,7 @@
use anyhow::Result;
use std::path::PathBuf;
pub fn get_config_path() -> Result<PathBuf> {
let config_path = dirs::config_dir().expect("Failed to obtain config dir");
Ok(config_path.join("pwsp"))
}
+144
View File
@@ -0,0 +1,144 @@
use crate::types::{
audio_player::AudioPlayer,
config::DaemonConfig,
socket::{MAX_MESSAGE_SIZE, Request, Response},
};
use anyhow::Result;
use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt};
use std::path::PathBuf;
use std::{env, error::Error, fs};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::UnixStream,
sync::{Mutex, OnceCell},
time::{Duration, sleep},
};
static AUDIO_PLAYER: OnceCell<Mutex<AudioPlayer>> = OnceCell::const_new();
pub async fn get_audio_player() -> Result<&'static Mutex<AudioPlayer>, String> {
AUDIO_PLAYER
.get_or_try_init(|| async {
println!("Initializing audio player");
match AudioPlayer::new().await {
Ok(player) => Ok(Mutex::new(player)),
Err(err) => Err(err.to_string()),
}
})
.await
}
pub fn get_daemon_config() -> DaemonConfig {
DaemonConfig::load_from_file().unwrap_or_else(|_| {
let config = DaemonConfig::default();
config.save_to_file().ok();
config
})
}
fn get_current_uid() -> u32 {
rustix::process::geteuid().as_raw()
}
pub fn get_runtime_dir() -> PathBuf {
dirs::runtime_dir().unwrap_or_else(|| {
let uid = get_current_uid();
env::temp_dir().join(format!("pwsp-{}", uid))
})
}
pub fn create_runtime_dir() -> Result<()> {
let runtime_dir = get_runtime_dir();
if runtime_dir.exists() {
let meta = fs::symlink_metadata(&runtime_dir)?;
if meta.is_symlink() {
return Err(anyhow::anyhow!("Runtime directory is a symlink"));
}
let uid = get_current_uid();
if meta.uid() != uid {
return Err(anyhow::anyhow!(
"Runtime directory is owned by another user"
));
}
if meta.permissions().mode() & 0o777 != 0o700 {
return Err(anyhow::anyhow!(
"Runtime directory has incorrect permissions"
));
}
} else {
fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(&runtime_dir)?;
}
Ok(())
}
pub fn is_daemon_running() -> Result<bool> {
let lock_file = fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(get_runtime_dir().join("daemon.lock"))?;
match lock_file.try_lock() {
Ok(_) => Ok(false),
Err(_) => Ok(true),
}
}
pub async fn wait_for_daemon() -> Result<()> {
if is_daemon_running()? {
return Ok(());
}
println!("Daemon not found, waiting for it...");
while !is_daemon_running()? {
sleep(Duration::from_millis(100)).await;
}
println!("Found running daemon");
Ok(())
}
pub async fn make_request(request: Request) -> Result<Response, Box<dyn Error + Send + Sync>> {
let socket_path = get_runtime_dir().join("daemon.sock");
let mut stream = UnixStream::connect(socket_path).await?;
// ---------- Send request (start) ----------
let request_data = serde_json::to_vec(&request)?;
let request_len = request_data.len() as u32;
if stream.write_all(&request_len.to_le_bytes()).await.is_err() {
return Err("Failed to send request length".into());
};
if stream.write_all(&request_data).await.is_err() {
return Err("Failed to send request".into());
}
// ---------- Send request (end) ----------
// ---------- Read response (start) ----------
let mut len_bytes = [0u8; 4];
if stream.read_exact(&mut len_bytes).await.is_err() {
return Err("Failed to read response length".into());
}
let response_len = u32::from_le_bytes(len_bytes) as usize;
if response_len > MAX_MESSAGE_SIZE {
eprintln!(
"Failed to read response from daemon: response too large ({} bytes)!",
response_len
);
return Err("Response too large".into());
}
let mut buffer = vec![0u8; response_len];
if stream.read_exact(&mut buffer).await.is_err() {
return Err("Failed to read response".into());
};
// ---------- Read response (end) ----------
Ok(serde_json::from_slice(&buffer)?)
}
+201
View File
@@ -0,0 +1,201 @@
use crate::{types::config::HotkeyConfig, utils::commands::parse_command};
use evdev::{Device, EventStream, EventSummary, KeyCode};
struct ModifierState {
ctrl: bool,
alt: bool,
shift: bool,
meta: bool,
}
impl ModifierState {
fn new() -> Self {
Self {
ctrl: false,
alt: false,
shift: false,
meta: false,
}
}
fn update(&mut self, key: KeyCode, pressed: bool) {
match key {
KeyCode::KEY_LEFTCTRL | KeyCode::KEY_RIGHTCTRL => self.ctrl = pressed,
KeyCode::KEY_LEFTALT | KeyCode::KEY_RIGHTALT => self.alt = pressed,
KeyCode::KEY_LEFTSHIFT | KeyCode::KEY_RIGHTSHIFT => self.shift = pressed,
KeyCode::KEY_LEFTMETA | KeyCode::KEY_RIGHTMETA => self.meta = pressed,
_ => {}
}
}
fn any_active(&self) -> bool {
self.ctrl || self.alt || self.shift || self.meta
}
fn is_modifier(key: KeyCode) -> bool {
matches!(
key,
KeyCode::KEY_LEFTCTRL
| KeyCode::KEY_RIGHTCTRL
| KeyCode::KEY_LEFTALT
| KeyCode::KEY_RIGHTALT
| KeyCode::KEY_LEFTSHIFT
| KeyCode::KEY_RIGHTSHIFT
| KeyCode::KEY_LEFTMETA
| KeyCode::KEY_RIGHTMETA
)
}
}
fn evdev_key_name(key: KeyCode) -> Option<&'static str> {
match key {
KeyCode::KEY_A => Some("A"),
KeyCode::KEY_B => Some("B"),
KeyCode::KEY_C => Some("C"),
KeyCode::KEY_D => Some("D"),
KeyCode::KEY_E => Some("E"),
KeyCode::KEY_F => Some("F"),
KeyCode::KEY_G => Some("G"),
KeyCode::KEY_H => Some("H"),
KeyCode::KEY_I => Some("I"),
KeyCode::KEY_J => Some("J"),
KeyCode::KEY_K => Some("K"),
KeyCode::KEY_L => Some("L"),
KeyCode::KEY_M => Some("M"),
KeyCode::KEY_N => Some("N"),
KeyCode::KEY_O => Some("O"),
KeyCode::KEY_P => Some("P"),
KeyCode::KEY_Q => Some("Q"),
KeyCode::KEY_R => Some("R"),
KeyCode::KEY_S => Some("S"),
KeyCode::KEY_T => Some("T"),
KeyCode::KEY_U => Some("U"),
KeyCode::KEY_V => Some("V"),
KeyCode::KEY_W => Some("W"),
KeyCode::KEY_X => Some("X"),
KeyCode::KEY_Y => Some("Y"),
KeyCode::KEY_Z => Some("Z"),
KeyCode::KEY_1 => Some("1"),
KeyCode::KEY_2 => Some("2"),
KeyCode::KEY_3 => Some("3"),
KeyCode::KEY_4 => Some("4"),
KeyCode::KEY_5 => Some("5"),
KeyCode::KEY_6 => Some("6"),
KeyCode::KEY_7 => Some("7"),
KeyCode::KEY_8 => Some("8"),
KeyCode::KEY_9 => Some("9"),
KeyCode::KEY_0 => Some("0"),
KeyCode::KEY_F1 => Some("F1"),
KeyCode::KEY_F2 => Some("F2"),
KeyCode::KEY_F3 => Some("F3"),
KeyCode::KEY_F4 => Some("F4"),
KeyCode::KEY_F5 => Some("F5"),
KeyCode::KEY_F6 => Some("F6"),
KeyCode::KEY_F7 => Some("F7"),
KeyCode::KEY_F8 => Some("F8"),
KeyCode::KEY_F9 => Some("F9"),
KeyCode::KEY_F10 => Some("F10"),
KeyCode::KEY_F11 => Some("F11"),
KeyCode::KEY_F12 => Some("F12"),
_ => None,
}
}
fn build_chord(modifiers: &ModifierState, key_name: &str) -> String {
let mut parts = Vec::with_capacity(5);
if modifiers.ctrl {
parts.push("Ctrl");
}
if modifiers.alt {
parts.push("Alt");
}
if modifiers.shift {
parts.push("Shift");
}
if modifiers.meta {
parts.push("Super");
}
parts.push(key_name);
parts.join("+")
}
fn is_keyboard(device: &Device) -> bool {
device
.supported_keys()
.is_some_and(|keys| keys.contains(KeyCode::KEY_A) && keys.contains(KeyCode::KEY_Z))
}
async fn handle_device_events(mut stream: EventStream) {
let mut modifiers = ModifierState::new();
loop {
match stream.next_event().await {
Ok(event) => {
if let EventSummary::Key(_, key, value) = event.destructure() {
// 0 = released, 1 = pressed, 2 = repeat
if value == 0 || value == 1 {
modifiers.update(key, value == 1);
}
// Only trigger on press, skip modifiers and bare keys
if value != 1 || ModifierState::is_modifier(key) || !modifiers.any_active() {
continue;
}
let Some(key_name) = evdev_key_name(key) else {
continue;
};
let chord = build_chord(&modifiers, key_name);
let config = match HotkeyConfig::load() {
Ok(c) => c,
Err(_) => continue,
};
let slots = config.slots_for_chord(&chord);
for slot in slots {
if let Some(cmd) = parse_command(&slot.action) {
cmd.execute().await;
}
}
}
}
Err(e) => {
eprintln!("Global hotkeys: device read error: {e}");
break;
}
}
}
}
pub async fn start_global_hotkey_listener() {
let keyboards: Vec<_> = evdev::enumerate()
.filter(|(_, dev)| is_keyboard(dev))
.collect();
if keyboards.is_empty() {
eprintln!(
"Global hotkeys: no keyboard devices found. \
Make sure your user is in the 'input' group."
);
return;
}
println!(
"Global hotkeys: found {} keyboard device(s)",
keyboards.len()
);
for (path, device) in keyboards {
match device.into_event_stream() {
Ok(stream) => {
println!("Global hotkeys: listening on {}", path.display());
tokio::spawn(handle_device_events(stream));
}
Err(e) => {
eprintln!("Global hotkeys: failed to open {}: {}", path.display(), e);
}
}
}
}
+141
View File
@@ -0,0 +1,141 @@
use crate::{
types::{
audio_player::FullState,
config::{GuiConfig, HotkeyConfig},
gui::AudioPlayerState,
socket::{Request, Response},
},
utils::daemon::{is_daemon_running, make_request},
};
use anyhow::{Result, anyhow};
use std::{
path::PathBuf,
sync::{Arc, Mutex},
time::Instant,
};
use tokio::time::{Duration, sleep};
pub fn get_gui_config() -> GuiConfig {
GuiConfig::load_from_file().unwrap_or_else(|_| {
let mut config = GuiConfig::default();
config.save_to_file().ok();
config
})
}
pub fn make_request_sync(request: Request) -> Result<Response> {
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current()
.block_on(make_request(request))
.map_err(|e| anyhow!(e))
})
}
pub fn make_request_async(request: Request) {
tokio::spawn(async move {
make_request(request).await.ok();
});
}
pub fn ensure_pwsp_audio_dir() -> PathBuf {
let audio_dir = dirs::audio_dir().unwrap_or("~/Music".into());
let pwsp_audio_dir = audio_dir.join("PWSP");
if !pwsp_audio_dir.exists() {
std::fs::create_dir_all(&pwsp_audio_dir).ok();
}
pwsp_audio_dir
}
pub fn format_time_pair(position: f32, duration: f32) -> String {
fn format_time(seconds: f32) -> String {
let total_seconds = seconds.round() as u32;
let minutes = total_seconds / 60;
let secs = total_seconds % 60;
format!("{:02}:{:02}", minutes, secs)
}
format!("{}/{}", format_time(position), format_time(duration))
}
pub fn start_app_state_thread(audio_player_state_shared: Arc<Mutex<AudioPlayerState>>) {
tokio::spawn(async move {
let sleep_duration = Duration::from_secs_f32(1.0 / 60.0);
let mut last_hotkey_poll = Instant::now();
loop {
let is_running = is_daemon_running().unwrap_or(false);
if !is_running {
{
let mut guard = audio_player_state_shared
.lock()
.unwrap_or_else(|e| e.into_inner());
guard.is_daemon_running = false;
}
sleep(Duration::from_millis(500)).await;
continue;
}
let full_state_req = Request::get_full_state();
let full_state_res = make_request(full_state_req).await.unwrap_or_default();
if full_state_res.status {
let full_state: FullState =
serde_json::from_str(&full_state_res.message).unwrap_or_default();
let mut guard = audio_player_state_shared
.lock()
.unwrap_or_else(|e| e.into_inner());
guard.state = match guard.new_state.clone() {
Some(new_state) => {
guard.new_state = None;
new_state
}
None => full_state.state,
};
guard.tracks = full_state.tracks;
guard.volume = full_state.volume;
guard.current_input = full_state
.current_input
.split(" - ")
.next()
.unwrap_or_default()
.to_string();
if guard.all_inputs != full_state.all_inputs {
guard.all_inputs = full_state.all_inputs;
let mut sorted: Vec<(String, String)> = guard
.all_inputs
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
sorted.sort_by(|a, b| a.0.cmp(&b.0));
guard.all_inputs_sorted = sorted;
}
guard.is_daemon_running = true;
}
// Poll hotkey config at a lower frequency (~every 2 seconds)
if last_hotkey_poll.elapsed() >= Duration::from_secs(2) {
let hotkey_res = make_request(Request::get_hotkeys())
.await
.unwrap_or_default();
if hotkey_res.status
&& let Ok(config) = serde_json::from_str::<HotkeyConfig>(&hotkey_res.message)
{
let mut guard = audio_player_state_shared
.lock()
.unwrap_or_else(|e| e.into_inner());
guard.hotkey_config = Some(config);
}
last_hotkey_poll = Instant::now();
}
sleep(sleep_duration).await;
}
});
}
+6
View File
@@ -0,0 +1,6 @@
pub mod commands;
pub mod config;
pub mod daemon;
pub mod global_hotkeys;
pub mod gui;
pub mod pipewire;
+383
View File
@@ -0,0 +1,383 @@
use crate::types::pipewire::{AudioDevice, DeviceType, Port, Terminate};
use anyhow::{Result, anyhow};
use pipewire::{
context::ContextRc, link::Link, main_loop::MainLoopRc, properties::properties,
registry::GlobalObject, spa::utils::dict::DictRef,
};
use std::{collections::HashMap, thread};
use tokio::{
sync::mpsc,
time::{Duration, timeout},
};
pub fn setup_pipewire_context() -> Result<(MainLoopRc, ContextRc), String> {
pipewire::init();
let main_loop = MainLoopRc::new(None).map_err(|e| e.to_string())?;
let context = ContextRc::new(&main_loop, None).map_err(|e| e.to_string())?;
Ok((main_loop, context))
}
fn parse_global_object(
global_object: &GlobalObject<&DictRef>,
) -> (Option<AudioDevice>, Option<Port>) {
let props = match global_object.props {
Some(p) => p,
None => return (None, None),
};
if let Some(media_class) = props.get("media.class") {
let node_id = global_object.id;
let node_nick = props.get("node.nick");
let node_name = props.get("node.name");
let node_description = props.get("node.description");
if media_class.starts_with("Audio/Source") {
let input_device = AudioDevice::new(
node_id,
node_nick,
node_description,
node_name,
DeviceType::Input,
);
return (Some(input_device), None);
} else if media_class.starts_with("Stream/Output/Audio") {
let output_device = AudioDevice::new(
node_id,
node_nick,
node_description,
node_name,
DeviceType::Output,
);
return (Some(output_device), None);
}
return (None, None);
}
if props.get("port.direction").is_some()
&& let (Some(node_id), Some(port_id), Some(port_name)) = (
props.get("node.id").and_then(|id| id.parse::<u32>().ok()),
props.get("port.id").and_then(|id| id.parse::<u32>().ok()),
props.get("port.name"),
)
{
let port = Port {
node_id,
port_id,
name: port_name.to_string(),
};
return (None, Some(port));
}
(None, None)
}
async fn pw_get_global_objects_thread(
main_sender: mpsc::Sender<(Option<AudioDevice>, Option<Port>)>,
pw_receiver: pipewire::channel::Receiver<Terminate>,
init_sender: tokio::sync::oneshot::Sender<Result<(), String>>,
) {
let (main_loop, context) = match setup_pipewire_context() {
Ok(res) => res,
Err(e) => {
let _ = init_sender.send(Err(e));
return;
}
};
// Stop main loop on Terminate message
let _receiver = pw_receiver.attach(main_loop.loop_(), {
let _main_loop = main_loop.clone();
move |_| _main_loop.quit()
});
let core = match context.connect(None) {
Ok(core) => core,
Err(e) => {
let _ = init_sender.send(Err(format!("Failed to connect to pipewire context: {}", e)));
return;
}
};
let registry = match core.get_registry() {
Ok(registry) => registry,
Err(e) => {
let _ = init_sender.send(Err(format!(
"Failed to get registry from pipewire context: {}",
e
)));
return;
}
};
let _listener = registry
.add_listener_local()
.global(move |global| {
// Try to parse every global object pipewire finds
let (device, port) = parse_global_object(global);
// Send message to the main thread
let sender_clone = main_sender.clone();
tokio::task::spawn(async move {
sender_clone.send((device, port)).await.ok();
});
})
.register();
// Signal successful initialization
if init_sender.send(Ok(())).is_err() {
return;
}
main_loop.run();
}
pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>)> {
// Channels to communicate with pipewire thread
let (main_sender, mut main_receiver) = mpsc::channel(10);
let (pw_sender, pw_receiver) = pipewire::channel::channel();
let (init_sender, init_receiver) = tokio::sync::oneshot::channel();
// Spawn pipewire thread in background
let _pw_thread = tokio::spawn(async move {
pw_get_global_objects_thread(main_sender, pw_receiver, init_sender).await
});
// Wait for initialization to complete
if let Err(e) = init_receiver.await {
return Err(anyhow!(e));
}
let mut input_devices: HashMap<u32, AudioDevice> = HashMap::new();
let mut output_devices: HashMap<u32, AudioDevice> = HashMap::new();
let mut ports: Vec<Port> = vec![];
loop {
// If we don't receive a message in 100ms, we can assume that pipewire thread is finished
match timeout(Duration::from_millis(100), main_receiver.recv()).await {
Ok(Some((device, port))) => {
if let Some(device) = device {
match device.device_type {
DeviceType::Input => {
input_devices.insert(device.id, device);
}
DeviceType::Output => {
output_devices.insert(device.id, device);
}
}
} else if let Some(port) = port {
ports.push(port);
}
}
Ok(None) | Err(_) => {
// Pipewire thread is finished and we can collect our devices
let _ = pw_sender.send(Terminate {});
for port in ports {
let node_id = port.node_id;
if let Some(input_device) = input_devices.get_mut(&node_id) {
input_device.add_port(port);
} else if let Some(output_device) = output_devices.get_mut(&node_id) {
output_device.add_port(port);
}
}
let mut input_devices: Vec<AudioDevice> = input_devices.into_values().collect();
let mut output_devices: Vec<AudioDevice> = output_devices.into_values().collect();
input_devices.sort_by_key(|a| a.id);
output_devices.sort_by_key(|a| a.id);
return Ok((input_devices, output_devices));
}
}
}
}
pub async fn get_device(device_name: &str) -> Result<AudioDevice> {
let (input_devices, output_devices) = get_all_devices().await?;
input_devices
.into_iter()
.chain(output_devices)
.find(|device| {
device.name == device_name
|| device.nick == device_name
|| device.name.contains(device_name)
|| device.nick.contains(device_name)
})
.ok_or_else(|| anyhow!("Device not found"))
}
pub fn create_virtual_mic() -> Result<pipewire::channel::Sender<Terminate>> {
let (pw_sender, pw_receiver) = pipewire::channel::channel::<Terminate>();
let (init_sender, init_receiver) = std::sync::mpsc::sync_channel(0);
let _pw_thread = thread::spawn(move || {
let (main_loop, context) = match setup_pipewire_context() {
Ok(res) => res,
Err(e) => {
let _ = init_sender.send(Err(e));
return;
}
};
let core = match context.connect(None) {
Ok(core) => core,
Err(e) => {
let _ =
init_sender.send(Err(format!("Failed to connect to pipewire context: {}", e)));
return;
}
};
let props = properties!(
"factory.name" => "support.null-audio-sink",
"node.name" => "pwsp-virtual-mic",
"node.description" => "PWSP Virtual Mic",
"media.class" => "Audio/Source/Virtual",
"audio.position" => "[ FL FR ]",
"audio.channels" => "2",
"object.linger" => "false", // Destroy the node on app exit
);
let _node = match core.create_object::<pipewire::node::Node>("adapter", &props) {
Ok(node) => node,
Err(e) => {
let _ = init_sender.send(Err(format!("Failed to create virtual mic: {}", e)));
return;
}
};
let _receiver = pw_receiver.attach(main_loop.loop_(), {
let _main_loop = main_loop.clone();
move |_| _main_loop.quit()
});
println!("Virtual mic created");
if init_sender.send(Ok(())).is_err() {
return;
}
main_loop.run();
});
if let Err(e) = init_receiver.recv()? {
return Err(anyhow!(e));
}
Ok(pw_sender)
}
pub async fn link_player_to_virtual_mic() -> Result<pipewire::channel::Sender<Terminate>> {
let pwsp_daemon_output = match get_device("pwsp-daemon").await {
Ok(device) => device,
Err(_) => {
return Err(anyhow!(
"Could not find alsa_playback.pwsp-daemon device, skipping device linking"
));
}
};
let pwsp_daemon_input = match get_device("pwsp-virtual-mic").await {
Ok(device) => device,
Err(_) => {
return Err(anyhow!(
"Could not find pwsp-virtual-mic device, skipping device linking"
));
}
};
let output_fl = match pwsp_daemon_output.output_fl {
Some(port) => port,
None => return Err(anyhow!("Failed to get pwsp-daemon output_fl")),
};
let output_fr = match pwsp_daemon_output.output_fr {
Some(port) => port,
None => return Err(anyhow!("Failed to get pwsp-daemon output_fr")),
};
let input_fl = match pwsp_daemon_input.input_fl {
Some(port) => port,
None => return Err(anyhow!("Failed to get pwsp-virtual-mic input_fl")),
};
let input_fr = match pwsp_daemon_input.input_fr {
Some(port) => port,
None => return Err(anyhow!("Failed to get pwsp-virtual-mic input_fr")),
};
create_link(output_fl, output_fr, input_fl, input_fr)
}
pub fn create_link(
output_fl: Port,
output_fr: Port,
input_fl: Port,
input_fr: Port,
) -> Result<pipewire::channel::Sender<Terminate>> {
let (pw_sender, pw_receiver) = pipewire::channel::channel::<Terminate>();
let (init_sender, init_receiver) = std::sync::mpsc::sync_channel(0);
let _pw_thread = thread::spawn(move || {
let (main_loop, context) = match setup_pipewire_context() {
Ok(res) => res,
Err(e) => {
let _ = init_sender.send(Err(e));
return;
}
};
let core = match context.connect(None) {
Ok(core) => core,
Err(e) => {
let _ =
init_sender.send(Err(format!("Failed to connect to pipewire context: {}", e)));
return;
}
};
let props_fl = properties! {
"link.output.node" => format!("{}", output_fl.node_id).as_str(),
"link.output.port" => format!("{}", output_fl.port_id).as_str(),
"link.input.node" => format!("{}", input_fl.node_id).as_str(),
"link.input.port" => format!("{}", input_fl.port_id).as_str(),
};
let props_fr = properties! {
"link.output.node" => format!("{}", output_fr.node_id).as_str(),
"link.output.port" => format!("{}", output_fr.port_id).as_str(),
"link.input.node" => format!("{}", input_fr.node_id).as_str(),
"link.input.port" => format!("{}", input_fr.port_id).as_str(),
};
let _link_fl = match core.create_object::<Link>("link-factory", &props_fl) {
Ok(link) => link,
Err(e) => {
let _ = init_sender.send(Err(format!("Failed to create link FL: {}", e)));
return;
}
};
let _link_fr = match core.create_object::<Link>("link-factory", &props_fr) {
Ok(link) => link,
Err(e) => {
let _ = init_sender.send(Err(format!("Failed to create link FR: {}", e)));
return;
}
};
let _receiver = pw_receiver.attach(main_loop.loop_(), {
let _main_loop = main_loop.clone();
move |_| _main_loop.quit()
});
println!(
"Link created: FL: {}-{} FR: {}-{}",
output_fl.node_id, input_fl.node_id, output_fr.node_id, input_fr.node_id
);
if init_sender.send(Ok(())).is_err() {
return;
}
main_loop.run();
});
if let Err(e) = init_receiver.recv()? {
return Err(anyhow!(e));
}
Ok(pw_sender)
}