From dac53fc2ad9a27cd8bfeece3a5d055fc68ec93cd Mon Sep 17 00:00:00 2001 From: Mollusk Date: Wed, 27 May 2026 16:03:07 -0400 Subject: [PATCH] feat: Add persistent configuration and enumerate PipeWire audio devices --- Cargo.lock | 39 +++++++++++++ Cargo.toml | 1 + src/app/mod.rs | 89 ++++++++++++++++++++-------- src/audio/mod.rs | 1 + src/audio/pw_cli.rs | 65 +++++++++++++++++++++ src/config.rs | 41 +++++++++++++ src/lib.rs | 1 + update_app.py | 139 ++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 352 insertions(+), 24 deletions(-) create mode 100644 src/audio/pw_cli.rs create mode 100644 src/config.rs create mode 100644 update_app.py diff --git a/Cargo.lock b/Cargo.lock index 2aa547c..ba03e9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1215,6 +1215,27 @@ dependencies = [ "crypto-common 0.2.2", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -4049,6 +4070,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "opus" version = "0.3.1" @@ -4149,6 +4176,7 @@ dependencies = [ "async-trait", "base64", "bytes", + "dirs", "iced", "iroh", "iroh-gossip", @@ -4581,6 +4609,17 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + [[package]] name = "regex" version = "1.12.3" diff --git a/Cargo.toml b/Cargo.toml index 67e8bb0..3cf5606 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ anyhow = "1.0.102" async-trait = "0.1.89" base64 = "0.22.1" bytes = "1.11.1" +dirs = "6.0.0" iced = "0.14.0" iroh = "1.0.0-rc.0" iroh-gossip = "0.99.0" diff --git a/src/app/mod.rs b/src/app/mod.rs index 8388a92..d2b8f65 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,8 +1,10 @@ use crate::core::{CoreController, messages::{CoreCommand, UiEvent}}; use crate::network::PeerState; +use crate::audio::pw_cli::{AudioDevice, enumerate_audio_devices}; +use crate::config::AppConfig; use iced::widget::{ - container, column, row, text, button, text_input, scrollable, slider, checkbox, Column, Space, + container, column, row, text, button, text_input, scrollable, slider, checkbox, pick_list, Column, Space, }; use iced::{ Color, Background, Border, Element, Subscription, Task, Theme, Event, keyboard, @@ -35,8 +37,8 @@ pub enum AppMessage { TogglePtt(bool), StartSettingHotkey, PeerVolumeChanged(EndpointId, f32), - InputDeviceChanged(String), - OutputDeviceChanged(String), + InputDeviceSelected(AudioDevice), + OutputDeviceSelected(AudioDevice), EventOccurred(Event), NavigateToSettings, NavigateBack, @@ -68,8 +70,11 @@ pub struct AppState { ptt_active: bool, ptt_hotkey: keyboard::Key, is_setting_hotkey: bool, - input_device: String, - output_device: String, + input_devices: Vec, + output_devices: Vec, + selected_input: Option, + selected_output: Option, + config: AppConfig, peers: HashMap, peer_volumes: HashMap, audio_levels: HashMap, @@ -83,6 +88,15 @@ impl Default for AppState { let controller = Arc::new(CoreController::new(ui_tx)); let _ = UI_RX.set(Mutex::new(Some(ui_rx))); + let config = AppConfig::load(); + let all_devices = enumerate_audio_devices(); + let input_devices: Vec<_> = all_devices.iter().filter(|d| d.is_input).cloned().collect(); + let output_devices: Vec<_> = all_devices.iter().filter(|d| !d.is_input).cloned().collect(); + + let selected_input = input_devices.iter().find(|d| d.name == config.input_device).cloned(); + let selected_output = output_devices.iter().find(|d| d.name == config.output_device).cloned(); + + Self { name: "Peer".to_string(), ticket_input: "".to_string(), @@ -95,8 +109,11 @@ impl Default for AppState { ptt_active: false, ptt_hotkey: keyboard::Key::Named(keyboard::key::Named::Space), is_setting_hotkey: false, - input_device: "".to_string(), - output_device: "".to_string(), + input_devices, + output_devices, + selected_input, + selected_output, + config, peers: HashMap::new(), peer_volumes: HashMap::new(), audio_levels: HashMap::new(), @@ -139,21 +156,27 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.ticket_input = val; } AppMessage::JoinPressed => { - state.status_message = "Connecting to room...".to_string(); - let _ = state.controller.send(CoreCommand::Join { - name: state.name.clone(), - ticket: state.ticket_input.clone(), - input_device: if state.input_device.is_empty() { None } else { Some(state.input_device.clone()) }, - output_device: if state.output_device.is_empty() { None } else { Some(state.output_device.clone()) }, - }); + let input_device = state.selected_input.as_ref().map(|d| d.name.clone()); + let output_device = state.selected_output.as_ref().map(|d| d.name.clone()); + if !state.ticket_input.is_empty() { + state.status_message = "Joining room...".to_string(); + let _ = state.controller.send(CoreCommand::Join { + name: state.name.clone(), + ticket: state.ticket_input.clone(), + input_device, + output_device, + }); + } } AppMessage::CreatePressed => { + let input_device = state.selected_input.as_ref().map(|d| d.name.clone()); + let output_device = state.selected_output.as_ref().map(|d| d.name.clone()); state.status_message = "Creating room...".to_string(); let _ = state.controller.send(CoreCommand::Join { name: state.name.clone(), - ticket: "".to_string(), - input_device: if state.input_device.is_empty() { None } else { Some(state.input_device.clone()) }, - output_device: if state.output_device.is_empty() { None } else { Some(state.output_device.clone()) }, + ticket: "create".to_string(), + input_device, + output_device, }); } AppMessage::LeavePressed => { @@ -218,11 +241,15 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.peer_volumes.insert(id, vol); let _ = state.controller.send(CoreCommand::SetPeerVolume(id, vol)); } - AppMessage::InputDeviceChanged(val) => { - state.input_device = val; + AppMessage::InputDeviceSelected(dev) => { + state.config.input_device = dev.name.clone(); + state.config.save(); + state.selected_input = Some(dev); } - AppMessage::OutputDeviceChanged(val) => { - state.output_device = val; + AppMessage::OutputDeviceSelected(dev) => { + state.config.output_device = dev.name.clone(); + state.config.save(); + state.selected_output = Some(dev); } AppMessage::EventOccurred(Event::Keyboard(keyboard::Event::KeyPressed { key, .. })) => { if state.is_setting_hotkey { @@ -337,11 +364,25 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { column![ text("Settings").size(24).color(color_blue), vertical_space(20.0), - text("Device Settings (Optional Node Target IDs)").size(14).color(color_subtext), + text("Device Settings").size(14).color(color_subtext), row![ - text_input("Input Target", &state.input_device).on_input(AppMessage::InputDeviceChanged).style(t_style.clone()).padding(8), + column![ + text("Input Target").size(12).color(color_subtext), + pick_list( + &state.input_devices[..], + state.selected_input.clone(), + AppMessage::InputDeviceSelected, + ).width(iced::Length::Fixed(160.0)) + ].spacing(4), horizontal_space(), - text_input("Output Target", &state.output_device).on_input(AppMessage::OutputDeviceChanged).style(t_style.clone()).padding(8), + column![ + text("Output Target").size(12).color(color_subtext), + pick_list( + &state.output_devices[..], + state.selected_output.clone(), + AppMessage::OutputDeviceSelected, + ).width(iced::Length::Fixed(160.0)) + ].spacing(4), ].spacing(10), vertical_space(30.0), button( diff --git a/src/audio/mod.rs b/src/audio/mod.rs index 4da3cdf..5822226 100644 --- a/src/audio/mod.rs +++ b/src/audio/mod.rs @@ -29,3 +29,4 @@ pub trait AudioBackend: Send + Sync { } pub mod pipewire_impl; +pub mod pw_cli; diff --git a/src/audio/pw_cli.rs b/src/audio/pw_cli.rs new file mode 100644 index 0000000..05778b0 --- /dev/null +++ b/src/audio/pw_cli.rs @@ -0,0 +1,65 @@ +use std::process::Command; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AudioDevice { + pub name: String, + pub description: String, + pub is_input: bool, +} + +impl std::fmt::Display for AudioDevice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.description) + } +} + +pub fn enumerate_audio_devices() -> Vec { + let mut devices = Vec::new(); + let output = Command::new("pw-cli") + .arg("list-objects") + .arg("Node") + .output(); + + if let Ok(out) = output { + let text = String::from_utf8_lossy(&out.stdout); + let mut current_name = String::new(); + let mut current_desc = String::new(); + let mut current_class = String::new(); + + for line in text.lines() { + let line = line.trim(); + if line.starts_with("id ") { + // Save previous if valid + if !current_name.is_empty() && current_class.starts_with("Audio/") { + devices.push(AudioDevice { + name: current_name.clone(), + description: if current_desc.is_empty() { current_name.clone() } else { current_desc.clone() }, + is_input: current_class == "Audio/Source", + }); + } + current_name.clear(); + current_desc.clear(); + current_class.clear(); + } else if let Some(val) = line.strip_prefix("node.name = \"") { + current_name = val.trim_end_matches('"').to_string(); + } else if let Some(val) = line.strip_prefix("node.description = \"") { + current_desc = val.trim_end_matches('"').to_string(); + } else if let Some(val) = line.strip_prefix("media.class = \"") { + current_class = val.trim_end_matches('"').to_string(); + } + } + + // Final one + if !current_name.is_empty() && current_class.starts_with("Audio/") { + devices.push(AudioDevice { + name: current_name.clone(), + description: if current_desc.is_empty() { current_name } else { current_desc }, + is_input: current_class == "Audio/Source", + }); + } + } + + // Sort devices for consistent display + devices.sort_by(|a, b| a.description.cmp(&b.description)); + devices +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..c135051 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,41 @@ +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AppConfig { + pub input_device: String, + pub output_device: String, +} + +impl AppConfig { + fn config_path() -> Option { + dirs::config_dir().map(|mut p| { + p.push("peerspeak"); + p.push("config.json"); + p + }) + } + + pub fn load() -> Self { + if let Some(path) = Self::config_path() { + if let Ok(contents) = fs::read_to_string(&path) { + if let Ok(config) = serde_json::from_str(&contents) { + return config; + } + } + } + Self::default() + } + + pub fn save(&self) { + if let Some(path) = Self::config_path() { + if let Some(dir) = path.parent() { + let _ = fs::create_dir_all(dir); + } + if let Ok(json) = serde_json::to_string_pretty(self) { + let _ = fs::write(path, json); + } + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 3b98cf9..a4a131c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ pub mod codec; pub mod network; pub mod core; pub mod app; +pub mod config; pub fn log_msg(msg: &str) { if let Ok(mut file) = std::fs::OpenOptions::new() diff --git a/update_app.py b/update_app.py new file mode 100644 index 0000000..6ff2b83 --- /dev/null +++ b/update_app.py @@ -0,0 +1,139 @@ +import re + +with open("src/app/mod.rs", "r") as f: + text = f.read() + +# 1. Imports +text = text.replace( + "use crate::core::{CoreController, messages::{CoreCommand, UiEvent}};\nuse crate::network::PeerState;", + "use crate::core::{CoreController, messages::{CoreCommand, UiEvent}};\nuse crate::network::PeerState;\nuse crate::audio::pw_cli::{AudioDevice, enumerate_audio_devices};\nuse crate::config::AppConfig;" +) + +# 2. Iced imports +text = text.replace( + "container, column, row, text, button, text_input, scrollable, slider, checkbox, Column, Space,", + "container, column, row, text, button, text_input, scrollable, slider, checkbox, pick_list, Column, Space," +) + +# 3. AppMessage +text = text.replace( + "InputDeviceChanged(String),\n OutputDeviceChanged(String),", + "InputDeviceSelected(AudioDevice),\n OutputDeviceSelected(AudioDevice)," +) + +# 4. AppState +text = text.replace( + "input_device: String,\n output_device: String,", + "input_devices: Vec,\n output_devices: Vec,\n selected_input: Option,\n selected_output: Option,\n config: AppConfig," +) + +# 5. AppState::default() +default_start = text.find("let _ = UI_RX.set(Mutex::new(Some(ui_rx)));") +if default_start != -1: + default_end = default_start + len("let _ = UI_RX.set(Mutex::new(Some(ui_rx)));") + insertion = """ + let config = AppConfig::load(); + let all_devices = enumerate_audio_devices(); + let input_devices: Vec<_> = all_devices.iter().filter(|d| d.is_input).cloned().collect(); + let output_devices: Vec<_> = all_devices.iter().filter(|d| !d.is_input).cloned().collect(); + + let selected_input = input_devices.iter().find(|d| d.name == config.input_device).cloned(); + let selected_output = output_devices.iter().find(|d| d.name == config.output_device).cloned(); +""" + text = text[:default_end] + "\n" + insertion + text[default_end:] + +text = text.replace( + "input_device: \"\".to_string(),\n output_device: \"\".to_string(),", + "input_devices,\n output_devices,\n selected_input,\n selected_output,\n config," +) + +# 6. JoinPressed / CreatePressed +join_old = """ AppMessage::JoinPressed => { + state.status_message = "Connecting to room...".to_string(); + let _ = state.controller.send(CoreCommand::Join { + name: state.name.clone(), + ticket: state.ticket_input.clone(), + input_device: if state.input_device.is_empty() { None } else { Some(state.input_device.clone()) }, + output_device: if state.output_device.is_empty() { None } else { Some(state.output_device.clone()) }, + }); + }""" +join_new = """ AppMessage::JoinPressed => { + let input_device = state.selected_input.as_ref().map(|d| d.name.clone()); + let output_device = state.selected_output.as_ref().map(|d| d.name.clone()); + if !state.ticket_input.is_empty() { + state.status_message = "Joining room...".to_string(); + let _ = state.controller.send(CoreCommand::Join { + name: state.name.clone(), + ticket: state.ticket_input.clone(), + input_device, + output_device, + }); + } + }""" +text = text.replace(join_old, join_new) + +create_old = """ AppMessage::CreatePressed => { + state.status_message = "Creating room...".to_string(); + let _ = state.controller.send(CoreCommand::Join { + name: state.name.clone(), + ticket: "".to_string(), + input_device: if state.input_device.is_empty() { None } else { Some(state.input_device.clone()) }, + output_device: if state.output_device.is_empty() { None } else { Some(state.output_device.clone()) }, + }); + }""" +create_new = """ AppMessage::CreatePressed => { + let input_device = state.selected_input.as_ref().map(|d| d.name.clone()); + let output_device = state.selected_output.as_ref().map(|d| d.name.clone()); + state.status_message = "Creating room...".to_string(); + let _ = state.controller.send(CoreCommand::Join { + name: state.name.clone(), + ticket: "create".to_string(), + input_device, + output_device, + }); + }""" +text = text.replace(create_old, create_new) + +# 7. DeviceChanged -> DeviceSelected +text = text.replace( + "AppMessage::InputDeviceChanged(val) => {\n state.input_device = val;\n }", + "AppMessage::InputDeviceSelected(dev) => {\n state.config.input_device = dev.name.clone();\n state.config.save();\n state.selected_input = Some(dev);\n }" +) +text = text.replace( + "AppMessage::OutputDeviceChanged(val) => {\n state.output_device = val;\n }", + "AppMessage::OutputDeviceSelected(dev) => {\n state.config.output_device = dev.name.clone();\n state.config.save();\n state.selected_output = Some(dev);\n }" +) + +# 8. View Settings +settings_old = """ text("Device Settings (Optional Node Target IDs)").size(14).color(color_subtext), + row![ + text_input("Input Target", &state.input_device).on_input(AppMessage::InputDeviceChanged).style(t_style.clone()).padding(8), + horizontal_space(), + text_input("Output Target", &state.output_device).on_input(AppMessage::OutputDeviceChanged).style(t_style.clone()).padding(8), + ].spacing(10),""" + +settings_new = """ text("Device Settings").size(14).color(color_subtext), + row![ + column![ + text("Input Target").size(12).color(color_subtext), + pick_list( + &state.input_devices[..], + state.selected_input.clone(), + AppMessage::InputDeviceSelected, + ).width(iced::Length::Fixed(160.0)) + ].spacing(4), + horizontal_space(), + column![ + text("Output Target").size(12).color(color_subtext), + pick_list( + &state.output_devices[..], + state.selected_output.clone(), + AppMessage::OutputDeviceSelected, + ).width(iced::Length::Fixed(160.0)) + ].spacing(4), + ].spacing(10),""" + +text = text.replace(settings_old, settings_new) + +with open("src/app/mod.rs", "w") as f: + f.write(text)