diff --git a/Cargo.lock b/Cargo.lock index 0660fe5..979941f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4871,7 +4871,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" [[package]] name = "peerspeak" -version = "0.6.0" +version = "0.6.1" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 2d2706f..af208df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "peerspeak" -version = "0.6.0" +version = "0.6.1" edition = "2024" description = "Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)" license = "MIT" diff --git a/src/app/mod.rs b/src/app/mod.rs index bdf2a56..0f8ad76 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,29 +1,31 @@ -use crate::core::{CoreController, messages::{CoreCommand, UiEvent}}; -use crate::network::PeerState; -use crate::notify::{self, Sound}; -use crate::audio::eq::{EqSettings, EQ_GAIN_DB_MAX, EQ_GAIN_DB_MIN}; use crate::audio::clip_player::{ ClipPlayer, SharedClipStatus, format_time as format_clip_time, progress as clip_progress, seek_target, status_snapshot, }; +use crate::audio::eq::{EQ_GAIN_DB_MAX, EQ_GAIN_DB_MIN, EqSettings}; use crate::audio::{AudioDevice, enumerate_audio_devices}; use crate::config::{AppConfig, NetworkMode, RecordingMode, RoomLayout}; -use crate::hotkeys::{format_binding, HotkeyAction, HotkeyContext, KeyBinding}; +use crate::core::{ + CoreController, + messages::{CoreCommand, UiEvent}, +}; +use crate::hotkeys::{HotkeyAction, HotkeyContext, KeyBinding, format_binding}; +use crate::network::PeerState; +use crate::notify::{self, Sound}; use crate::presence::PresenceMode; use crate::theme::{AppTheme, Palette}; use crate::widget::context_input::{context_input, locked_value}; use crate::widget::selectable_text::selectable_rich_text; -use iced::widget::{ - container, column, row, text, button, scrollable, slider, checkbox, pick_list, - radio, tooltip, progress_bar, canvas, Canvas, Column, stack, mouse_area, - span, responsive, -}; -use iced::widget::text_input; use iced::widget::canvas::{Action, Frame, Geometry, Path, Program}; +use iced::widget::text_input; +use iced::widget::{ + Canvas, Column, button, canvas, checkbox, column, container, mouse_area, pick_list, + progress_bar, radio, responsive, row, scrollable, slider, span, stack, text, tooltip, +}; use iced::{ - Color, Background, Border, Element, Subscription, Task, Theme, Event, keyboard, mouse, - Point, Rectangle, Renderer, Size, + Background, Border, Color, Element, Event, Point, Rectangle, Renderer, Size, Subscription, + Task, Theme, keyboard, mouse, }; use iroh::EndpointId; use std::collections::{HashMap, HashSet, VecDeque}; @@ -181,13 +183,22 @@ struct AttachmentCache { impl AttachmentCache { fn new(cap: usize) -> Self { - Self { entries: HashMap::new(), order: VecDeque::new(), cap: cap.max(1) } + Self { + entries: HashMap::new(), + order: VecDeque::new(), + cap: cap.max(1), + } } /// Insert or replace an entry. A brand-new key evicts the oldest entries /// until there is room; replacing an existing key keeps its position (and so /// its age), only updating the value. - fn insert(&mut self, key: AttachmentKey, state: AttachmentState, handle: Option) { + fn insert( + &mut self, + key: AttachmentKey, + state: AttachmentState, + handle: Option, + ) { if !self.entries.contains_key(&key) { while self.entries.len() >= self.cap { match self.order.pop_front() { @@ -211,7 +222,13 @@ impl AttachmentCache { } fn is_ready(&self, key: &AttachmentKey) -> bool { - matches!(self.entries.get(key), Some(AttachmentEntry { state: AttachmentState::Ready(_), .. })) + matches!( + self.entries.get(key), + Some(AttachmentEntry { + state: AttachmentState::Ready(_), + .. + }) + ) } fn clear(&mut self) { @@ -560,17 +577,20 @@ impl std::fmt::Display for GameOverrideChoice { } fn core_subscription() -> impl iced::futures::Stream { - iced::stream::channel(100, |mut output: iced::futures::channel::mpsc::Sender| async move { - if let Some(rx_lock) = UI_RX.get() { - let mut guard = rx_lock.lock().await; - if let Some(mut rx) = guard.take() { - use iced::futures::sink::SinkExt; - while let Some(event) = rx.recv().await { - let _ = output.send(event).await; + iced::stream::channel( + 100, + |mut output: iced::futures::channel::mpsc::Sender| async move { + if let Some(rx_lock) = UI_RX.get() { + let mut guard = rx_lock.lock().await; + if let Some(mut rx) = guard.take() { + use iced::futures::sink::SinkExt; + while let Some(event) = rx.recv().await { + let _ = output.send(event).await; + } } } - } - }) + }, + ) } fn now_ms() -> u64 { @@ -599,7 +619,10 @@ struct MusicTrack { /// Which music tab is shown. Phase 1: only `Personal` is functional; `Public` /// is a stub placeholder for the Phase 2 tune-in view. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MusicTab { Personal, Public } +pub enum MusicTab { + Personal, + Public, +} pub struct AppState { name: String, @@ -869,8 +892,7 @@ impl Default for AppState { // persisted divider positions against THAT size, not a hardcoded default, // so they stay valid for the window we're actually about to show. let (ww, wh) = (config.window_width, config.window_height); - config.participants_width = - clamp_participants_width(config.participants_width, ww); + config.participants_width = clamp_participants_width(config.participants_width, ww); config.chat_height = clamp_chat_height(config.chat_height, wh); config.controls_width = clamp_controls_width(config.controls_width, ww); config.chat_drawer_width = clamp_chat_drawer_width(config.chat_drawer_width, ww); @@ -880,7 +902,9 @@ impl Default for AppState { for sound in Sound::ALL { notify::set_sound_enabled(sound, config.sound_enabled(sound)); } - let _ = controller.send(CoreCommand::SetNoiseGateThreshold(config.noise_gate_threshold)); + let _ = controller.send(CoreCommand::SetNoiseGateThreshold( + config.noise_gate_threshold, + )); let _ = controller.send(CoreCommand::SetInputVolume(config.input_volume)); let _ = controller.send(CoreCommand::SetOutputVolume(config.output_volume)); let _ = controller.send(CoreCommand::SetNetworkMode(config.network_mode)); @@ -889,8 +913,12 @@ impl Default for AppState { let _ = controller.send(CoreCommand::SetPresenceMode(config.presence_mode)); // Game detection (W17/W18): seed the opt-in broadcast flag + the user's // process→name mappings from config. The manual override starts at Auto. - let _ = controller.send(CoreCommand::SetGamePresenceEnabled(config.game_presence_enabled)); - let _ = controller.send(CoreCommand::SetGameProcessMap(config.game_process_map.clone())); + let _ = controller.send(CoreCommand::SetGamePresenceEnabled( + config.game_presence_enabled, + )); + let _ = controller.send(CoreCommand::SetGameProcessMap( + config.game_process_map.clone(), + )); for (peer, settings) in &config.peer_eq { if let Ok(id) = peer.parse::() { let _ = controller.send(CoreCommand::SetPeerEq(id, *settings)); @@ -915,10 +943,20 @@ impl Default for AppState { crate::screenshare::is_available(config.pixelpass_path.as_deref()); 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 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(); + 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(); let background_image = load_background_bytes(&config); let (clip_player, clip_status) = ClipPlayer::new(config.clip_volume); @@ -1067,7 +1105,13 @@ pub fn run_gui() -> iced::Result { // position, so we center there and leave placement to the compositor. let saved = AppConfig::load(); let init_size = iced::Size::new(saved.window_width, saved.window_height); - let init_position = initial_window_position(saved.window_x, saved.window_y, is_wayland()); + let init_position = initial_window_position( + saved.window_x, + saved.window_y, + saved.window_width.round() as i32, + saved.window_height.round() as i32, + is_wayland(), + ); iced::application(AppState::default, update, view_with_background) .title("PeerSpeak P2P Voice Chat") .theme(theme) @@ -1130,16 +1174,93 @@ fn is_wayland() -> bool { fn initial_window_position( saved_x: Option, saved_y: Option, + win_w: i32, + win_h: i32, is_wayland: bool, ) -> iced::window::Position { match (saved_x, saved_y) { (Some(x), Some(y)) if !is_wayland => { - iced::window::Position::Specific(iced::Point::new(x as f32, y as f32)) + clamp_window_position(x, y, win_w, win_h, screen_bounds()) } _ => iced::window::Position::Centered, } } +#[derive(Debug, Clone, Copy)] +struct ScreenBounds { + x: i32, + y: i32, + width: i32, + height: i32, +} + +const MIN_VISIBLE_WINDOW_MARGIN: i32 = 48; +const WINDOW_POSITION_SANITY_LIMIT: i64 = 32_000; + +fn screen_bounds() -> Option { + // iced 0.14 only exposes monitor size through window tasks after a window + // exists, not the startup virtual desktop bounds needed here. Keep the pure + // clamp ready for when dependency-free bounds can be supplied. + None +} + +fn clamp_window_position( + saved_x: i32, + saved_y: i32, + win_w: i32, + win_h: i32, + bounds: Option, +) -> iced::window::Position { + let specific = |x, y| iced::window::Position::Specific(iced::Point::new(x as f32, y as f32)); + let win_w = win_w.max(1); + let win_h = win_h.max(1); + + if let Some(bounds) = bounds { + if bounds.width <= 0 + || bounds.height <= 0 + || !has_min_visible_overlap(saved_x, win_w, bounds.x, bounds.width) + || !has_min_visible_overlap(saved_y, win_h, bounds.y, bounds.height) + { + return iced::window::Position::Centered; + } + + return specific( + clamp_window_position_axis(saved_x, win_w, bounds.x, bounds.width), + clamp_window_position_axis(saved_y, win_h, bounds.y, bounds.height), + ); + } + + if i64::from(saved_x).abs() > WINDOW_POSITION_SANITY_LIMIT + || i64::from(saved_y).abs() > WINDOW_POSITION_SANITY_LIMIT + { + return iced::window::Position::Centered; + } + + specific(saved_x, saved_y) +} + +fn has_min_visible_overlap(start: i32, len: i32, bounds_start: i32, bounds_len: i32) -> bool { + let required = MIN_VISIBLE_WINDOW_MARGIN.min(len).min(bounds_len).max(1); + let start = i64::from(start); + let end = start + i64::from(len); + let bounds_start = i64::from(bounds_start); + let bounds_end = bounds_start + i64::from(bounds_len); + let overlap = end.min(bounds_end) - start.max(bounds_start); + + overlap >= i64::from(required) +} + +fn clamp_window_position_axis(start: i32, len: i32, bounds_start: i32, bounds_len: i32) -> i32 { + if len >= bounds_len { + return bounds_start; + } + + let bounds_start = i64::from(bounds_start); + let max_start = bounds_start + i64::from(bounds_len - len); + + i64::from(start).clamp(bounds_start, max_start) as i32 +} + fn subscription(state: &AppState) -> Subscription { let core_sub = Subscription::run(core_subscription).map(AppMessage::UiEventReceived); let event_sub = iced::event::listen().map(AppMessage::EventOccurred); @@ -1163,12 +1284,17 @@ fn subscription(state: &AppState) -> Subscription { // Advance the friends "scanned Nm ago" indicator. Minute granularity, so a 30s // tick keeps it within ~30s of accurate; only runs once a rescan has happened. let rescan_label_sub = if state.last_rescan.is_some() { - iced::time::every(std::time::Duration::from_secs(30)) - .map(|_| AppMessage::RescanLabelTick) + iced::time::every(std::time::Duration::from_secs(30)).map(|_| AppMessage::RescanLabelTick) } else { Subscription::none() }; - Subscription::batch(vec![core_sub, event_sub, audio_sub, clock_skew_sub, rescan_label_sub]) + Subscription::batch(vec![ + core_sub, + event_sub, + audio_sub, + clock_skew_sub, + rescan_label_sub, + ]) } fn shutdown_timeout_task() -> Task { @@ -1227,7 +1353,10 @@ fn toggle_mute(state: &mut AppState) { } let _ = state.controller.send(CoreCommand::ToggleMute); state.is_muted = !state.is_muted; - notify::play(Sound::MicToggle, state.config.custom_sound_mic_toggle.as_deref()); + notify::play( + Sound::MicToggle, + state.config.custom_sound_mic_toggle.as_deref(), + ); } fn toggle_deafen(state: &mut AppState) { @@ -1236,7 +1365,10 @@ fn toggle_deafen(state: &mut AppState) { } let _ = state.controller.send(CoreCommand::ToggleDeafen); state.is_deafened = !state.is_deafened; - notify::play(Sound::MicToggle, state.config.custom_sound_mic_toggle.as_deref()); + notify::play( + Sound::MicToggle, + state.config.custom_sound_mic_toggle.as_deref(), + ); } fn handle_hotkey_pressed(state: &mut AppState, action: HotkeyAction) { @@ -1468,7 +1600,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); - crate::recents::push_recent(&mut state.config.recents, label, ticket.clone(), now); + crate::recents::push_recent( + &mut state.config.recents, + label, + ticket.clone(), + now, + ); state.config.save(); state.ticket = ticket; state.self_id = self_id; @@ -1478,14 +1615,20 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { // The core tore down any standalone mic monitor when joining; // the in-call meter now drives mic_level. state.mic_test_active = false; - notify::play(Sound::SelfJoin, state.config.custom_sound_self_join.as_deref()); + notify::play( + Sound::SelfJoin, + state.config.custom_sound_self_join.as_deref(), + ); } UiEvent::RoomLeft => { state.reset_room_state(); state.ticket = "".to_string(); state.status_message = "Ready to connect".to_string(); state.current_screen = Screen::Home; - notify::play(Sound::SelfLeave, state.config.custom_sound_self_leave.as_deref()); + notify::play( + Sound::SelfLeave, + state.config.custom_sound_self_leave.as_deref(), + ); } UiEvent::RoomReset => { state.reset_room_state(); @@ -1493,9 +1636,15 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.status_message = "Ready to connect".to_string(); state.current_screen = Screen::Home; } - UiEvent::PeerJoined { id, state: peer_state } => { + UiEvent::PeerJoined { + id, + state: peer_state, + } => { state.peers.insert(id, peer_state); - notify::play(Sound::PeerJoin, state.config.custom_sound_peer_join.as_deref()); + notify::play( + Sound::PeerJoin, + state.config.custom_sound_peer_join.as_deref(), + ); } UiEvent::PeerLeft { id } => { state.peers.remove(&id); @@ -1509,7 +1658,10 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.music_listen_loaded = None; state.music_listen_inflight = None; } - notify::play(Sound::PeerLeave, state.config.custom_sound_peer_leave.as_deref()); + notify::play( + Sound::PeerLeave, + state.config.custom_sound_peer_leave.as_deref(), + ); } // Core-only recovery phase: presentation for this state lands in // the separate UI follow-up. In particular, do not play the @@ -1521,9 +1673,15 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.locally_muted.remove(&id); state.connecting.remove(&id); state.ever_connected.remove(&id); - notify::play(Sound::ReconnectFailed, state.config.custom_sound_reconnect_failed.as_deref()); + notify::play( + Sound::ReconnectFailed, + state.config.custom_sound_reconnect_failed.as_deref(), + ); } - UiEvent::PeerUpdated { id, state: peer_state } => { + UiEvent::PeerUpdated { + id, + state: peer_state, + } => { state.peers.insert(id, peer_state); if state.music_listening_to == Some(id) { reconcile_listen(state); @@ -1533,7 +1691,10 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { if let Some(sound) = reconnect_attempt_chime(&mut state.connecting, &state.ever_connected, id) { - notify::play(sound, state.config.custom_sound_reconnect_attempt.as_deref()); + notify::play( + sound, + state.config.custom_sound_reconnect_attempt.as_deref(), + ); } } UiEvent::PeerConnected { id } => { @@ -1561,7 +1722,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.recording_started = None; state.status_message = format!("Saved recording → {path}"); } - UiEvent::ChatMessage { from, name, text, attachment } => { + UiEvent::ChatMessage { + from, + name, + text, + attachment, + } => { // Incoming peer content is untrusted — sanitize name + text. // (The attachment filename was already sanitized in core.) let text = sanitize_chat(&text); @@ -1569,13 +1735,16 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { // image with no caption is still a real message). if !text.is_empty() || attachment.is_some() { let name = sanitize_chat(&name); - push_chat(&mut state.chat_messages, ChatEntry { - name, - text, - mine: false, - from: Some(from), - attachment, - }); + push_chat( + &mut state.chat_messages, + ChatEntry { + name, + text, + mine: false, + from: Some(from), + attachment, + }, + ); } } UiEvent::AttachmentReady { from, id, data } => { @@ -1590,7 +1759,9 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { .then(|| iced::widget::image::Handle::from_bytes(data.clone())); let needs_save = state.pending_saves.remove(&key); let needs_play = state.pending_plays.remove(&id); - state.attachments.insert(key, AttachmentState::Ready(data), handle); + state + .attachments + .insert(key, AttachmentState::Ready(data), handle); if needs_play { play_ready_audio(state, key); } @@ -1602,7 +1773,9 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { let key = (from, id); state.pending_saves.remove(&key); state.pending_plays.remove(&id); - state.attachments.insert(key, AttachmentState::Failed(error.clone()), None); + state + .attachments + .insert(key, AttachmentState::Failed(error.clone()), None); state.status_message = format!("Attachment failed: {error}"); } UiEvent::MusicReady { from, id, data } => { @@ -1645,7 +1818,10 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.music_prefetch_inflight = None; } } - UiEvent::AudioAppsListed { apps, app_audio_supported } => { + UiEvent::AudioAppsListed { + apps, + app_audio_supported, + } => { // Only meaningful while the picker is open; if the user // already cancelled, drop it. if state.share_picker_open { @@ -1694,7 +1870,10 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.share_audio_dropped = !active; } } - UiEvent::ClockSkewWarning { skew_secs, peer_ahead } => { + UiEvent::ClockSkewWarning { + skew_secs, + peer_ahead, + } => { show_clock_skew_warning( state, skew_secs, @@ -1702,7 +1881,11 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { std::time::Instant::now(), ); } - UiEvent::IdentityStatus { node_id, persisted, error } => { + UiEvent::IdentityStatus { + node_id, + persisted, + error, + } => { state.self_node_id = Some(node_id); state.identity_persisted = persisted; state.identity_error = error; @@ -1793,7 +1976,9 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { } AppMessage::PeerGateChanged(id, threshold) => { let threshold = set_peer_gate_config(&mut state.config, id, threshold); - let _ = state.controller.send(CoreCommand::SetPeerGate(id, threshold)); + let _ = state + .controller + .send(CoreCommand::SetPeerGate(id, threshold)); } AppMessage::PeerEqChanged(id, band, gain_db) => { let settings = set_peer_eq_config(&mut state.config, id, band, gain_db); @@ -1807,7 +1992,9 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.locally_muted.insert(id); true }; - let _ = state.controller.send(CoreCommand::SetPeerMuted(id, now_muted)); + let _ = state + .controller + .send(CoreCommand::SetPeerMuted(id, now_muted)); } AppMessage::InputDeviceSelected(dev) => { state.config.input_device = dev.name.clone(); @@ -1834,12 +2021,16 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { AppMessage::NoiseGateChanged(val) => { state.config.noise_gate_threshold = val; state.config.save(); - let _ = state.controller.send(CoreCommand::SetNoiseGateThreshold(val)); + let _ = state + .controller + .send(CoreCommand::SetNoiseGateThreshold(val)); } AppMessage::NoiseGateDragging(val) => { // Live drag: apply immediately, defer the disk write to release. state.config.noise_gate_threshold = val; - let _ = state.controller.send(CoreCommand::SetNoiseGateThreshold(val)); + let _ = state + .controller + .send(CoreCommand::SetNoiseGateThreshold(val)); } AppMessage::NetworkModeSelected(mode) => { state.config.network_mode = mode; @@ -1882,15 +2073,24 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { Ok(id) => { let name = { let n = state.friend_add_name.trim(); - if n.is_empty() { short_id(id_str) } else { n.to_string() } + if n.is_empty() { + short_id(id_str) + } else { + n.to_string() + } }; - let _ = state.controller.send(CoreCommand::AddFriend { id, name, addr: None }); + let _ = state.controller.send(CoreCommand::AddFriend { + id, + name, + addr: None, + }); state.friend_add_id.clear(); state.friend_add_name.clear(); state.friend_add_error = None; } Err(_) => { - state.friend_add_error = Some("That doesn't look like a valid node ID.".to_string()); + state.friend_add_error = + Some("That doesn't look like a valid node ID.".to_string()); } } } @@ -1906,7 +2106,11 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { { let name = { let n = crate::sanitize::sanitize_name(&peer.name); - if n.is_empty() { short_id(&id.to_string()) } else { n } + if n.is_empty() { + short_id(&id.to_string()) + } else { + n + } }; let _ = state.controller.send(CoreCommand::AddFriend { id, @@ -1922,7 +2126,9 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { } AppMessage::RenameFriend(id, new_name) => { if !state.friends_read_only { - let _ = state.controller.send(CoreCommand::RenameFriend(id, new_name)); + let _ = state + .controller + .send(CoreCommand::RenameFriend(id, new_name)); } } AppMessage::RefreshFriends => { @@ -1997,7 +2203,11 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { // Applied on the next join, since the audio graph is rebuilt then. } AppMessage::CustomSoundPathChanged(sound, path) => { - let path_opt = if path.trim().is_empty() { None } else { Some(path) }; + let path_opt = if path.trim().is_empty() { + None + } else { + Some(path) + }; match sound { Sound::SelfJoin => state.config.custom_sound_self_join = path_opt, Sound::PeerJoin => state.config.custom_sound_peer_join = path_opt, @@ -2035,7 +2245,9 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { AppMessage::ToggleRecording => { // Optimistic intent; the core flips `recording` for real via the // Recording{Started,Stopped} events (so a failed start won't lie). - let _ = state.controller.send(CoreCommand::SetRecording(!state.recording)); + let _ = state + .controller + .send(CoreCommand::SetRecording(!state.recording)); } AppMessage::ChatInputChanged(val) => { state.chat_input = val; @@ -2198,8 +2410,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.status_message = "Background updated.".to_string(); } Err(e) => { - state.status_message = - format!("Couldn't save background: {e}"); + state.status_message = format!("Couldn't save background: {e}"); } } } @@ -2233,7 +2444,9 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.config.game_presence_enabled = enabled; state.config.save(); // Core publishes/clears our game immediately (D8). - let _ = state.controller.send(CoreCommand::SetGamePresenceEnabled(enabled)); + let _ = state + .controller + .send(CoreCommand::SetGamePresenceEnabled(enabled)); } AppMessage::GameOverrideSelected(choice) => { state.game_override = choice; @@ -2246,7 +2459,9 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { None => crate::game::ManualOverride::Auto, }, }; - let _ = state.controller.send(CoreCommand::SetGameOverride(override_)); + let _ = state + .controller + .send(CoreCommand::SetGameOverride(override_)); } AppMessage::GameMapExeChanged(val) => { state.game_map_exe_input = val; @@ -2262,17 +2477,17 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.config.save(); state.game_map_exe_input.clear(); state.game_map_name_input.clear(); - let _ = state - .controller - .send(CoreCommand::SetGameProcessMap(state.config.game_process_map.clone())); + let _ = state.controller.send(CoreCommand::SetGameProcessMap( + state.config.game_process_map.clone(), + )); } } AppMessage::RemoveGameMapping(exe) => { if state.config.game_process_map.remove(&exe).is_some() { state.config.save(); - let _ = state - .controller - .send(CoreCommand::SetGameProcessMap(state.config.game_process_map.clone())); + let _ = state.controller.send(CoreCommand::SetGameProcessMap( + state.config.game_process_map.clone(), + )); } } AppMessage::PickGameBackground(game_id) => { @@ -2302,10 +2517,10 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { } match std::fs::write(&path, &png) { Ok(()) => { - state - .config - .game_backgrounds - .insert(game_id.clone(), path.to_string_lossy().into_owned()); + state.config.game_backgrounds.insert( + game_id.clone(), + path.to_string_lossy().into_owned(), + ); state.config.save(); // Apply at once if it's the running game. if state.current_game.as_ref().map(|g| g.id.as_str()) @@ -2366,13 +2581,16 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { let text = sanitize_chat(&state.chat_input); if !text.is_empty() { // Local echo (gossip suppresses our own author, so it won't come back). - push_chat(&mut state.chat_messages, ChatEntry { - name: format!("{} (You)", state.name), - text: text.clone(), - mine: true, - from: Some(state.self_id.clone()), - attachment: None, - }); + push_chat( + &mut state.chat_messages, + ChatEntry { + name: format!("{} (You)", state.name), + text: text.clone(), + mine: true, + from: Some(state.self_id.clone()), + attachment: None, + }, + ); let _ = state.controller.send(CoreCommand::SendChat(text)); state.chat_input.clear(); } @@ -2451,9 +2669,10 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { } else if let Some(att) = find_attachment_source(state, key) { state.pending_saves.insert(key); state.status_message = format!("Downloading {}…", att.name); - let _ = state - .controller - .send(CoreCommand::FetchAttachment { from: key.0, attachment: att }); + let _ = state.controller.send(CoreCommand::FetchAttachment { + from: key.0, + attachment: att, + }); } } AppMessage::AttachmentSaved(msg) => { @@ -2470,9 +2689,10 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { // the id-keyed clip player; same-id collisions are cosmetic.) if state.pending_plays.insert(key.1) { state.status_message = format!("Loading {}…", att.name); - let _ = state - .controller - .send(CoreCommand::FetchAttachment { from: key.0, attachment: att }); + let _ = state.controller.send(CoreCommand::FetchAttachment { + from: key.0, + attachment: att, + }); } } } @@ -2515,7 +2735,9 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.config.save(); // Reapply the now-effective level to whatever is currently playing. if let Some(id) = status_snapshot(&state.clip_status).playing_id { - state.clip_player.set_volume(effective_clip_volume(state, id)); + state + .clip_player + .set_volume(effective_clip_volume(state, id)); } } AppMessage::MusicSelectTab(tab) => { @@ -2525,15 +2747,14 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { return Task::perform( async { let handles = rfd::AsyncFileDialog::new() - .add_filter("Audio & playlists", &["mp3", "flac", "ogg", "oga", "wav", "m3u", "m3u8", "pls"]) + .add_filter( + "Audio & playlists", + &["mp3", "flac", "ogg", "oga", "wav", "m3u", "m3u8", "pls"], + ) .set_title("Add music to your playlist") .pick_files() .await; - handles.map(|hs| { - hs.into_iter() - .map(|h| h.path().to_path_buf()) - .collect() - }) + handles.map(|hs| hs.into_iter().map(|h| h.path().to_path_buf()).collect()) }, AppMessage::MusicFilesPicked, ); @@ -2544,10 +2765,11 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { if let Some(kind) = crate::playlist::playlist_kind(&path) { match std::fs::read_to_string(&path) { Ok(contents) => { - let base_dir = path - .parent() - .unwrap_or_else(|| std::path::Path::new(".")); - for entry in crate::playlist::parse_playlist(&contents, base_dir, kind) { + let base_dir = + path.parent().unwrap_or_else(|| std::path::Path::new(".")); + for entry in + crate::playlist::parse_playlist(&contents, base_dir, kind) + { let name = entry .file_name() .map(|n| n.to_string_lossy().into_owned()) @@ -2586,7 +2808,11 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.music_active = false; broadcast_music_timeline_at(state, true, snap.position); } - } else if let Some(i) = state.music_current.or(if state.music_playlist.is_empty() { None } else { Some(0) }) { + } else if let Some(i) = state.music_current.or(if state.music_playlist.is_empty() { + None + } else { + Some(0) + }) { play_music_index(state, i); } } @@ -2619,7 +2845,10 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { AppMessage::MusicSetSourceVolume(volume) => { let volume = volume.clamp(0.0, 2.0); if let Some(peer) = state.music_listening_to { - state.config.music_source_volume.insert(peer.to_string(), volume); + state + .config + .music_source_volume + .insert(peer.to_string(), volume); state.music_player.set_volume(volume); state.config.save(); } @@ -2741,9 +2970,10 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.mic_level = 0.0; } let input_device = state.selected_input.as_ref().map(|d| d.name.clone()); - let _ = state - .controller - .send(CoreCommand::SetMicMonitor { enabled, input_device }); + let _ = state.controller.send(CoreCommand::SetMicMonitor { + enabled, + input_device, + }); } AppMessage::EventOccurred(Event::Keyboard(keyboard::Event::KeyPressed { key, .. })) => { if state.image_lightbox.is_some() @@ -2759,20 +2989,24 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { } else { state.hotkey_capture = Some(action); } - } else if let Some(action) = state - .config - .hotkeys - .lookup_key(&key, HotkeyContext { in_call: in_call(state) }) - { + } else if let Some(action) = state.config.hotkeys.lookup_key( + &key, + HotkeyContext { + in_call: in_call(state), + }, + ) { handle_hotkey_pressed(state, action); } } - AppMessage::EventOccurred(Event::Keyboard(keyboard::Event::KeyReleased { key, .. })) => { - if state - .config - .hotkeys - .lookup_key(&key, HotkeyContext { in_call: in_call(state) }) - == Some(HotkeyAction::PushToTalk) + AppMessage::EventOccurred(Event::Keyboard(keyboard::Event::KeyReleased { + key, .. + })) => { + if state.config.hotkeys.lookup_key( + &key, + HotkeyContext { + in_call: in_call(state), + }, + ) == Some(HotkeyAction::PushToTalk) && state.ptt_enabled && state.ptt_active { @@ -2793,8 +3027,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { // write on every resize tick.) state.config.participants_width = clamp_participants_width(state.config.participants_width, size.width); - state.config.chat_height = - clamp_chat_height(state.config.chat_height, size.height); + state.config.chat_height = clamp_chat_height(state.config.chat_height, size.height); state.config.controls_width = clamp_controls_width(state.config.controls_width, size.width); state.config.chat_drawer_width = @@ -2870,7 +3103,9 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { /// One-line explanation of a network posture for the settings picker. fn network_mode_hint(mode: NetworkMode) -> &'static str { match mode { - NetworkMode::RelayNoDiscovery => "n0 relay for NAT traversal; no presence published to n0 DNS.", + NetworkMode::RelayNoDiscovery => { + "n0 relay for NAT traversal; no presence published to n0 DNS." + } NetworkMode::N0Full => "n0 relay + DNS discovery. Most reliable, most metadata shared.", NetworkMode::DirectOnly => "Fully serverless. May fail behind strict/CGNAT networks.", } @@ -2880,7 +3115,9 @@ fn network_mode_hint(mode: NetworkMode) -> &'static str { fn recording_mode_hint(mode: RecordingMode) -> &'static str { match mode { RecordingMode::Mixed => "One WAV: your mic blended with everyone you hear.", - RecordingMode::Multitrack => "One WAV per person + your mic, sample-aligned — mix it yourself.", + RecordingMode::Multitrack => { + "One WAV per person + your mic, sample-aligned — mix it yourself." + } RecordingMode::Both => "Per-person stems + your mic AND a ready-made mixed WAV.", } } @@ -2888,12 +3125,8 @@ fn recording_mode_hint(mode: RecordingMode) -> &'static str { /// One-line explanation of a presence posture for the settings picker (W7). fn presence_mode_hint(mode: PresenceMode) -> &'static str { match mode { - PresenceMode::Invisible => { - "Answer no one — appear offline to everyone, even friends." - } - PresenceMode::Normal => { - "Answer friends only, from your saved address. No presence beacon." - } + PresenceMode::Invisible => "Answer no one — appear offline to everyone, even friends.", + PresenceMode::Normal => "Answer friends only, from your saved address. No presence beacon.", PresenceMode::Discoverable => { "Also publish so friends can still find you after you change networks (opt-in)." } @@ -3068,7 +3301,9 @@ fn play_ready_audio(state: &mut AppState, key: AttachmentKey) { state.clip_player.play(id, bytes); // Apply this clip's effective gain; the command lands after Play so it // takes effect on the freshly connected player. - state.clip_player.set_volume(effective_clip_volume(state, id)); + state + .clip_player + .set_volume(effective_clip_volume(state, id)); } else { state.invalid_audio.insert(id); state.status_message = "This attachment is not valid supported audio.".to_string(); @@ -3197,8 +3432,9 @@ struct MusicPresenceUpdate { } fn send_music_presence(state: &AppState, update: MusicPresenceUpdate) { - let _ = state.controller.send(CoreCommand::SetMusicPresence(Some( - music_presence_for( + let _ = state + .controller + .send(CoreCommand::SetMusicPresence(Some(music_presence_for( update.id, update.name, update.size, @@ -3206,8 +3442,7 @@ fn send_music_presence(state: &AppState, update: MusicPresenceUpdate) { update.position, update.next_id, update.next_size, - ), - ))); + )))); } fn broadcast_track( @@ -3263,24 +3498,27 @@ fn broadcast_track( Some(id), next_id, ); - send_music_presence(state, MusicPresenceUpdate { - id, - name, - size: bytes.len() as u64, - paused, - position, - next_id, - next_size, - }); + send_music_presence( + state, + MusicPresenceUpdate { + id, + name, + size: bytes.len() as u64, + paused, + position, + next_id, + next_size, + }, + ); } -fn broadcast_current_music_file( - state: &mut AppState, - paused: bool, - position: std::time::Duration, -) { - let Some(index) = state.music_current else { return; }; - let Some(track) = state.music_playlist.get(index) else { return; }; +fn broadcast_current_music_file(state: &mut AppState, paused: bool, position: std::time::Duration) { + let Some(index) = state.music_current else { + return; + }; + let Some(track) = state.music_playlist.get(index) else { + return; + }; match std::fs::read(&track.path) { Ok(bytes) if crate::files::is_probably_audio(&bytes) => { broadcast_track(state, index, track.name.clone(), &bytes, paused, position); @@ -3289,36 +3527,45 @@ fn broadcast_current_music_file( } } -fn broadcast_music_timeline_at( - state: &AppState, - paused: bool, - position: std::time::Duration, -) { +fn broadcast_music_timeline_at(state: &AppState, paused: bool, position: std::time::Duration) { if !can_broadcast_music(state) { return; } - let Some(id) = state.music_broadcast_id else { return; }; - let Some(index) = state.music_current else { return; }; - let Some(track) = state.music_playlist.get(index) else { return; }; - let Ok(meta) = std::fs::metadata(&track.path) else { return; }; + let Some(id) = state.music_broadcast_id else { + return; + }; + let Some(index) = state.music_current else { + return; + }; + let Some(track) = state.music_playlist.get(index) else { + return; + }; + let Ok(meta) = std::fs::metadata(&track.path) else { + return; + }; let (next_id, next_size) = state .music_broadcast_next .map(|(_, id, size)| (Some(id), Some(size))) .unwrap_or((None, None)); - send_music_presence(state, MusicPresenceUpdate { - id, - name: track.name.clone(), - size: meta.len(), - paused, - position, - next_id, - next_size, - }); + send_music_presence( + state, + MusicPresenceUpdate { + id, + name: track.name.clone(), + size: meta.len(), + paused, + position, + next_id, + next_size, + }, + ); } /// Read a playlist track's bytes from disk and start the music player on it. fn play_music_index(state: &mut AppState, index: usize) { - let Some(track) = state.music_playlist.get(index) else { return; }; + let Some(track) = state.music_playlist.get(index) else { + return; + }; let path = track.path.clone(); let name = track.name.clone(); match std::fs::read(&path) { @@ -3328,15 +3575,21 @@ fn play_music_index(state: &mut AppState, index: usize) { state.music_active = true; state.music_player.play(music_slot_id(index), bytes); state.music_player.set_volume(effective_music_volume(state)); - broadcast_track(state, index, name, &broadcast_bytes, false, std::time::Duration::ZERO); + broadcast_track( + state, + index, + name, + &broadcast_bytes, + false, + std::time::Duration::ZERO, + ); } Ok(_) => { state.music_player.stop(); state.music_current = Some(index); state.music_active = false; stop_music_broadcast(state); - state.status_message = - format!("'{}' is not a supported audio file.", name); + state.status_message = format!("'{}' is not a supported audio file.", name); } Err(e) => { state.music_player.stop(); @@ -3349,7 +3602,9 @@ fn play_music_index(state: &mut AppState, index: usize) { } fn reconcile_listen(state: &mut AppState) { - let Some(peer) = state.music_listening_to else { return; }; + let Some(peer) = state.music_listening_to else { + return; + }; let Some(music) = state.peers.get(&peer).and_then(|p| p.music.clone()) else { if state.music_listen_loaded.is_some() || state.music_listen_inflight.is_some() @@ -3367,7 +3622,11 @@ fn reconcile_listen(state: &mut AppState) { }; if Some(music.id) != state.music_listen_loaded { - if let Some((id, data)) = state.music_prefetch.take().filter(|(id, _)| *id == music.id) { + if let Some((id, data)) = state + .music_prefetch + .take() + .filter(|(id, _)| *id == music.id) + { state.music_player.play(id, data); state.music_player.set_volume(effective_music_volume(state)); state.music_listen_loaded = Some(id); @@ -3394,7 +3653,9 @@ fn reconcile_listen(state: &mut AppState) { let mut expected = if music.paused { music.position_ms } else { - music.position_ms.saturating_add(now_ms().saturating_sub(music.anchor_ms)) + music + .position_ms + .saturating_add(now_ms().saturating_sub(music.anchor_ms)) }; if let Some(total) = snap.total { expected = expected.min(total.as_millis() as u64); @@ -3500,7 +3761,11 @@ fn connect_card(state: &AppState) -> Element<'_, AppMessage> { background: Some(Background::Color(bg)), border: Border { color: b_color, - width: if b_color == Color::TRANSPARENT { 0.0 } else { 1.0 }, + width: if b_color == Color::TRANSPARENT { + 0.0 + } else { + 1.0 + }, radius: radius.into(), }, ..Default::default() @@ -3515,14 +3780,22 @@ fn connect_card(state: &AppState) -> Element<'_, AppMessage> { button::Style { background: Some(Background::Color(active_bg)), text_color: text_c, - border: Border { color: Color::TRANSPARENT, width: 0.0, radius: radius.into() }, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: radius.into(), + }, ..Default::default() } } }; let t_style = move |_theme: &Theme, _status: text_input::Status| text_input::Style { background: Background::Color(color_crust), - border: Border { color: color_surface, width: 1.0, radius: 6.0.into() }, + border: Border { + color: color_surface, + width: 1.0, + radius: 6.0.into(), + }, icon: color_subtext, placeholder: pal.overlay, value: color_text, @@ -3530,7 +3803,9 @@ fn connect_card(state: &AppState) -> Element<'_, AppMessage> { }; let logo = text("PEERSPEAK").size(38).color(color_blue); - let subtitle = text("NAT-traversing full-mesh voice chat").size(16).color(color_subtext); + let subtitle = text("NAT-traversing full-mesh voice chat") + .size(16) + .color(color_subtext); let nickname_input = column![ text("Nickname").size(14).color(color_subtext), @@ -3550,11 +3825,15 @@ fn connect_card(state: &AppState) -> Element<'_, AppMessage> { .style(t_style) .padding(10), vertical_space(8.0), - button(btn_content(IconKind::Create, "Create New Room", color_crust)) - .on_press(AppMessage::CreatePressed) - .style(b_style(color_blue, color_lavender, color_crust, 8.0)) - .padding(12) - .width(iced::Length::Fill), + button(btn_content( + IconKind::Create, + "Create New Room", + color_crust + )) + .on_press(AppMessage::CreatePressed) + .style(b_style(color_blue, color_lavender, color_crust, 8.0)) + .padding(12) + .width(iced::Length::Fill), ]; let join_group = column![ @@ -3565,11 +3844,15 @@ fn connect_card(state: &AppState) -> Element<'_, AppMessage> { .style(t_style) .padding(10), vertical_space(8.0), - button(text("Join Room").size(16).align_x(iced::alignment::Horizontal::Center)) - .on_press(AppMessage::JoinPressed) - .style(b_style(color_surface, color_blue, color_text, 8.0)) - .padding(12) - .width(iced::Length::Fill) + button( + text("Join Room") + .size(16) + .align_x(iced::alignment::Horizontal::Center) + ) + .on_press(AppMessage::JoinPressed) + .style(b_style(color_surface, color_blue, color_text, 8.0)) + .padding(12) + .width(iced::Length::Fill) ]; let status = text(&state.status_message).size(14).color(color_subtext); @@ -3583,7 +3866,10 @@ fn connect_card(state: &AppState) -> Element<'_, AppMessage> { vertical_space(16.0), create_group, vertical_space(16.0), - text("— OR —").size(12).color(color_surface).align_x(iced::alignment::Horizontal::Center), + text("— OR —") + .size(12) + .color(color_surface) + .align_x(iced::alignment::Horizontal::Center), vertical_space(16.0), join_group, vertical_space(10.0), @@ -3617,7 +3903,11 @@ fn recents_card(state: &AppState) -> Element<'_, AppMessage> { background: Some(Background::Color(bg)), border: Border { color: b_color, - width: if b_color == Color::TRANSPARENT { 0.0 } else { 1.0 }, + width: if b_color == Color::TRANSPARENT { + 0.0 + } else { + 1.0 + }, radius: radius.into(), }, ..Default::default() @@ -3632,7 +3922,11 @@ fn recents_card(state: &AppState) -> Element<'_, AppMessage> { button::Style { background: Some(Background::Color(active_bg)), text_color: text_c, - border: Border { color: Color::TRANSPARENT, width: 0.0, radius: radius.into() }, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: radius.into(), + }, ..Default::default() } } @@ -3655,7 +3949,11 @@ fn recents_card(state: &AppState) -> Element<'_, AppMessage> { for r in &state.config.recents { let label = { let n = crate::sanitize::sanitize_name(&r.name); - if n.is_empty() { "Untitled room".to_string() } else { n } + if n.is_empty() { + "Untitled room".to_string() + } else { + n + } }; let when = crate::recents::relative_time(now, r.joined_at); let entry = button( @@ -3696,10 +3994,14 @@ fn recents_card(state: &AppState) -> Element<'_, AppMessage> { }; container(content) - .style(c_style(if empty { color_crust } else { color_mantle }, color_surface, 8.0)) - .padding(if empty { 16 } else { 24 }) - .width(if empty { 340 } else { 380 }) - .into() + .style(c_style( + if empty { color_crust } else { color_mantle }, + color_surface, + 8.0, + )) + .padding(if empty { 16 } else { 24 }) + .width(if empty { 340 } else { 380 }) + .into() } fn friends_panel(state: &AppState) -> Element<'_, AppMessage> { @@ -3722,7 +4024,11 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> { background: Some(Background::Color(bg)), border: Border { color: b_color, - width: if b_color == Color::TRANSPARENT { 0.0 } else { 1.0 }, + width: if b_color == Color::TRANSPARENT { + 0.0 + } else { + 1.0 + }, radius: radius.into(), }, ..Default::default() @@ -3737,14 +4043,22 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> { button::Style { background: Some(Background::Color(active_bg)), text_color: text_c, - border: Border { color: Color::TRANSPARENT, width: 0.0, radius: radius.into() }, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: radius.into(), + }, ..Default::default() } } }; let t_style = move |_theme: &Theme, _status: text_input::Status| text_input::Style { background: Background::Color(color_crust), - border: Border { color: color_surface, width: 1.0, radius: 6.0.into() }, + border: Border { + color: color_surface, + width: 1.0, + radius: 6.0.into(), + }, icon: color_subtext, placeholder: color_overlay, value: color_text, @@ -3754,11 +4068,7 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> { // The live friends list: status dot, inline rename, short id, remove. let mut friend_rows = column![].spacing(6).width(iced::Length::Fill); if !has_friends { - friend_rows = friend_rows.push( - text("No friends yet.") - .size(12) - .color(color_subtext), - ); + friend_rows = friend_rows.push(text("No friends yet.").size(12).color(color_subtext)); } else { for f in state.friends.list() { let fid = f.id; @@ -3766,8 +4076,11 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> { // Live presence (W7 B2); a missing entry = offline. In-room → Join. let status: Element = match state.friend_presence.get(&fid) { Some(crate::presence::FriendPresence::InRoom { name, ticket }) => { - let label = - if name.is_empty() { "in a room".to_string() } else { format!("in {name}") }; + let label = if name.is_empty() { + "in a room".to_string() + } else { + format!("in {name}") + }; row![ text(label).size(11).color(color_green), button(text("Join").size(12)) @@ -3946,7 +4259,9 @@ fn view_with_background(state: &AppState) -> Element<'_, AppMessage> { .width(iced::Length::Fill) .height(iced::Length::Fill) .style(move |_: &Theme| container::Style { - background: Some(Background::Color(crate::background::scrim_color(pal.base, dim))), + background: Some(Background::Color(crate::background::scrim_color( + pal.base, dim, + ))), ..Default::default() }); iced::widget::stack![image_layer, scrim, content] @@ -3994,7 +4309,11 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { background: Some(Background::Color(bg)), border: Border { color: b_color, - width: if b_color == Color::TRANSPARENT { 0.0 } else { 1.0 }, + width: if b_color == Color::TRANSPARENT { + 0.0 + } else { + 1.0 + }, radius: radius.into(), }, ..Default::default() @@ -4020,19 +4339,17 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { } }; - let t_style = move |_theme: &Theme, _status: text_input::Status| { - text_input::Style { - background: Background::Color(color_crust), - border: Border { - color: color_surface, - width: 1.0, - radius: 6.0.into(), - }, - icon: color_subtext, - placeholder: color_overlay, - value: color_text, - selection: color_blue, - } + let t_style = move |_theme: &Theme, _status: text_input::Status| text_input::Style { + background: Background::Color(color_crust), + border: Border { + color: color_surface, + width: 1.0, + radius: 6.0.into(), + }, + icon: color_subtext, + placeholder: color_overlay, + value: color_text, + selection: color_blue, }; // The Hotkeys info button is always available (hotkeys are app-wide). The @@ -4049,26 +4366,25 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { ) .gap(8); - let player_bar_button: Element<'_, AppMessage> = - if state.current_screen == Screen::Room { - let active = state.config.show_player_bar; - let bg = if active { color_blue } else { color_surface }; - let fg = if active { color_crust } else { color_text }; - tooltip( - button(text("♪").size(15)) - .on_press(AppMessage::TogglePlayerBar) - .style(b_style(bg, color_blue, fg, 6.0)) - .padding(8), - container(text("Player bar").size(11).color(color_text)) - .padding(8) - .style(c_style(color_crust, color_surface, 6.0)), - iced::widget::tooltip::Position::Bottom, - ) - .gap(8) - .into() - } else { - iced::widget::Space::new().width(0.0).height(0.0).into() - }; + let player_bar_button: Element<'_, AppMessage> = if state.current_screen == Screen::Room { + let active = state.config.show_player_bar; + let bg = if active { color_blue } else { color_surface }; + let fg = if active { color_crust } else { color_text }; + tooltip( + button(text("♪").size(15)) + .on_press(AppMessage::TogglePlayerBar) + .style(b_style(bg, color_blue, fg, 6.0)) + .padding(8), + container(text("Player bar").size(11).color(color_text)) + .padding(8) + .style(c_style(color_crust, color_surface, 6.0)), + iced::widget::tooltip::Position::Bottom, + ) + .gap(8) + .into() + } else { + iced::widget::Space::new().width(0.0).height(0.0).into() + }; let layout_button: Element<'_, AppMessage> = if state.current_screen == Screen::Home { iced::widget::Space::new().width(0.0).height(0.0).into() @@ -4077,7 +4393,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { button( Canvas::new(LayoutIcon { fg: color_text }) .width(iced::Length::Fixed(18.0)) - .height(iced::Length::Fixed(18.0)) + .height(iced::Length::Fixed(18.0)), ) .on_press(AppMessage::OpenLayoutPicker) .style(b_style(color_surface, color_blue, color_text, 6.0)) @@ -4104,10 +4420,13 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .spacing(6) .align_y(iced::alignment::Vertical::Center) ) - .on_press(AppMessage::NavigateToSettings) - .style(b_style(color_surface, color_blue, color_text, 6.0)) - .padding(8) - ].width(iced::Length::Fill).padding(10).spacing(8); + .on_press(AppMessage::NavigateToSettings) + .style(b_style(color_surface, color_blue, color_text, 6.0)) + .padding(8) + ] + .width(iced::Length::Fill) + .padding(10) + .spacing(8); if state.current_screen == Screen::Settings { let path_field = |label: &'static str, sound: Sound| { @@ -4132,12 +4451,16 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { text(label).size(12).color(color_subtext), horizontal_space(), validation_widget, - ].spacing(6).align_y(iced::alignment::Vertical::Center), + ] + .spacing(6) + .align_y(iced::alignment::Vertical::Center), context_input("Default (embedded)...", path) .on_input(move |val| AppMessage::CustomSoundPathChanged(sound, val)) .style(t_style) .padding(8) - ].spacing(4).width(iced::Length::Fill) + ] + .spacing(4) + .width(iced::Length::Fill) }; // Live mic level meter for gate calibration. Shares the gate slider's @@ -4154,17 +4477,14 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { (IconKind::Mic, "Test mic", color_surface) }; button( - row![ - icon(mic_kind, 13.0, color_text), - text(label).size(12), - ] - .spacing(5) - .align_y(iced::alignment::Vertical::Center) + row![icon(mic_kind, 13.0, color_text), text(label).size(12),] + .spacing(5) + .align_y(iced::alignment::Vertical::Center), ) - .on_press(AppMessage::ToggleMicTest(!state.mic_test_active)) - .style(b_style(bg, color_blue, color_text, 6.0)) - .padding(6) - .into() + .on_press(AppMessage::ToggleMicTest(!state.mic_test_active)) + .style(b_style(bg, color_blue, color_text, 6.0)) + .padding(6) + .into() }; // Unified meter + draggable gate (Discord/OBS-style): the live mic level // fills the bar and the yellow handle is the gate threshold, dragged @@ -4197,11 +4517,17 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { row![ text(status_label).size(12).color(status_color), horizontal_space(), - text(format!("gate {:.1}%", gate_thresh * 100.0)).size(11).color(color_subtext), + text(format!("gate {:.1}%", gate_thresh * 100.0)) + .size(11) + .color(color_subtext), horizontal_space(), mic_test_control, - ].align_y(iced::alignment::Vertical::Center).spacing(8), - ].spacing(6).width(iced::Length::Fill); + ] + .align_y(iced::alignment::Vertical::Center) + .spacing(8), + ] + .spacing(6) + .width(iced::Length::Fill); // Inline room-layout chooser (Settings shows the thumbnails outright, no // popup button). Same SelectRoomLayout message, applied live + persisted. @@ -4222,7 +4548,9 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .on_press(AppMessage::SelectRoomLayout(layout)) .padding(2) .style(b_style(Color::TRANSPARENT, color_surface, color_text, 8.0)), - text(label).size(11).color(if selected { color_blue } else { color_subtext }), + text(label) + .size(11) + .color(if selected { color_blue } else { color_subtext }), ] .spacing(4) .align_x(iced::alignment::Horizontal::Center) @@ -4254,10 +4582,14 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .into() }; // 10 themes laid out as two rows of five (no flex-wrap in iced 0.14). - let theme_row1: Vec> = - AppTheme::ALL[0..5].iter().map(|&t| theme_choice(t)).collect(); - let theme_row2: Vec> = - AppTheme::ALL[5..].iter().map(|&t| theme_choice(t)).collect(); + let theme_row1: Vec> = AppTheme::ALL[0..5] + .iter() + .map(|&t| theme_choice(t)) + .collect(); + let theme_row2: Vec> = AppTheme::ALL[5..] + .iter() + .map(|&t| theme_choice(t)) + .collect(); // Title comes from the "Theme" section header (added below), so this body // is just the swatch rows + hint. let theme_section = column![ @@ -4314,7 +4646,11 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .on_press(AppMessage::SelectAvatar(a)) .padding(2) .style(b_style( - if selected { color_surface } else { Color::TRANSPARENT }, + if selected { + color_surface + } else { + Color::TRANSPARENT + }, color_surface, color_text, 8.0, @@ -4330,9 +4666,15 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { let mut avatar_tiles: Vec> = Vec::new(); // Show the current custom avatar (if any) as the first, selected tile. if matches!(state.config.avatar, crate::avatar::Avatar::Custom(_)) { - avatar_tiles.push(avatar_choice(state.config.avatar.clone(), "Custom".to_string())); + avatar_tiles.push(avatar_choice( + state.config.avatar.clone(), + "Custom".to_string(), + )); } - avatar_tiles.push(avatar_choice(crate::avatar::Avatar::Monogram, "Monogram".to_string())); + avatar_tiles.push(avatar_choice( + crate::avatar::Avatar::Monogram, + "Monogram".to_string(), + )); for i in 0..crate::avatar::PRESET_COUNT { avatar_tiles.push(avatar_choice( crate::avatar::Avatar::Preset(i), @@ -4386,7 +4728,12 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { // pick_list can't host per-option tooltips, so the modes are radios.) let mode_radio = |mode: RecordingMode, label: &'static str| -> Element<'_, AppMessage> { tooltip( - radio(label, mode, Some(state.config.recording_mode), AppMessage::RecordingModeSelected), + radio( + label, + mode, + Some(state.config.recording_mode), + AppMessage::RecordingModeSelected, + ), container(text(recording_mode_hint(mode)).size(11).color(color_text)) .padding(8) .max_width(300.0) @@ -4418,9 +4765,13 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { ] .spacing(2) .width(iced::Length::Fill), - container(text(binding).size(12).color(if capturing { color_yellow } else { color_subtext })) - .width(iced::Length::Fixed(110.0)) - .align_x(iced::alignment::Horizontal::Right), + container(text(binding).size(12).color(if capturing { + color_yellow + } else { + color_subtext + })) + .width(iced::Length::Fixed(110.0)) + .align_x(iced::alignment::Horizontal::Right), button(text("Set").size(12)) .on_press(AppMessage::StartHotkeyCapture(action)) .style(b_style(color_surface, color_blue, color_text, 6.0)) @@ -4482,9 +4833,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .unwrap_or("the key file could not be read or written"); container( column![ - text("⚠ Identity not saved") - .size(13) - .color(color_red), + text("⚠ Identity not saved").size(13).color(color_red), text(format!( "Your identity couldn't be saved to disk ({reason}). It won't \ survive the next launch, so your friends will stop recognising \ @@ -4498,8 +4847,15 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .padding(10) .width(iced::Length::Fill) .style(move |_t: &Theme| container::Style { - background: Some(Background::Color(Color { a: 0.12, ..color_red })), - border: Border { color: color_red, width: 1.0, radius: 8.0.into() }, + background: Some(Background::Color(Color { + a: 0.12, + ..color_red + })), + border: Border { + color: color_red, + width: 1.0, + radius: 8.0.into(), + }, ..Default::default() }) .into() @@ -4528,21 +4884,28 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .spacing(10) .align_y(iced::alignment::Vertical::Center) .into(), - None => text(format!("ID: {id_display}")).size(13).color(color_text).into(), + None => text(format!("ID: {id_display}")) + .size(13) + .color(color_text) + .into(), }; let identity_section = column![ - text("Your permanent ID — friends recognise you by this. It stays the \ + text( + "Your permanent ID — friends recognise you by this. It stays the \ same across launches; regenerate only to start fresh as a new \ - identity (friends who saved the old one will no longer reach you).") - .size(12) - .color(color_subtext), + identity (friends who saved the old one will no longer reach you)." + ) + .size(12) + .color(color_subtext), id_row, button(text("Regenerate identity").size(13)) .on_press(AppMessage::OpenRegenerateIdentityConfirm) .style(b_style(color_surface, color_maroon, color_text, 6.0)) .padding(8), identity_warning, - ].spacing(8).width(iced::Length::Fill); + ] + .spacing(8) + .width(iced::Length::Fill); // Presence + Friends moved to the home screen (see `friends_panel`). @@ -4827,8 +5190,16 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { let category_button = |category: SettingsCategory| -> Element<'_, AppMessage> { let selected = state.settings_category == category; let label_color = if selected { color_blue } else { color_text }; - let border_color = if selected { color_blue } else { Color::TRANSPARENT }; - let bg = if selected { color_surface } else { Color::TRANSPARENT }; + let border_color = if selected { + color_blue + } else { + Color::TRANSPARENT + }; + let bg = if selected { + color_surface + } else { + Color::TRANSPARENT + }; button( container( column![ @@ -4863,11 +5234,9 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .into() }; - let mut settings_nav = column![ - text("SETTINGS").size(11).color(color_subtext), - ] - .spacing(8) - .width(iced::Length::Fill); + let mut settings_nav = column![text("SETTINGS").size(11).color(color_subtext),] + .spacing(8) + .width(iced::Length::Fill); for category in SettingsCategory::ALL { settings_nav = settings_nav.push(category_button(category)); } @@ -4885,7 +5254,8 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { &SettingsCategory::ALL[..], Some(state.settings_category), AppMessage::SelectSettingsCategory, - ).width(iced::Length::Fill), + ) + .width(iced::Length::Fill), vertical_space(10.0), settings_body, ] @@ -4917,12 +5287,9 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { row![ container( button( - row![ - text("←").size(16), - text("Back").size(14), - ] - .spacing(6) - .align_y(iced::alignment::Vertical::Center) + row![text("←").size(16), text("Back").size(14),] + .spacing(6) + .align_y(iced::alignment::Vertical::Center) ) .on_press(AppMessage::NavigateBack) .style(b_style(color_surface, color_blue, color_text, 8.0)) @@ -4941,18 +5308,14 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .style(c_style(color_crust, color_surface, 8.0)); let settings_box = container( - column![ - settings_header, - vertical_space(12.0), - settings_content, - ] - .width(iced::Length::Fill) - .height(iced::Length::Fill), + column![settings_header, vertical_space(12.0), settings_content,] + .width(iced::Length::Fill) + .height(iced::Length::Fill), ) - .style(c_style(color_mantle, color_surface, 12.0)) - .padding(24) - .width(iced::Length::Fill) - .height(iced::Length::Fill); + .style(c_style(color_mantle, color_surface, 12.0)) + .padding(24) + .width(iced::Length::Fill) + .height(iced::Length::Fill); let settings_screen = container(settings_box) .width(iced::Length::Fill) @@ -5010,21 +5373,21 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { }); let home = container( - column![ - top_bar, - vertical_space(20.0), - body - ].align_x(iced::alignment::Horizontal::Center) + column![top_bar, vertical_space(20.0), body] + .align_x(iced::alignment::Horizontal::Center), ) - .width(iced::Length::Fill) - .height(iced::Length::Fill) - .style(c_style(root_bg, Color::TRANSPARENT, 0.0)); + .width(iced::Length::Fill) + .height(iced::Length::Fill) + .style(c_style(root_bg, Color::TRANSPARENT, 0.0)); with_hotkey_info(with_layout_picker(home.into(), state), state) } else { // --- ROOM SCREEN --- let participant_count = state.peers.len() + 1; // peers + you - let call_secs = state.call_started.map(|t| t.elapsed().as_secs()).unwrap_or(0); + let call_secs = state + .call_started + .map(|t| t.elapsed().as_secs()) + .unwrap_or(0); // The room's cosmetic label (W7) rides in our share ticket; show it under // the wordmark when the room was named. Sanitized since a joined ticket is // peer-supplied. @@ -5045,25 +5408,34 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { horizontal_space(), row![ icon(IconKind::People, 15.0, color_subtext), - text(format!("{participant_count} in room")).size(14).color(color_subtext), + text(format!("{participant_count} in room")) + .size(14) + .color(color_subtext), ] .spacing(5) .align_y(iced::alignment::Vertical::Center), row![ icon(IconKind::Clock, 15.0, color_subtext), - text(format_duration(call_secs)).size(14).color(color_subtext), + text(format_duration(call_secs)) + .size(14) + .color(color_subtext), ] .spacing(5) .align_y(iced::alignment::Vertical::Center), if state.recording { - let rec_secs = state.recording_started.map(|t| t.elapsed().as_secs()).unwrap_or(0); + let rec_secs = state + .recording_started + .map(|t| t.elapsed().as_secs()) + .unwrap_or(0); container( row![ icon(IconKind::Record, 12.0, color_red), - text(format!("REC {}", format_duration(rec_secs))).size(13).color(color_red), + text(format!("REC {}", format_duration(rec_secs))) + .size(13) + .color(color_red), ] .spacing(5) - .align_y(iced::alignment::Vertical::Center) + .align_y(iced::alignment::Vertical::Center), ) .style(c_style(color_crust, color_red, 6.0)) .padding(6) @@ -5091,33 +5463,30 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .spacing(5) .align_y(iced::alignment::Vertical::Center) ) - .on_press(AppMessage::CopyToClipboard) - .style(b_style(color_surface, color_blue, color_text, 6.0)) - .padding(6), + .on_press(AppMessage::CopyToClipboard) + .style(b_style(color_surface, color_blue, color_text, 6.0)) + .padding(6), // Drawer layout: a chat toggle (the drawer is collapsed by default). { - let el: Element<'_, AppMessage> = - if state.config.room_layout == RoomLayout::Drawer { - let (lbl, bg, fg) = if state.drawer_chat_open { - ("Hide chat", color_blue, color_crust) - } else { - ("Chat", color_surface, color_text) - }; - button( - row![ - icon(IconKind::Chat, 14.0, fg), - text(lbl).size(12), - ] - .spacing(5) - .align_y(iced::alignment::Vertical::Center) - ) - .on_press(AppMessage::ToggleDrawerChat) - .style(b_style(bg, color_blue, fg, 6.0)) - .padding(6) - .into() + let el: Element<'_, AppMessage> = if state.config.room_layout == RoomLayout::Drawer + { + let (lbl, bg, fg) = if state.drawer_chat_open { + ("Hide chat", color_blue, color_crust) } else { - iced::widget::Space::new().width(0.0).height(0.0).into() + ("Chat", color_surface, color_text) }; + button( + row![icon(IconKind::Chat, 14.0, fg), text(lbl).size(12),] + .spacing(5) + .align_y(iced::alignment::Vertical::Center), + ) + .on_press(AppMessage::ToggleDrawerChat) + .style(b_style(bg, color_blue, fg, 6.0)) + .padding(6) + .into() + } else { + iced::widget::Space::new().width(0.0).height(0.0).into() + }; el } ] @@ -5131,11 +5500,15 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { // Peers Column let mut peers_list = Column::new().spacing(10); - + // Add ourselves — name/status row plus a live mic meter so you can // confirm you're being picked up (and see mute / PTT / gate at work). let transmitting = !state.is_muted && (!state.ptt_enabled || state.ptt_active); - let self_mic_color = if transmitting { color_green } else { color_subtext }; + let self_mic_color = if transmitting { + color_green + } else { + color_subtext + }; let self_card = container( column![ row![ @@ -5334,12 +5707,13 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { // broadcasting a game (game presence, W17). let mut name_col = column![ text(&peer.name).size(16).color(color_text), - text(format!("ID: {}", short_id(&peer_id.to_string()))).size(11).color(color_subtext), + text(format!("ID: {}", short_id(&peer_id.to_string()))) + .size(11) + .color(color_subtext), ]; if let Some(game) = &peer.game { - name_col = name_col.push( - text(format!("Playing {game}")).size(11).color(color_blue), - ); + name_col = name_col + .push(text(format!("Playing {game}")).size(11).color(color_blue)); } name_col }, @@ -5356,9 +5730,14 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .style(move |_t: &Theme| iced::widget::progress_bar::Style { background: Background::Color(color_crust), bar: Background::Color(vu_color), - border: Border { color: color_surface, width: 1.0, radius: 4.0.into() }, + border: Border { + color: color_surface, + width: 1.0, + radius: 4.0.into() + }, }), - ].spacing(8); + ] + .spacing(8); // Peer volume slider let current_vol = state @@ -5370,10 +5749,14 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { card_content = card_content.push( row![ text("Vol:").size(12).color(color_subtext), - slider(0.0..=2.0, current_vol, move |v| AppMessage::PeerVolumeChanged(peer_id_clone, v)) - .step(0.01) - .on_release(AppMessage::PersistConfig) - ].spacing(8).align_y(iced::alignment::Vertical::Center) + slider(0.0..=2.0, current_vol, move |v| { + AppMessage::PeerVolumeChanged(peer_id_clone, v) + }) + .step(0.01) + .on_release(AppMessage::PersistConfig) + ] + .spacing(8) + .align_y(iced::alignment::Vertical::Center), ); let peer_key = peer_id.to_string(); @@ -5383,9 +5766,13 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { text("Pan:").size(12).color(color_subtext), container(text(pan_label(current_pan)).size(11).color(color_subtext)) .width(iced::Length::Fixed(58.0)), - slider(-1.0..=1.0, current_pan, move |v| AppMessage::PeerPanChanged(peer_id_clone, v)) - .step(0.05) - .on_release(AppMessage::PersistConfig), + slider( + -1.0..=1.0, + current_pan, + move |v| AppMessage::PeerPanChanged(peer_id_clone, v) + ) + .step(0.05) + .on_release(AppMessage::PersistConfig), ] .spacing(8) .align_y(iced::alignment::Vertical::Center), @@ -5394,40 +5781,55 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { // Peer noise gate: suppress this peer's background noise on our end. // Threshold is normalized RMS on the same 0..METER_MAX scale as the // mic gate; 0 = off. - let current_gate = state.config.peer_gate.get(&peer_key).copied().unwrap_or(0.0); + let current_gate = state + .config + .peer_gate + .get(&peer_key) + .copied() + .unwrap_or(0.0); let gate_label = if current_gate <= 0.0 { "Off".to_string() } else { - format!("{:.0}%", (current_gate / METER_MAX * 100.0).clamp(0.0, 100.0)) + format!( + "{:.0}%", + (current_gate / METER_MAX * 100.0).clamp(0.0, 100.0) + ) }; card_content = card_content.push( row![ text("Gate:").size(12).color(color_subtext), container(text(gate_label).size(11).color(color_subtext)) .width(iced::Length::Fixed(58.0)), - slider(0.0..=METER_MAX, current_gate, move |v| AppMessage::PeerGateChanged(peer_id_clone, v)) - .step(0.001) - .on_release(AppMessage::PersistConfig), + slider(0.0..=METER_MAX, current_gate, move |v| { + AppMessage::PeerGateChanged(peer_id_clone, v) + }) + .step(0.001) + .on_release(AppMessage::PersistConfig), ] .spacing(8) .align_y(iced::alignment::Vertical::Center), ); let eq = peer_eq_settings(&state.config, peer_id); - let eq_row = |label: &'static str, band: EqBand, value: f32| -> Element<'_, AppMessage> { - row![ - container(text(format!("{label} {value:+.1} dB")).size(11).color(color_subtext)) + let eq_row = + |label: &'static str, band: EqBand, value: f32| -> Element<'_, AppMessage> { + row![ + container( + text(format!("{label} {value:+.1} dB")) + .size(11) + .color(color_subtext) + ) .width(iced::Length::Fixed(86.0)), - slider(EQ_GAIN_DB_MIN..=EQ_GAIN_DB_MAX, value, move |v| { - AppMessage::PeerEqChanged(peer_id_clone, band, v) - }) - .step(0.5) - .on_release(AppMessage::PersistConfig), - ] - .spacing(8) - .align_y(iced::alignment::Vertical::Center) - .into() - }; + slider(EQ_GAIN_DB_MIN..=EQ_GAIN_DB_MAX, value, move |v| { + AppMessage::PeerEqChanged(peer_id_clone, band, v) + }) + .step(0.5) + .on_release(AppMessage::PersistConfig), + ] + .spacing(8) + .align_y(iced::alignment::Vertical::Center) + .into() + }; card_content = card_content.push( column![ text("EQ").size(11).color(color_subtext), @@ -5439,42 +5841,90 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { ); let card = container(card_content) - .style(c_style( - if is_speaking { color_base } else { color_mantle }, - if is_connecting { color_yellow } else if is_speaking { color_green } else { color_surface }, - 6.0 - )) - .padding(12); + .style(c_style( + if is_speaking { + color_base + } else { + color_mantle + }, + if is_connecting { + color_yellow + } else if is_speaking { + color_green + } else { + color_surface + }, + 6.0, + )) + .padding(12); peers_list = peers_list.push(card); } let scroll_peers = scrollable(peers_list); - let peers_panel = container( - column![ - text("Room Participants").size(18).color(color_blue), - vertical_space(10.0), - scroll_peers - ] - ) + let peers_panel = container(column![ + text("Room Participants").size(18).color(color_blue), + vertical_space(10.0), + scroll_peers + ]) .style(c_style(color_mantle, Color::TRANSPARENT, 0.0)) .padding(15) .height(iced::Length::Fill); // Control Panel Column - let mute_text = if state.is_muted { "Unmute Mic" } else { "Mute Mic" }; - let mute_bg = if state.is_muted { color_red } else { color_surface }; - let mute_hover = if state.is_muted { color_maroon } else { color_blue }; - let mute_fg = if state.is_muted { color_crust } else { color_text }; + let mute_text = if state.is_muted { + "Unmute Mic" + } else { + "Mute Mic" + }; + let mute_bg = if state.is_muted { + color_red + } else { + color_surface + }; + let mute_hover = if state.is_muted { + color_maroon + } else { + color_blue + }; + let mute_fg = if state.is_muted { + color_crust + } else { + color_text + }; - let deafen_text = if state.is_deafened { "Undeafen Audio" } else { "Deafen Audio" }; - let deafen_bg = if state.is_deafened { color_red } else { color_surface }; - let deafen_hover = if state.is_deafened { color_maroon } else { color_blue }; - let deafen_fg = if state.is_deafened { color_crust } else { color_text }; + let deafen_text = if state.is_deafened { + "Undeafen Audio" + } else { + "Deafen Audio" + }; + let deafen_bg = if state.is_deafened { + color_red + } else { + color_surface + }; + let deafen_hover = if state.is_deafened { + color_maroon + } else { + color_blue + }; + let deafen_fg = if state.is_deafened { + color_crust + } else { + color_text + }; - let mute_kind = if state.is_muted { IconKind::MicOff } else { IconKind::Mic }; - let deafen_kind = if state.is_deafened { IconKind::Deafen } else { IconKind::Headphones }; + let mute_kind = if state.is_muted { + IconKind::MicOff + } else { + IconKind::Mic + }; + let deafen_kind = if state.is_deafened { + IconKind::Deafen + } else { + IconKind::Headphones + }; let music_panel: Element<'_, AppMessage> = { let music_status = status_snapshot(&state.music_status); let music_playing = music_status.playing_id.is_some(); @@ -5488,26 +5938,50 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { let personal_selected = state.music_tab == MusicTab::Personal; let public_selected = state.music_tab == MusicTab::Public; let tab_row = row![ - button(text("Personal").size(12).align_x(iced::alignment::Horizontal::Center)) - .on_press(AppMessage::MusicSelectTab(MusicTab::Personal)) - .style(b_style( - if personal_selected { color_blue } else { color_surface }, - color_blue, - if personal_selected { color_crust } else { color_text }, - 6.0, - )) - .padding(8) - .width(iced::Length::Fill), - button(text("Public").size(12).align_x(iced::alignment::Horizontal::Center)) - .on_press(AppMessage::MusicSelectTab(MusicTab::Public)) - .style(b_style( - if public_selected { color_blue } else { color_surface }, - color_blue, - if public_selected { color_crust } else { color_text }, - 6.0, - )) - .padding(8) - .width(iced::Length::Fill), + button( + text("Personal") + .size(12) + .align_x(iced::alignment::Horizontal::Center) + ) + .on_press(AppMessage::MusicSelectTab(MusicTab::Personal)) + .style(b_style( + if personal_selected { + color_blue + } else { + color_surface + }, + color_blue, + if personal_selected { + color_crust + } else { + color_text + }, + 6.0, + )) + .padding(8) + .width(iced::Length::Fill), + button( + text("Public") + .size(12) + .align_x(iced::alignment::Horizontal::Center) + ) + .on_press(AppMessage::MusicSelectTab(MusicTab::Public)) + .style(b_style( + if public_selected { + color_blue + } else { + color_surface + }, + color_blue, + if public_selected { + color_crust + } else { + color_text + }, + 6.0, + )) + .padding(8) + .width(iced::Length::Fill), ] .spacing(8); @@ -5528,13 +6002,21 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { let btn = button(text("▲").size(13)) .style(b_style(color_surface, color_overlay, color_text, 6.0)) .padding(6); - if i > 0 { btn.on_press(AppMessage::MusicMoveUp(i)) } else { btn } + if i > 0 { + btn.on_press(AppMessage::MusicMoveUp(i)) + } else { + btn + } }; let down = { let btn = button(text("▼").size(13)) .style(b_style(color_surface, color_overlay, color_text, 6.0)) .padding(6); - if i < last { btn.on_press(AppMessage::MusicMoveDown(i)) } else { btn } + if i < last { + btn.on_press(AppMessage::MusicMoveDown(i)) + } else { + btn + } }; tracks = tracks.push( row![ @@ -5552,7 +6034,12 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { down, button(text("×").size(13)) .on_press(AppMessage::MusicRemove(i)) - .style(b_style(color_surface, color_overlay, color_text, 6.0)) + .style(b_style( + color_surface, + color_overlay, + color_text, + 6.0 + )) .padding(6), ] .spacing(6) @@ -5592,13 +6079,21 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .size(11) .color(color_subtext), text("Music volume").size(11).color(color_subtext), - slider(0.0..=2.0, state.config.music_volume, AppMessage::MusicSetVolume) - .step(0.01), - button(text("Browse").size(12).align_x(iced::alignment::Horizontal::Center)) - .on_press(AppMessage::MusicBrowse) - .style(b_style(color_surface, color_blue, color_text, 6.0)) - .padding(8) - .width(iced::Length::Fill), + slider( + 0.0..=2.0, + state.config.music_volume, + AppMessage::MusicSetVolume + ) + .step(0.01), + button( + text("Browse") + .size(12) + .align_x(iced::alignment::Horizontal::Center) + ) + .on_press(AppMessage::MusicBrowse) + .style(b_style(color_surface, color_blue, color_text, 6.0)) + .padding(8) + .width(iced::Length::Fill), checkbox(state.music_broadcasting) .label("Let others tune in") .text_size(12) @@ -5618,7 +6113,9 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { if Some(*id) == self_id { continue; } - let Some(music) = &peer.music else { continue; }; + let Some(music) = &peer.music else { + continue; + }; count += 1; broadcast_rows = broadcast_rows.push( row![ @@ -5645,43 +6142,48 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { ); } - let listen_block: Element<'_, AppMessage> = - if let Some(peer) = state.music_listening_to { - let name = state - .peers - .get(&peer) - .map(|p| p.name.clone()) - .unwrap_or_else(|| "source".to_string()); - let elapsed = format_clip_time(music_status.position); - let duration = music_status - .total - .map(format_clip_time) - .unwrap_or_else(|| "--:--".to_string()); - column![ - row![ - text(format!("▶ Listening to {name}")) - .size(12) - .color(color_blue) - .width(iced::Length::Fill), - button(text("Stop").size(12)) - .on_press(AppMessage::MusicStopListen) - .style(b_style(color_surface, color_maroon, color_text, 6.0)) - .padding(6), - ] - .spacing(8) - .align_y(iced::alignment::Vertical::Center), - text(format!("{elapsed} / {duration}")) - .size(11) - .color(color_subtext), - text("Listen volume").size(11).color(color_subtext), - slider(0.0..=2.0, effective_music_volume(state), AppMessage::MusicSetSourceVolume) - .step(0.01), + let listen_block: Element<'_, AppMessage> = if let Some(peer) = + state.music_listening_to + { + let name = state + .peers + .get(&peer) + .map(|p| p.name.clone()) + .unwrap_or_else(|| "source".to_string()); + let elapsed = format_clip_time(music_status.position); + let duration = music_status + .total + .map(format_clip_time) + .unwrap_or_else(|| "--:--".to_string()); + column![ + row![ + text(format!("▶ Listening to {name}")) + .size(12) + .color(color_blue) + .width(iced::Length::Fill), + button(text("Stop").size(12)) + .on_press(AppMessage::MusicStopListen) + .style(b_style(color_surface, color_maroon, color_text, 6.0)) + .padding(6), ] .spacing(8) - .into() - } else { - column![].into() - }; + .align_y(iced::alignment::Vertical::Center), + text(format!("{elapsed} / {duration}")) + .size(11) + .color(color_subtext), + text("Listen volume").size(11).color(color_subtext), + slider( + 0.0..=2.0, + effective_music_volume(state), + AppMessage::MusicSetSourceVolume + ) + .step(0.01), + ] + .spacing(8) + .into() + } else { + column![].into() + }; column![ listen_block, @@ -5695,17 +6197,11 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { } }; - container( - column![ - tab_row, - content, - ] - .spacing(10) - ) - .style(c_style(color_mantle, color_surface, 6.0)) - .padding(10) - .width(iced::Length::Fill) - .into() + container(column![tab_row, content,].spacing(10)) + .style(c_style(color_mantle, color_surface, 6.0)) + .padding(10) + .width(iced::Length::Fill) + .into() }; let music_bar_status = status_snapshot(&state.music_status); let music_bar_playing = music_bar_status.playing_id.is_some(); @@ -5761,15 +6257,30 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { text(format!("{elapsed} / {duration}")) .size(11) .color(color_subtext), - button(text(if state.playlist_drawer_open { "⤡" } else { "⤢" }).size(13)) - .on_press(AppMessage::TogglePlaylistDrawer) - .style(b_style( - if state.playlist_drawer_open { color_blue } else { color_surface }, - color_blue, - if state.playlist_drawer_open { color_crust } else { color_text }, - 6.0, - )) - .padding(7), + button( + text(if state.playlist_drawer_open { + "⤡" + } else { + "⤢" + }) + .size(13) + ) + .on_press(AppMessage::TogglePlaylistDrawer) + .style(b_style( + if state.playlist_drawer_open { + color_blue + } else { + color_surface + }, + color_blue, + if state.playlist_drawer_open { + color_crust + } else { + color_text + }, + 6.0, + )) + .padding(7), ] .spacing(8) .align_y(iced::alignment::Vertical::Center) @@ -5778,7 +6289,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .align_x(iced::alignment::Horizontal::Right), ] .spacing(8) - .align_y(iced::alignment::Vertical::Center) + .align_y(iced::alignment::Vertical::Center), ) .style(c_style(color_mantle, color_surface, 8.0)) .padding(10) @@ -5786,18 +6297,20 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .height(iced::Length::Fixed(56.0)); let ctrl_buttons = column![ button(btn_content(mute_kind, mute_text, mute_fg)) - .on_press(AppMessage::ToggleMutePressed) - .style(b_style(mute_bg, mute_hover, mute_fg, 8.0)) - .padding(14) - .width(iced::Length::Fill), + .on_press(AppMessage::ToggleMutePressed) + .style(b_style(mute_bg, mute_hover, mute_fg, 8.0)) + .padding(14) + .width(iced::Length::Fill), vertical_space(10.0), button(btn_content(deafen_kind, deafen_text, deafen_fg)) - .on_press(AppMessage::ToggleDeafenPressed) - .style(b_style(deafen_bg, deafen_hover, deafen_fg, 8.0)) - .padding(14) - .width(iced::Length::Fill), + .on_press(AppMessage::ToggleDeafenPressed) + .style(b_style(deafen_bg, deafen_hover, deafen_fg, 8.0)) + .padding(14) + .width(iced::Length::Fill), vertical_space(20.0), - checkbox(state.ptt_enabled).label("Push-to-Talk").on_toggle(AppMessage::TogglePtt), + checkbox(state.ptt_enabled) + .label("Push-to-Talk") + .on_toggle(AppMessage::TogglePtt), vertical_space(10.0), if state.ptt_enabled { let ptt_binding = if state.hotkey_capture == Some(HotkeyAction::PushToTalk) { @@ -5806,13 +6319,20 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { format_binding(state.config.hotkeys.binding(HotkeyAction::PushToTalk)) }; column![ - text(format!("PTT key: {ptt_binding}")).size(14).color(color_subtext), - button(text("Set PTT Key").size(12).align_x(iced::alignment::Horizontal::Center)) - .on_press(AppMessage::StartHotkeyCapture(HotkeyAction::PushToTalk)) - .style(b_style(color_surface, color_blue, color_text, 6.0)) - .padding(8) - .width(iced::Length::Fill) - ].spacing(8) + text(format!("PTT key: {ptt_binding}")) + .size(14) + .color(color_subtext), + button( + text("Set PTT Key") + .size(12) + .align_x(iced::alignment::Horizontal::Center) + ) + .on_press(AppMessage::StartHotkeyCapture(HotkeyAction::PushToTalk)) + .style(b_style(color_surface, color_blue, color_text, 6.0)) + .padding(8) + .width(iced::Length::Fill) + ] + .spacing(8) } else { column![] }, @@ -5843,10 +6363,13 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { #[cfg(not(target_os = "linux"))] { column![ - checkbox(false) - .label("Echo cancellation"), - text("Not available on Windows yet.").size(11).color(color_subtext), - ].spacing(4).into() + checkbox(false).label("Echo cancellation"), + text("Not available on Windows yet.") + .size(11) + .color(color_subtext), + ] + .spacing(4) + .into() } }; control @@ -5854,15 +6377,27 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { vertical_space(20.0), { let (rec_kind, rec_label, rec_bg, rec_hover, rec_fg) = if state.recording { - (IconKind::Stop, "Stop Recording", color_red, color_maroon, color_crust) + ( + IconKind::Stop, + "Stop Recording", + color_red, + color_maroon, + color_crust, + ) } else { - (IconKind::Record, "Record Call", color_surface, color_blue, color_text) + ( + IconKind::Record, + "Record Call", + color_surface, + color_blue, + color_text, + ) }; button(btn_content(rec_kind, rec_label, rec_fg)) - .on_press(AppMessage::ToggleRecording) - .style(b_style(rec_bg, rec_hover, rec_fg, 8.0)) - .padding(14) - .width(iced::Length::Fill) + .on_press(AppMessage::ToggleRecording) + .style(b_style(rec_bg, rec_hover, rec_fg, 8.0)) + .padding(14) + .width(iced::Length::Fill) }, vertical_space(20.0), { @@ -5872,9 +6407,21 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { // button (A11) — so it never reads as a broken in-app feature. let (share_kind, share_label, share_bg, share_hover, share_fg) = if state.self_sharing { - (IconKind::Stop, "Stop Sharing", color_red, color_maroon, color_crust) + ( + IconKind::Stop, + "Stop Sharing", + color_red, + color_maroon, + color_crust, + ) } else { - (IconKind::Monitor, "Share Screen", color_surface, color_blue, color_text) + ( + IconKind::Monitor, + "Share Screen", + color_surface, + color_blue, + color_text, + ) }; let share_press = if state.pixelpass_available { AppMessage::ToggleScreenShare @@ -5900,19 +6447,17 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { // Controls panel — width is set per layout below. The controls SCROLL when // the window is too short, and Leave stays pinned at the bottom so the exit // control is always reachable instead of being clipped off-screen (A12). - let control_panel = container( - column![ - text("Controls").size(18).color(color_blue), - vertical_space(15.0), - scrollable(ctrl_buttons) - .width(iced::Length::Fill) - .height(iced::Length::Fill), - vertical_space(12.0), - leave_btn, - vertical_space(8.0), - text(&state.status_message).size(12).color(color_subtext) - ] - ) + let control_panel = container(column![ + text("Controls").size(18).color(color_blue), + vertical_space(15.0), + scrollable(ctrl_buttons) + .width(iced::Length::Fill) + .height(iced::Length::Fill), + vertical_space(12.0), + leave_btn, + vertical_space(8.0), + text(&state.status_message).size(12).color(color_subtext) + ]) .style(c_style(color_mantle, Color::TRANSPARENT, 0.0)) .padding(15) .height(iced::Length::Fill); @@ -5935,9 +6480,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { let spans: Vec<_> = crate::sanitize::linkify(&m.text) .into_iter() .map(|seg| match seg { - crate::sanitize::Segment::Text(t) => { - span(t).size(13).color(color_text) - } + crate::sanitize::Segment::Text(t) => span(t).size(13).color(color_text), crate::sanitize::Segment::Link(u) => { span(u.clone()).size(13).color(color_blue).link(u) } @@ -5986,152 +6529,137 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .and_then(|f| f.parse::().ok()) .map(|eid| (eid, att.id)); let data = key.as_ref().and_then(|k| state.attachments.get(k)); - let elem: Element<'_, AppMessage> = - if let Some(AttachmentState::Failed(e)) = data { - text(format!("⚠ {} — {e}", att.name)) - .size(12) - .color(color_red) - .into() - } else if att.kind == crate::files::AttachmentKind::Image { - match key.as_ref().and_then(|k| state.attachments.handle(k)) { - Some(handle) => { - // Click an inline image to open it enlarged in the - // lightbox overlay (pointer cursor signals it's - // interactive). Only wired when we have a valid - // (author, id) key to look the handle back up. - let img = iced::widget::image(handle.clone()) - .width(iced::Length::Fixed(260.0)); - match key { - Some(k) => mouse_area(img) - .interaction(mouse::Interaction::Pointer) - .on_press(AppMessage::OpenImageLightbox(k)) - .into(), - None => img.into(), - } - } - None => text(format!("🖼 {} — loading…", att.name)) - .size(12) - .color(color_subtext) - .into(), - } - } else if crate::files::looks_like_audio_name(&att.name) - && !state.invalid_audio.contains(&att.id) - { - let active = clip_status.playing_id == Some(att.id); - let loading = state.pending_plays.contains(&att.id) - && !matches!(data, Some(AttachmentState::Ready(_))); - let position = if active { - clip_status.position - } else { - std::time::Duration::ZERO - }; - let total = active.then_some(clip_status.total).flatten(); - let play_button = if loading { - button(text("Loading…").size(12)) - } else if active && clip_status.paused { - button(text("Play").size(12)).on_press(AppMessage::ResumeAudio) - } else if active { - button(text("Pause").size(12)).on_press(AppMessage::PauseAudio) - } else { - button(text("Play").size(12)) - .on_press_maybe(key.map(AppMessage::PlayAudio)) - } - .style(b_style( - color_blue, - color_lavender, - color_crust, - 6.0, - )) - .padding(6); - let elapsed = format_clip_time(position); - let duration = total - .map(format_clip_time) - .unwrap_or_else(|| "--:--".to_string()); - column![ - row![ - text(format!( - "{} ({})", - att.name, - crate::files::human_size(att.size) - )) - .size(12) - .color(color_text), - button(text(if matches!(data, Some(AttachmentState::Ready(_))) { - "Save" - } else { - "Download" - }) - .size(12)) - .on_press_maybe(key.map(AppMessage::SaveAttachment)) - .style(b_style( - color_surface, - color_overlay, - color_text, - 6.0, - )) - .padding(6), - ] - .spacing(8) - .align_y(iced::alignment::Vertical::Center), - row![ - play_button, - slider( - 0.0..=1.0, - if active { - clip_progress(position, total) - } else { - 0.0 - }, - move |fraction| AppMessage::SeekAudio(att.id, fraction), - ) - .step(0.001) - .width(iced::Length::Fixed(180.0)), - text(format!("{elapsed} / {duration}")) - .size(11) - .color(color_subtext), - // Per-clip volume. In universal mode this shows - // and drives the shared level; otherwise it is - // this clip's own remembered level. - text("🔊").size(12).color(color_subtext), - slider( - 0.0..=2.0, - effective_clip_volume(state, att.id), - move |v| AppMessage::SetClipVolumeFor(att.id, v), - ) - .step(0.01) - .width(iced::Length::Fixed(80.0)), - ] - .spacing(8) - .align_y(iced::alignment::Vertical::Center), - ] - .spacing(4) + let elem: Element<'_, AppMessage> = if let Some(AttachmentState::Failed(e)) = + data + { + text(format!("⚠ {} — {e}", att.name)) + .size(12) + .color(color_red) .into() + } else if att.kind == crate::files::AttachmentKind::Image { + match key.as_ref().and_then(|k| state.attachments.handle(k)) { + Some(handle) => { + // Click an inline image to open it enlarged in the + // lightbox overlay (pointer cursor signals it's + // interactive). Only wired when we have a valid + // (author, id) key to look the handle back up. + let img = iced::widget::image(handle.clone()) + .width(iced::Length::Fixed(260.0)); + match key { + Some(k) => mouse_area(img) + .interaction(mouse::Interaction::Pointer) + .on_press(AppMessage::OpenImageLightbox(k)) + .into(), + None => img.into(), + } + } + None => text(format!("🖼 {} — loading…", att.name)) + .size(12) + .color(color_subtext) + .into(), + } + } else if crate::files::looks_like_audio_name(&att.name) + && !state.invalid_audio.contains(&att.id) + { + let active = clip_status.playing_id == Some(att.id); + let loading = state.pending_plays.contains(&att.id) + && !matches!(data, Some(AttachmentState::Ready(_))); + let position = if active { + clip_status.position } else { - let ready = - matches!(data, Some(AttachmentState::Ready(_))); - let btn_label = if ready { "Save" } else { "Download" }; + std::time::Duration::ZERO + }; + let total = active.then_some(clip_status.total).flatten(); + let play_button = if loading { + button(text("Loading…").size(12)) + } else if active && clip_status.paused { + button(text("Play").size(12)).on_press(AppMessage::ResumeAudio) + } else if active { + button(text("Pause").size(12)).on_press(AppMessage::PauseAudio) + } else { + button(text("Play").size(12)) + .on_press_maybe(key.map(AppMessage::PlayAudio)) + } + .style(b_style(color_blue, color_lavender, color_crust, 6.0)) + .padding(6); + let elapsed = format_clip_time(position); + let duration = total + .map(format_clip_time) + .unwrap_or_else(|| "--:--".to_string()); + column![ row![ text(format!( - "📎 {} ({})", + "{} ({})", att.name, crate::files::human_size(att.size) )) .size(12) .color(color_text), - button(text(btn_label).size(12)) - .on_press_maybe(key.map(AppMessage::SaveAttachment)) - .style(b_style( - color_blue, - color_lavender, - color_crust, - 6.0, - )) - .padding(6), + button( + text(if matches!(data, Some(AttachmentState::Ready(_))) { + "Save" + } else { + "Download" + }) + .size(12) + ) + .on_press_maybe(key.map(AppMessage::SaveAttachment)) + .style(b_style(color_surface, color_overlay, color_text, 6.0,)) + .padding(6), ] .spacing(8) - .align_y(iced::alignment::Vertical::Center) - .into() - }; + .align_y(iced::alignment::Vertical::Center), + row![ + play_button, + slider( + 0.0..=1.0, + if active { + clip_progress(position, total) + } else { + 0.0 + }, + move |fraction| AppMessage::SeekAudio(att.id, fraction), + ) + .step(0.001) + .width(iced::Length::Fixed(180.0)), + text(format!("{elapsed} / {duration}")) + .size(11) + .color(color_subtext), + // Per-clip volume. In universal mode this shows + // and drives the shared level; otherwise it is + // this clip's own remembered level. + text("🔊").size(12).color(color_subtext), + slider(0.0..=2.0, effective_clip_volume(state, att.id), move |v| { + AppMessage::SetClipVolumeFor(att.id, v) + },) + .step(0.01) + .width(iced::Length::Fixed(80.0)), + ] + .spacing(8) + .align_y(iced::alignment::Vertical::Center), + ] + .spacing(4) + .into() + } else { + let ready = matches!(data, Some(AttachmentState::Ready(_))); + let btn_label = if ready { "Save" } else { "Download" }; + row![ + text(format!( + "📎 {} ({})", + att.name, + crate::files::human_size(att.size) + )) + .size(12) + .color(color_text), + button(text(btn_label).size(12)) + .on_press_maybe(key.map(AppMessage::SaveAttachment)) + .style(b_style(color_blue, color_lavender, color_crust, 6.0,)) + .padding(6), + ] + .spacing(8) + .align_y(iced::alignment::Vertical::Center) + .into() + }; chat_col = chat_col.push( row![ iced::widget::Space::new().width(iced::Length::Fixed(30.0)), @@ -6175,27 +6703,36 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .text_size(12) .on_toggle(AppMessage::ToggleUniversalClipVolume), text("🔊").size(13).color(color_subtext), - slider(0.0..=2.0, state.config.clip_volume, AppMessage::SetClipVolume) - .step(0.01) - .width(iced::Length::Fixed(110.0)), + slider( + 0.0..=2.0, + state.config.clip_volume, + AppMessage::SetClipVolume + ) + .step(0.01) + .width(iced::Length::Fixed(110.0)), ] .spacing(10) .align_y(iced::alignment::Vertical::Center); - let chat_inner = column![ - chat_header, - chat_scroll, - chat_input_row, - ] - .spacing(8); + let chat_inner = column![chat_header, chat_scroll, chat_input_row,].spacing(8); // Divider constructors (fresh widget per call). let vdiv = |kind| { - Canvas::new(Divider { kind, vertical: true, line: color_surface, grip: color_lavender }) - .width(iced::Length::Fixed(DIVIDER_THICKNESS)) - .height(iced::Length::Fill) + Canvas::new(Divider { + kind, + vertical: true, + line: color_surface, + grip: color_lavender, + }) + .width(iced::Length::Fixed(DIVIDER_THICKNESS)) + .height(iced::Length::Fill) }; let hdiv = |kind| { - Canvas::new(Divider { kind, vertical: false, line: color_surface, grip: color_lavender }) + Canvas::new(Divider { + kind, + vertical: false, + line: color_surface, + grip: color_lavender, + }) .width(iced::Length::Fill) .height(iced::Length::Fixed(DIVIDER_THICKNESS)) }; @@ -6208,7 +6745,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .width(iced::Length::Fill) .height(iced::Length::Fill), ] - .spacing(0) + .spacing(0), ) .style(c_style(color_mantle, color_surface, 8.0)) .padding(12) @@ -6229,7 +6766,8 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { RoomLayout::BottomDock => { // Cap Participants so the Fill Controls panel keeps its minimum. let avail = body_w - 30.0; - let pwb = pw.min((avail - CONTROLS_MIN_W - DIVIDER_THICKNESS).max(PARTICIPANTS_MIN_W)); + let pwb = + pw.min((avail - CONTROLS_MIN_W - DIVIDER_THICKNESS).max(PARTICIPANTS_MIN_W)); let main = row![ peers_panel.width(iced::Length::Fixed(pwb)), vdiv(DividerKind::Panels), @@ -6272,7 +6810,10 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { // Participants + Chat drawer are both fixed; cap Participants so // the Fill Controls panel between them keeps its minimum. let pwd = pw.min( - (avail - state.config.chat_drawer_width - CONTROLS_MIN_W - 2.0 * DIVIDER_THICKNESS) + (avail + - state.config.chat_drawer_width + - CONTROLS_MIN_W + - 2.0 * DIVIDER_THICKNESS) .max(PARTICIPANTS_MIN_W), ); let chat = container(chat_inner) @@ -6291,7 +6832,8 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .height(iced::Length::Fill) .into() } else { - let pwd = pw.min((avail - CONTROLS_MIN_W - DIVIDER_THICKNESS).max(PARTICIPANTS_MIN_W)); + let pwd = pw + .min((avail - CONTROLS_MIN_W - DIVIDER_THICKNESS).max(PARTICIPANTS_MIN_W)); row![ peers_panel.width(iced::Length::Fixed(pwd)), vdiv(DividerKind::Panels), @@ -6304,40 +6846,55 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { } }; - let clock_skew_banner: Element<'_, AppMessage> = - if let Some(warning) = state.clock_skew_warning { - let direction = if warning.peer_ahead { "ahead" } else { "behind" }; - let skew = format_clock_skew_duration(warning.skew_secs); - let copy = format!( - "A peer couldn't be seen - clocks are out of sync by ~{skew} (peer clock looks {direction}). Check your system clock (turn on automatic time sync)." - ); - column![ - vertical_space(10.0), - container( - row![ - icon(IconKind::Clock, 16.0, color_yellow), - text(copy).size(12).color(color_text).width(iced::Length::Fill), - button(text("Dismiss").size(12)) - .on_press(AppMessage::DismissClockSkewWarning) - .style(b_style(color_surface, color_blue, color_text, 6.0)) - .padding(6), - ] - .spacing(10) - .align_y(iced::alignment::Vertical::Center) - ) - .padding(10) - .width(iced::Length::Fill) - .style(move |_theme: &Theme| container::Style { - text_color: Some(color_text), - background: Some(Background::Color(Color { a: 0.14, ..color_yellow })), - border: Border { color: color_yellow, width: 1.0, radius: 8.0.into() }, - ..Default::default() - }) - ] - .into() + let clock_skew_banner: Element<'_, AppMessage> = if let Some(warning) = + state.clock_skew_warning + { + let direction = if warning.peer_ahead { + "ahead" } else { - iced::widget::Space::new().width(0.0).height(0.0).into() + "behind" }; + let skew = format_clock_skew_duration(warning.skew_secs); + let copy = format!( + "A peer couldn't be seen - clocks are out of sync by ~{skew} (peer clock looks {direction}). Check your system clock (turn on automatic time sync)." + ); + column![ + vertical_space(10.0), + container( + row![ + icon(IconKind::Clock, 16.0, color_yellow), + text(copy) + .size(12) + .color(color_text) + .width(iced::Length::Fill), + button(text("Dismiss").size(12)) + .on_press(AppMessage::DismissClockSkewWarning) + .style(b_style(color_surface, color_blue, color_text, 6.0)) + .padding(6), + ] + .spacing(10) + .align_y(iced::alignment::Vertical::Center) + ) + .padding(10) + .width(iced::Length::Fill) + .style(move |_theme: &Theme| container::Style { + text_color: Some(color_text), + background: Some(Background::Color(Color { + a: 0.14, + ..color_yellow + })), + border: Border { + color: color_yellow, + width: 1.0, + radius: 8.0.into() + }, + ..Default::default() + }) + ] + .into() + } else { + iced::widget::Space::new().width(0.0).height(0.0).into() + }; let main_area = column![ header_container, @@ -6363,10 +6920,10 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { }; let room = container(column![top_bar, inner]) - .padding(15) - .width(iced::Length::Fill) - .height(iced::Length::Fill) - .style(c_style(root_bg, Color::TRANSPARENT, 0.0)); + .padding(15) + .width(iced::Length::Fill) + .height(iced::Length::Fill) + .style(c_style(root_bg, Color::TRANSPARENT, 0.0)); with_hotkey_info( with_share_picker( @@ -6466,7 +7023,11 @@ impl Program for GateMeter { // Level fill, coloured by whether we're above the gate. let level_frac = (self.level / METER_MAX).clamp(0.0, 1.0); - let fill = if self.level >= self.threshold { self.fill_on } else { self.fill_off }; + let fill = if self.level >= self.threshold { + self.fill_on + } else { + self.fill_off + }; if level_frac > 0.0 { frame.fill_rectangle(Point::ORIGIN, Size::new(w * level_frac, h), fill); } @@ -6476,18 +7037,38 @@ impl Program for GateMeter { let thr_frac = (self.threshold / METER_MAX).clamp(0.0, 1.0); let x = (w * thr_frac).clamp(3.0, (w - 3.0).max(3.0)); // Dark edge (slightly larger), then bright core. - frame.fill(&Path::rectangle(Point::new(x - 3.0, 0.0), Size::new(6.0, h)), self.handle_edge); - frame.fill(&Path::rectangle(Point::new(x - 1.0, 0.0), Size::new(2.0, h)), self.handle); + frame.fill( + &Path::rectangle(Point::new(x - 3.0, 0.0), Size::new(6.0, h)), + self.handle_edge, + ); + frame.fill( + &Path::rectangle(Point::new(x - 1.0, 0.0), Size::new(2.0, h)), + self.handle, + ); // Grip caps top and bottom. - frame.fill(&Path::rectangle(Point::new(x - 5.5, 0.0), Size::new(11.0, 6.0)), self.handle_edge); - frame.fill(&Path::rectangle(Point::new(x - 4.0, 1.0), Size::new(8.0, 4.0)), self.handle); - frame.fill(&Path::rectangle(Point::new(x - 5.5, h - 6.0), Size::new(11.0, 6.0)), self.handle_edge); - frame.fill(&Path::rectangle(Point::new(x - 4.0, h - 5.0), Size::new(8.0, 4.0)), self.handle); + frame.fill( + &Path::rectangle(Point::new(x - 5.5, 0.0), Size::new(11.0, 6.0)), + self.handle_edge, + ); + frame.fill( + &Path::rectangle(Point::new(x - 4.0, 1.0), Size::new(8.0, 4.0)), + self.handle, + ); + frame.fill( + &Path::rectangle(Point::new(x - 5.5, h - 6.0), Size::new(11.0, 6.0)), + self.handle_edge, + ); + frame.fill( + &Path::rectangle(Point::new(x - 4.0, h - 5.0), Size::new(8.0, 4.0)), + self.handle, + ); // Border. frame.stroke( &Path::rectangle(Point::ORIGIN, Size::new(w, h)), - canvas::Stroke::default().with_color(self.border).with_width(1.0), + canvas::Stroke::default() + .with_color(self.border) + .with_width(1.0), ); vec![frame.into_geometry()] @@ -6591,7 +7172,10 @@ impl Program for Divider { let h = bounds.height; if self.vertical { let x = w / 2.0; - frame.fill(&Path::rectangle(Point::new(x - 1.0, 0.0), Size::new(2.0, h)), self.line); + frame.fill( + &Path::rectangle(Point::new(x - 1.0, 0.0), Size::new(2.0, h)), + self.line, + ); let cy = h / 2.0; for i in -1..=1 { let dy = cy + i as f32 * 6.0; @@ -6602,7 +7186,10 @@ impl Program for Divider { } } else { let y = h / 2.0; - frame.fill(&Path::rectangle(Point::new(0.0, y - 1.0), Size::new(w, 2.0)), self.line); + frame.fill( + &Path::rectangle(Point::new(0.0, y - 1.0), Size::new(w, 2.0)), + self.line, + ); let cx = w / 2.0; for i in -1..=1 { let dx = cx + i as f32 * 6.0; @@ -6687,7 +7274,11 @@ fn with_layout_picker<'a>( _ => Color::TRANSPARENT, })), text_color: text_c, - border: Border { color: Color::TRANSPARENT, width: 0.0, radius: 8.0.into() }, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 8.0.into(), + }, ..Default::default() }); let (lbl_color, marker): (Color, Element<'a, AppMessage>) = if selected { @@ -6721,14 +7312,20 @@ fn with_layout_picker<'a>( thumb(RoomLayout::Drawer, "Drawer"), ] .spacing(20), - text("Click a layout to apply it instantly.").size(11).color(subtext), + text("Click a layout to apply it instantly.") + .size(11) + .color(subtext), ] .spacing(16), ) .style(move |_t: &Theme| container::Style { text_color: Some(text_c), background: Some(Background::Color(mantle)), - border: Border { color: surface, width: 1.0, radius: 12.0.into() }, + border: Border { + color: surface, + width: 1.0, + radius: 12.0.into(), + }, ..Default::default() }) .padding(20) @@ -6808,7 +7405,11 @@ fn with_hotkey_info<'a>( .style(move |_t: &Theme| container::Style { text_color: Some(text_c), background: Some(Background::Color(mantle)), - border: Border { color: surface, width: 1.0, radius: 8.0.into() }, + border: Border { + color: surface, + width: 1.0, + radius: 8.0.into(), + }, ..Default::default() }) .padding(16) @@ -6954,38 +7555,43 @@ fn with_share_picker<'a>( .on_press(AppMessage::CloseSharePicker); // One selectable row: a radio-style dot + label. Highlighted when selected. - let opt_row = |selected: bool, label: String, sublabel: Option<&'static str>, msg: AppMessage| { - let dot = text(if selected { "●" } else { "○" }) - .size(14) - .color(if selected { blue } else { subtext }); - let mut labels = column![text(label).size(13).color(text_c)].spacing(2); - if let Some(s) = sublabel { - labels = labels.push(text(s).size(11).color(yellow)); - } - button( - row![dot, labels] - .spacing(10) - .align_y(iced::alignment::Vertical::Center), - ) - .on_press(msg) - .width(iced::Length::Fill) - .padding(8) - .style(move |_t: &Theme, status: button::Status| { - let bg = if selected { - Some(Background::Color(surface)) - } else if matches!(status, button::Status::Hovered) { - Some(Background::Color(Color { a: 0.5, ..surface })) - } else { - None - }; - button::Style { - background: bg, - text_color: text_c, - border: Border { color: Color::TRANSPARENT, width: 0.0, radius: 6.0.into() }, - ..Default::default() + let opt_row = + |selected: bool, label: String, sublabel: Option<&'static str>, msg: AppMessage| { + let dot = text(if selected { "●" } else { "○" }) + .size(14) + .color(if selected { blue } else { subtext }); + let mut labels = column![text(label).size(13).color(text_c)].spacing(2); + if let Some(s) = sublabel { + labels = labels.push(text(s).size(11).color(yellow)); } - }) - }; + button( + row![dot, labels] + .spacing(10) + .align_y(iced::alignment::Vertical::Center), + ) + .on_press(msg) + .width(iced::Length::Fill) + .padding(8) + .style(move |_t: &Theme, status: button::Status| { + let bg = if selected { + Some(Background::Color(surface)) + } else if matches!(status, button::Status::Hovered) { + Some(Background::Color(Color { a: 0.5, ..surface })) + } else { + None + }; + button::Style { + background: bg, + text_color: text_c, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 6.0.into(), + }, + ..Default::default() + } + }) + }; // "All system audio" first (the whole-desktop default — carries the echo // warning), then each currently-playing app. @@ -7038,7 +7644,11 @@ fn with_share_picker<'a>( _ => mantle, })), text_color: text_c, - border: Border { color: surface, width: 1.0, radius: 6.0.into() }, + border: Border { + color: surface, + width: 1.0, + radius: 6.0.into(), + }, ..Default::default() }) .padding(8); @@ -7050,7 +7660,11 @@ fn with_share_picker<'a>( _ => blue, })), text_color: crust, - border: Border { color: Color::TRANSPARENT, width: 0.0, radius: 6.0.into() }, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 6.0.into(), + }, ..Default::default() }) .padding(8); @@ -7071,19 +7685,18 @@ fn with_share_picker<'a>( .align_y(iced::alignment::Vertical::Center), text("Capture audio from:").size(13).color(text_c), list, - row![ - horizontal_space(), - cancel_btn, - share_btn, - ] - .spacing(8), + row![horizontal_space(), cancel_btn, share_btn,].spacing(8), ] .spacing(14), ) .style(move |_t: &Theme| container::Style { text_color: Some(text_c), background: Some(Background::Color(mantle)), - border: Border { color: surface, width: 1.0, radius: 12.0.into() }, + border: Border { + color: surface, + width: 1.0, + radius: 12.0.into(), + }, ..Default::default() }) .padding(20) @@ -7140,7 +7753,9 @@ fn with_regenerate_confirm<'a>( ) .size(13) .color(text_c), - text("Takes effect on your next room join.").size(12).color(subtext), + text("Takes effect on your next room join.") + .size(12) + .color(subtext), row![ horizontal_space(), button(text("Cancel").size(13)) @@ -7151,7 +7766,11 @@ fn with_regenerate_confirm<'a>( _ => mantle, })), text_color: text_c, - border: Border { color: surface, width: 1.0, radius: 6.0.into() }, + border: Border { + color: surface, + width: 1.0, + radius: 6.0.into() + }, ..Default::default() }) .padding(8), @@ -7163,18 +7782,27 @@ fn with_regenerate_confirm<'a>( _ => maroon, })), text_color: text_c, - border: Border { color: Color::TRANSPARENT, width: 0.0, radius: 6.0.into() }, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 6.0.into() + }, ..Default::default() }) .padding(8), - ].spacing(10), + ] + .spacing(10), ] .spacing(14), ) .style(move |_t: &Theme| container::Style { text_color: Some(text_c), background: Some(Background::Color(mantle)), - border: Border { color: surface, width: 1.0, radius: 12.0.into() }, + border: Border { + color: surface, + width: 1.0, + radius: 12.0.into(), + }, ..Default::default() }) .padding(20) @@ -7240,7 +7868,11 @@ fn with_image_lightbox<'a>( _ => mantle, })), text_color: text_c, - border: Border { color: surface, width: 1.0, radius: 6.0.into() }, + border: Border { + color: surface, + width: 1.0, + radius: 6.0.into(), + }, ..Default::default() }) .padding(8); @@ -7288,7 +7920,9 @@ impl Program for LayoutIcon { let (w, h) = (bounds.width, bounds.height); f.stroke( &Path::rectangle(Point::new(1.0, 1.5), Size::new(w - 2.0, h - 3.0)), - canvas::Stroke::default().with_color(self.fg).with_width(1.5), + canvas::Stroke::default() + .with_color(self.fg) + .with_width(1.5), ); // Vertical split into two panes. f.fill( @@ -7367,7 +8001,9 @@ impl Program for LayoutThumb { let bw: f32 = if self.selected { 2.0 } else { 1.0 }; f.stroke( &Path::rectangle(Point::new(bw / 2.0, bw / 2.0), Size::new(w - bw, h - bw)), - canvas::Stroke::default().with_color(self.border).with_width(bw), + canvas::Stroke::default() + .with_color(self.border) + .with_width(bw), ); vec![f.into_geometry()] } @@ -7430,7 +8066,9 @@ impl Program for ThemeSwatch { let bw: f32 = if self.selected { 2.0 } else { 1.0 }; f.stroke( &Path::rectangle(Point::new(bw / 2.0, bw / 2.0), Size::new(w - bw, h - bw)), - canvas::Stroke::default().with_color(self.border).with_width(bw), + canvas::Stroke::default() + .with_color(self.border) + .with_width(bw), ); vec![f.into_geometry()] } @@ -7472,10 +8110,14 @@ struct Icon { /// Build an icon element at `size` px in `color`. Used throughout the room/ /// settings UI in place of emoji. fn icon<'a>(kind: IconKind, size: f32, color: Color) -> Element<'a, AppMessage> { - Canvas::new(Icon { kind, color, weight: 2.0 }) - .width(size) - .height(size) - .into() + Canvas::new(Icon { + kind, + color, + weight: 2.0, + }) + .width(size) + .height(size) + .into() } /// A circular monogram avatar (W4): the participant's initial(s) on a colour @@ -7490,15 +8132,23 @@ fn avatar_badge<'a>(name: &str, key: &str, size: f32) -> Element<'a, AppMessage> } else { Color::WHITE }; - container(text(crate::avatar::initials(name)).size(size * 0.42).color(fg)) - .center_x(iced::Length::Fixed(size)) - .center_y(iced::Length::Fixed(size)) - .style(move |_t: &Theme| container::Style { - background: Some(Background::Color(bg)), - border: Border { color: Color::TRANSPARENT, width: 0.0, radius: (size / 2.0).into() }, - ..Default::default() - }) - .into() + container( + text(crate::avatar::initials(name)) + .size(size * 0.42) + .color(fg), + ) + .center_x(iced::Length::Fixed(size)) + .center_y(iced::Length::Fixed(size)) + .style(move |_t: &Theme| container::Style { + background: Some(Background::Color(bg)), + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: (size / 2.0).into(), + }, + ..Default::default() + }) + .into() } /// Maximum distinct avatar images we keep handles for. Each avatar is bounded to @@ -7525,11 +8175,9 @@ thread_local! { /// keeps the same id across redraws — see [`AVATAR_HANDLE_CACHE`]. fn cached_image_handle(bytes: bytes::Bytes) -> iced::widget::image::Handle { AVATAR_HANDLE_CACHE.with(|cache| { - cache - .borrow_mut() - .get_or_insert(bytes.as_ref(), || { - iced::widget::image::Handle::from_bytes(bytes.clone()) - }) + cache.borrow_mut().get_or_insert(bytes.as_ref(), || { + iced::widget::image::Handle::from_bytes(bytes.clone()) + }) }) } @@ -7687,7 +8335,10 @@ impl Program for Icon { f.stroke(&poly(&[(12.0, 16.0), (12.0, 20.0)], false), stk()); // up-arrow inside (the "share" cue) f.stroke(&poly(&[(12.0, 13.0), (12.0, 7.5)], false), stk()); - f.stroke(&poly(&[(9.5, 10.0), (12.0, 7.5), (14.5, 10.0)], false), stk()); + f.stroke( + &poly(&[(9.5, 10.0), (12.0, 7.5), (14.5, 10.0)], false), + stk(), + ); } IconKind::Eye => { f.stroke( @@ -7716,7 +8367,10 @@ impl Program for Icon { } IconKind::Chat => { f.stroke(&rrect(3.0, 4.0, 18.0, 12.0, 3.0), stk()); - f.stroke(&poly(&[(8.0, 16.0), (8.0, 20.5), (12.5, 16.0)], false), stk()); + f.stroke( + &poly(&[(8.0, 16.0), (8.0, 20.5), (12.5, 16.0)], false), + stk(), + ); } IconKind::People => { f.stroke(&Path::circle(p(9.0, 9.0), 3.2 * s), stk()); @@ -7752,7 +8406,10 @@ impl Program for Icon { stk(), ); f.stroke(&poly(&[(9.5, 12.0), (20.0, 12.0)], false), stk()); - f.stroke(&poly(&[(15.5, 8.0), (20.0, 12.0), (15.5, 16.0)], false), stk()); + f.stroke( + &poly(&[(15.5, 8.0), (20.0, 12.0), (15.5, 16.0)], false), + stk(), + ); } IconKind::Create => { f.stroke(&Path::circle(p(12.0, 12.0), 8.5 * s), stk()); @@ -7774,11 +8431,13 @@ impl Program for Icon { #[cfg(test)] mod tests { use super::{ - attachment_default_name, clear_expired_clock_skew_warning, format_clock_skew_duration, - format_duration, format_relative_ago, initial_window_position, now_playing_label, - reconnect_attempt_chime, reconnected_chime, set_peer_gate_config, set_peer_volume_config, show_clock_skew_warning, update, AppConfig, - AppMessage, AppState, AttachmentCache, AttachmentState, ChatEntry, ClockSkewBanner, - GateMeter, METER_MAX, UiEvent, CLOCK_SKEW_WARNING_VISIBLE_SECS, + AppConfig, AppMessage, AppState, AttachmentCache, AttachmentState, + CLOCK_SKEW_WARNING_VISIBLE_SECS, ChatEntry, ClockSkewBanner, GateMeter, METER_MAX, + ScreenBounds, UiEvent, attachment_default_name, clamp_window_position, + clear_expired_clock_skew_warning, format_clock_skew_duration, format_duration, + format_relative_ago, initial_window_position, now_playing_label, reconnect_attempt_chime, + reconnected_chime, set_peer_gate_config, set_peer_volume_config, show_clock_skew_warning, + update, }; use iroh::SecretKey; @@ -7890,11 +8549,20 @@ mod tests { }; // Attacker's line is FIRST in history, so a bare-id scan would pick it. let messages = vec![mk(attacker, "evil.sh"), mk(victim, "report.pdf")]; - assert_eq!(attachment_default_name(&messages, (victim, shared_id)), "report.pdf"); - assert_eq!(attachment_default_name(&messages, (attacker, shared_id)), "evil.sh"); + assert_eq!( + attachment_default_name(&messages, (victim, shared_id)), + "report.pdf" + ); + assert_eq!( + attachment_default_name(&messages, (attacker, shared_id)), + "evil.sh" + ); // Unknown line → safe fallback. let unknown = SecretKey::generate().public(); - assert_eq!(attachment_default_name(&messages, (unknown, shared_id)), "download"); + assert_eq!( + attachment_default_name(&messages, (unknown, shared_id)), + "download" + ); } #[test] @@ -7904,15 +8572,18 @@ mod tests { let attachment_id = [9u8; 32]; let now = std::time::Instant::now(); - state.peers.insert(peer, crate::network::PeerState { - name: "Peer".to_string(), - is_muted: false, - addr: iroh::EndpointAddr::from(peer), - sharing: None, - avatar: crate::avatar::Avatar::default(), - game: None, - music: None, - }); + state.peers.insert( + peer, + crate::network::PeerState { + name: "Peer".to_string(), + is_muted: false, + addr: iroh::EndpointAddr::from(peer), + sharing: None, + avatar: crate::avatar::Avatar::default(), + game: None, + music: None, + }, + ); state.audio_levels.insert(peer, 0.5); state.locally_muted.insert(peer); state.chat_messages.push(ChatEntry { @@ -7984,7 +8655,10 @@ mod tests { assert!(!state.share_starting); assert!(!state.share_audio_dropped); assert!(!state.share_audio_app_active); - assert!(state.share_app_audio_supported, "reset is optimistic by default"); + assert!( + state.share_app_audio_supported, + "reset is optimistic by default" + ); assert!(state.clock_skew_warning.is_none()); assert!(state.music_broadcast_id.is_none()); assert!(state.music_broadcast_next.is_none()); @@ -7995,7 +8669,10 @@ mod tests { assert!(state.music_prefetch_inflight.is_none()); for _ in 0..50 { - if crate::audio::clip_player::status_snapshot(&state.clip_status).playing_id.is_none() { + if crate::audio::clip_player::status_snapshot(&state.clip_status) + .playing_id + .is_none() + { return; } std::thread::sleep(std::time::Duration::from_millis(2)); @@ -8070,13 +8747,19 @@ mod tests { ..Default::default() }; let _ = update(&mut state, AppMessage::ConfirmShareScreen); - assert!(state.share_starting, "confirm should mark a start in flight"); + assert!( + state.share_starting, + "confirm should mark a start in flight" + ); assert!(!state.share_picker_open, "confirm should close the picker"); assert!(!state.self_sharing, "core hasn't acked the start yet"); // Clicking Share again during startup must NOT reopen the picker. let _ = update(&mut state, AppMessage::ToggleScreenShare); - assert!(!state.share_picker_open, "picker must stay closed while starting"); + assert!( + !state.share_picker_open, + "picker must stay closed while starting" + ); assert!(state.share_starting); // A stray confirm during startup is ignored (no double-start). @@ -8174,12 +8857,18 @@ mod tests { &mut state, AppMessage::UiEventReceived(UiEvent::ScreenShareStarted), ); - assert!(!state.share_audio_app_active, "no app selected ⇒ not app-specific"); + assert!( + !state.share_audio_app_active, + "no app selected ⇒ not app-specific" + ); let _ = update( &mut state, AppMessage::UiEventReceived(UiEvent::ShareAudioActive(false)), ); - assert!(!state.share_audio_dropped, "whole-desktop share ignores app_audio"); + assert!( + !state.share_audio_dropped, + "whole-desktop share ignores app_audio" + ); // (b) After stop: a straggling event can't resurrect the warning. let mut state = AppState { @@ -8221,7 +8910,10 @@ mod tests { ); assert!(!state.share_app_audio_supported); assert!(state.share_audio_apps.is_empty(), "no per-app rows offered"); - assert!(state.share_audio_selection.is_none(), "forced to whole-desktop"); + assert!( + state.share_audio_selection.is_none(), + "forced to whole-desktop" + ); // A supported pixelpass keeps the app list and a valid selection. let _ = update( @@ -8274,10 +8966,96 @@ mod tests { assert!(!config.peer_volume.contains_key(&id.to_string())); } + fn assert_specific_position(position: iced::window::Position, x: i32, y: i32) { + match position { + iced::window::Position::Specific(p) => { + assert_eq!(p.x, x as f32); + assert_eq!(p.y, y as f32); + } + other => panic!("expected Specific, got {other:?}"), + } + } + + #[test] + fn clamp_window_position_preserves_fully_inside_bounds() { + let bounds = ScreenBounds { + x: 0, + y: 0, + width: 1200, + height: 900, + }; + + assert_specific_position( + clamp_window_position(120, 80, 900, 760, Some(bounds)), + 120, + 80, + ); + } + + #[test] + fn clamp_window_position_pulls_visible_edge_back_inside_bounds() { + let bounds = ScreenBounds { + x: 0, + y: 0, + width: 1000, + height: 800, + }; + + assert_specific_position( + clamp_window_position(760, 650, 300, 200, Some(bounds)), + 700, + 600, + ); + } + + #[test] + fn clamp_window_position_centers_disconnected_monitor_position() { + let bounds = ScreenBounds { + x: 0, + y: 0, + width: 1000, + height: 800, + }; + + assert!(matches!( + clamp_window_position(5000, 5000, 900, 760, Some(bounds)), + iced::window::Position::Centered + )); + } + + #[test] + fn clamp_window_position_preserves_negative_origin_monitor_position() { + let bounds = ScreenBounds { + x: -1920, + y: -200, + width: 1920, + height: 1080, + }; + + assert_specific_position( + clamp_window_position(-1800, -120, 900, 760, Some(bounds)), + -1800, + -120, + ); + } + + #[test] + fn clamp_window_position_without_bounds_preserves_sane_and_rejects_absurd() { + assert_specific_position(clamp_window_position(120, -40, 900, 760, None), 120, -40); + assert!(matches!( + clamp_window_position(32_001, 0, 900, 760, None), + iced::window::Position::Centered + )); + assert!(matches!( + clamp_window_position(0, -32_001, 900, 760, None), + iced::window::Position::Centered + )); + } + #[test] fn x11_restores_saved_window_position() { // On X11 (is_wayland = false) a saved position becomes Specific(x, y). - match initial_window_position(Some(120), Some(-40), false) { + match initial_window_position(Some(120), Some(-40), 900, 760, false) { iced::window::Position::Specific(p) => { assert_eq!(p.x, 120.0); assert_eq!(p.y, -40.0); // negative coords (left/above primary) preserved @@ -8289,7 +9067,7 @@ mod tests { #[test] fn wayland_always_centers_even_with_saved_position() { assert!(matches!( - initial_window_position(Some(120), Some(40), true), + initial_window_position(Some(120), Some(40), 900, 760, true), iced::window::Position::Centered )); } @@ -8297,16 +9075,16 @@ mod tests { #[test] fn missing_or_partial_saved_position_centers() { assert!(matches!( - initial_window_position(None, None, false), + initial_window_position(None, None, 900, 760, false), iced::window::Position::Centered )); // A half-saved position (one axis missing) is not enough to restore. assert!(matches!( - initial_window_position(Some(10), None, false), + initial_window_position(Some(10), None, 900, 760, false), iced::window::Position::Centered )); assert!(matches!( - initial_window_position(None, Some(10), false), + initial_window_position(None, Some(10), 900, 760, false), iced::window::Position::Centered )); } @@ -8337,11 +9115,23 @@ mod tests { assert_eq!(format_relative_ago(Duration::from_secs(59 * 60)), "59m ago"); // Hours, with minutes only when non-zero. assert_eq!(format_relative_ago(Duration::from_secs(3600)), "1h ago"); - assert_eq!(format_relative_ago(Duration::from_secs(3600 + 120)), "1h 2m ago"); - assert_eq!(format_relative_ago(Duration::from_secs(23 * 3600 + 59 * 60)), "23h 59m ago"); + assert_eq!( + format_relative_ago(Duration::from_secs(3600 + 120)), + "1h 2m ago" + ); + assert_eq!( + format_relative_ago(Duration::from_secs(23 * 3600 + 59 * 60)), + "23h 59m ago" + ); // Days, with hours only when non-zero. - assert_eq!(format_relative_ago(Duration::from_secs(24 * 3600)), "1d ago"); - assert_eq!(format_relative_ago(Duration::from_secs(50 * 3600)), "2d 2h ago"); + assert_eq!( + format_relative_ago(Duration::from_secs(24 * 3600)), + "1d ago" + ); + assert_eq!( + format_relative_ago(Duration::from_secs(50 * 3600)), + "2d 2h ago" + ); } #[test] @@ -8351,25 +9141,46 @@ mod tests { assert_eq!( labels, vec![ - "Audio", "Hotkeys", "Recording", "Profile", "Appearance", "Network", - "Notifications", "Games" + "Audio", + "Hotkeys", + "Recording", + "Profile", + "Appearance", + "Network", + "Notifications", + "Games" ] ); assert_eq!(SettingsCategory::Audio.hint(), "Devices, mic gate, echo"); assert_eq!(SettingsCategory::Profile.hint(), "Avatar and identity"); - assert_eq!(SettingsCategory::Games.hint(), "Detection, presence, backgrounds"); + assert_eq!( + SettingsCategory::Games.hint(), + "Detection, presence, backgrounds" + ); } #[test] fn home_layout_prioritizes_connect_on_empty_home() { - use super::{home_layout_mode, HomeLayoutMode}; - assert_eq!(home_layout_mode(1280.0, false, false), HomeLayoutMode::FocusedEmpty); - assert_eq!(home_layout_mode(760.0, false, false), HomeLayoutMode::Stacked); - assert_eq!(home_layout_mode(1280.0, true, false), HomeLayoutMode::ThreeColumn); - assert_eq!(home_layout_mode(1100.0, true, true), HomeLayoutMode::Stacked); + use super::{HomeLayoutMode, home_layout_mode}; + assert_eq!( + home_layout_mode(1280.0, false, false), + HomeLayoutMode::FocusedEmpty + ); + assert_eq!( + home_layout_mode(760.0, false, false), + HomeLayoutMode::Stacked + ); + assert_eq!( + home_layout_mode(1280.0, true, false), + HomeLayoutMode::ThreeColumn + ); + assert_eq!( + home_layout_mode(1100.0, true, true), + HomeLayoutMode::Stacked + ); } - use super::{clamp_chat_height, clamp_participants_width, CHAT_MIN_H, PARTICIPANTS_MIN_W}; + use super::{CHAT_MIN_H, PARTICIPANTS_MIN_W, clamp_chat_height, clamp_participants_width}; #[test] fn participants_width_clamps_to_min_and_leaves_room_for_controls() { @@ -8379,7 +9190,10 @@ mod tests { // Below the minimum snaps up to it. assert_eq!(clamp_participants_width(50.0, window_w), PARTICIPANTS_MIN_W); // Too wide leaves at least CONTROLS_MIN_W (220) for the controls panel. - assert_eq!(clamp_participants_width(window_w, window_w), window_w - 220.0); + assert_eq!( + clamp_participants_width(window_w, window_w), + window_w - 220.0 + ); } #[test] @@ -8409,7 +9223,7 @@ mod tests { #[test] fn sanitize_chat_caps_length() { - use super::{sanitize_chat, CHAT_MSG_MAX_CHARS}; + use super::{CHAT_MSG_MAX_CHARS, sanitize_chat}; let long = "x".repeat(CHAT_MSG_MAX_CHARS + 500); assert_eq!(sanitize_chat(&long).chars().count(), CHAT_MSG_MAX_CHARS); } @@ -8437,7 +9251,7 @@ mod tests { #[test] fn controls_and_drawer_width_clamps() { - use super::{clamp_chat_drawer_width, clamp_controls_width, CHAT_MIN_W, CONTROLS_MIN_W}; + use super::{CHAT_MIN_W, CONTROLS_MIN_W, clamp_chat_drawer_width, clamp_controls_width}; let window_w = 1000.0; // Mid-range passes through. assert_eq!(clamp_controls_width(300.0, window_w), 300.0); @@ -8454,7 +9268,7 @@ mod tests { #[test] fn playlist_drawer_width_clamps() { - use super::{clamp_playlist_drawer_width, CHAT_MIN_W, CONTROLS_MIN_W, PARTICIPANTS_MIN_W}; + use super::{CHAT_MIN_W, CONTROLS_MIN_W, PARTICIPANTS_MIN_W, clamp_playlist_drawer_width}; let window_w = 1000.0; let max = window_w - PARTICIPANTS_MIN_W - CONTROLS_MIN_W; // Mid-range passes through. @@ -8597,7 +9411,7 @@ mod tests { #[test] fn test_push_chat_single() { - use super::{push_chat, ChatEntry}; + use super::{ChatEntry, push_chat}; let mut messages = Vec::new(); let entry = ChatEntry { name: "Alice".to_string(), @@ -8615,7 +9429,7 @@ mod tests { #[test] fn test_push_chat_below_cap() { - use super::{push_chat, ChatEntry, CHAT_HISTORY_MAX}; + use super::{CHAT_HISTORY_MAX, ChatEntry, push_chat}; let mut messages = Vec::new(); for i in 0..CHAT_HISTORY_MAX - 10 { push_chat( @@ -8632,13 +9446,19 @@ mod tests { assert_eq!(messages.len(), CHAT_HISTORY_MAX - 10); assert_eq!(messages[0].name, "User0"); assert_eq!(messages[0].text, "Msg0"); - assert_eq!(messages[messages.len() - 1].name, format!("User{}", CHAT_HISTORY_MAX - 11)); - assert_eq!(messages[messages.len() - 1].text, format!("Msg{}", CHAT_HISTORY_MAX - 11)); + assert_eq!( + messages[messages.len() - 1].name, + format!("User{}", CHAT_HISTORY_MAX - 11) + ); + assert_eq!( + messages[messages.len() - 1].text, + format!("Msg{}", CHAT_HISTORY_MAX - 11) + ); } #[test] fn test_push_chat_above_cap() { - use super::{push_chat, ChatEntry, CHAT_HISTORY_MAX}; + use super::{CHAT_HISTORY_MAX, ChatEntry, push_chat}; let mut messages = Vec::new(); let total_pushes = CHAT_HISTORY_MAX + 5; for i in 0..total_pushes { @@ -8658,7 +9478,13 @@ mod tests { assert_eq!(messages[0].name, "User5"); assert_eq!(messages[0].text, "Msg5"); // The last remaining should be index total_pushes - 1. - assert_eq!(messages[messages.len() - 1].name, format!("User{}", total_pushes - 1)); - assert_eq!(messages[messages.len() - 1].text, format!("Msg{}", total_pushes - 1)); + assert_eq!( + messages[messages.len() - 1].name, + format!("User{}", total_pushes - 1) + ); + assert_eq!( + messages[messages.len() - 1].text, + format!("Msg{}", total_pushes - 1) + ); } } diff --git a/src/audio/cpal_impl.rs b/src/audio/cpal_impl.rs index 4ae5b7d..fb5648f 100644 --- a/src/audio/cpal_impl.rs +++ b/src/audio/cpal_impl.rs @@ -578,7 +578,9 @@ fn choose_config(device: &Device, output: bool) -> Result| { ranges .iter() - .find(|r| usable_range(r) && supports_48k(r) && channels.is_none_or(|c| r.channels() == c)) + .find(|r| { + usable_range(r) && supports_48k(r) && channels.is_none_or(|c| r.channels() == c) + }) .cloned() }; @@ -666,15 +668,30 @@ fn run_capture( let device_rate = config.sample_rate.0; let stream = match sample_format { SampleFormat::F32 => build_input::( - &device, &config, producer, channels, overrun.clone(), callbacks.clone(), + &device, + &config, + producer, + channels, + overrun.clone(), + callbacks.clone(), err_code.clone(), ), SampleFormat::I16 => build_input::( - &device, &config, producer, channels, overrun.clone(), callbacks.clone(), + &device, + &config, + producer, + channels, + overrun.clone(), + callbacks.clone(), err_code.clone(), ), SampleFormat::U16 => build_input::( - &device, &config, producer, channels, overrun.clone(), callbacks.clone(), + &device, + &config, + producer, + channels, + overrun.clone(), + callbacks.clone(), err_code.clone(), ), other => Err(AudioError::Stream(format!( @@ -764,7 +781,10 @@ fn run_capture( // Surface a stream error the RT callback flagged (it can't log itself). let ec = err_code.load(Ordering::Relaxed); if ec != STREAM_ERR_NONE && ec != last_err { - crate::log_msg(&format!("cpal capture stream error: {}", stream_err_text(ec))); + crate::log_msg(&format!( + "cpal capture stream error: {}", + stream_err_text(ec) + )); last_err = ec; } if !drained { @@ -914,16 +934,34 @@ fn run_playback( let device_rate = config.sample_rate.0; let stream = match sample_format { SampleFormat::F32 => build_output::( - &device, &config, consumer, ring_fill.clone(), underrun.clone(), - max_cb.clone(), callbacks.clone(), err_code.clone(), + &device, + &config, + consumer, + ring_fill.clone(), + underrun.clone(), + max_cb.clone(), + callbacks.clone(), + err_code.clone(), ), SampleFormat::I16 => build_output::( - &device, &config, consumer, ring_fill.clone(), underrun.clone(), - max_cb.clone(), callbacks.clone(), err_code.clone(), + &device, + &config, + consumer, + ring_fill.clone(), + underrun.clone(), + max_cb.clone(), + callbacks.clone(), + err_code.clone(), ), SampleFormat::U16 => build_output::( - &device, &config, consumer, ring_fill.clone(), underrun.clone(), - max_cb.clone(), callbacks.clone(), err_code.clone(), + &device, + &config, + consumer, + ring_fill.clone(), + underrun.clone(), + max_cb.clone(), + callbacks.clone(), + err_code.clone(), ), other => Err(AudioError::Stream(format!( "unsupported playback sample format: {other:?}" @@ -1199,7 +1237,10 @@ fn spawn_health_logger( // Surface a stream error the RT callback flagged (it can't log itself). let ec = err_code.load(Ordering::Relaxed); if ec != STREAM_ERR_NONE && ec != last_err { - crate::log_msg(&format!("cpal playback stream error: {}", stream_err_text(ec))); + crate::log_msg(&format!( + "cpal playback stream error: {}", + stream_err_text(ec) + )); last_err = ec; } // Report the device's per-cycle demand (in internal 48 kHz-stereo diff --git a/src/audio/echo_cancel.rs b/src/audio/echo_cancel.rs index 5dc97b6..ad51a7e 100644 --- a/src/audio/echo_cancel.rs +++ b/src/audio/echo_cancel.rs @@ -54,7 +54,10 @@ impl Drop for EchoCancelGuard { .arg("unload-module") .arg(&self.module_index) .output(); - crate::log_msg(&format!("Echo cancel: unloaded module {}", self.module_index)); + crate::log_msg(&format!( + "Echo cancel: unloaded module {}", + self.module_index + )); } } @@ -65,7 +68,10 @@ impl Drop for EchoCancelGuard { /// `None` (or an empty string) to bind to the system defaults. Returns `Err` with /// a human-readable reason if `pactl` is missing, the load fails, or the nodes /// don't appear — the caller should fall back to the direct devices. -pub fn enable(real_source: Option<&str>, real_sink: Option<&str>) -> Result { +pub fn enable( + real_source: Option<&str>, + real_sink: Option<&str>, +) -> Result { // Best-effort: clear any stale instance left by a crashed prior run so we // don't stack duplicate modules / fight over the virtual node names. unload_stale(); @@ -101,7 +107,11 @@ pub fn enable(real_source: Option<&str>, real_sink: Option<&str>) -> Result().is_err() { return Err(format!("unexpected pactl output: {module_index:?}")); } - let guard = EchoCancelGuard { module_index, source_name, sink_name }; + let guard = EchoCancelGuard { + module_index, + source_name, + sink_name, + }; // The virtual nodes appear shortly after the module loads; wait for both so // the subsequent capture/playback streams can actually target them. If they @@ -134,7 +144,12 @@ fn wait_for_nodes(source_name: &str, sink_name: &str) -> bool { /// Whether `pactl list short` lists a node named `name`. /// `kind` is "sources" or "sinks". fn node_present(kind: &str, name: &str) -> bool { - let Ok(out) = Command::new("pactl").arg("list").arg(kind).arg("short").output() else { + let Ok(out) = Command::new("pactl") + .arg("list") + .arg(kind) + .arg("short") + .output() + else { return false; }; String::from_utf8_lossy(&out.stdout) @@ -167,7 +182,12 @@ fn process_is_alive(_pid: u32) -> bool { /// Unloads leftover PeerSpeak `module-echo-cancel` instances only when their /// owning process is gone. Best-effort and conservative on non-Linux platforms. fn unload_stale() { - let Ok(out) = Command::new("pactl").arg("list").arg("modules").arg("short").output() else { + let Ok(out) = Command::new("pactl") + .arg("list") + .arg("modules") + .arg("short") + .output() + else { return; }; for line in String::from_utf8_lossy(&out.stdout).lines() { @@ -179,7 +199,10 @@ fn unload_stale() { && ec_module_is_stale(args, process_is_alive) && index.parse::().is_ok() { - let _ = Command::new("pactl").arg("unload-module").arg(index).output(); + let _ = Command::new("pactl") + .arg("unload-module") + .arg(index) + .output(); crate::log_msg(&format!("Echo cancel: cleaned up stale module {index}")); } } @@ -198,13 +221,25 @@ mod tests { let guard = enable(None, None).expect("module-echo-cancel should load"); let source_name = guard.source_name().to_string(); let sink_name = guard.sink_name().to_string(); - assert!(node_present("sources", &source_name), "cleaned source must exist"); - assert!(node_present("sinks", &sink_name), "reference sink must exist"); + assert!( + node_present("sources", &source_name), + "cleaned source must exist" + ); + assert!( + node_present("sinks", &sink_name), + "reference sink must exist" + ); drop(guard); // Give pactl a moment to tear the nodes down. std::thread::sleep(Duration::from_millis(300)); - assert!(!node_present("sources", &source_name), "source must be gone after unload"); - assert!(!node_present("sinks", &sink_name), "sink must be gone after unload"); + assert!( + !node_present("sources", &source_name), + "source must be gone after unload" + ); + assert!( + !node_present("sinks", &sink_name), + "sink must be gone after unload" + ); } #[test] @@ -220,7 +255,10 @@ mod tests { pid_from_ec_args("source_name=peerspeak_echocancel_source.not-a-pid"), None ); - assert_eq!(pid_from_ec_args("source_name=someone_elses_source.4242"), None); + assert_eq!( + pid_from_ec_args("source_name=someone_elses_source.4242"), + None + ); } #[test] diff --git a/src/audio/eq.rs b/src/audio/eq.rs index ad357b0..dec827b 100644 --- a/src/audio/eq.rs +++ b/src/audio/eq.rs @@ -251,7 +251,10 @@ mod tests { let before = rms(&low); eq.process_frame(&mut low); let after = rms(&low); - assert!(after > before * 1.6, "low shelf should boost low RMS: {before} -> {after}"); + assert!( + after > before * 1.6, + "low shelf should boost low RMS: {before} -> {after}" + ); } #[test] @@ -264,7 +267,10 @@ mod tests { let before = rms(&high); eq.process_frame(&mut high); let after = rms(&high); - assert!(after > before * 1.6, "high shelf should boost high RMS: {before} -> {after}"); + assert!( + after > before * 1.6, + "high shelf should boost high RMS: {before} -> {after}" + ); } #[test] @@ -275,7 +281,10 @@ mod tests { Biquad::peaking(DEFAULT_SAMPLE_RATE, MID_PEAK_HZ, gain, MID_Q), Biquad::high_shelf(DEFAULT_SAMPLE_RATE, HIGH_SHELF_HZ, gain, SHELF_Q), ] { - assert!(b.coeffs.all_finite(), "coefficients must be finite at {gain} dB"); + assert!( + b.coeffs.all_finite(), + "coefficients must be finite at {gain} dB" + ); } } } @@ -289,12 +298,11 @@ mod tests { }); let mut frame = sine(1_000.0, 48_000, 30_000.0); eq.process_frame(&mut frame); - let peak = frame - .iter() - .map(|&s| i32::from(s).abs()) - .max() - .unwrap_or(0); - assert!(peak > 1_000, "processed signal should retain audible energy"); + let peak = frame.iter().map(|&s| i32::from(s).abs()).max().unwrap_or(0); + assert!( + peak > 1_000, + "processed signal should retain audible energy" + ); assert!( frame.iter().any(|&s| s > 0) && frame.iter().any(|&s| s < 0), "a boosted sine should retain both polarities" diff --git a/src/audio/gate.rs b/src/audio/gate.rs index 2b3073a..e1a1e4d 100644 --- a/src/audio/gate.rs +++ b/src/audio/gate.rs @@ -169,7 +169,10 @@ mod tests { assert!(g.process(&mut f, 0.05), "loud frame must transmit"); last = peak(&f); } - assert!(last >= 9900, "gain should reach ~1.0 on sustained loud input, got peak {last}"); + assert!( + last >= 9900, + "gain should reach ~1.0 on sustained loud input, got peak {last}" + ); } #[test] @@ -179,8 +182,15 @@ mod tests { g.process(&mut f, 0.05); // 5ms attack @48k = 240 samples; across a 960-sample frame the gain ramps // 0->1, so the early samples are well below full scale (no instant click). - assert!(f[0].abs() < 5000, "attack should start near zero, got {}", f[0]); - assert!(f[FRAME - 1].abs() > 9000, "attack should complete within the frame"); + assert!( + f[0].abs() < 5000, + "attack should start near zero, got {}", + f[0] + ); + assert!( + f[FRAME - 1].abs() > 9000, + "attack should complete within the frame" + ); } #[test] @@ -193,8 +203,14 @@ mod tests { } // First quiet frame right after speech: hold keeps it open (not chopped). let mut q = frame(50); // rms ~0.0015, below close (0.03) - assert!(g.process(&mut q, 0.05), "first quiet frame must stay open (hangover)"); - assert!(peak(&q) > 0, "held-open frame must not be silenced immediately"); + assert!( + g.process(&mut q, 0.05), + "first quiet frame must stay open (hangover)" + ); + assert!( + peak(&q) > 0, + "held-open frame must not be silenced immediately" + ); // Hold is 200ms = 10 frames; keep feeding quiet until it fully closes. let mut closed = false; @@ -205,7 +221,10 @@ mod tests { break; } } - assert!(closed, "gate must eventually close and stop transmitting after sustained silence"); + assert!( + closed, + "gate must eventually close and stop transmitting after sustained silence" + ); } #[test] @@ -216,8 +235,14 @@ mod tests { g.process(&mut f, 0.05); // open=0.05, close=0.03 // A frame between close and open thresholds: rms ~0.04 (amp ~1310). let mut mid = frame(1310); - assert!(g.process(&mut mid, 0.05), "between-threshold frame must keep an open gate open"); - assert!(g.open, "hysteresis: gate stays open above the close threshold"); + assert!( + g.process(&mut mid, 0.05), + "between-threshold frame must keep an open gate open" + ); + assert!( + g.open, + "hysteresis: gate stays open above the close threshold" + ); } #[test] @@ -225,7 +250,10 @@ mod tests { let mut g = NoiseGate::new(SR); // Never opened; feed silence — should report don't-transmit promptly. let mut f = frame(0); - assert!(!g.process(&mut f, 0.05), "an unopened gate on silence must not transmit"); + assert!( + !g.process(&mut f, 0.05), + "an unopened gate on silence must not transmit" + ); } #[test] @@ -266,7 +294,11 @@ mod tests { let mut f2 = frame(10000); assert!(g.process(&mut f2, 0.05)); // enabled - assert!(f2[0].abs() > 9000, "expected first sample of enabled frame to have no fade-in, got {}", f2[0]); + assert!( + f2[0].abs() > 9000, + "expected first sample of enabled frame to have no fade-in, got {}", + f2[0] + ); } #[test] @@ -302,7 +334,10 @@ mod tests { let mut f = frame(1310); assert!(g.process(&mut f, 0.05)); } - assert!(g.open, "gate must stay open (hold refreshed by mid-level input)"); + assert!( + g.open, + "gate must stay open (hold refreshed by mid-level input)" + ); } #[test] @@ -333,6 +368,10 @@ mod tests { last_peak = peak(&f); } assert!(g.open); - assert!(last_peak >= 9900, "peak of the 3rd reopened frame must be >= 9900, got {}", last_peak); + assert!( + last_peak >= 9900, + "peak of the 3rd reopened frame must be >= 9900, got {}", + last_peak + ); } } diff --git a/src/audio/limiter.rs b/src/audio/limiter.rs index 0047008..302c778 100644 --- a/src/audio/limiter.rs +++ b/src/audio/limiter.rs @@ -123,7 +123,10 @@ mod tests { let out = lim.process(&loud, 1.0); let ceiling = lim.ceiling().ceil() as i16; for &s in &out { - assert!(s > 0, "positive loud input stays positive (no wrap), got {s}"); + assert!( + s > 0, + "positive loud input stays positive (no wrap), got {s}" + ); assert!(s <= ceiling, "sample {s} exceeded ceiling {ceiling}"); } } @@ -175,7 +178,10 @@ mod tests { let out_pos = lim.process(&pos_loud, 1.0); for &s in &out_pos { assert!(s > 0, "positive input stays positive, got {s}"); - assert!(s <= ceiling_ceil, "positive sample {s} exceeded ceiling {ceiling_ceil}"); + assert!( + s <= ceiling_ceil, + "positive sample {s} exceeded ceiling {ceiling_ceil}" + ); } // Sustained negative loud sum @@ -185,7 +191,10 @@ mod tests { let neg_ceiling = -ceiling_ceil; for &s in &out_neg { assert!(s < 0, "negative input stays negative, got {s}"); - assert!(s >= neg_ceiling, "negative sample {s} exceeded negative ceiling {neg_ceiling}"); + assert!( + s >= neg_ceiling, + "negative sample {s} exceeded negative ceiling {neg_ceiling}" + ); } } @@ -200,8 +209,14 @@ mod tests { let out = lim.process(&input, 8.0); for &s in &out { assert!(s > 0, "positive stays positive"); - assert!(s <= ceiling_ceil, "sample {s} must be limited to ceiling {ceiling_ceil}"); - assert!((s - ceiling_ceil).abs() <= 2, "sample {s} should ride the ceiling {ceiling_ceil}"); + assert!( + s <= ceiling_ceil, + "sample {s} must be limited to ceiling {ceiling_ceil}" + ); + assert!( + (s - ceiling_ceil).abs() <= 2, + "sample {s} should ride the ceiling {ceiling_ceil}" + ); } } @@ -213,7 +228,10 @@ mod tests { let out = lim.process(&input, 0.5); for (i, &s) in out.iter().enumerate() { let expected = (input[i] as f32 * 0.5).round() as i16; - assert!((s - expected).abs() <= 1, "sample {s} should be close to expected {expected}"); + assert!( + (s - expected).abs() <= 1, + "sample {s} should be close to expected {expected}" + ); } // Subsequently feed a new sample at unity gain. It must be transparent, @@ -230,7 +248,12 @@ mod tests { let loud = vec![200_000i32; 10]; let out = lim.process(&loud, 1.0); - assert!(out[0] <= ceiling_ceil, "first sample {} must not overshoot ceiling {}", out[0], ceiling_ceil); + assert!( + out[0] <= ceiling_ceil, + "first sample {} must not overshoot ceiling {}", + out[0], + ceiling_ceil + ); } /// 5. Release direction & monotonicity. @@ -247,13 +270,23 @@ mod tests { // Output should be monotonic (non-decreasing) for i in 1..out.len() { - assert!(out[i] >= out[i - 1], "output must be monotonic; index {} was {}, index {} was {}", i - 1, out[i - 1], i, out[i]); + assert!( + out[i] >= out[i - 1], + "output must be monotonic; index {} was {}, index {} was {}", + i - 1, + out[i - 1], + i, + out[i] + ); } // The end sample should be closer to the original input than the start sample let start_diff = (mid_val as i16 - out[0]).abs(); let end_diff = (mid_val as i16 - *out.last().unwrap()).abs(); - assert!(end_diff < start_diff, "end diff {end_diff} should be smaller than start diff {start_diff}"); + assert!( + end_diff < start_diff, + "end diff {end_diff} should be smaller than start diff {start_diff}" + ); } /// 6. Release is gradual, not instantaneous. @@ -265,7 +298,11 @@ mod tests { // Immediately follow with a sub-ceiling sample let out = lim.process(&[10_000i32], 1.0); - assert!(out[0] < 10_000, "first quiet sample should still be attenuated (got {})", out[0]); + assert!( + out[0] < 10_000, + "first quiet sample should still be attenuated (got {})", + out[0] + ); } /// 7. State carries across process calls. @@ -287,7 +324,10 @@ mod tests { let mut out_split = out_split1; out_split.extend(&out_split2); - assert_eq!(out_single, out_split, "splitting process calls must produce identical output to a single call"); + assert_eq!( + out_single, out_split, + "splitting process calls must produce identical output to a single call" + ); // Test 2: Pre-loaded limiter vs fresh limiter on the same input let mut lim_preloaded = SoftLimiter::new(SR); @@ -299,8 +339,16 @@ mod tests { let out_preloaded = lim_preloaded.process(&test_input, 1.0); let out_fresh = lim_fresh.process(&test_input, 1.0); - assert_ne!(out_preloaded, out_fresh, "pre-loaded and fresh limiter outputs should differ"); - assert!(out_preloaded[0] < out_fresh[0], "pre-loaded limiter first sample {} should be smaller than fresh limiter first sample {}", out_preloaded[0], out_fresh[0]); + assert_ne!( + out_preloaded, out_fresh, + "pre-loaded and fresh limiter outputs should differ" + ); + assert!( + out_preloaded[0] < out_fresh[0], + "pre-loaded limiter first sample {} should be smaller than fresh limiter first sample {}", + out_preloaded[0], + out_fresh[0] + ); } /// 8. Empty input. @@ -320,7 +368,10 @@ mod tests { // Gain 0.0 let out_zero = lim.process(&input, 0.0); assert_eq!(out_zero.len(), input.len()); - assert!(out_zero.iter().all(|&s| s == 0), "0.0 gain should result in all zeros"); + assert!( + out_zero.iter().all(|&s| s == 0), + "0.0 gain should result in all zeros" + ); // Gain 1.0 let out_unity = lim.process(&input, 1.0); @@ -354,6 +405,9 @@ mod tests { let out = lim.process(&input, 1.0); let expected: Vec = input.iter().map(|&s| s as i16).collect(); - assert_eq!(out, expected, "below ceiling input must be bit-exact at unity gain"); + assert_eq!( + out, expected, + "below ceiling input must be bit-exact at unity gain" + ); } } diff --git a/src/audio/mod.rs b/src/audio/mod.rs index 8fd5479..6bca441 100644 --- a/src/audio/mod.rs +++ b/src/audio/mod.rs @@ -1,6 +1,6 @@ -use std::sync::mpsc::{Receiver, Sender}; use std::sync::Arc; use std::sync::atomic::AtomicUsize; +use std::sync::mpsc::{Receiver, Sender}; use thiserror::Error; /// Playback output channel count. Capture/encode/network remain mono; only the @@ -35,7 +35,11 @@ pub enum AudioError { pub trait AudioBackend: Send + Sync { /// Starts capturing raw PCM audio from the input device (microphone), /// sending chunks of samples (e.g. `Vec`) to the provided Sender. - fn start_capture(&self, tx: Sender>, target_node: Option) -> Result<(), AudioError>; + fn start_capture( + &self, + tx: Sender>, + target_node: Option, + ) -> Result<(), AudioError>; /// Starts playing back raw PCM audio to the output device (speaker), /// reading mixed/incoming chunks of samples from the provided Receiver. @@ -65,16 +69,16 @@ pub mod pan; // Linear resamplers used by the Windows/cpal backend (W4). Platform-neutral and // pure, so it builds (and its tests run) everywhere even though only the cpal // backend wires it in. -pub mod resample; +#[cfg(windows)] +pub mod cpal_impl; #[cfg(target_os = "linux")] pub mod echo_cancel; #[cfg(target_os = "linux")] pub mod pipewire_impl; -#[cfg(windows)] -pub mod cpal_impl; #[cfg(target_os = "linux")] pub mod pw_cli; pub mod recorder; +pub mod resample; /// A selectable audio device for the input/output pickers. `name` is the stable /// identifier the backend uses to request the device (`target_node`); @@ -96,10 +100,10 @@ impl std::fmt::Display for AudioDevice { // Enumerate audio input/output devices for the pickers (sorted by description), // returning the same `AudioDevice` shape regardless of platform: PipeWire // (`pw-cli`) on Linux, cpal/WASAPI on Windows. -#[cfg(target_os = "linux")] -pub use pw_cli::enumerate_audio_devices; #[cfg(windows)] pub use cpal_impl::enumerate_audio_devices; +#[cfg(target_os = "linux")] +pub use pw_cli::enumerate_audio_devices; /// The audio backend implementation for the current platform. /// diff --git a/src/audio/multitrack.rs b/src/audio/multitrack.rs index 497b7ab..768d29a 100644 --- a/src/audio/multitrack.rs +++ b/src/audio/multitrack.rs @@ -108,7 +108,13 @@ pub fn track_filename(name: &str, id: &EndpointId) -> String { let clean = crate::sanitize::sanitize_name(name); let mut slug: String = clean .chars() - .map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' }) + .map(|c| { + if c.is_ascii_alphanumeric() { + c.to_ascii_lowercase() + } else { + '-' + } + }) .collect(); // Collapse runs of '-' and trim them off the ends. while slug.contains("--") { @@ -284,7 +290,10 @@ mod tests { let short: String = id.to_string().chars().take(8).collect(); assert_eq!(track_filename("Alice", &id), format!("alice-{short}.wav")); // Spaces / punctuation collapse to single dashes, trimmed. - assert_eq!(track_filename(" Bob the Builder! ", &id), format!("bob-the-builder-{short}.wav")); + assert_eq!( + track_filename(" Bob the Builder! ", &id), + format!("bob-the-builder-{short}.wav") + ); // A name that sanitizes/slugs to nothing falls back to "peer". assert_eq!(track_filename("!!!", &id), format!("peer-{short}.wav")); } @@ -327,10 +336,18 @@ mod tests { rec.finalize().unwrap(); let expected = 3 * frame; - assert_eq!(wav_samples(&dir.join("me.wav")), expected, "mic padded to full length"); + assert_eq!( + wav_samples(&dir.join("me.wav")), + expected, + "mic padded to full length" + ); assert_eq!(wav_samples(&dir.join("mix.wav")), expected); assert_eq!(wav_samples(&dir.join(track_filename("p1", &p1))), expected); - assert_eq!(wav_samples(&dir.join(track_filename("p2", &p2))), expected, "silent peer still full length"); + assert_eq!( + wav_samples(&dir.join(track_filename("p2", &p2))), + expected, + "silent peer still full length" + ); } #[test] @@ -357,8 +374,14 @@ mod tests { rec.finalize().unwrap(); // Both tracks are the full 5 cycles long (late one was back-padded). - assert_eq!(wav_samples(&dir.join(track_filename("early", &early))), 5 * frame); - assert_eq!(wav_samples(&dir.join(track_filename("late", &late))), 5 * frame); + assert_eq!( + wav_samples(&dir.join(track_filename("early", &early))), + 5 * frame + ); + assert_eq!( + wav_samples(&dir.join(track_filename("late", &late))), + 5 * frame + ); // The late track's first 2 cycles are silence, then the real audio. let bytes = std::fs::read(dir.join(track_filename("late", &late))).unwrap(); @@ -378,6 +401,9 @@ mod tests { rec.end_cycle().unwrap(); rec.finalize().unwrap(); assert!(dir.join("me.wav").exists()); - assert!(!dir.join("mix.wav").exists(), "no mix track in stems-only mode"); + assert!( + !dir.join("mix.wav").exists(), + "no mix track in stems-only mode" + ); } } diff --git a/src/audio/pan.rs b/src/audio/pan.rs index be6bc07..f91b0df 100644 --- a/src/audio/pan.rs +++ b/src/audio/pan.rs @@ -23,7 +23,10 @@ pub fn pan_gains(pan: f32) -> (f32, f32) { /// still following the same equal-power curve as a peer is moved away from center. pub fn playback_pan_gains(pan: f32) -> (f32, f32) { let (left, right) = pan_gains(pan); - (left * std::f32::consts::SQRT_2, right * std::f32::consts::SQRT_2) + ( + left * std::f32::consts::SQRT_2, + right * std::f32::consts::SQRT_2, + ) } #[cfg(test)] @@ -36,8 +39,14 @@ mod tests { fn hard_left_and_right_are_endpoints() { assert_eq!(pan_gains(-1.0), (1.0, 0.0)); let (l, r) = pan_gains(1.0); - assert!(l.abs() < EPS, "left at hard-right should be zero-ish, got {l}"); - assert!((r - 1.0).abs() < EPS, "right at hard-right should be one, got {r}"); + assert!( + l.abs() < EPS, + "left at hard-right should be zero-ish, got {l}" + ); + assert!( + (r - 1.0).abs() < EPS, + "right at hard-right should be one, got {r}" + ); } #[test] @@ -55,8 +64,14 @@ mod tests { let mut prev_r = f32::NEG_INFINITY; for pan in pans { let (l, r) = pan_gains(pan); - assert!(l <= prev_l + EPS, "left gain must not rise as pan moves right"); - assert!(r >= prev_r - EPS, "right gain must not fall as pan moves right"); + assert!( + l <= prev_l + EPS, + "left gain must not rise as pan moves right" + ); + assert!( + r >= prev_r - EPS, + "right gain must not fall as pan moves right" + ); prev_l = l; prev_r = r; } diff --git a/src/audio/pipewire_impl.rs b/src/audio/pipewire_impl.rs index d7b2506..c3cd319 100644 --- a/src/audio/pipewire_impl.rs +++ b/src/audio/pipewire_impl.rs @@ -1,13 +1,16 @@ use crate::audio::{AudioBackend, AudioError}; -use std::sync::mpsc::{Sender, Receiver, RecvTimeoutError}; -use std::sync::{Arc, Mutex}; -use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; -use std::thread::{self, JoinHandle}; -use std::time::Duration; use pipewire as pw; use pw::{properties::properties, spa}; +use ringbuf::{ + HeapRb, + traits::{Consumer, Producer, Split}, +}; use spa::pod::Pod; -use ringbuf::{HeapRb, traits::{Consumer, Producer, Split}}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender}; +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; +use std::time::Duration; pub struct PipeWireBackend { capture_state: Mutex>, @@ -41,7 +44,11 @@ impl PipeWireBackend { } impl AudioBackend for PipeWireBackend { - fn start_capture(&self, tx: Sender>, target_node: Option) -> Result<(), AudioError> { + fn start_capture( + &self, + tx: Sender>, + target_node: Option, + ) -> Result<(), AudioError> { let mut capture_guard = self.capture_state.lock().unwrap(); if capture_guard.is_some() { return Err(AudioError::Stream("Capture already started".to_string())); @@ -108,12 +115,17 @@ impl AudioBackend for PipeWireBackend { } } -fn run_capture(cmd_rx: pw::channel::Receiver<()>, tx: Sender>, target_node: Option) -> Result<(), AudioError> { - let mainloop = pw::main_loop::MainLoopRc::new(None) - .map_err(|e| AudioError::Init(e.to_string()))?; +fn run_capture( + cmd_rx: pw::channel::Receiver<()>, + tx: Sender>, + target_node: Option, +) -> Result<(), AudioError> { + let mainloop = + pw::main_loop::MainLoopRc::new(None).map_err(|e| AudioError::Init(e.to_string()))?; let context = pw::context::ContextRc::new(&mainloop, None) .map_err(|e| AudioError::Init(e.to_string()))?; - let core = context.connect_rc(None) + let core = context + .connect_rc(None) .map_err(|e| AudioError::Init(e.to_string()))?; // Ring buffer setup: 9600 samples (200ms capacity for mono 48kHz) @@ -181,15 +193,16 @@ fn run_capture(cmd_rx: pw::channel::Receiver<()>, tx: Sender>, target_n let mut params = [Pod::from_bytes(&values).unwrap()]; - stream.connect( - spa::utils::Direction::Input, - None, - pw::stream::StreamFlags::AUTOCONNECT - | pw::stream::StreamFlags::MAP_BUFFERS - | pw::stream::StreamFlags::RT_PROCESS, - &mut params, - ) - .map_err(|e| AudioError::Stream(e.to_string()))?; + stream + .connect( + spa::utils::Direction::Input, + None, + pw::stream::StreamFlags::AUTOCONNECT + | pw::stream::StreamFlags::MAP_BUFFERS + | pw::stream::StreamFlags::RT_PROCESS, + &mut params, + ) + .map_err(|e| AudioError::Stream(e.to_string()))?; // Spawn the worker thread to pop from consumer and send Vec frames let running = Arc::new(AtomicBool::new(true)); @@ -257,11 +270,7 @@ const WORKER_POLL: Duration = Duration::from_millis(100); /// every `WORKER_POLL` even when no frames arrive — this is what lets `stop()` /// join the worker promptly instead of hanging on a parked blocking `recv()` /// (bug A7). Pure w.r.t. its inputs (no PipeWire), so it's unit-testable. -fn drain_loop( - rx: &Receiver>, - running: &AtomicBool, - mut on_frame: impl FnMut(Vec), -) { +fn drain_loop(rx: &Receiver>, running: &AtomicBool, mut on_frame: impl FnMut(Vec)) { while running.load(Ordering::Relaxed) { match rx.recv_timeout(WORKER_POLL) { Ok(frame) => on_frame(frame), @@ -293,7 +302,11 @@ fn publish_frame>( fn frames_to_produce(requested: usize, mapped_frames: usize) -> usize { /// Safe per-cycle fallback when the graph doesn't report a quantum. const FALLBACK_FRAMES: usize = 1024; - let want = if requested > 0 { requested } else { FALLBACK_FRAMES }; + let want = if requested > 0 { + requested + } else { + FALLBACK_FRAMES + }; want.min(mapped_frames) } @@ -303,11 +316,12 @@ fn run_playback( target_node: Option, fill_gauge: Arc, ) -> Result<(), AudioError> { - let mainloop = pw::main_loop::MainLoopRc::new(None) - .map_err(|e| AudioError::Init(e.to_string()))?; + let mainloop = + pw::main_loop::MainLoopRc::new(None).map_err(|e| AudioError::Init(e.to_string()))?; let context = pw::context::ContextRc::new(&mainloop, None) .map_err(|e| AudioError::Init(e.to_string()))?; - let core = context.connect_rc(None) + let core = context + .connect_rc(None) .map_err(|e| AudioError::Init(e.to_string()))?; // Ring buffer setup: 19200 interleaved samples (200ms capacity for stereo @@ -428,7 +442,9 @@ fn run_playback( } if starved > 0 { // One wait-free atomic add per quantum — RT-safe. - user_data.underrun_samples.fetch_add(starved, Ordering::Relaxed); + user_data + .underrun_samples + .fetch_add(starved, Ordering::Relaxed); } // Decrement the exact occupancy counter by the samples we // actually pulled (excluding underruns, which removed @@ -493,7 +509,11 @@ fn run_playback( pw::spa::pod::Value::Choice(pw::spa::pod::ChoiceValue::Int( pw::spa::utils::Choice( pw::spa::utils::ChoiceFlags::empty(), - pw::spa::utils::ChoiceEnum::Range { default: 8, min: 2, max: 64 }, + pw::spa::utils::ChoiceEnum::Range { + default: 8, + min: 2, + max: 64, + }, ), )), ), @@ -524,15 +544,16 @@ fn run_playback( Pod::from_bytes(&buffers_values).unwrap(), ]; - stream.connect( - spa::utils::Direction::Output, - None, - pw::stream::StreamFlags::AUTOCONNECT - | pw::stream::StreamFlags::MAP_BUFFERS - | pw::stream::StreamFlags::RT_PROCESS, - &mut params, - ) - .map_err(|e| AudioError::Stream(e.to_string()))?; + stream + .connect( + spa::utils::Direction::Output, + None, + pw::stream::StreamFlags::AUTOCONNECT + | pw::stream::StreamFlags::MAP_BUFFERS + | pw::stream::StreamFlags::RT_PROCESS, + &mut params, + ) + .map_err(|e| AudioError::Stream(e.to_string()))?; // Spawn a worker thread to read from rx and push to producer let running = Arc::new(AtomicBool::new(true)); @@ -607,7 +628,10 @@ fn run_playback( #[cfg(test)] mod tests { use super::{drain_loop, for_each_capture_sample, frames_to_produce, publish_frame}; - use ringbuf::{HeapRb, traits::{Consumer, Producer, Split}}; + use ringbuf::{ + HeapRb, + traits::{Consumer, Producer, Split}, + }; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -647,9 +671,7 @@ mod tests { #[test] fn capture_size_larger_than_mapping_is_clamped() { let mut samples = Vec::new(); - for_each_capture_sample(&[1, 0, 2, 0, 3], usize::MAX, |sample| { - samples.push(sample) - }); + for_each_capture_sample(&[1, 0, 2, 0, 3], usize::MAX, |sample| samples.push(sample)); assert_eq!(samples, vec![1, 2]); } diff --git a/src/audio/pw_cli.rs b/src/audio/pw_cli.rs index f1e4e61..1fcf106 100644 --- a/src/audio/pw_cli.rs +++ b/src/audio/pw_cli.rs @@ -16,11 +16,20 @@ pub fn enumerate_audio_devices() -> Vec { /// Emits the in-progress node as an `AudioDevice` if it's a complete Audio/* /// node, then resets the accumulators for the next block. Non-audio or /// incomplete blocks are dropped (but still reset). -fn push_device(name: &mut String, desc: &mut String, class: &mut String, out: &mut Vec) { +fn push_device( + name: &mut String, + desc: &mut String, + class: &mut String, + out: &mut Vec, +) { if !name.is_empty() && class.starts_with("Audio/") { out.push(AudioDevice { name: name.clone(), - description: if desc.is_empty() { name.clone() } else { desc.clone() }, + description: if desc.is_empty() { + name.clone() + } else { + desc.clone() + }, is_input: class == "Audio/Source", }); } @@ -44,7 +53,12 @@ fn parse_pw_nodes(text: &str) -> Vec { for line in text.lines() { let line = line.trim(); if line.starts_with("id ") { - push_device(&mut current_name, &mut current_desc, &mut current_class, &mut devices); + push_device( + &mut current_name, + &mut current_desc, + &mut current_class, + &mut devices, + ); } 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 = \"") { @@ -53,7 +67,12 @@ fn parse_pw_nodes(text: &str) -> Vec { current_class = val.trim_end_matches('"').to_string(); } } - push_device(&mut current_name, &mut current_desc, &mut current_class, &mut devices); + push_device( + &mut current_name, + &mut current_desc, + &mut current_class, + &mut devices, + ); devices.sort_by(|a, b| a.description.cmp(&b.description)); devices @@ -108,8 +127,14 @@ mod tests { fn source_is_input_sink_is_output() { let devices = parse_pw_nodes(SAMPLE_NODES); // Find devices by name or description to verify is_input - let mic = devices.iter().find(|d| d.name == "alsa_input.builtin").unwrap(); - let speakers = devices.iter().find(|d| d.name == "alsa_output.builtin").unwrap(); + let mic = devices + .iter() + .find(|d| d.name == "alsa_input.builtin") + .unwrap(); + let speakers = devices + .iter() + .find(|d| d.name == "alsa_output.builtin") + .unwrap(); let bare = devices.iter().find(|d| d.name == "bare.sink").unwrap(); assert!(mic.is_input); diff --git a/src/audio/recorder.rs b/src/audio/recorder.rs index 7c3615f..be71472 100644 --- a/src/audio/recorder.rs +++ b/src/audio/recorder.rs @@ -2,21 +2,26 @@ //! //! Records the **full call as you experienced it**: the mixed incoming audio //! (everyone you hear) summed with your own transmitted mic, into a single mono -//! WAV. Writing is driven by the playout mixer (one [`Recorder::write_frame`] -//! per produced 20ms frame, paced by the hardware clock); your mic arrives -//! separately from the capture thread via [`Recorder::push_mic`] and is buffered -//! in a small FIFO so the two independently-clocked streams stay roughly aligned. +//! WAV. Mixing/enqueue is driven by the playout mixer (one +//! [`Recorder::write_frame`] per produced 20ms frame, paced by the hardware +//! clock), while disk writes happen on a dedicated writer thread; your mic +//! arrives separately from the capture thread via [`Recorder::push_mic`] and is +//! buffered in a small FIFO so the two independently-clocked streams stay +//! roughly aligned. //! Minor clock drift just slowly grows/shrinks that FIFO (capped, so the lag //! between your voice and the recording is bounded) — harmless for a voice //! recording, no realtime crackle concern. //! //! No external crates: the WAV writer emits the 44-byte canonical header itself -//! and patches the two size fields on [`Recorder::finalize`]. +//! and patches the two size fields on the writer thread during +//! [`Recorder::finalize`]. use std::collections::VecDeque; use std::fs::{File, OpenOptions}; use std::io::{self, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; +use std::sync::mpsc::{self, SyncSender, TrySendError}; +use std::thread::{self, JoinHandle}; /// Capture sample rate (mono, 48kHz, matching the rest of the audio path). const SAMPLE_RATE: u32 = 48_000; @@ -25,6 +30,8 @@ const CHANNELS: u16 = 1; const RIFF_DATA_OVERHEAD: u64 = 36; const MAX_RIFF_DATA_BYTES: u64 = u32::MAX as u64 - RIFF_DATA_OVERHEAD; const MAX_NAME_ATTEMPTS: usize = 1_000; +const WRITER_QUEUE_FRAMES: usize = 256; +const DROP_LOG_INTERVAL_FRAMES: u64 = 256; /// Cap on buffered mic samples (~200ms). Bounds how far recording lag can drift /// if the capture clock runs persistently faster than playout — past this we drop @@ -118,13 +125,15 @@ impl WavWriter { } } -/// A live call recorder: a [`WavWriter`] plus a small mic FIFO that aligns your -/// transmitted mic with the playout mixer's incoming-mix frames. +/// A live call recorder: a writer-thread queue plus a small mic FIFO that aligns +/// your transmitted mic with the playout mixer's incoming-mix frames. pub struct Recorder { - writer: WavWriter, + frame_tx: SyncSender>, + writer_thread: JoinHandle>, /// Your transmitted mic samples, awaiting alignment with the next mix frame. mic_fifo: VecDeque, path: PathBuf, + dropped_frames: u64, } impl Recorder { @@ -142,10 +151,15 @@ impl Recorder { let path = dir.join(name); match OpenOptions::new().write(true).create_new(true).open(&path) { Ok(file) => { + let writer = WavWriter::from_file(file)?; + let (frame_tx, frame_rx) = mpsc::sync_channel(WRITER_QUEUE_FRAMES); + let writer_thread = thread::spawn(move || writer_thread_main(writer, frame_rx)); return Ok(Self { - writer: WavWriter::from_file(file)?, + frame_tx, + writer_thread, mic_fifo: VecDeque::new(), path, + dropped_frames: 0, }); } Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, @@ -179,21 +193,73 @@ impl Recorder { /// treated as silence (you weren't transmitting), so quiet stretches record /// the incoming mix alone. pub fn write_frame(&mut self, mixed: &[i16]) -> io::Result<()> { - let mut out = Vec::with_capacity(mixed.len()); - for &m in mixed { - let mic = self.mic_fifo.pop_front().unwrap_or(0); - let sum = (m as i32 + mic as i32).clamp(i16::MIN as i32, i16::MAX as i32); - out.push(sum as i16); + let out = mix_with_mic(mixed, &mut self.mic_fifo); + match self.frame_tx.try_send(out) { + Ok(()) => Ok(()), + Err(TrySendError::Full(_)) => { + self.dropped_frames = self.dropped_frames.saturating_add(1); + if self.dropped_frames == 1 + || self.dropped_frames.is_multiple_of(DROP_LOG_INTERVAL_FRAMES) + { + crate::log_msg(&format!( + "recording: writer queue full; dropped {} frame(s)", + self.dropped_frames + )); + } + Ok(()) + } + Err(TrySendError::Disconnected(_)) => Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "recording writer thread stopped", + )), } - self.writer.write_samples(&out) } /// Finish the file, patching its size fields. Consumes the recorder. pub fn finalize(self) -> io::Result<()> { - self.writer.finalize() + let Self { + frame_tx, + writer_thread, + mic_fifo: _, + path: _, + dropped_frames: _, + } = self; + drop(frame_tx); + writer_thread + .join() + .unwrap_or_else(|_| Err(io::Error::other("recording writer thread panicked"))) } } +fn writer_thread_main(mut writer: WavWriter, frame_rx: mpsc::Receiver>) -> io::Result<()> { + let mut first_write_error = None; + + for frame in frame_rx { + if first_write_error.is_none() + && let Err(e) = writer.write_samples(&frame) + { + first_write_error = Some(e); + } + } + + let finalize_result = writer.finalize(); + if let Some(e) = first_write_error { + Err(e) + } else { + finalize_result + } +} + +fn mix_with_mic(mixed: &[i16], mic_fifo: &mut VecDeque) -> Vec { + let mut out = Vec::with_capacity(mixed.len()); + for &m in mixed { + let mic = mic_fifo.pop_front().unwrap_or(0); + let sum = (m as i32 + mic as i32).clamp(i16::MIN as i32, i16::MAX as i32); + out.push(sum as i16); + } + out +} + /// Civil date (year, month, day) from a count of days since the Unix epoch. /// Howard Hinnant's `civil_from_days`; valid across the whole practical range. fn civil_from_days(z: i64) -> (i64, u32, u32) { @@ -222,6 +288,23 @@ pub fn timestamp_filename(unix_secs: u64) -> String { #[cfg(test)] mod tests { use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0); + + fn unique_temp_dir(prefix: &str) -> PathBuf { + let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!("{prefix}-{}-{id}", std::process::id())) + } + + fn read_wav_samples(path: &Path) -> (Vec, Vec) { + let bytes = std::fs::read(path).unwrap(); + let samples = bytes[44..] + .chunks_exact(2) + .map(|sample| i16::from_le_bytes([sample[0], sample[1]])) + .collect(); + (bytes, samples) + } #[test] fn timestamp_filename_is_utc_and_padded() { @@ -236,10 +319,7 @@ mod tests { #[test] fn same_second_recordings_get_unique_files_without_truncation() { - let dir = std::env::temp_dir().join(format!( - "peerspeak-collision-{}", - std::process::id() - )); + let dir = unique_temp_dir("peerspeak-collision"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); @@ -258,6 +338,40 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn recorder_thread_writes_mixed_samples_and_header_on_finalize() { + let dir = unique_temp_dir("peerspeak-recorder-thread"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + + let mut recorder = Recorder::create(&dir, 1_700_000_123).unwrap(); + let path = recorder.path().to_path_buf(); + + recorder.push_mic(&[1000, i16::MAX, -1000, i16::MIN, 2222]); + recorder.write_frame(&[10, 20, -32700]).unwrap(); + recorder.push_mic(&[300, -300]); + recorder + .write_frame(&[0, 1000, i16::MAX, i16::MIN]) + .unwrap(); + recorder.finalize().unwrap(); + + let expected = vec![1010, i16::MAX, i16::MIN, i16::MIN, 3222, i16::MAX, i16::MIN]; + let expected_data_bytes = u32::try_from(expected.len() * 2).unwrap(); + let (bytes, samples) = read_wav_samples(&path); + + assert_eq!(&bytes[0..4], b"RIFF"); + assert_eq!(&bytes[8..12], b"WAVE"); + assert_eq!(&bytes[36..40], b"data"); + let riff = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]); + let data = u32::from_le_bytes([bytes[40], bytes[41], bytes[42], bytes[43]]); + assert_eq!(data, expected_data_bytes); + assert_eq!(riff, RIFF_DATA_OVERHEAD as u32 + expected_data_bytes); + assert_eq!(bytes.len(), 44 + expected.len() * 2); + assert_eq!(samples, expected); + + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn wav_header_round_trips_sizes() { let dir = std::env::temp_dir(); @@ -300,38 +414,32 @@ mod tests { #[test] fn mic_is_summed_with_mix_when_present() { - let dir = std::env::temp_dir(); - let mut r = Recorder { - writer: WavWriter::new(&dir.join(format!("ps-sum-{}.wav", std::process::id()))) - .unwrap(), - mic_fifo: VecDeque::new(), - path: PathBuf::new(), - }; - r.push_mic(&[1000, 2000, 3000]); - // write_frame pops mic per-sample and sums; we can't read the file mid-stream, - // so assert the FIFO drains exactly by frame length. - r.write_frame(&[10, 20]).unwrap(); - assert_eq!(r.mic_fifo.len(), 1, "two samples consumed, one mic left"); - r.write_frame(&[0, 0]).unwrap(); + let mut mic_fifo = VecDeque::from([1000, 2000, 3000]); + + let first = mix_with_mic(&[10, 20], &mut mic_fifo); + assert_eq!(first, vec![1010, 2020]); + assert_eq!(mic_fifo.len(), 1, "two samples consumed, one mic left"); + + let second = mix_with_mic(&[0, 0], &mut mic_fifo); + assert_eq!(second, vec![3000, 0]); assert_eq!( - r.mic_fifo.len(), + mic_fifo.len(), 0, "remaining mic sample consumed; rest is silence" ); - let _ = r.finalize(); } #[test] fn mic_fifo_is_capped() { - let dir = std::env::temp_dir(); - let mut r = Recorder { - writer: WavWriter::new(&dir.join(format!("ps-cap-{}.wav", std::process::id()))) - .unwrap(), - mic_fifo: VecDeque::new(), - path: PathBuf::new(), - }; + let dir = unique_temp_dir("peerspeak-cap"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + + let mut r = Recorder::create(&dir, 1_700_000_001).unwrap(); r.push_mic(&vec![5i16; MAX_MIC_FIFO * 2]); assert_eq!(r.mic_fifo.len(), MAX_MIC_FIFO, "FIFO is bounded to the cap"); - let _ = r.finalize(); + r.finalize().unwrap(); + + let _ = std::fs::remove_dir_all(&dir); } } diff --git a/src/audio/resample.rs b/src/audio/resample.rs index 4f5723f..ae4a31c 100644 --- a/src/audio/resample.rs +++ b/src/audio/resample.rs @@ -145,7 +145,10 @@ impl StereoPullResampler { self.frac -= 1.0; } let f = self.frac as f32; - let out = (lerp(self.prev.0, self.cur.0, f), lerp(self.prev.1, self.cur.1, f)); + let out = ( + lerp(self.prev.0, self.cur.0, f), + lerp(self.prev.1, self.cur.1, f), + ); self.frac += self.step; Some(out) } @@ -273,7 +276,10 @@ mod tests { } } // At step 2.0 we consume ~2 input frames per output frame. - assert!(idx > emitted, "consumed {idx} input, emitted {emitted} output"); + assert!( + idx > emitted, + "consumed {idx} input, emitted {emitted} output" + ); } /// A zero rate must not produce a zero `step` (which would spin `push`'s inner diff --git a/src/avatar.rs b/src/avatar.rs index 019de4d..1dd7c2c 100644 --- a/src/avatar.rs +++ b/src/avatar.rs @@ -206,7 +206,10 @@ pub struct ByteLru { impl ByteLru { /// Create an LRU holding at most `cap` entries (`cap` is clamped to >= 1). pub fn new(cap: usize) -> Self { - Self { cap: cap.max(1), entries: Vec::new() } + Self { + cap: cap.max(1), + entries: Vec::new(), + } } /// Return the cached value for these exact `bytes`, building and inserting it @@ -349,7 +352,10 @@ mod tests { fn preset_png_in_range_and_out_of_range() { // Every declared preset index resolves to embedded bytes. for i in 0..PRESET_COUNT { - assert!(Avatar::Preset(i).preset_png().is_some(), "preset {i} missing"); + assert!( + Avatar::Preset(i).preset_png().is_some(), + "preset {i} missing" + ); } // Out-of-range index gracefully yields None (→ monogram fallback). assert!(Avatar::Preset(PRESET_COUNT).preset_png().is_none()); @@ -406,7 +412,10 @@ mod tests { #[test] fn sanitize_incoming_rejects_junk_and_oversize() { // Not valid base64 / not a PNG → downgraded to monogram. - assert_eq!(Avatar::Custom("not base64!!!".into()).sanitize_incoming(), Avatar::Monogram); + assert_eq!( + Avatar::Custom("not base64!!!".into()).sanitize_incoming(), + Avatar::Monogram + ); // Over the byte cap → downgraded without even decoding. let huge = Avatar::Custom("A".repeat(CUSTOM_MAX_B64 + 1)); assert_eq!(huge.sanitize_incoming(), Avatar::Monogram); diff --git a/src/background.rs b/src/background.rs index 3fd46eb..85c721b 100644 --- a/src/background.rs +++ b/src/background.rs @@ -66,7 +66,10 @@ pub fn game_background_filename(game_id: &str) -> String { /// recedes the image so body text and panel chrome stay readable, and it re-tints /// per theme since `base` comes from the active palette. pub fn scrim_color(base: Color, dim: f32) -> Color { - Color { a: dim.clamp(0.0, 1.0), ..base } + Color { + a: dim.clamp(0.0, 1.0), + ..base + } } #[cfg(test)] diff --git a/src/bin/specview.rs b/src/bin/specview.rs index a8cdb76..59b4a7b 100644 --- a/src/bin/specview.rs +++ b/src/bin/specview.rs @@ -105,7 +105,11 @@ fn cmd_gen(args: &[String]) -> Result<(), String> { "pink" => generators::pink_noise(amp, len, seed), "impulse" => generators::impulse(amp, len), "silence" => generators::silence(len), - other => return Err(format!("unknown kind {other:?} (sine sweep white pink impulse silence)")), + other => { + return Err(format!( + "unknown kind {other:?} (sine sweep white pink impulse silence)" + )); + } }; wav::write(Path::new(out), &samples, SAMPLE_RATE)?; @@ -125,8 +129,12 @@ fn cmd_gen(args: &[String]) -> Result<(), String> { /// in which frequency range any residual lives. fn cmd_erle(args: &[String]) -> Result<(), String> { let (positional, flags) = parse_args(args); - let before = positional.first().ok_or("erle needs ")?; - let after = positional.get(1).ok_or("erle needs ")?; + let before = positional + .first() + .ok_or("erle needs ")?; + let after = positional + .get(1) + .ok_or("erle needs ")?; let b = wav::read(Path::new(before))?; let a = wav::read(Path::new(after))?; @@ -239,17 +247,34 @@ fn cmd_aec(args: &[String]) -> Result<(), String> { 1000.0 * tail as f32 / sr as f32, metrics::dbfs(atten), ); - println!(" filter: {taps} taps, mu {mu}{}", if has_near { " (with near-end / double-talk)" } else { "" }); + println!( + " filter: {taps} taps, mu {mu}{}", + if has_near { + " (with near-end / double-talk)" + } else { + "" + } + ); if has_near { let dtd = if flags.present("no-dtd") { "off" } else { "on" }; println!( " double-talk: detector {dtd}, threshold {dtd_threshold}, flagged {:.0}% of samples{}", 100.0 * canceller.double_talk_rate(), - if onset > 0 { format!(", near-end onset {:.1}s", onset as f32 / sr as f32) } else { String::new() }, + if onset > 0 { + format!(", near-end onset {:.1}s", onset as f32 / sr as f32) + } else { + String::new() + }, ); } - println!(" mic before: {:.1} dBFS rms", metrics::dbfs(metrics::rms(&mic))); - println!(" residual echo after: {:.1} dBFS rms", metrics::dbfs(metrics::rms(&residual))); + println!( + " mic before: {:.1} dBFS rms", + metrics::dbfs(metrics::rms(&mic)) + ); + println!( + " residual echo after: {:.1} dBFS rms", + metrics::dbfs(metrics::rms(&residual)) + ); println!(" ERLE broadband: {broadband:+.1} dB"); println!(" ERLE early/late: {early:+.1} -> {late:+.1} dB (rise = filter converging)"); @@ -278,9 +303,21 @@ fn cmd_aec(args: &[String]) -> Result<(), String> { } if flags.present("show") { println!("\n--- mic (echo present) ---"); - print!("{}", render::render(&stft::analyze(&mic, sr, 2048, 512), &render::RenderOpts::default())); + print!( + "{}", + render::render( + &stft::analyze(&mic, sr, 2048, 512), + &render::RenderOpts::default() + ) + ); println!("\n--- cleaned (post-AEC) ---"); - print!("{}", render::render(&stft::analyze(&cleaned, sr, 2048, 512), &render::RenderOpts::default())); + print!( + "{}", + render::render( + &stft::analyze(&cleaned, sr, 2048, 512), + &render::RenderOpts::default() + ) + ); } Ok(()) } @@ -339,13 +376,22 @@ impl Flags { self.bools.iter().any(|b| b == key) || self.map.contains_key(key) } fn f32_or(&self, key: &str, default: f32) -> f32 { - self.map.get(key).and_then(|v| v.parse().ok()).unwrap_or(default) + self.map + .get(key) + .and_then(|v| v.parse().ok()) + .unwrap_or(default) } fn usize_or(&self, key: &str, default: usize) -> usize { - self.map.get(key).and_then(|v| v.parse().ok()).unwrap_or(default) + self.map + .get(key) + .and_then(|v| v.parse().ok()) + .unwrap_or(default) } fn u64_or(&self, key: &str, default: u64) -> u64 { - self.map.get(key).and_then(|v| v.parse().ok()).unwrap_or(default) + self.map + .get(key) + .and_then(|v| v.parse().ok()) + .unwrap_or(default) } } diff --git a/src/bin/test_net.rs b/src/bin/test_net.rs index 6c488f5..1ebe540 100644 --- a/src/bin/test_net.rs +++ b/src/bin/test_net.rs @@ -1,9 +1,6 @@ -use peerspeak::network::{ - gossip::IrohGossipState, - RoomState, PeerState, -}; use iroh::{Endpoint, endpoint::presets}; use iroh_gossip::net::Gossip; +use peerspeak::network::{PeerState, RoomState, gossip::IrohGossipState}; use tokio::time::{self, Duration}; #[tokio::main] @@ -18,7 +15,7 @@ async fn main() -> Result<(), Box> { .address_lookup(lookup_a.clone()) .bind() .await?; - + endpoint_a.online().await; println!("Node A online. ID: {}", endpoint_a.id()); @@ -27,7 +24,12 @@ async fn main() -> Result<(), Box> { .accept(iroh_gossip::net::GOSSIP_ALPN, gossip_a.clone()) .spawn(); - let room_a = IrohGossipState::new(endpoint_a.clone(), gossip_a.clone(), lookup_a.clone(), secret_a); + let room_a = IrohGossipState::new( + endpoint_a.clone(), + gossip_a.clone(), + lookup_a.clone(), + secret_a, + ); // 2. Node B (Client) Setup let lookup_b = iroh::address_lookup::memory::MemoryLookup::new(); @@ -46,7 +48,12 @@ async fn main() -> Result<(), Box> { .accept(iroh_gossip::net::GOSSIP_ALPN, gossip_b.clone()) .spawn(); - let room_b = IrohGossipState::new(endpoint_b.clone(), gossip_b.clone(), lookup_b.clone(), secret_b); + let room_b = IrohGossipState::new( + endpoint_b.clone(), + gossip_b.clone(), + lookup_b.clone(), + secret_b, + ); // 3. Create room on Node A let topic_id = rand::random(); diff --git a/src/codec/opus_impl.rs b/src/codec/opus_impl.rs index 039b857..714914e 100644 --- a/src/codec/opus_impl.rs +++ b/src/codec/opus_impl.rs @@ -1,5 +1,5 @@ -use crate::codec::{AudioEncoder, AudioDecoder, CodecError}; -use opus::{Encoder, Decoder, Application, Channels}; +use crate::codec::{AudioDecoder, AudioEncoder, CodecError}; +use opus::{Application, Channels, Decoder, Encoder}; pub struct OpusEncoder { encoder: Encoder, @@ -8,7 +8,11 @@ pub struct OpusEncoder { impl OpusEncoder { /// Creates a new Opus encoder. /// Standard voice parameters: sample_rate = 48000, channels = Channels::Mono, application = Application::Voip - pub fn new(sample_rate: u32, channels: Channels, application: Application) -> Result { + pub fn new( + sample_rate: u32, + channels: Channels, + application: Application, + ) -> Result { let encoder = Encoder::new(sample_rate, channels, application) .map_err(|e| CodecError::Init(format!("Failed to create Opus encoder: {}", e)))?; Ok(Self { encoder }) @@ -20,9 +24,11 @@ impl AudioEncoder for OpusEncoder { // We allocate a buffer for the compressed output. // A maximum packet size of 4000 bytes is more than enough for a single voice frame. let mut compressed = vec![0u8; 4000]; - let len = self.encoder.encode(pcm, &mut compressed) + let len = self + .encoder + .encode(pcm, &mut compressed) .map_err(|e| CodecError::Encode(format!("Opus encoding failed: {}", e)))?; - + compressed.truncate(len); Ok(compressed) } @@ -42,10 +48,18 @@ impl OpusDecoder { /// Creates a new Opus decoder. /// Standard voice parameters: sample_rate = 48000, channels = Channels::Mono. /// `frame_samples` is the per-channel length of one transmitted frame (e.g. 960). - pub fn new(sample_rate: u32, channels: Channels, frame_samples: usize) -> Result { + pub fn new( + sample_rate: u32, + channels: Channels, + frame_samples: usize, + ) -> Result { let decoder = Decoder::new(sample_rate, channels) .map_err(|e| CodecError::Init(format!("Failed to create Opus decoder: {}", e)))?; - Ok(Self { decoder, channels, frame_samples }) + Ok(Self { + decoder, + channels, + frame_samples, + }) } fn channels_count(&self) -> usize { @@ -73,7 +87,9 @@ impl AudioDecoder for OpusDecoder { } }; - let decoded_per_channel = self.decoder.decode(input, &mut pcm, false) + let decoded_per_channel = self + .decoder + .decode(input, &mut pcm, false) .map_err(|e| CodecError::Decode(format!("Opus decoding failed: {}", e)))?; pcm.truncate(decoded_per_channel * channels_count); @@ -100,7 +116,10 @@ mod tests { // encode it let compressed = encoder.encode(&pcm).unwrap(); - assert!(!compressed.is_empty(), "Compressed buffer should not be empty"); + assert!( + !compressed.is_empty(), + "Compressed buffer should not be empty" + ); assert!( compressed.len() < pcm.len() * std::mem::size_of::(), "Compressed size ({}) should be smaller than raw PCM size ({})", @@ -110,13 +129,21 @@ mod tests { // decode it let decoded = decoder.decode(Some(&compressed)).unwrap(); - assert_eq!(decoded.len(), 960, "Decoded sample count should be exactly 960"); + assert_eq!( + decoded.len(), + 960, + "Decoded sample count should be exactly 960" + ); // 2. Round-trip carries signal energy (not silence) let sum_sq: f64 = decoded.iter().map(|&x| (x as f64).powi(2)).sum(); let rms = (sum_sq / decoded.len() as f64).sqrt(); // Since input had amplitude ~10000, let's verify RMS is significantly above 0 (e.g. > 100.0) - assert!(rms > 100.0, "Decoded signal should carry energy (RMS was {})", rms); + assert!( + rms > 100.0, + "Decoded signal should carry energy (RMS was {})", + rms + ); } #[test] @@ -125,11 +152,19 @@ mod tests { // decode(None) returns exactly frame_samples (960) samples let plc_none = decoder.decode(None).unwrap(); - assert_eq!(plc_none.len(), 960, "decode(None) should yield exactly 960 samples"); + assert_eq!( + plc_none.len(), + 960, + "decode(None) should yield exactly 960 samples" + ); // decode(Some(&[])) (empty slice) does the same let plc_empty = decoder.decode(Some(&[])).unwrap(); - assert_eq!(plc_empty.len(), 960, "decode(Some(&[])) should yield exactly 960 samples"); + assert_eq!( + plc_empty.len(), + 960, + "decode(Some(&[])) should yield exactly 960 samples" + ); } #[test] @@ -140,7 +175,11 @@ mod tests { let pcm = vec![0i16; 960]; let compressed = encoder.encode(&pcm).unwrap(); let decoded = decoder.decode(Some(&compressed)).unwrap(); - assert_eq!(decoded.len(), 960, "Decoded sample count should match packet duration"); + assert_eq!( + decoded.len(), + 960, + "Decoded sample count should match packet duration" + ); } #[test] @@ -149,11 +188,18 @@ mod tests { // decode(None) returns exactly frame_samples * 2 (1920) samples let plc_none = decoder.decode(None).unwrap(); - assert_eq!(plc_none.len(), 960 * 2, "Stereo decode(None) should yield exactly 1920 samples"); + assert_eq!( + plc_none.len(), + 960 * 2, + "Stereo decode(None) should yield exactly 1920 samples" + ); // decode(Some(&[])) (empty slice) does the same let plc_empty = decoder.decode(Some(&[])).unwrap(); - assert_eq!(plc_empty.len(), 960 * 2, "Stereo decode(Some(&[])) should yield exactly 1920 samples"); + assert_eq!( + plc_empty.len(), + 960 * 2, + "Stereo decode(Some(&[])) should yield exactly 1920 samples" + ); } } - diff --git a/src/config.rs b/src/config.rs index 602d6c9..8627ab7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,9 +1,12 @@ use crate::notify::Sound; use crate::theme::AppTheme; +use anyhow::Context; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, HashMap}; use std::fs; -use std::path::PathBuf; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; /// Relay/discovery posture, trading connectivity against how much the n0 /// infrastructure learns about you. See the network module for details. @@ -24,8 +27,11 @@ pub enum NetworkMode { impl NetworkMode { /// All variants, for presentation in a picker. - pub const ALL: [NetworkMode; 3] = - [NetworkMode::RelayNoDiscovery, NetworkMode::N0Full, NetworkMode::DirectOnly]; + pub const ALL: [NetworkMode; 3] = [ + NetworkMode::RelayNoDiscovery, + NetworkMode::N0Full, + NetworkMode::DirectOnly, + ]; } /// Arrangement of the in-call room screen, chosen via the layout picker. @@ -42,8 +48,11 @@ pub enum RoomLayout { impl RoomLayout { /// All variants, in picker display order. - pub const ALL: [RoomLayout; 3] = - [RoomLayout::ThreeColumn, RoomLayout::BottomDock, RoomLayout::Drawer]; + pub const ALL: [RoomLayout; 3] = [ + RoomLayout::ThreeColumn, + RoomLayout::BottomDock, + RoomLayout::Drawer, + ]; } /// What a call recording captures. `Mixed` is the original single-file behaviour; @@ -62,8 +71,11 @@ pub enum RecordingMode { impl RecordingMode { /// All variants, in picker display order. - pub const ALL: [RecordingMode; 3] = - [RecordingMode::Mixed, RecordingMode::Multitrack, RecordingMode::Both]; + pub const ALL: [RecordingMode; 3] = [ + RecordingMode::Mixed, + RecordingMode::Multitrack, + RecordingMode::Both, + ]; /// True when this mode writes per-peer stem tracks (Multitrack or Both). pub fn is_multitrack(self) -> bool { @@ -345,6 +357,13 @@ pub struct AppConfig { pub window_y: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LoadOutcome { + Missing, + Loaded, + Recovered, +} + impl Default for AppConfig { fn default() -> Self { Self { @@ -475,24 +494,115 @@ impl AppConfig { } pub fn load() -> Self { - if let Some(path) = Self::config_path() - && let Ok(contents) = fs::read_to_string(&path) - && let Ok(config) = serde_json::from_str(&contents) { - return config; - } - Self::default() + let Some(path) = Self::config_path() else { + return Self::default(); + }; + let (config, _) = Self::load_from(&path); + config } 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 Err(e) = self.save_to(&path) { + crate::log_msg(&format!("config: save failed: {e:#}")); } - if let Ok(json) = serde_json::to_string_pretty(self) { - let _ = fs::write(path, json); + } else { + crate::log_msg("config: save failed: could not determine a config directory"); + } + } + + pub fn load_from(path: &Path) -> (Self, LoadOutcome) { + match fs::read_to_string(path) { + Ok(contents) => match serde_json::from_str(&contents) { + Ok(config) => (config, LoadOutcome::Loaded), + Err(e) => { + let backup = recover_corrupt_config(path, &format!("failed to parse: {e}")); + (Self::default(), backup) + } + }, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + (Self::default(), LoadOutcome::Missing) + } + Err(e) => { + let backup = recover_corrupt_config(path, &format!("failed to read: {e}")); + (Self::default(), backup) } } } + + pub fn save_to(&self, path: &Path) -> anyhow::Result<()> { + let parent = path + .parent() + .context("config path has no parent directory")?; + fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + + let json = serde_json::to_string_pretty(self).context("failed to encode config")?; + let tmp = config_tmp_path(path)?; + let result = (|| -> anyhow::Result<()> { + { + let mut f = fs::File::create(&tmp) + .with_context(|| format!("failed to create {}", tmp.display()))?; + f.write_all(json.as_bytes()) + .with_context(|| format!("failed to write {}", tmp.display()))?; + f.sync_all().ok(); + } + fs::rename(&tmp, path).with_context(|| { + format!("failed to rename {} -> {}", tmp.display(), path.display()) + })?; + Ok(()) + })(); + + if result.is_err() { + let _ = fs::remove_file(&tmp); + } + result + } +} + +fn config_tmp_path(path: &Path) -> anyhow::Result { + let parent = path + .parent() + .context("config path has no parent directory")?; + let mut name = path + .file_name() + .context("config path has no file name")? + .to_os_string(); + name.push(format!(".tmp.{}", std::process::id())); + Ok(parent.join(name)) +} + +fn corrupt_backup_path(path: &Path) -> PathBuf { + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + let mut name = path + .file_name() + .map(|n| n.to_os_string()) + .unwrap_or_else(|| "config.json".into()); + name.push(format!(".corrupt.{secs}")); + parent.join(name) +} + +fn recover_corrupt_config(path: &Path, reason: &str) -> LoadOutcome { + let backup = corrupt_backup_path(path); + match fs::rename(path, &backup) { + Ok(()) => { + crate::log_msg(&format!( + "config: {reason}; moved damaged config to {}", + backup.display() + )); + } + Err(e) => { + crate::log_msg(&format!( + "config: {reason}; failed to move damaged config to {}: {e}", + backup.display() + )); + } + } + LoadOutcome::Recovered } #[cfg(test)] @@ -507,14 +617,109 @@ mod tests { assert_eq!(original, deserialized); } + fn temp_config_path(tag: &str) -> PathBuf { + let mut p = std::env::temp_dir(); + p.push(format!( + "peerspeak-configtest-{}-{}", + std::process::id(), + tag + )); + p.push("config.json"); + p + } + + #[test] + fn save_to_then_load_from_round_trips() { + let path = temp_config_path("roundtrip"); + let _ = fs::remove_dir_all(path.parent().unwrap()); + let cfg = AppConfig { + username: "Ada".into(), + input_device: "mic".into(), + output_device: "speaker".into(), + noise_gate_threshold: 0.42, + ..AppConfig::default() + }; + + cfg.save_to(&path).unwrap(); + let (loaded, outcome) = AppConfig::load_from(&path); + + assert_eq!(outcome, LoadOutcome::Loaded); + assert_eq!(loaded, cfg); + let _ = fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn load_from_missing_returns_default_without_corrupt_backup() { + let path = temp_config_path("missing"); + let _ = fs::remove_dir_all(path.parent().unwrap()); + + let (loaded, outcome) = AppConfig::load_from(&path); + + assert_eq!(outcome, LoadOutcome::Missing); + assert_eq!(loaded, AppConfig::default()); + assert!(!path.parent().unwrap().exists()); + } + + #[test] + fn load_from_corrupt_file_preserves_original_bytes() { + let path = temp_config_path("corrupt"); + let _ = fs::remove_dir_all(path.parent().unwrap()); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let corrupt = b"{ this is not json"; + fs::write(&path, corrupt).unwrap(); + + let (loaded, outcome) = AppConfig::load_from(&path); + + assert_eq!(outcome, LoadOutcome::Recovered); + assert_eq!(loaded, AppConfig::default()); + assert_ne!(fs::read(&path).ok().as_deref(), Some(corrupt.as_slice())); + let backups: Vec<_> = fs::read_dir(path.parent().unwrap()) + .unwrap() + .map(|entry| entry.unwrap().path()) + .filter(|entry| { + entry + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("config.json.corrupt.")) + }) + .collect(); + assert_eq!(backups.len(), 1, "expected one corrupt backup"); + assert_eq!(fs::read(&backups[0]).unwrap(), corrupt); + let _ = fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn save_to_leaves_no_tmp_file_after_success() { + let path = temp_config_path("atomic"); + let _ = fs::remove_dir_all(path.parent().unwrap()); + + AppConfig::default().save_to(&path).unwrap(); + + let tmp_files: Vec<_> = fs::read_dir(path.parent().unwrap()) + .unwrap() + .map(|entry| entry.unwrap().path()) + .filter(|entry| { + entry + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.contains(".tmp.")) + }) + .collect(); + assert!(tmp_files.is_empty(), "leftover temp files: {tmp_files:?}"); + let _ = fs::remove_dir_all(path.parent().unwrap()); + } + #[test] fn test_backward_compat_default_fill() { let minimal_json = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#; let deserialized: AppConfig = serde_json::from_str(minimal_json).unwrap(); - + assert_eq!(deserialized.network_mode, NetworkMode::RelayNoDiscovery); // Configs predating the presence posture load as friends-only (no beacon). - assert_eq!(deserialized.presence_mode, crate::presence::PresenceMode::Normal); + assert_eq!( + deserialized.presence_mode, + crate::presence::PresenceMode::Normal + ); assert!(!deserialized.echo_cancellation_enabled); assert!(deserialized.notifications_enabled); // Configs predating the volume sliders must load at unity gain. @@ -542,7 +747,10 @@ mod tests { // Configs predating the per-sound flags (W6) enable every chime, so an // upgrade is silent-change-free. for sound in Sound::ALL { - assert!(deserialized.sound_enabled(sound), "{sound:?} should default on"); + assert!( + deserialized.sound_enabled(sound), + "{sound:?} should default on" + ); } // The accessor and mutator agree round-trip. let mut cfg = AppConfig::default(); @@ -589,7 +797,10 @@ mod tests { }"#; let cfg: AppConfig = serde_json::from_str(legacy_json).unwrap(); // The pre-existing single background survives untouched (still Option). - assert_eq!(cfg.background.as_deref(), Some("/home/eric/.config/peerspeak/background.png")); + assert_eq!( + cfg.background.as_deref(), + Some("/home/eric/.config/peerspeak/background.png") + ); assert!((cfg.background_dim - 0.4).abs() < f32::EPSILON); // The new game-detection fields default to off/empty → silent, opt-in upgrade. assert!(!cfg.game_presence_enabled); @@ -601,9 +812,12 @@ mod tests { fn test_game_maps_serialize_deterministically() { // BTreeMap ordering makes the serialized config stable across runs. let mut cfg = AppConfig::default(); - cfg.game_backgrounds.insert("steam:730".into(), "/a.png".into()); - cfg.game_backgrounds.insert("exe:hl2_linux".into(), "/b.png".into()); - cfg.game_process_map.insert("hl2_linux".into(), "Half-Life 2".into()); + cfg.game_backgrounds + .insert("steam:730".into(), "/a.png".into()); + cfg.game_backgrounds + .insert("exe:hl2_linux".into(), "/b.png".into()); + cfg.game_process_map + .insert("hl2_linux".into(), "Half-Life 2".into()); let json = serde_json::to_string(&cfg).unwrap(); // Keys appear in sorted order (exe: before steam:). let bg = json.find("game_backgrounds").unwrap(); @@ -661,8 +875,7 @@ mod tests { recording_mode: RecordingMode::Both, ..AppConfig::default() }; - let back: AppConfig = - serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap(); + let back: AppConfig = serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap(); assert_eq!(back.recording_mode, RecordingMode::Both); // is_multitrack() classifies correctly. assert!(!RecordingMode::Mixed.is_multitrack()); @@ -748,7 +961,10 @@ mod tests { assert_eq!(round_tripped.input_volume, 1.5); assert_eq!(round_tripped.output_volume, 0.25); assert_eq!(round_tripped.clip_volume, 0.7); - assert_eq!(round_tripped.music_playlist, vec!["/tmp/song.ogg".to_string()]); + assert_eq!( + round_tripped.music_playlist, + vec!["/tmp/song.ogg".to_string()] + ); assert_eq!(round_tripped.music_volume, 0.6); assert!(round_tripped.music_broadcast); assert!(!round_tripped.show_player_bar); @@ -757,7 +973,8 @@ mod tests { #[test] fn test_notifications_enabled_specifically() { - let missing_notifications = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#; + let missing_notifications = + r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#; let config_missing: AppConfig = serde_json::from_str(missing_notifications).unwrap(); assert!(config_missing.notifications_enabled); @@ -813,11 +1030,14 @@ mod tests { "unrecognized_field_xyz_123": "some_value" }"#; let deserialized_res: Result = serde_json::from_str(json_with_extra); - + // Assert that deserialization succeeds even with unrecognized/unknown fields. // This confirms that serde does not reject unknown fields (i.e. default behavior). - assert!(deserialized_res.is_ok(), "Config deserialization failed when an unknown field was present"); - + assert!( + deserialized_res.is_ok(), + "Config deserialization failed when an unknown field was present" + ); + let config = deserialized_res.unwrap(); assert_eq!(config.input_device, ""); assert_eq!(config.output_device, ""); diff --git a/src/core/messages.rs b/src/core/messages.rs index e163a06..e953a43 100644 --- a/src/core/messages.rs +++ b/src/core/messages.rs @@ -9,7 +9,15 @@ pub enum CoreCommand { /// Join a room. `ticket` is "create" (or empty) to mint a fresh room, else a /// share ticket to join. `room_name` is the creator's chosen cosmetic label /// for a NEW room; it's ignored when joining (the label rides in the ticket). - Join { name: String, ticket: String, room_name: String, input_device: Option, output_device: Option, echo_cancellation: bool, avatar: crate::avatar::Avatar }, + Join { + name: String, + ticket: String, + room_name: String, + input_device: Option, + output_device: Option, + echo_cancellation: bool, + avatar: crate::avatar::Avatar, + }, Leave, /// Orderly app shutdown: finalize recordings, leave any active room, stop local /// audio/screen-share work, close the persistent network stack, then ack with @@ -41,7 +49,10 @@ pub enum CoreCommand { /// Start/stop a standalone capture-only stream that reports the raw mic /// level via [`UiEvent::MicLevel`], for gate calibration outside a call. /// Ignored while a room session is active (the in-call meter covers that). - SetMicMonitor { enabled: bool, input_device: Option }, + SetMicMonitor { + enabled: bool, + input_device: Option, + }, /// Set the relay/discovery posture. Takes effect on the next room join, /// since the endpoint is (re)built then. SetNetworkMode(NetworkMode), @@ -56,22 +67,40 @@ pub enum CoreCommand { /// Send a chat message carrying a file attachment. The app has already read + /// capped the file and built the descriptor; core makes the bytes available /// on the file plane and broadcasts the descriptor. - SendChatFile { text: String, attachment: crate::files::ChatAttachment, data: Vec }, + SendChatFile { + text: String, + attachment: crate::files::ChatAttachment, + data: Vec, + }, /// Fetch a received attachment's bytes from its sender over the file plane /// (used for on-demand file/chip downloads; images are auto-fetched on /// receipt). Replies with `AttachmentReady`/`AttachmentFailed`. - FetchAttachment { from: EndpointId, attachment: crate::files::ChatAttachment }, + FetchAttachment { + from: EndpointId, + attachment: crate::files::ChatAttachment, + }, /// Register `data` as fetchable under `id` for room members (the current /// broadcast track). Called once per track when broadcasting. - ServeMusicTrack { id: crate::files::AttachmentId, data: std::sync::Arc> }, + ServeMusicTrack { + id: crate::files::AttachmentId, + data: std::sync::Arc>, + }, /// Drop a music blob that is no longer current-or-next. ForgetMusicTrack(crate::files::AttachmentId), /// Set (or clear) our broadcast music timeline and re-announce presence. SetMusicPresence(Option), /// Fetch a source peer's current track bytes after tuning into them. - FetchMusic { from: EndpointId, id: crate::files::AttachmentId, size: u64 }, + FetchMusic { + from: EndpointId, + id: crate::files::AttachmentId, + size: u64, + }, /// Fetch a source peer's advertised next track bytes before it becomes current. - PrefetchMusic { from: EndpointId, id: crate::files::AttachmentId, size: u64 }, + PrefetchMusic { + from: EndpointId, + id: crate::files::AttachmentId, + size: u64, + }, /// Set the pixelpass binary location (config override, empty = use `$PATH`). /// Sent at startup so screen-share can resolve the binary. SetPixelpassPath(Option), @@ -84,7 +113,9 @@ pub enum CoreCommand { /// `audio_app` selects which app's audio to capture: `Some(name)` captures /// only that app (avoiding the call-loopback echo, A23); `None` shares the /// whole desktop audio (the legacy behavior). - StartScreenShare { audio_app: Option }, + StartScreenShare { + audio_app: Option, + }, /// Stop sharing our screen: kill the pixelpass host and clear the presence /// ticket. No-op when not sharing. StopScreenShare, @@ -98,7 +129,11 @@ pub enum CoreCommand { /// Add a friend (W7). Core owns the friends store: it mutates + persists it and /// replies with [`UiEvent::FriendsUpdated`]. `addr` seeds `last_addr` if known /// (e.g. added from a room). Idempotent — re-adding an existing id is a no-op. - AddFriend { id: EndpointId, name: String, addr: Option }, + AddFriend { + id: EndpointId, + name: String, + addr: Option, + }, /// Remove a friend by id (W7). RemoveFriend(EndpointId), /// Locally rename a friend (W7). @@ -130,6 +165,17 @@ pub enum DeliveryClass { BestEffort, } +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum CoalesceKey { + InputVolume, + OutputVolume, + NoiseGate, + PeerVolume(EndpointId), + PeerPan(EndpointId), + PeerGate(EndpointId), + PeerEq(EndpointId), +} + /// Route a command by how bad it is to drop it. Discrete, human-paced user /// actions are Reliable (must land). The only high-frequency commands are the /// continuous audio sliders, where dropping intermediate values is harmless; @@ -178,10 +224,7 @@ pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass { from: _, attachment: _, } - | CoreCommand::ServeMusicTrack { - id: _, - data: _, - } + | CoreCommand::ServeMusicTrack { id: _, data: _ } | CoreCommand::ForgetMusicTrack(_) | CoreCommand::SetMusicPresence(_) | CoreCommand::FetchMusic { @@ -215,57 +258,188 @@ pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass { } } +/// Coalescing bucket for high-frequency continuous controls. A key exists +/// exactly for [`DeliveryClass::BestEffort`] commands. +pub fn coalesce_key(cmd: &CoreCommand) -> Option { + match cmd { + CoreCommand::SetPeerVolume(peer_id, _) => Some(CoalesceKey::PeerVolume(*peer_id)), + CoreCommand::SetPeerPan(peer_id, _) => Some(CoalesceKey::PeerPan(*peer_id)), + CoreCommand::SetPeerGate(peer_id, _) => Some(CoalesceKey::PeerGate(*peer_id)), + CoreCommand::SetPeerEq(peer_id, _) => Some(CoalesceKey::PeerEq(*peer_id)), + CoreCommand::SetInputVolume(_) => Some(CoalesceKey::InputVolume), + CoreCommand::SetOutputVolume(_) => Some(CoalesceKey::OutputVolume), + CoreCommand::SetNoiseGateThreshold(_) => Some(CoalesceKey::NoiseGate), + + CoreCommand::Join { + name: _, + ticket: _, + room_name: _, + input_device: _, + output_device: _, + echo_cancellation: _, + avatar: _, + } + | CoreCommand::Leave + | CoreCommand::Shutdown + | CoreCommand::ToggleMute + | CoreCommand::SetAvatar(_) + | CoreCommand::ToggleDeafen + | CoreCommand::SetPttMode(_) + | CoreCommand::SetPttActive(_) + | CoreCommand::SetPeerMuted(_, _) + | CoreCommand::SetMicMonitor { + enabled: _, + input_device: _, + } + | CoreCommand::SetNetworkMode(_) + | CoreCommand::SetRecording(_) + | CoreCommand::SetRecordingMode(_) + | CoreCommand::SendChat(_) + | CoreCommand::SendChatFile { + text: _, + attachment: _, + data: _, + } + | CoreCommand::FetchAttachment { + from: _, + attachment: _, + } + | CoreCommand::ServeMusicTrack { id: _, data: _ } + | CoreCommand::ForgetMusicTrack(_) + | CoreCommand::SetMusicPresence(_) + | CoreCommand::FetchMusic { + from: _, + id: _, + size: _, + } + | CoreCommand::PrefetchMusic { + from: _, + id: _, + size: _, + } + | CoreCommand::SetPixelpassPath(_) + | CoreCommand::ListAudioApps + | CoreCommand::StartScreenShare { audio_app: _ } + | CoreCommand::StopScreenShare + | CoreCommand::ViewShare(_) + | CoreCommand::RegenerateIdentity + | CoreCommand::AddFriend { + id: _, + name: _, + addr: _, + } + | CoreCommand::RemoveFriend(_) + | CoreCommand::RenameFriend(_, _) + | CoreCommand::RefreshFriends + | CoreCommand::SetPresenceMode(_) + | CoreCommand::SetGamePresenceEnabled(_) + | CoreCommand::SetGameOverride(_) + | CoreCommand::SetGameProcessMap(_) => None, + } +} + #[derive(Debug, Clone)] pub enum UiEvent { - RoomJoined { ticket: String, self_id: String }, + RoomJoined { + ticket: String, + self_id: String, + }, RoomLeft, /// Clear room-scoped UI state after a failed in-call room switch, without a /// leave chime. The persistent identity remains unchanged. RoomReset, - PeerJoined { id: EndpointId, state: PeerState }, - PeerLeft { id: EndpointId }, + PeerJoined { + id: EndpointId, + state: PeerState, + }, + PeerLeft { + id: EndpointId, + }, /// The fixed reconnect grace expired and bounded background gossip recovery /// has started. This is non-terminal and must not play the failure chime. - PeerRecoveryStarted { id: EndpointId }, - PeerConnectionFailed { id: EndpointId }, - PeerUpdated { id: EndpointId, state: PeerState }, + PeerRecoveryStarted { + id: EndpointId, + }, + PeerConnectionFailed { + id: EndpointId, + }, + PeerUpdated { + id: EndpointId, + state: PeerState, + }, /// Audio link to a peer is being (re)established — show a connecting state. - PeerConnecting { id: EndpointId }, + PeerConnecting { + id: EndpointId, + }, /// Audio link to a peer is up and carrying audio. - PeerConnected { id: EndpointId }, + PeerConnected { + id: EndpointId, + }, AudioLevels(Vec<(EndpointId, f32)>), /// Raw (pre-gate, pre-mute) normalized RMS of the local mic, `0.0..=1.0`, /// for the settings level meter. Throttled to ~10/sec. MicLevel(f32), /// Call recording started; carries the absolute WAV path being written. - RecordingStarted { path: String }, + RecordingStarted { + path: String, + }, /// Call recording stopped; carries the finished WAV path. - RecordingStopped { path: String }, + RecordingStopped { + path: String, + }, /// A room text-chat message arrived from a peer (never our own — local /// messages are echoed by the UI on send). `from` is the sender's node id /// string, used to key their avatar (W4). - ChatMessage { from: String, name: String, text: String, attachment: Option }, + ChatMessage { + from: String, + name: String, + text: String, + attachment: Option, + }, /// An attachment's bytes are now available (auto-fetched for images, or /// fetched on demand for files). Keyed by `(from, id)`: the id is /// attacker-chosen, so a malicious peer can reuse a victim's id — the author /// disambiguates whose bytes these are and stops content aliasing (Tier C /// F-12). - AttachmentReady { from: EndpointId, id: crate::files::AttachmentId, data: Vec }, + AttachmentReady { + from: EndpointId, + id: crate::files::AttachmentId, + data: Vec, + }, /// An attachment fetch failed (sender gone, too large, decode error, etc.). - AttachmentFailed { from: EndpointId, id: crate::files::AttachmentId, error: String }, + AttachmentFailed { + from: EndpointId, + id: crate::files::AttachmentId, + error: String, + }, /// A tuned-in source's track bytes arrived; play them in the music sink. - MusicReady { from: EndpointId, id: crate::files::AttachmentId, data: Vec }, + MusicReady { + from: EndpointId, + id: crate::files::AttachmentId, + data: Vec, + }, /// A tuned-in source's next-track bytes arrived; cache them for a gapless swap. - MusicPrefetched { from: EndpointId, id: crate::files::AttachmentId, data: Vec }, + MusicPrefetched { + from: EndpointId, + id: crate::files::AttachmentId, + data: Vec, + }, /// A music-track fetch failed (source gone, too large, etc.). - MusicFetchFailed { from: EndpointId, id: crate::files::AttachmentId, error: String }, + MusicFetchFailed { + from: EndpointId, + id: crate::files::AttachmentId, + error: String, + }, /// The apps currently producing audio, for the screen-share audio picker /// (A23). Sorted, deduplicated `application.name`s; empty when nothing is /// playing or enumeration isn't available. `app_audio_supported` reports /// whether the resolved pixelpass understands `--strict-audio`: when `false` /// (an older pixelpass) the picker must offer whole-desktop audio only, since /// a per-app share would pass a flag that older binary rejects (audit P2). - AudioAppsListed { apps: Vec, app_audio_supported: bool }, + AudioAppsListed { + apps: Vec, + app_audio_supported: bool, + }, /// Our own screen share started; the UI flips the Share button to "Stop". ScreenShareStarted, /// Our own screen share stopped (or failed to start). @@ -278,24 +452,37 @@ pub enum UiEvent { /// A validly signed peer cannot be admitted because its gossip timestamp is /// outside the replay freshness window. `peer_ahead` describes the peer's /// sender-stamped timestamp relative to this machine's clock. - ClockSkewWarning { skew_secs: u64, peer_ahead: bool }, + ClockSkewWarning { + skew_secs: u64, + peer_ahead: bool, + }, /// Our node identity (W7): the current node id string, and whether it is /// PERSISTED to disk. Sent once at startup and again after a regenerate. /// `persisted = false` means the key file couldn't be read/written and we're /// running on an ephemeral fallback — a degraded state the UI must surface, /// since the id (and thus friend recognition) won't survive the next launch. /// `error` carries the reason when degraded, for the UI explainer. - IdentityStatus { node_id: String, persisted: bool, error: Option }, + IdentityStatus { + node_id: String, + persisted: bool, + error: Option, + }, /// The friends list (W7), now owned by core. Sent at startup (after load) and /// after every add/remove/rename so the GUI renders from this snapshot instead /// of owning the store. `read_only` is true when `friends.json` failed to load /// (malformed) — the GUI shows a degraded warning and disables edits so we never /// overwrite the damaged file (backlog A16). - FriendsUpdated { friends: Vec, read_only: bool }, + FriendsUpdated { + friends: Vec, + read_only: bool, + }, /// A friend's live presence from a successful ping reply (W7): online, or in a /// joinable gathering (with a one-click ticket). Emitted by the outbound ping /// scheduler; absence of a recent event = treat as offline. - FriendPresence { id: EndpointId, presence: FriendPresence }, + FriendPresence { + id: EndpointId, + presence: FriendPresence, + }, /// A manual "Rescan" pass finished (every friend has been probed and its /// per-friend `FriendPresence` already emitted). Lets the GUI clear the /// transient "Rescanning…" status. Sent only for the on-demand button, not the @@ -305,7 +492,9 @@ pub enum UiEvent { /// time-box elapsed and the core auto-reverted to `Normal`; on discovery apply /// failure, this carries the previous truthful mode. The GUI must mirror + /// persist this so its presence picker matches the endpoint's discovery state. - PresenceModeReverted { mode: PresenceMode }, + PresenceModeReverted { + mode: PresenceMode, + }, /// The locally-detected running game changed (game detection). Carries the /// debounced `DetectedGame` (id + display name + source) or `None` when nothing /// is detected. The GUI uses the stable `id` to switch the per-game background @@ -319,7 +508,7 @@ pub enum UiEvent { #[cfg(test)] mod tests { - use super::{delivery_class, CoreCommand, DeliveryClass}; + use super::{CoalesceKey, CoreCommand, DeliveryClass, coalesce_key, delivery_class}; use crate::audio::eq::EqSettings; use crate::presence::PresenceMode; use iroh::{EndpointId, SecretKey}; @@ -332,17 +521,37 @@ mod tests { fn continuous_audio_controls_are_best_effort() { let peer = endpoint_id(); let commands = [ - CoreCommand::SetPeerVolume(peer, 0.7), - CoreCommand::SetPeerPan(peer, -0.2), - CoreCommand::SetPeerGate(peer, 0.1), - CoreCommand::SetPeerEq(peer, EqSettings::default()), - CoreCommand::SetInputVolume(0.8), - CoreCommand::SetOutputVolume(0.9), - CoreCommand::SetNoiseGateThreshold(0.02), + ( + CoreCommand::SetPeerVolume(peer, 0.7), + CoalesceKey::PeerVolume(peer), + ), + ( + CoreCommand::SetPeerPan(peer, -0.2), + CoalesceKey::PeerPan(peer), + ), + ( + CoreCommand::SetPeerGate(peer, 0.1), + CoalesceKey::PeerGate(peer), + ), + ( + CoreCommand::SetPeerEq(peer, EqSettings::default()), + CoalesceKey::PeerEq(peer), + ), + (CoreCommand::SetInputVolume(0.8), CoalesceKey::InputVolume), + (CoreCommand::SetOutputVolume(0.9), CoalesceKey::OutputVolume), + ( + CoreCommand::SetNoiseGateThreshold(0.02), + CoalesceKey::NoiseGate, + ), ]; - for cmd in commands { + for (cmd, key) in commands { assert_eq!(delivery_class(&cmd), DeliveryClass::BestEffort); + assert_eq!(coalesce_key(&cmd), Some(key)); + assert_eq!( + coalesce_key(&cmd).is_some(), + delivery_class(&cmd) == DeliveryClass::BestEffort + ); } } @@ -370,6 +579,11 @@ mod tests { for cmd in commands { assert_eq!(delivery_class(&cmd), DeliveryClass::Reliable); + assert_eq!(coalesce_key(&cmd), None); + assert_eq!( + coalesce_key(&cmd).is_some(), + delivery_class(&cmd) == DeliveryClass::BestEffort + ); } } } diff --git a/src/core/mod.rs b/src/core/mod.rs index cc56f5a..593da21 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1,69 +1,139 @@ -pub mod messages; pub mod jitter; +pub mod messages; mod recovery; -use crate::audio::{AudioBackend, PlatformAudioBackend}; use crate::audio::eq::{Eq, EqSettings}; +use crate::audio::{AudioBackend, PlatformAudioBackend}; use crate::codec::{AudioEncoder, opus_impl::OpusEncoder}; -use crate::core::jitter::{JitterBuffer, FRAME_SAMPLES}; -use crate::network::{ - NetworkTransport, RoomState, SelfPresence, RoomEvent, ConnEvent, PeerSpeakTicket, - iroh_impl::{IrohTransport, AudioRouter, FileRouter}, - gossip::IrohGossipState, +use crate::core::jitter::{FRAME_SAMPLES, JitterBuffer}; +use crate::core::messages::{ + CoalesceKey, CoreCommand, DeliveryClass, UiEvent, coalesce_key, delivery_class, }; -use crate::core::messages::{CoreCommand, DeliveryClass, UiEvent, delivery_class}; use crate::core::recovery::RecoveryCoordinator; +use crate::network::{ + ConnEvent, NetworkTransport, PeerSpeakTicket, RoomEvent, RoomState, SelfPresence, + gossip::IrohGossipState, + iroh_impl::{AudioRouter, FileRouter, IrohTransport}, +}; +use crate::audio::multitrack::MultitrackRecorder; use crate::config::{NetworkMode, RecordingMode}; use crate::presence::PresenceMode; -use crate::audio::multitrack::MultitrackRecorder; -use iroh::{Endpoint, EndpointAddr, EndpointId, RelayMode, SecretKey, endpoint::presets, protocol::Router}; +use iroh::{ + Endpoint, EndpointAddr, EndpointId, RelayMode, SecretKey, endpoint::presets, protocol::Router, +}; use iroh_gossip::net::Gossip; -use tokio::sync::{mpsc, Mutex}; use std::collections::{HashMap, HashSet}; -use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex as StdMutex}; use std::time::Duration; +use tokio::sync::{Mutex, mpsc}; + +type CoalesceStore = Arc>>; pub struct CoreController { reliable_tx: mpsc::UnboundedSender, - besteffort_tx: mpsc::Sender, + coalesce: CoalesceStore, + besteffort_wake_tx: mpsc::Sender<()>, +} + +#[derive(Clone)] +pub struct CoreCommandSender { + reliable_tx: mpsc::UnboundedSender, + coalesce: CoalesceStore, + besteffort_wake_tx: mpsc::Sender<()>, +} + +fn coalesce_insert(map: &mut HashMap, cmd: CoreCommand) -> bool { + let Some(key) = coalesce_key(&cmd) else { + return false; + }; + map.insert(key, cmd); + true +} + +fn coalesce_pop(map: &mut HashMap) -> Option { + let key = map.keys().next().cloned()?; + map.remove(&key) +} + +impl CoreCommandSender { + pub fn send(&self, cmd: CoreCommand) -> std::future::Ready> { + std::future::ready(match delivery_class(&cmd) { + DeliveryClass::Reliable => self.reliable_tx.send(cmd).map_err(|e| e.0), + DeliveryClass::BestEffort => { + let inserted = { + let mut map = self.coalesce.lock().unwrap(); + coalesce_insert(&mut map, cmd) + }; + debug_assert!(inserted); + let _ = self.besteffort_wake_tx.try_send(()); + Ok(()) + } + }) + } } impl CoreController { pub fn new(ui_tx: mpsc::Sender) -> Self { let (reliable_tx, reliable_rx) = mpsc::unbounded_channel(); - let (besteffort_tx, besteffort_rx) = mpsc::channel(100); + let coalesce = Arc::new(StdMutex::new(HashMap::new())); + let (besteffort_wake_tx, besteffort_wake_rx) = mpsc::channel(1); + let loop_coalesce = coalesce.clone(); + let loop_besteffort_wake_tx = besteffort_wake_tx.clone(); std::thread::spawn(move || { let rt = tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime"); rt.block_on(async move { crate::log_msg("Starting core network loop in dedicated Tokio runtime"); - if let Err(e) = run_core_loop(reliable_rx, besteffort_rx, ui_tx).await { + if let Err(e) = run_core_loop( + reliable_rx, + loop_coalesce, + besteffort_wake_rx, + loop_besteffort_wake_tx, + ui_tx, + ) + .await + { crate::log_msg(&format!("App core loop failed: {:?}", e)); } }); }); - Self { reliable_tx, besteffort_tx } - } - - /// Queue a command for the core loop. Reliable commands only fail when the - /// core loop is dead; best-effort slider commands keep today's bounded - /// try-send behavior. (We return a plain bool rather than the channel's - /// `Result` so the bulky `CoreCommand` isn't carried back by value in every - /// caller's error type.) - pub fn send(&self, cmd: CoreCommand) -> bool { - match delivery_class(&cmd) { - DeliveryClass::Reliable => self.reliable_tx.send(cmd).is_ok(), - DeliveryClass::BestEffort => self.besteffort_tx.try_send(cmd).is_ok(), + Self { + reliable_tx, + coalesce, + besteffort_wake_tx, } } - /// Clone the command sender for asynchronous one-shot sends that should wait - /// for channel capacity instead of failing immediately on a full queue. - pub fn command_sender(&self) -> mpsc::Sender { - self.besteffort_tx.clone() + /// Queue a command for the core loop. Reliable commands only fail when the + /// core loop is dead; best-effort slider commands are coalesced by control and + /// never fail to record their latest value. (We return a plain bool rather + /// than the channel's `Result` so the bulky `CoreCommand` isn't carried back by + /// value in every caller's error type.) + pub fn send(&self, cmd: CoreCommand) -> bool { + match delivery_class(&cmd) { + DeliveryClass::Reliable => self.reliable_tx.send(cmd).is_ok(), + DeliveryClass::BestEffort => { + let inserted = { + let mut map = self.coalesce.lock().unwrap(); + coalesce_insert(&mut map, cmd) + }; + debug_assert!(inserted); + let _ = self.besteffort_wake_tx.try_send(()); + true + } + } + } + + /// Clone a command sender for asynchronous one-shot sends. + pub fn command_sender(&self) -> CoreCommandSender { + CoreCommandSender { + reliable_tx: self.reliable_tx.clone(), + coalesce: self.coalesce.clone(), + besteffort_wake_tx: self.besteffort_wake_tx.clone(), + } } } @@ -137,8 +207,7 @@ type GraceTimers = Arc>>; -type KnownPeers = - Arc>>>; +type KnownPeers = Arc>>>; /// Per-topic cap on the retained rejoin-bootstrap / recovery target table /// (Tier C recovery-identity cap). Set comfortably above the live-roster cap @@ -254,7 +323,9 @@ fn arm_grace_timer( seen_evict.lock().unwrap().remove(&peer_id); let Some(recovery) = recovery_evict else { - let _ = ui_evict.send(UiEvent::PeerConnectionFailed { id: peer_id }).await; + let _ = ui_evict + .send(UiEvent::PeerConnectionFailed { id: peer_id }) + .await; return; }; if !recovery.coordinator.is_active(&peer_id) { @@ -267,13 +338,17 @@ fn arm_grace_timer( peer_id )); recovery.cancel(peer_id); - let _ = ui_evict.send(UiEvent::PeerConnectionFailed { id: peer_id }).await; + let _ = ui_evict + .send(UiEvent::PeerConnectionFailed { id: peer_id }) + .await; return; }; match recovery.coordinator.activate(peer_id, addr) { Ok(true) => { - let _ = ui_evict.send(UiEvent::PeerRecoveryStarted { id: peer_id }).await; + let _ = ui_evict + .send(UiEvent::PeerRecoveryStarted { id: peer_id }) + .await; } Ok(false) => {} Err(()) => { @@ -281,7 +356,9 @@ fn arm_grace_timer( "Cannot recover peer {:?}: recovery coordinator unavailable", peer_id )); - let _ = ui_evict.send(UiEvent::PeerConnectionFailed { id: peer_id }).await; + let _ = ui_evict + .send(UiEvent::PeerConnectionFailed { id: peer_id }) + .await; } } }); @@ -302,11 +379,7 @@ fn apply_volume(frame: &mut [i16], vol: f32) { /// Apply the listener's per-peer volume for the audio sender id currently being /// mixed. The map key must be the same `EndpointId` used for the jitter buffer. -fn apply_peer_volume( - frame: &mut [i16], - peer_id: EndpointId, - volumes: &HashMap, -) { +fn apply_peer_volume(frame: &mut [i16], peer_id: EndpointId, volumes: &HashMap) { let vol = volumes.get(&peer_id).copied().unwrap_or(1.0); apply_volume(frame, vol); } @@ -602,7 +675,8 @@ impl ActiveSession { crate::log_msg("Stopping audio backend..."); let _ = audio_backend_clone.stop(); crate::log_msg("Audio backend stopped"); - }).await; + }) + .await; // Unload the echo-cancel module now that the audio streams releasing its // virtual nodes have stopped. (Dropping the guard runs `pactl unload`.) @@ -676,7 +750,8 @@ fn apply_discovery( plan: crate::discovery::LookupPlan, ) -> Result<(), anyhow::Error> { use iroh::address_lookup::{ - AddressLookupBuilder, dns::DnsAddressLookup, + AddressLookupBuilder, + dns::DnsAddressLookup, pkarr::{PkarrPublisher, PkarrResolver}, }; let services = endpoint.address_lookup()?; @@ -742,7 +817,11 @@ async fn build_net_stack( .await?; // Install the n0 DNS services for the current (mode, publish) posture. - apply_discovery(&endpoint, &memory_lookup, crate::discovery::lookup_plan(network_mode, publish))?; + apply_discovery( + &endpoint, + &memory_lookup, + crate::discovery::lookup_plan(network_mode, publish), + )?; // Bring the endpoint online in the background so launch isn't blocked on the // relay handshake; joins/sends just work once it's up (online() is idempotent). @@ -794,8 +873,7 @@ const MAX_INFLIGHT_ATTACHMENT_FETCHES: usize = 4; /// In-flight `(author, attachment_id)` markers for bounded, deduplicated auto- /// fetches (Tier C F-02). Bounded by [`MAX_INFLIGHT_ATTACHMENT_FETCHES`]. -type InflightAttachments = - Arc>>; +type InflightAttachments = Arc>>; /// RAII bookkeeping for one bounded auto-fetch: holds the concurrency permit for /// the task's lifetime and clears the in-flight `(author, id)` marker when the @@ -853,12 +931,20 @@ fn spawn_attachment_fetch( return; } let _ = ui_tx - .send(UiEvent::AttachmentReady { from, id: att.id, data }) + .send(UiEvent::AttachmentReady { + from, + id: att.id, + data, + }) .await; } Err(e) => { let _ = ui_tx - .send(UiEvent::AttachmentFailed { from, id: att.id, error: e.to_string() }) + .send(UiEvent::AttachmentFailed { + from, + id: att.id, + error: e.to_string(), + }) .await; } } @@ -875,13 +961,15 @@ fn spawn_music_fetch( tokio::spawn(async move { match transport.fetch_blob(from, id, size).await { Ok(data) => { - let _ = ui_tx - .send(UiEvent::MusicReady { from, id, data }) - .await; + let _ = ui_tx.send(UiEvent::MusicReady { from, id, data }).await; } Err(e) => { let _ = ui_tx - .send(UiEvent::MusicFetchFailed { from, id, error: e.to_string() }) + .send(UiEvent::MusicFetchFailed { + from, + id, + error: e.to_string(), + }) .await; } } @@ -904,7 +992,11 @@ fn spawn_music_prefetch( } Err(e) => { let _ = ui_tx - .send(UiEvent::MusicFetchFailed { from, id, error: e.to_string() }) + .send(UiEvent::MusicFetchFailed { + from, + id, + error: e.to_string(), + }) .await; } } @@ -956,13 +1048,14 @@ async fn persist_and_emit_friends( ui_tx: &mpsc::Sender, ) { let store = friends.lock().unwrap().clone(); - if !read_only - && let Err(e) = crate::friends::save(&store) - { + if !read_only && let Err(e) = crate::friends::save(&store) { crate::log_msg(&format!("friends: save failed: {e:#}")); } let _ = ui_tx - .send(UiEvent::FriendsUpdated { friends: store.friends, read_only }) + .send(UiEvent::FriendsUpdated { + friends: store.friends, + read_only, + }) .await; } @@ -995,7 +1088,10 @@ async fn probe_friends_once( let Some(addr) = f.last_addr.clone() else { // Nothing to dial yet — report Offline so a prior status can't stick. let _ = ui_tx - .send(UiEvent::FriendPresence { id, presence: crate::presence::FriendPresence::Offline }) + .send(UiEvent::FriendPresence { + id, + presence: crate::presence::FriendPresence::Offline, + }) .await; continue; }; @@ -1017,7 +1113,9 @@ async fn probe_friends_once( async fn run_core_loop( mut reliable_rx: mpsc::UnboundedReceiver, - mut besteffort_rx: mpsc::Receiver, + coalesce: CoalesceStore, + mut besteffort_wake_rx: mpsc::Receiver<()>, + besteffort_wake_tx: mpsc::Sender<()>, ui_tx: mpsc::Sender, ) -> Result<(), anyhow::Error> { let memory_lookup = iroh::address_lookup::memory::MemoryLookup::new(); @@ -1145,7 +1243,9 @@ async fn run_core_loop( let friends = Arc::new(std::sync::Mutex::new(initial_friends)); // Our presence posture, shared with the listener + scheduler. The GUI pushes it // at startup + on change via SetPresenceMode; persistence stays in AppConfig. - let presence_mode = Arc::new(std::sync::Mutex::new(crate::presence::PresenceMode::default())); + let presence_mode = Arc::new(std::sync::Mutex::new( + crate::presence::PresenceMode::default(), + )); // The gathering we're currently in (its restamped member ticket + label), so the // listener can offer friends a one-click Join. `None` when not in a call. Set on // Join, cleared on Leave. @@ -1178,7 +1278,15 @@ async fn run_core_loop( // rebuilt only when the network mode or identity changes. Moving `memory_lookup` // in — all later access is via `net.memory_lookup`. let initial_publish = presence_mode.lock().unwrap().publishes_to_discovery(); - let mut net = match build_net_stack(secret_key.clone(), network_mode, memory_lookup, friends_handler.clone(), initial_publish).await { + let mut net = match build_net_stack( + secret_key.clone(), + network_mode, + memory_lookup, + friends_handler.clone(), + initial_publish, + ) + .await + { Ok(stack) => stack, Err(e) => { // Only a local socket bind can fail here (the relay handshake is @@ -1226,8 +1334,22 @@ async fn run_core_loop( Some(cmd) => cmd, None => break, }, - maybe_cmd = besteffort_rx.recv() => match maybe_cmd { - Some(cmd) => cmd, + maybe_wake = besteffort_wake_rx.recv() => match maybe_wake { + Some(()) => { + let (cmd, has_more) = { + let mut map = coalesce.lock().unwrap(); + let cmd = coalesce_pop(&mut map); + let has_more = !map.is_empty(); + (cmd, has_more) + }; + if has_more { + let _ = besteffort_wake_tx.try_send(()); + } + match cmd { + Some(cmd) => cmd, + None => continue, + } + } None => break, }, game_change = next_game_change(&mut game_rx) => { @@ -1337,7 +1459,14 @@ async fn run_core_loop( CoreCommand::Shutdown => { crate::log_msg("Core shutdown requested"); // Finalize recordings while capture/mixer feeders are still alive. - stop_recording(&recorder, &is_recording, &multitrack, &is_multitrack, &ui_tx).await; + stop_recording( + &recorder, + &is_recording, + &multitrack, + &is_multitrack, + &ui_tx, + ) + .await; stop_mic_monitor(&audio_backend, mic_monitor.take()); if let Some(session) = active_session.take() { @@ -1352,14 +1481,29 @@ async fn run_core_loop( break; } - CoreCommand::Join { name, ticket, room_name, input_device, output_device, echo_cancellation, avatar } => { + CoreCommand::Join { + name, + ticket, + room_name, + input_device, + output_device, + echo_cancellation, + avatar, + } => { presence.name = name.clone(); presence.avatar = avatar; let was_in_room = active_session.is_some(); // Finalize any recording before tearing down the old session — its // capture/mixer feeders are about to stop. - stop_recording(&recorder, &is_recording, &multitrack, &is_multitrack, &ui_tx).await; + stop_recording( + &recorder, + &is_recording, + &multitrack, + &is_multitrack, + &ui_tx, + ) + .await; // Clean up any existing session FIRST (this calls `transport.leave()` // on the current endpoint), before any stack rebuild closes it. @@ -1378,7 +1522,14 @@ async fn run_core_loop( let lookup = net.memory_lookup.clone(); net.shutdown().await; let publish = presence_mode.lock().unwrap().publishes_to_discovery(); - net = build_net_stack(secret_key.clone(), network_mode, lookup, friends_handler.clone(), publish).await?; + net = build_net_stack( + secret_key.clone(), + network_mode, + lookup, + friends_handler.clone(), + publish, + ) + .await?; net_rebuild_pending = false; } @@ -1408,7 +1559,11 @@ async fn run_core_loop( // The creator's chosen cosmetic label rides in the ticket so // every joiner inherits it; sanitize it before it leaves here. let label = crate::sanitize::sanitize_name(&room_name); - let ticket = PeerSpeakTicket { host_addr, topic_id, name: label }; + let ticket = PeerSpeakTicket { + host_addr, + topic_id, + name: label, + }; ticket.to_string() } else { let ticket_str = ticket.trim().to_string(); @@ -1425,7 +1580,9 @@ async fn run_core_loop( if was_in_room { let _ = ui_tx.send(UiEvent::RoomReset).await; } - let _ = ui_tx.send(UiEvent::Error("invalid room ticket".to_string())).await; + let _ = ui_tx + .send(UiEvent::Error("invalid room ticket".to_string())) + .await; continue; } }; @@ -1447,11 +1604,8 @@ async fn run_core_loop( // Fresh join starts not sharing; clear any stale share ticket. current_sharing = None; - let self_state = presence.to_state( - is_muted.load(Ordering::Relaxed), - endpoint.addr(), - None, - ); + let self_state = + presence.to_state(is_muted.load(Ordering::Relaxed), endpoint.addr(), None); // Snapshot THIS room's retained peers (by topic) as extra bootstrap // targets so a rejoin can dial them (A8) — including after a detour @@ -1475,12 +1629,17 @@ async fn run_core_loop( self_state.sharing.is_some(), extra_bootstrap_ids )); - if let Err(e) = room_state.join(&ticket_str, self_state.clone(), extra_bootstrap).await { + if let Err(e) = room_state + .join(&ticket_str, self_state.clone(), extra_bootstrap) + .await + { crate::log_msg(&format!("Error room_state.join failed: {:?}", e)); if was_in_room { let _ = ui_tx.send(UiEvent::RoomReset).await; } - let _ = ui_tx.send(UiEvent::Error(format!("Failed to join room: {}", e))).await; + let _ = ui_tx + .send(UiEvent::Error(format!("Failed to join room: {}", e))) + .await; net.audio_router.clear(); net.file_router.clear(); continue; @@ -1517,7 +1676,9 @@ async fn run_core_loop( "Echo cancellation unavailable, using direct devices: {e}" )); let _ = ui_tx - .send(UiEvent::Error(format!("Echo cancellation unavailable: {e}"))) + .send(UiEvent::Error(format!( + "Echo cancellation unavailable: {e}" + ))) .await; (input_device.clone(), output_device.clone()) } @@ -1528,13 +1689,16 @@ async fn run_core_loop( #[cfg(not(target_os = "linux"))] let _ = echo_cancellation; #[cfg(not(target_os = "linux"))] - let (capture_target, playback_target) = (input_device.clone(), output_device.clone()); + let (capture_target, playback_target) = + (input_device.clone(), output_device.clone()); if let Err(e) = audio_backend.start_capture(capture_tx, capture_target) { if was_in_room { let _ = ui_tx.send(UiEvent::RoomReset).await; } - let _ = ui_tx.send(UiEvent::Error(format!("Failed to start capture: {}", e))).await; + let _ = ui_tx + .send(UiEvent::Error(format!("Failed to start capture: {}", e))) + .await; let _ = room_state.leave().await; net.audio_router.clear(); net.file_router.clear(); @@ -1545,11 +1709,15 @@ async fn run_core_loop( // here (drain side + fill side); the mixer reads it to pace // production to the hardware clock instead of a fixed timer. let ring_fill = Arc::new(AtomicUsize::new(0)); - if let Err(e) = audio_backend.start_playback(playback_rx, playback_target, ring_fill.clone()) { + if let Err(e) = + audio_backend.start_playback(playback_rx, playback_target, ring_fill.clone()) + { if was_in_room { let _ = ui_tx.send(UiEvent::RoomReset).await; } - let _ = ui_tx.send(UiEvent::Error(format!("Failed to start playback: {}", e))).await; + let _ = ui_tx + .send(UiEvent::Error(format!("Failed to start playback: {}", e))) + .await; let _ = audio_backend.stop(); let _ = room_state.leave().await; net.audio_router.clear(); @@ -1557,7 +1725,8 @@ async fn run_core_loop( continue; } - let jitter: Arc>> = Arc::new(Mutex::new(HashMap::new())); + let jitter: Arc>> = + Arc::new(Mutex::new(HashMap::new())); // 1. Capture & encoding thread let is_muted_clone = is_muted.clone(); @@ -1573,14 +1742,15 @@ async fn run_core_loop( let is_multitrack_capture = is_multitrack.clone(); let capture_thread = std::thread::spawn(move || { - use opus::{Channels, Application}; - let mut encoder = match OpusEncoder::new(48000, Channels::Mono, Application::Voip) { - Ok(enc) => enc, - Err(e) => { - crate::log_msg(&format!("Capture thread error: {:?}", e)); - return; - } - }; + use opus::{Application, Channels}; + let mut encoder = + match OpusEncoder::new(48000, Channels::Mono, Application::Voip) { + Ok(enc) => enc, + Err(e) => { + crate::log_msg(&format!("Capture thread error: {:?}", e)); + return; + } + }; // Per-sender packet sequence number, prepended to every frame so // receivers can reorder and conceal loss. Wraps after ~years. let mut seq: u32 = 0; @@ -1595,7 +1765,10 @@ async fn run_core_loop( while let Ok(mut pcm) = capture_rx.recv() { // Apply the input gain first so the meter, gate, and what we // transmit all reflect the same (gained) signal. - apply_volume(&mut pcm, f32::from_bits(input_gain_clone.load(Ordering::Relaxed))); + apply_volume( + &mut pcm, + f32::from_bits(input_gain_clone.load(Ordering::Relaxed)), + ); if let Some(peak) = mic_meter.push(&pcm) { let _ = ui_tx_capture.try_send(UiEvent::MicLevel(peak)); @@ -1604,7 +1777,9 @@ async fn run_core_loop( if is_muted_clone.load(Ordering::Relaxed) { continue; } - if ptt_mode_clone.load(Ordering::Relaxed) && !ptt_active_clone.load(Ordering::Relaxed) { + if ptt_mode_clone.load(Ordering::Relaxed) + && !ptt_active_clone.load(Ordering::Relaxed) + { continue; } @@ -1673,7 +1848,10 @@ async fn run_core_loop( match JitterBuffer::new() { Ok(jb) => entry.insert(jb), Err(e) => { - crate::log_msg(&format!("Failed to init jitter buffer for {:?}: {:?}", from_peer, e)); + crate::log_msg(&format!( + "Failed to init jitter buffer for {:?}: {:?}", + from_peer, e + )); continue; } } @@ -1735,7 +1913,9 @@ async fn run_core_loop( loop { // Pace to the hardware clock: only produce while the ring // is draining below target. Otherwise yield and re-check. - if ring_fill_mixer.load(Ordering::Relaxed) >= crate::audio::PLAYBACK_TARGET_SAMPLES { + if ring_fill_mixer.load(Ordering::Relaxed) + >= crate::audio::PLAYBACK_TARGET_SAMPLES + { tokio::time::sleep(IDLE_NAP).await; continue; } @@ -1831,9 +2011,11 @@ async fn run_core_loop( peer_frames.push((frame, pan)); } } - peer_eqs.retain(|id, _| peers_seen.contains(id) || current_eq.contains_key(id)); - peer_noise_gates - .retain(|id, _| peers_seen.contains(id) || current_gates.contains_key(id)); + peer_eqs + .retain(|id, _| peers_seen.contains(id) || current_eq.contains_key(id)); + peer_noise_gates.retain(|id, _| { + peers_seen.contains(id) || current_gates.contains_key(id) + }); // Lossless i32 sum, then the limiter applies the master // output gain (in f32, so a boost past the ceiling is @@ -1870,7 +2052,8 @@ async fn run_core_loop( &multitrack_mixer, &is_multitrack_mixer, &ui_tx_mixer, - ).await; + ) + .await; let _ = ui_tx_mixer .send(UiEvent::Error(format!( "Recording stopped — write failed: {e}" @@ -1893,7 +2076,8 @@ async fn run_core_loop( &multitrack_mixer, &is_multitrack_mixer, &ui_tx_mixer, - ).await; + ) + .await; let _ = ui_tx_mixer .send(UiEvent::Error(format!( "Recording stopped — write failed: {e}" @@ -1929,7 +2113,9 @@ async fn run_core_loop( if was_in_room { let _ = ui_tx.send(UiEvent::RoomReset).await; } - let _ = ui_tx.send(UiEvent::Error(format!("Failed to subscribe events: {}", e))).await; + let _ = ui_tx + .send(UiEvent::Error(format!("Failed to subscribe events: {}", e))) + .await; continue; } }; @@ -2046,9 +2232,13 @@ async fn run_core_loop( && let Some(mt) = multitrack_events.lock().unwrap().as_mut() && let Err(e) = mt.add_peer(peer_id, &state.name) { - crate::log_msg(&format!("multitrack add_peer (join) failed: {e}")); + crate::log_msg(&format!( + "multitrack add_peer (join) failed: {e}" + )); } - let _ = ui_tx_events.send(UiEvent::PeerJoined { id: peer_id, state }).await; + let _ = ui_tx_events + .send(UiEvent::PeerJoined { id: peer_id, state }) + .await; } RoomEvent::PeerLeft(peer_id) => { // Graceful leave — evict immediately. @@ -2105,9 +2295,17 @@ async fn run_core_loop( )); } } - let _ = ui_tx_events.send(UiEvent::PeerUpdated { id: peer_id, state }).await; + let _ = ui_tx_events + .send(UiEvent::PeerUpdated { id: peer_id, state }) + .await; } - RoomEvent::ChatMessage { from, name, text, ts: _, attachment } => { + RoomEvent::ChatMessage { + from, + name, + text, + ts: _, + attachment, + } => { // Auto-fetch image attachments so they render inline // without a click; non-image files wait for an explicit // FetchAttachment (the "Save" chip). The descriptor was @@ -2124,7 +2322,11 @@ async fn run_core_loop( let key = (from, att.id); let already_inflight = inflight_attachments.lock().unwrap().contains(&key); - if should_auto_fetch(is_image, roster.contains(&from), already_inflight) { + if should_auto_fetch( + is_image, + roster.contains(&from), + already_inflight, + ) { // Reserve the dedup slot, then a permit. If the // pool is exhausted, drop the auto-fetch (and the // dedup marker) — the descriptor still shows and @@ -2154,12 +2356,14 @@ async fn run_core_loop( } } } - let _ = ui_tx_events.send(UiEvent::ChatMessage { - from: from.to_string(), - name, - text, - attachment, - }).await; + let _ = ui_tx_events + .send(UiEvent::ChatMessage { + from: from.to_string(), + name, + text, + attachment, + }) + .await; } RoomEvent::ClockSkewSuspected { author, skew_ms } => { crate::log_msg(&format!( @@ -2183,7 +2387,9 @@ async fn run_core_loop( // rejoin (PeerJoined/PeerUpdated) or a transport // reconnect (ConnEvent::Connected) cancels it first. transport_events.keep_audio_sender_for_reconnect_grace(peer_id); - let _ = ui_tx_events.send(UiEvent::PeerConnecting { id: peer_id }).await; + let _ = ui_tx_events + .send(UiEvent::PeerConnecting { id: peer_id }) + .await; arm_grace_timer( &grace_timers_events, &seen_connected_events, @@ -2209,7 +2415,12 @@ async fn run_core_loop( if was_in_room { let _ = ui_tx.send(UiEvent::RoomReset).await; } - let _ = ui_tx.send(UiEvent::Error(format!("Failed to subscribe conn events: {}", e))).await; + let _ = ui_tx + .send(UiEvent::Error(format!( + "Failed to subscribe conn events: {}", + e + ))) + .await; continue; } }; @@ -2266,13 +2477,25 @@ async fn run_core_loop( name: room_label, ticket: share_ticket.clone(), }); - let _ = ui_tx.send(UiEvent::RoomJoined { ticket: share_ticket, self_id }).await; + let _ = ui_tx + .send(UiEvent::RoomJoined { + ticket: share_ticket, + self_id, + }) + .await; active_session = Some(session); } CoreCommand::Leave => { // Finalize any recording first, while the audio feeders are alive. - stop_recording(&recorder, &is_recording, &multitrack, &is_multitrack, &ui_tx).await; + stop_recording( + &recorder, + &is_recording, + &multitrack, + &is_multitrack, + &ui_tx, + ) + .await; current_sharing = None; if let Some(session) = active_session.take() { session.shutdown(audio_backend.clone()).await; @@ -2289,7 +2512,14 @@ async fn run_core_loop( let lookup = net.memory_lookup.clone(); net.shutdown().await; let publish = presence_mode.lock().unwrap().publishes_to_discovery(); - net = build_net_stack(secret_key.clone(), network_mode, lookup, friends_handler.clone(), publish).await?; + net = build_net_stack( + secret_key.clone(), + network_mode, + lookup, + friends_handler.clone(), + publish, + ) + .await?; net_rebuild_pending = false; } } @@ -2300,11 +2530,8 @@ async fn run_core_loop( is_muted.store(new_state, Ordering::Relaxed); if let Some(session) = &active_session { - let self_state = presence.to_state( - new_state, - net.endpoint.addr(), - current_sharing.clone(), - ); + let self_state = + presence.to_state(new_state, net.endpoint.addr(), current_sharing.clone()); let _ = session.room_state.update_self_state(self_state).await; } } @@ -2392,7 +2619,10 @@ async fn run_core_loop( output_gain.store(vol.to_bits(), Ordering::Relaxed); } - CoreCommand::SetMicMonitor { enabled, input_device } => { + CoreCommand::SetMicMonitor { + enabled, + input_device, + } => { // During a call the in-call capture thread already reports the // mic level, and it owns the backend's capture stream — leave it be. if active_session.is_some() { @@ -2405,7 +2635,8 @@ async fn run_core_loop( Ok(()) => { let ui = ui_tx.clone(); let gain = input_gain.clone(); - let thread = std::thread::spawn(move || run_mic_monitor(rx, ui, gain)); + let thread = + std::thread::spawn(move || run_mic_monitor(rx, ui, gain)); mic_monitor = Some(MicMonitor { thread }); } Err(e) => { @@ -2437,7 +2668,14 @@ async fn run_core_loop( let lookup = net.memory_lookup.clone(); net.shutdown().await; let publish = presence_mode.lock().unwrap().publishes_to_discovery(); - net = build_net_stack(secret_key.clone(), network_mode, lookup, friends_handler.clone(), publish).await?; + net = build_net_stack( + secret_key.clone(), + network_mode, + lookup, + friends_handler.clone(), + publish, + ) + .await?; } else { net_rebuild_pending = true; } @@ -2471,7 +2709,14 @@ async fn run_core_loop( let lookup = net.memory_lookup.clone(); net.shutdown().await; let publish = presence_mode.lock().unwrap().publishes_to_discovery(); - net = build_net_stack(secret_key.clone(), network_mode, lookup, friends_handler.clone(), publish).await?; + net = build_net_stack( + secret_key.clone(), + network_mode, + lookup, + friends_handler.clone(), + publish, + ) + .await?; } else { net_rebuild_pending = true; } @@ -2634,7 +2879,9 @@ async fn run_core_loop( .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); - let result: Result = if recording_mode.is_multitrack() { + let result: Result = if recording_mode + .is_multitrack() + { // Multitrack/Both: a per-session directory of stems. std::fs::create_dir_all(&base) .map_err(|e| e.to_string()) @@ -2656,7 +2903,9 @@ async fn run_core_loop( if let Some(session) = &active_session { for (id, st) in session.room_state.active_peers() { if let Err(e) = mt.add_peer(id, &st.name) { - crate::log_msg(&format!("multitrack add_peer failed: {e}")); + crate::log_msg(&format!( + "multitrack add_peer failed: {e}" + )); } } } @@ -2682,10 +2931,11 @@ async fn run_core_loop( match result { Ok(path) => { is_recording.store(true, Ordering::Relaxed); - crate::log_msg(&format!("Recording started ({recording_mode:?}): {path}")); - let _ = ui_tx - .send(UiEvent::RecordingStarted { path }) - .await; + crate::log_msg(&format!( + "Recording started ({recording_mode:?}): {path}" + )); + let _ = + ui_tx.send(UiEvent::RecordingStarted { path }).await; } Err(e) => { let _ = ui_tx @@ -2702,7 +2952,14 @@ async fn run_core_loop( } } } else { - stop_recording(&recorder, &is_recording, &multitrack, &is_multitrack, &ui_tx).await; + stop_recording( + &recorder, + &is_recording, + &multitrack, + &is_multitrack, + &ui_tx, + ) + .await; } } @@ -2714,7 +2971,11 @@ async fn run_core_loop( } } - CoreCommand::SendChatFile { text, attachment, data } => { + CoreCommand::SendChatFile { + text, + attachment, + data, + } => { if let Some(session) = &active_session { // Make the bytes fetchable by room members, then broadcast the // descriptor alongside the (possibly empty) caption text. @@ -2769,25 +3030,13 @@ async fn run_core_loop( CoreCommand::FetchMusic { from, id, size } => { if let Some(session) = &active_session { - spawn_music_fetch( - session.transport.clone(), - ui_tx.clone(), - from, - id, - size, - ); + spawn_music_fetch(session.transport.clone(), ui_tx.clone(), from, id, size); } } CoreCommand::PrefetchMusic { from, id, size } => { if let Some(session) = &active_session { - spawn_music_prefetch( - session.transport.clone(), - ui_tx.clone(), - from, - id, - size, - ); + spawn_music_prefetch(session.transport.clone(), ui_tx.clone(), from, id, size); } } @@ -2813,14 +3062,19 @@ async fn run_core_loop( Vec::new() }; let _ = ui_tx - .send(UiEvent::AudioAppsListed { apps, app_audio_supported }) + .send(UiEvent::AudioAppsListed { + apps, + app_audio_supported, + }) .await; } CoreCommand::StartScreenShare { audio_app } => { let Some(session) = &mut active_session else { let _ = ui_tx - .send(UiEvent::Error("Join a call before sharing your screen".into())) + .send(UiEvent::Error( + "Join a call before sharing your screen".into(), + )) .await; continue; }; @@ -2843,8 +3097,9 @@ async fn run_core_loop( // audio drops. The channel closes when the host dies (drain hits // EOF), ending the forwarder task on its own. let notices = audio_app.as_deref().map(|_| { - let (tx, mut rx) = - tokio::sync::mpsc::unbounded_channel::(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::< + crate::screenshare::PixelpassEvent, + >(); let ui_tx_notices = ui_tx.clone(); tokio::spawn(async move { while let Some(ev) = rx.recv().await { @@ -2937,24 +3192,152 @@ async fn run_core_loop( #[cfg(test)] mod tests { use super::{ - admit_retained, apply_peer_volume, apply_volume, audio_datagram_len_ok, frame_level, - mix_frames, mix_stereo_frames, next_game_change, should_auto_fetch, stereo_to_mono, - KnownPeers, MicLevelMeter, PeerSpeakTicket, MAX_OPUS_PAYLOAD, MAX_RETAINED_PEERS, - MIC_LEVEL_REPORT_SAMPLES, + KnownPeers, MAX_OPUS_PAYLOAD, MAX_RETAINED_PEERS, MIC_LEVEL_REPORT_SAMPLES, MicLevelMeter, + PeerSpeakTicket, admit_retained, apply_peer_volume, apply_volume, audio_datagram_len_ok, + coalesce_insert, coalesce_pop, frame_level, mix_frames, mix_stereo_frames, + next_game_change, should_auto_fetch, stereo_to_mono, }; + use crate::core::messages::{CoalesceKey, CoreCommand, coalesce_key}; + use std::collections::{HashMap, HashSet}; + + fn endpoint_id() -> iroh::EndpointId { + iroh::SecretKey::generate().public() + } #[test] fn admit_retained_rejects_only_new_ids_at_the_cap() { // Below the cap, a brand-new identity is retained. assert!(admit_retained(0, true, MAX_RETAINED_PEERS)); - assert!(admit_retained(MAX_RETAINED_PEERS - 1, true, MAX_RETAINED_PEERS)); + assert!(admit_retained( + MAX_RETAINED_PEERS - 1, + true, + MAX_RETAINED_PEERS + )); // At the cap, a brand-new identity is refused — this is the bound that stops // an insider grace-cycling distinct identities from growing the retain table. - assert!(!admit_retained(MAX_RETAINED_PEERS, true, MAX_RETAINED_PEERS)); + assert!(!admit_retained( + MAX_RETAINED_PEERS, + true, + MAX_RETAINED_PEERS + )); // A peer already tracked always refreshes, even at (or past) the cap: it only // updates an existing address and never adds a slot. - assert!(admit_retained(MAX_RETAINED_PEERS, false, MAX_RETAINED_PEERS)); - assert!(admit_retained(MAX_RETAINED_PEERS + 5, false, MAX_RETAINED_PEERS)); + assert!(admit_retained( + MAX_RETAINED_PEERS, + false, + MAX_RETAINED_PEERS + )); + assert!(admit_retained( + MAX_RETAINED_PEERS + 5, + false, + MAX_RETAINED_PEERS + )); + } + + #[test] + fn coalesce_insert_overwrites_same_peer_control_with_latest_value() { + let peer = endpoint_id(); + let mut map = HashMap::new(); + + assert!(coalesce_insert( + &mut map, + CoreCommand::SetPeerVolume(peer, 0.2) + )); + assert!(coalesce_insert( + &mut map, + CoreCommand::SetPeerVolume(peer, 0.9) + )); + + assert_eq!(map.len(), 1); + match coalesce_pop(&mut map) { + Some(CoreCommand::SetPeerVolume(id, volume)) => { + assert_eq!(id, peer); + assert_eq!(volume, 0.9); + } + other => panic!("expected latest peer volume, got {other:?}"), + } + assert!(map.is_empty()); + } + + #[test] + fn coalesce_insert_keeps_distinct_peers_for_same_control() { + let peer_a = endpoint_id(); + let peer_b = endpoint_id(); + let mut map = HashMap::new(); + + assert!(coalesce_insert( + &mut map, + CoreCommand::SetPeerVolume(peer_a, 0.2) + )); + assert!(coalesce_insert( + &mut map, + CoreCommand::SetPeerVolume(peer_b, 0.9) + )); + + assert_eq!(map.len(), 2); + let mut seen = HashMap::new(); + while let Some(cmd) = coalesce_pop(&mut map) { + match cmd { + CoreCommand::SetPeerVolume(id, volume) => { + seen.insert(id, volume); + } + other => panic!("expected peer volume command, got {other:?}"), + } + } + assert_eq!(seen.get(&peer_a), Some(&0.2)); + assert_eq!(seen.get(&peer_b), Some(&0.9)); + } + + #[test] + fn coalesce_insert_overwrites_global_control_with_latest_value() { + let mut map = HashMap::new(); + + assert!(coalesce_insert(&mut map, CoreCommand::SetInputVolume(0.1))); + assert!(coalesce_insert(&mut map, CoreCommand::SetInputVolume(0.4))); + + assert_eq!(map.len(), 1); + match coalesce_pop(&mut map) { + Some(CoreCommand::SetInputVolume(volume)) => assert_eq!(volume, 0.4), + other => panic!("expected latest input volume, got {other:?}"), + } + assert!(map.is_empty()); + } + + #[test] + fn coalesce_pop_empty_map_returns_none() { + let mut map = HashMap::new(); + + assert!(coalesce_pop(&mut map).is_none()); + } + + #[test] + fn coalesce_pop_drains_each_distinct_key_once() { + let peer_a = endpoint_id(); + let peer_b = endpoint_id(); + let mut map = HashMap::new(); + + assert!(coalesce_insert( + &mut map, + CoreCommand::SetPeerPan(peer_a, -0.3) + )); + assert!(coalesce_insert( + &mut map, + CoreCommand::SetPeerGate(peer_b, 0.4) + )); + assert!(coalesce_insert(&mut map, CoreCommand::SetOutputVolume(0.8))); + + let expected = HashSet::from([ + CoalesceKey::PeerPan(peer_a), + CoalesceKey::PeerGate(peer_b), + CoalesceKey::OutputVolume, + ]); + let mut seen = HashSet::new(); + while let Some(cmd) = coalesce_pop(&mut map) { + seen.insert(coalesce_key(&cmd).expect("coalesced command should have a key")); + } + + assert_eq!(seen, expected); + assert!(map.is_empty()); } #[test] @@ -2980,12 +3363,14 @@ mod tests { host_addr: iroh::EndpointAddr::from(original_host), topic_id, name: "Room".to_string(), - }.to_string(); + } + .to_string(); let restamped = PeerSpeakTicket { host_addr: iroh::EndpointAddr::from(member_host), topic_id, name: "Room".to_string(), - }.to_string(); + } + .to_string(); assert_ne!(original, restamped); let original_topic = PeerSpeakTicket::topic_of(&original).unwrap(); @@ -3023,7 +3408,10 @@ mod tests { drop(tx); assert_eq!(next_game_change(&mut rx).await, None); - assert!(rx.is_none(), "closed receiver must disable its select source"); + assert!( + rx.is_none(), + "closed receiver must disable its select source" + ); } #[test] @@ -3054,7 +3442,10 @@ mod tests { assert_eq!(m.push(&frame(100, chunk)), None); let reported = m.push(&frame(100, chunk)).expect("window complete"); let loud = frame_level(&frame(8000, chunk)); - assert!((reported - loud).abs() < 1e-6, "peak should hold the loud frame"); + assert!( + (reported - loud).abs() < 1e-6, + "peak should hold the loud frame" + ); } #[test] @@ -3063,14 +3454,18 @@ mod tests { // Fill and report a loud window. assert!(m.push(&frame(8000, MIC_LEVEL_REPORT_SAMPLES)).is_some()); // The next window of silence must report ~zero, not the stale loud peak. - let reported = m.push(&frame(0, MIC_LEVEL_REPORT_SAMPLES)).expect("second window"); + let reported = m + .push(&frame(0, MIC_LEVEL_REPORT_SAMPLES)) + .expect("second window"); assert_eq!(reported, 0.0, "peak and accumulator reset between windows"); } #[test] fn mic_meter_silence_reports_zero() { let mut m = MicLevelMeter::new(); - let reported = m.push(&frame(0, MIC_LEVEL_REPORT_SAMPLES)).expect("window complete"); + let reported = m + .push(&frame(0, MIC_LEVEL_REPORT_SAMPLES)) + .expect("window complete"); assert_eq!(reported, 0.0); } diff --git a/src/dsp/aec.rs b/src/dsp/aec.rs index 18306f4..e91d6b5 100644 --- a/src/dsp/aec.rs +++ b/src/dsp/aec.rs @@ -283,8 +283,14 @@ mod tests { let q = echo.len() / 4; let early = erle(&echo[..q], &cleaned[..q]); let late = erle(&echo[3 * q..], &cleaned[3 * q..]); - assert!(late > early + 10.0, "should improve markedly: early {early:.1} late {late:.1}"); - assert!(late > 20.0, "converged ERLE should exceed 20 dB, got {late:.1}"); + assert!( + late > early + 10.0, + "should improve markedly: early {early:.1} late {late:.1}" + ); + assert!( + late > 20.0, + "converged ERLE should exceed 20 dB, got {late:.1}" + ); } #[test] @@ -307,7 +313,10 @@ mod tests { let mut aec = Nlms::new(128, 0.5, 1e-6); let out = aec.process(&silent_ref, &near); for (a, b) in near.iter().zip(&out) { - assert!((a - b).abs() < 1e-6, "near-end should pass through: {a} vs {b}"); + assert!( + (a - b).abs() < 1e-6, + "near-end should pass through: {a} vs {b}" + ); } } @@ -341,14 +350,24 @@ mod tests { let mut late_hits = 0; for i in 0..far.len() { if dtd.update(far[i], mic[i]) { - if i < onset { early_hits += 1 } else { late_hits += 1 } + if i < onset { + early_hits += 1 + } else { + late_hits += 1 + } } } // Echo-only stretch should rarely trip; near-end stretch should trip a lot. let early_rate = early_hits as f32 / onset as f32; let late_rate = late_hits as f32 / (far.len() - onset) as f32; - assert!(early_rate < 0.10, "false-positive rate {early_rate:.2} too high"); - assert!(late_rate > 0.50, "missed double-talk, rate only {late_rate:.2}"); + assert!( + early_rate < 0.10, + "false-positive rate {early_rate:.2} too high" + ); + assert!( + late_rate > 0.50, + "missed double-talk, rate only {late_rate:.2}" + ); } #[test] @@ -380,6 +399,9 @@ mod tests { erle_dtd > erle_no + 15.0, "DTD should hold the echo path: with {erle_dtd:.1} dB vs without {erle_no:.1} dB" ); - assert!(erle_dtd > 15.0, "held filter should still cancel echo: {erle_dtd:.1} dB"); + assert!( + erle_dtd > 15.0, + "held filter should still cancel echo: {erle_dtd:.1} dB" + ); } } diff --git a/src/dsp/echo_path.rs b/src/dsp/echo_path.rs index e8ca149..7ea405a 100644 --- a/src/dsp/echo_path.rs +++ b/src/dsp/echo_path.rs @@ -115,7 +115,10 @@ mod tests { let path = EchoPath::synthetic(480, 480, 0.5, 99); let echo = path.apply(&far); let ratio = rms(&echo) / rms(&far); - assert!((0.3..0.7).contains(&ratio), "echo/far rms ratio {ratio} off target"); + assert!( + (0.3..0.7).contains(&ratio), + "echo/far rms ratio {ratio} off target" + ); } #[test] diff --git a/src/dsp/fft.rs b/src/dsp/fft.rs index 7ec15ff..1bb5699 100644 --- a/src/dsp/fft.rs +++ b/src/dsp/fft.rs @@ -141,7 +141,10 @@ mod tests { buf[0] = Complex::new(1.0, 0.0); fft(&mut buf); for c in &buf { - assert!(approx(c.magnitude(), 1.0, 1e-9), "expected flat 1.0, got {c:?}"); + assert!( + approx(c.magnitude(), 1.0, 1e-9), + "expected flat 1.0, got {c:?}" + ); } } diff --git a/src/dsp/metrics.rs b/src/dsp/metrics.rs index 8285566..239e56b 100644 --- a/src/dsp/metrics.rs +++ b/src/dsp/metrics.rs @@ -62,11 +62,31 @@ pub struct Band { /// Voice-relevant bands for spotting *where* residual echo or noise lives. pub const VOICE_BANDS: &[Band] = &[ - Band { label: "low (80-300)", low_hz: 80.0, high_hz: 300.0 }, - Band { label: "low-mid (300-1k)", low_hz: 300.0, high_hz: 1000.0 }, - Band { label: "mid (1k-3k)", low_hz: 1000.0, high_hz: 3000.0 }, - Band { label: "high-mid (3k-6k)", low_hz: 3000.0, high_hz: 6000.0 }, - Band { label: "high (6k-12k)", low_hz: 6000.0, high_hz: 12000.0 }, + Band { + label: "low (80-300)", + low_hz: 80.0, + high_hz: 300.0, + }, + Band { + label: "low-mid (300-1k)", + low_hz: 300.0, + high_hz: 1000.0, + }, + Band { + label: "mid (1k-3k)", + low_hz: 1000.0, + high_hz: 3000.0, + }, + Band { + label: "high-mid (3k-6k)", + low_hz: 3000.0, + high_hz: 6000.0, + }, + Band { + label: "high (6k-12k)", + low_hz: 6000.0, + high_hz: 12000.0, + }, ]; /// Sums the linear magnitude energy within `[low_hz, high_hz)` across a single diff --git a/src/dsp/render.rs b/src/dsp/render.rs index 161c921..4866a4f 100644 --- a/src/dsp/render.rs +++ b/src/dsp/render.rs @@ -202,7 +202,8 @@ fn legend(opts: &RenderOpts) -> String { if opts.ascii { for i in 0..steps { let v = i as f32 / (steps - 1) as f32; - let idx = ((v * (ASCII_RAMP.len() - 1) as f32).round() as usize).min(ASCII_RAMP.len() - 1); + let idx = + ((v * (ASCII_RAMP.len() - 1) as f32).round() as usize).min(ASCII_RAMP.len() - 1); s.push(ASCII_RAMP[idx] as char); } } else { @@ -243,7 +244,11 @@ mod tests { fn render_produces_grid_of_expected_height() { let sig = generators::sine(2000.0, 0.8, 48_000, 48_000); let spec = stft::analyze(&sig, 48_000, 1024, 512); - let opts = RenderOpts { width: 40, height: 10, ..Default::default() }; + let opts = RenderOpts { + width: 40, + height: 10, + ..Default::default() + }; let out = render(&spec, &opts); // Header + 10 body rows + time axis (2) + legend = non-trivial. let lines = out.lines().count(); diff --git a/src/dsp/stft.rs b/src/dsp/stft.rs index edf778c..cc434cf 100644 --- a/src/dsp/stft.rs +++ b/src/dsp/stft.rs @@ -94,7 +94,10 @@ mod tests { .unwrap() .0; let peak_hz = s.bin_hz(peak_bin); - assert!((peak_hz - freq as f32).abs() < 100.0, "peak at {peak_hz} Hz, want {freq}"); + assert!( + (peak_hz - freq as f32).abs() < 100.0, + "peak at {peak_hz} Hz, want {freq}" + ); } #[test] diff --git a/src/dsp/wav.rs b/src/dsp/wav.rs index 37d5975..8023a2e 100644 --- a/src/dsp/wav.rs +++ b/src/dsp/wav.rs @@ -34,7 +34,12 @@ pub fn read(path: &Path) -> Result { let mut pos = 12usize; while pos + 8 <= bytes.len() { let id = &bytes[pos..pos + 4]; - let size = u32::from_le_bytes([bytes[pos + 4], bytes[pos + 5], bytes[pos + 6], bytes[pos + 7]]) as usize; + let size = u32::from_le_bytes([ + bytes[pos + 4], + bytes[pos + 5], + bytes[pos + 6], + bytes[pos + 7], + ]) as usize; let body_start = pos + 8; let body_end = (body_start + size).min(bytes.len()); match id { @@ -45,7 +50,9 @@ pub fn read(path: &Path) -> Result { sample_rate = u32::from_le_bytes([fmt[4], fmt[5], fmt[6], fmt[7]]); bits = u16::from_le_bytes([fmt[14], fmt[15]]); if audio_format != 1 { - return Err(format!("unsupported WAV format tag {audio_format} (need PCM=1)")); + return Err(format!( + "unsupported WAV format tag {audio_format} (need PCM=1)" + )); } } b"data" => { @@ -75,7 +82,10 @@ pub fn read(path: &Path) -> Result { samples.push(avg / 32768.0); } - Ok(WavData { samples, sample_rate }) + Ok(WavData { + samples, + sample_rate, + }) } /// Writes mono `f32` samples (clamped to `[-1, 1]`) as a 16-bit PCM WAV. Used by diff --git a/src/files.rs b/src/files.rs index c256611..d3d7aec 100644 --- a/src/files.rs +++ b/src/files.rs @@ -76,10 +76,7 @@ pub fn sanitize_filename(raw: &str) -> String { .trim(); // Drop control chars; turn other whitespace into single spaces later. - let cleaned: String = base - .chars() - .filter(|c| !c.is_control()) - .collect(); + let cleaned: String = base.chars().filter(|c| !c.is_control()).collect(); let collapsed = cleaned.split_whitespace().collect::>().join(" "); let collapsed = collapsed.trim_matches('.').trim(); @@ -145,7 +142,10 @@ pub fn looks_like_audio_name(name: &str) -> bool { let Some((_, extension)) = name.rsplit_once('.') else { return false; }; - matches!(extension.to_ascii_lowercase().as_str(), "wav" | "mp3" | "ogg" | "oga" | "flac") + matches!( + extension.to_ascii_lowercase().as_str(), + "wav" | "mp3" | "ogg" | "oga" | "flac" + ) } /// The attachment kind for some file bytes: [`AttachmentKind::Image`] if it @@ -240,7 +240,11 @@ mod tests { let long_stem = "x".repeat(200); let name = format!("{long_stem}.png"); let out = sanitize_filename(&name); - assert!(out.chars().count() <= MAX_FILENAME_LEN, "len was {}", out.chars().count()); + assert!( + out.chars().count() <= MAX_FILENAME_LEN, + "len was {}", + out.chars().count() + ); assert!(out.ends_with(".png"), "extension preserved: {out}"); } @@ -254,7 +258,9 @@ mod tests { #[test] fn image_sniffing_recognizes_containers() { - assert!(is_probably_image(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0])); + assert!(is_probably_image(&[ + 0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0 + ])); assert!(is_probably_image(&[0xFF, 0xD8, 0xFF, 0xE0])); assert!(is_probably_image(b"GIF89a....")); let mut webp = b"RIFF".to_vec(); diff --git a/src/friends.rs b/src/friends.rs index 6de6100..323935f 100644 --- a/src/friends.rs +++ b/src/friends.rs @@ -66,7 +66,11 @@ impl FriendStore { if self.contains(&id) { return false; } - self.friends.push(Friend { id, name, last_addr: addr }); + self.friends.push(Friend { + id, + name, + last_addr: addr, + }); true } @@ -118,21 +122,24 @@ pub fn friends_path() -> Option { /// *parse* error bubbles up so a hand-edit being debugged isn't silently /// overwritten with an empty list. pub fn load() -> Result { - let path = friends_path().context("could not determine a config directory for the friends list")?; + let path = + friends_path().context("could not determine a config directory for the friends list")?; load_at(&path) } /// Save the store. Atomic via tempfile-in-same-dir + rename. pub fn save(store: &FriendStore) -> Result<()> { - let path = friends_path().context("could not determine a config directory for the friends list")?; + let path = + friends_path().context("could not determine a config directory for the friends list")?; save_at(&path, store) } /// Path-injectable core of [`load`], so the round-trip is testable in a temp dir. fn load_at(path: &Path) -> Result { match fs::read_to_string(path) { - Ok(s) => serde_json::from_str(&s) - .with_context(|| format!("failed to parse {}", path.display())), + Ok(s) => { + serde_json::from_str(&s).with_context(|| format!("failed to parse {}", path.display())) + } Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(FriendStore::default()), Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())), } @@ -141,11 +148,14 @@ fn load_at(path: &Path) -> Result { /// Path-injectable core of [`save`]. Atomic write: tempfile-in-same-dir, then /// rename, so a crash mid-write can't leave a truncated list. fn save_at(path: &Path, store: &FriendStore) -> Result<()> { - let parent = path.parent().context("friends path has no parent directory")?; + let parent = path + .parent() + .context("friends path has no parent directory")?; fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?; let json = serde_json::to_string_pretty(store).context("failed to encode the friends list")?; let tmp = parent.join(format!(".friends.json.tmp.{}", std::process::id())); - fs::write(&tmp, json.as_bytes()).with_context(|| format!("failed to write {}", tmp.display()))?; + fs::write(&tmp, json.as_bytes()) + .with_context(|| format!("failed to write {}", tmp.display()))?; fs::rename(&tmp, path) .with_context(|| format!("failed to rename {} -> {}", tmp.display(), path.display()))?; Ok(()) @@ -219,7 +229,11 @@ mod tests { /// A unique temp path; `save_at` creates the nested dir (exercises create_dir_all). fn temp_path(tag: &str) -> PathBuf { let mut p = std::env::temp_dir(); - p.push(format!("peerspeak-friendstest-{}-{}", std::process::id(), tag)); + p.push(format!( + "peerspeak-friendstest-{}-{}", + std::process::id(), + tag + )); p.push("friends.json"); p } diff --git a/src/game/detector.rs b/src/game/detector.rs index dcf3ede..a8063c6 100644 --- a/src/game/detector.rs +++ b/src/game/detector.rs @@ -9,11 +9,9 @@ //! is factored into the pure [`poll_once`] so the wiring of resolve + match + //! debounce is unit-tested without any I/O. -use super::{ - builtin_denylist, match_processes, resolve, Debouncer, DetectedGame, ManualOverride, -}; use super::scan; use super::steam::SteamProbe; +use super::{Debouncer, DetectedGame, ManualOverride, builtin_denylist, match_processes, resolve}; use std::collections::BTreeMap; use std::io; use std::sync::atomic::{AtomicBool, Ordering}; @@ -145,9 +143,14 @@ fn worker_loop( let steam_game = steam.detect(); let processes = scan::running_executables(); - if let Some(new_current) = - poll_once(&mut debouncer, &override_, steam_game, &processes, &process_map, &denylist) - { + if let Some(new_current) = poll_once( + &mut debouncer, + &override_, + steam_game, + &processes, + &process_map, + &denylist, + ) { // A closed receiver means core shut down; stop quietly. if tx.send(new_current).is_err() { return; @@ -169,11 +172,18 @@ mod tests { use super::*; fn game(id: &str, name: &str, source: GameSource) -> DetectedGame { - DetectedGame { id: id.into(), name: Some(name.into()), source } + DetectedGame { + id: id.into(), + name: Some(name.into()), + source, + } } fn map(pairs: &[(&str, &str)]) -> BTreeMap { - pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect() + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() } #[test] @@ -185,17 +195,38 @@ mod tests { // First poll: detected but not yet published (needs two hits). assert_eq!( - poll_once(&mut d, &ManualOverride::Auto, Some(steam.clone()), &[], &empty, &deny), + poll_once( + &mut d, + &ManualOverride::Auto, + Some(steam.clone()), + &[], + &empty, + &deny + ), None ); // Second poll: published. assert_eq!( - poll_once(&mut d, &ManualOverride::Auto, Some(steam.clone()), &[], &empty, &deny), + poll_once( + &mut d, + &ManualOverride::Auto, + Some(steam.clone()), + &[], + &empty, + &deny + ), Some(Some(steam)) ); // Third identical poll: no change event. assert_eq!( - poll_once(&mut d, &ManualOverride::Auto, Some(game("steam:730", "CS2", GameSource::Steam)), &[], &empty, &deny), + poll_once( + &mut d, + &ManualOverride::Auto, + Some(game("steam:730", "CS2", GameSource::Steam)), + &[], + &empty, + &deny + ), None ); } @@ -209,7 +240,9 @@ mod tests { poll_once(&mut d, &ManualOverride::Auto, None, &procs, &user, &deny); let change = poll_once(&mut d, &ManualOverride::Auto, None, &procs, &user, &deny); - let published = change.expect("should publish on second hit").expect("a game"); + let published = change + .expect("should publish on second hit") + .expect("a game"); assert_eq!(published.id, "exe:hl2_linux"); assert_eq!(published.name.as_deref(), Some("Half-Life 2")); } diff --git a/src/game/mod.rs b/src/game/mod.rs index 2e786f8..d77f813 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -92,11 +92,20 @@ pub fn resolve( processes: &[DetectedGame], ) -> Resolution { match override_ { - ManualOverride::ForceNone => Resolution { game: None, immediate: true }, - ManualOverride::Force(g) => Resolution { game: Some(g.clone()), immediate: true }, + ManualOverride::ForceNone => Resolution { + game: None, + immediate: true, + }, + ManualOverride::Force(g) => Resolution { + game: Some(g.clone()), + immediate: true, + }, ManualOverride::Auto => { let game = steam.or_else(|| processes.first().cloned()); - Resolution { game, immediate: false } + Resolution { + game, + immediate: false, + } } } } @@ -192,7 +201,11 @@ impl Debouncer { /// lowercase it. Keeps any extension (`minecraft.exe` stays distinct from a /// hypothetical `minecraft`), trims surrounding whitespace. pub fn normalize_exe(raw: &str) -> String { - raw.rsplit(['/', '\\']).next().unwrap_or(raw).trim().to_lowercase() + raw.rsplit(['/', '\\']) + .next() + .unwrap_or(raw) + .trim() + .to_lowercase() } /// Launcher/helper executables that must NEVER be reported as a game even if a @@ -245,8 +258,10 @@ pub fn match_processes( denylist: &BTreeSet<&str>, ) -> Vec { // Normalize the user map once so lookups are basename/case-insensitive. - let normalized_map: BTreeMap = - user_map.iter().map(|(k, v)| (normalize_exe(k), v)).collect(); + let normalized_map: BTreeMap = user_map + .iter() + .map(|(k, v)| (normalize_exe(k), v)) + .collect(); let mut seen: BTreeSet = BTreeSet::new(); let mut out: Vec = Vec::new(); @@ -288,8 +303,14 @@ mod tests { #[test] fn stable_ids_are_namespaced() { assert_eq!(DetectedGame::steam_id(730), "steam:730"); - assert_eq!(DetectedGame::exe_id("/usr/games/hl2_linux"), "exe:hl2_linux"); - assert_eq!(DetectedGame::exe_id("C:\\Games\\Minecraft.exe"), "exe:minecraft.exe"); + assert_eq!( + DetectedGame::exe_id("/usr/games/hl2_linux"), + "exe:hl2_linux" + ); + assert_eq!( + DetectedGame::exe_id("C:\\Games\\Minecraft.exe"), + "exe:minecraft.exe" + ); } #[test] @@ -318,8 +339,16 @@ mod tests { #[test] fn resolve_falls_back_to_first_process_then_none() { let procs = vec![ - DetectedGame { id: "exe:a".into(), name: Some("A".into()), source: GameSource::Process }, - DetectedGame { id: "exe:b".into(), name: Some("B".into()), source: GameSource::Process }, + DetectedGame { + id: "exe:a".into(), + name: Some("A".into()), + source: GameSource::Process, + }, + DetectedGame { + id: "exe:b".into(), + name: Some("B".into()), + source: GameSource::Process, + }, ]; let r = resolve(&ManualOverride::Auto, None, &procs); assert_eq!(r.game.as_ref().unwrap().id, "exe:a"); @@ -426,7 +455,10 @@ mod tests { // --- process matching -------------------------------------------------- fn map(pairs: &[(&str, &str)]) -> BTreeMap { - pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect() + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() } #[test] @@ -461,14 +493,13 @@ mod tests { let user = map(&[("zed", "Zed"), ("alpha", "Alpha")]); let deny = builtin_denylist(); // Same game twice (two processes) + reverse discovery order. - let running = vec![ - "/b/zed".into(), - "/a/alpha".into(), - "/c/alpha".into(), - ]; + let running = vec!["/b/zed".into(), "/a/alpha".into(), "/c/alpha".into()]; let got = match_processes(&running, &user, &deny); // Deduped to two, sorted by id (alpha before zed) regardless of scan order. - assert_eq!(got.iter().map(|g| g.id.as_str()).collect::>(), vec!["exe:alpha", "exe:zed"]); + assert_eq!( + got.iter().map(|g| g.id.as_str()).collect::>(), + vec!["exe:alpha", "exe:zed"] + ); } #[test] diff --git a/src/game/scan.rs b/src/game/scan.rs index 84c6fc6..7005ced 100644 --- a/src/game/scan.rs +++ b/src/game/scan.rs @@ -62,7 +62,7 @@ fn linux_proc_executables() -> Vec { fn windows_toolhelp_executables() -> Vec { use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; use windows_sys::Win32::System::Diagnostics::ToolHelp::{ - CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, + CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS, }; @@ -78,7 +78,11 @@ fn windows_toolhelp_executables() -> Vec { let mut ok = unsafe { Process32FirstW(snapshot, &mut entry) }; while ok != 0 { // szExeFile is a NUL-terminated UTF-16 array (the basename, e.g. game.exe). - let end = entry.szExeFile.iter().position(|&c| c == 0).unwrap_or(entry.szExeFile.len()); + let end = entry + .szExeFile + .iter() + .position(|&c| c == 0) + .unwrap_or(entry.szExeFile.len()); let name = String::from_utf16_lossy(&entry.szExeFile[..end]); if !name.is_empty() { out.push(name); @@ -101,7 +105,10 @@ mod tests { // The test runner itself is a process, so /proc enumeration must be // non-empty and include something that normalizes to our own exe basename. let exes = running_executables(); - assert!(!exes.is_empty(), "expected to see running processes via /proc"); + assert!( + !exes.is_empty(), + "expected to see running processes via /proc" + ); // Our own /proc/self/exe basename should appear among them. let me = std::fs::read_link("/proc/self/exe") .ok() @@ -109,7 +116,8 @@ mod tests { if let Some(me) = me { let me_norm = super::super::normalize_exe(&me); assert!( - exes.iter().any(|e| super::super::normalize_exe(e) == me_norm), + exes.iter() + .any(|e| super::super::normalize_exe(e) == me_norm), "running list should include our own executable {me_norm:?}" ); } diff --git a/src/game/steam.rs b/src/game/steam.rs index 581ddee..1dcee6c 100644 --- a/src/game/steam.rs +++ b/src/game/steam.rs @@ -25,8 +25,7 @@ const MAX_STEAM_PATH_BYTES: u32 = 4 * 1024; #[cfg(any(windows, test))] fn validate_reg_len(len: u32) -> Option { - (len != 0 && len.is_multiple_of(2) && len <= MAX_STEAM_PATH_BYTES) - .then_some(len as usize / 2) + (len != 0 && len.is_multiple_of(2) && len <= MAX_STEAM_PATH_BYTES).then_some(len as usize / 2) } #[cfg(any(windows, test))] @@ -48,7 +47,14 @@ fn decode_reg_sz(mut buf: Vec, returned_bytes: u32) -> Option { pub fn parse_running_app_id(registry_vdf: &str) -> Option { let root = vdf::parse(registry_vdf).ok()?; let raw = root - .get_path(&["Registry", "HKCU", "Software", "Valve", "Steam", "RunningAppID"]) + .get_path(&[ + "Registry", + "HKCU", + "Software", + "Valve", + "Steam", + "RunningAppID", + ]) .and_then(Value::as_str)?; let id: u32 = raw.trim().parse().ok()?; (id != 0).then_some(id) @@ -196,7 +202,13 @@ impl SteamProbe { return cached.name.clone(); } let name = read_capped(&manifest).and_then(|c| parse_app_name(&c)); - self.manifests.insert(app_id, CachedManifest { mtime, name: name.clone() }); + self.manifests.insert( + app_id, + CachedManifest { + mtime, + name: name.clone(), + }, + ); name } @@ -240,7 +252,11 @@ impl SteamProbe { paths.push(root.clone()); } } - self.libraries = CachedLibraries { source, mtime, paths: paths.clone() }; + self.libraries = CachedLibraries { + source, + mtime, + paths: paths.clone(), + }; paths } } @@ -360,8 +376,8 @@ mod win { use std::path::PathBuf; use windows_sys::Win32::Foundation::ERROR_SUCCESS; use windows_sys::Win32::System::Registry::{ - RegCloseKey, RegOpenKeyExW, RegQueryValueExW, HKEY, HKEY_CURRENT_USER, KEY_READ, - REG_DWORD, REG_SZ, + HKEY, HKEY_CURRENT_USER, KEY_READ, REG_DWORD, REG_SZ, RegCloseKey, RegOpenKeyExW, + RegQueryValueExW, }; /// UTF-16, NUL-terminated, for a Win32 wide-string argument. @@ -374,9 +390,8 @@ mod win { let subkey = wide("Software\\Valve\\Steam"); let mut hkey: HKEY = std::ptr::null_mut(); // SAFETY: valid HKEY constant, NUL-terminated subkey, out-param for the handle. - let rc = unsafe { - RegOpenKeyExW(HKEY_CURRENT_USER, subkey.as_ptr(), 0, KEY_READ, &mut hkey) - }; + let rc = + unsafe { RegOpenKeyExW(HKEY_CURRENT_USER, subkey.as_ptr(), 0, KEY_READ, &mut hkey) }; (rc == ERROR_SUCCESS).then_some(hkey) } @@ -463,13 +478,20 @@ mod tests { #[test] fn registry_string_lengths_are_bounded_and_trimmed() { - assert_eq!(validate_reg_len(5), None, "odd byte lengths are invalid UTF-16"); + assert_eq!( + validate_reg_len(5), + None, + "odd byte lengths are invalid UTF-16" + ); assert_eq!(validate_reg_len(MAX_STEAM_PATH_BYTES + 2), None); assert_eq!(validate_reg_len(8), Some(4)); let raw = "C:\\Steam\0ignored".encode_utf16().collect::>(); let returned_bytes = ("C:\\Steam\0".encode_utf16().count() * 2) as u32; - assert_eq!(decode_reg_sz(raw, returned_bytes).as_deref(), Some("C:\\Steam")); + assert_eq!( + decode_reg_sz(raw, returned_bytes).as_deref(), + Some("C:\\Steam") + ); } #[test] @@ -495,10 +517,13 @@ mod tests { "contentstatsid" "12345" }"#; let got = parse_library_paths(current); - assert_eq!(got, vec![ - PathBuf::from("/home/eric/.local/share/Steam"), - PathBuf::from("/mnt/games/SteamLibrary"), - ]); + assert_eq!( + got, + vec![ + PathBuf::from("/home/eric/.local/share/Steam"), + PathBuf::from("/mnt/games/SteamLibrary"), + ] + ); // Legacy shape: numeric keys map straight to path strings. let legacy = r#""LibraryFolders" { @@ -522,13 +547,25 @@ mod tests { let environ = b"PATH=/usr/bin\0SteamAppId=440\0HOME=/home/x\0SteamGameId=440\0"; assert_eq!(parse_steam_app_id_from_environ(environ), Some(440)); // Nonzero requirement: SteamAppId=0 (the launcher itself) is ignored. - assert_eq!(parse_steam_app_id_from_environ(b"SteamAppId=0\0FOO=bar\0"), None); + assert_eq!( + parse_steam_app_id_from_environ(b"SteamAppId=0\0FOO=bar\0"), + None + ); // Absent → None (a non-Steam process). - assert_eq!(parse_steam_app_id_from_environ(b"PATH=/usr/bin\0HOME=/home/x\0"), None); + assert_eq!( + parse_steam_app_id_from_environ(b"PATH=/usr/bin\0HOME=/home/x\0"), + None + ); // Not fooled by a different var that merely contains the substring. - assert_eq!(parse_steam_app_id_from_environ(b"MY_SteamAppId=999\0"), None); + assert_eq!( + parse_steam_app_id_from_environ(b"MY_SteamAppId=999\0"), + None + ); // Garbage value → None, no panic. - assert_eq!(parse_steam_app_id_from_environ(b"SteamAppId=notanumber\0"), None); + assert_eq!( + parse_steam_app_id_from_environ(b"SteamAppId=notanumber\0"), + None + ); } #[test] diff --git a/src/game/vdf.rs b/src/game/vdf.rs index 30f438f..de94f0b 100644 --- a/src/game/vdf.rs +++ b/src/game/vdf.rs @@ -234,10 +234,20 @@ mod tests { } "#; let root = parse(acf).unwrap(); - assert_eq!(root.get_path(&["AppState", "name"]).and_then(Value::as_str), Some("Counter-Strike 2")); - assert_eq!(root.get_path(&["AppState", "appid"]).and_then(Value::as_str), Some("730")); + assert_eq!( + root.get_path(&["AppState", "name"]).and_then(Value::as_str), + Some("Counter-Strike 2") + ); + assert_eq!( + root.get_path(&["AppState", "appid"]) + .and_then(Value::as_str), + Some("730") + ); // Case-insensitive key lookup. - assert_eq!(root.get_path(&["appstate", "NAME"]).and_then(Value::as_str), Some("Counter-Strike 2")); + assert_eq!( + root.get_path(&["appstate", "NAME"]).and_then(Value::as_str), + Some("Counter-Strike 2") + ); } #[test] @@ -262,8 +272,14 @@ mod tests { "#; let root = parse(vdf).unwrap(); let lf = root.get("libraryfolders").unwrap(); - assert_eq!(lf.get_path(&["0", "path"]).and_then(Value::as_str), Some(r"C:\Program Files (x86)\Steam")); - assert_eq!(lf.get_path(&["1", "path"]).and_then(Value::as_str), Some("/home/eric/.local/share/Steam")); + assert_eq!( + lf.get_path(&["0", "path"]).and_then(Value::as_str), + Some(r"C:\Program Files (x86)\Steam") + ); + assert_eq!( + lf.get_path(&["1", "path"]).and_then(Value::as_str), + Some("/home/eric/.local/share/Steam") + ); // The library folder ids are iterable for discovery. let ids: Vec<&str> = lf.entries().iter().map(|(k, _)| k.as_str()).collect(); assert_eq!(ids, vec!["0", "1"]); @@ -292,7 +308,14 @@ mod tests { "#; let root = parse(reg).unwrap(); let appid = root - .get_path(&["Registry", "HKCU", "Software", "Valve", "Steam", "RunningAppID"]) + .get_path(&[ + "Registry", + "HKCU", + "Software", + "Valve", + "Steam", + "RunningAppID", + ]) .and_then(Value::as_str); assert_eq!(appid, Some("570")); } @@ -301,7 +324,10 @@ mod tests { fn handles_comments_and_barewords() { let vdf = "// a comment\n\"root\"\n{\n\tbarekey barevalue // trailing\n}\n"; let root = parse(vdf).unwrap(); - assert_eq!(root.get_path(&["root", "barekey"]).and_then(Value::as_str), Some("barevalue")); + assert_eq!( + root.get_path(&["root", "barekey"]).and_then(Value::as_str), + Some("barevalue") + ); } #[test] diff --git a/src/hotkeys.rs b/src/hotkeys.rs index 2ea2709..9b68b32 100644 --- a/src/hotkeys.rs +++ b/src/hotkeys.rs @@ -89,9 +89,9 @@ impl HotkeyAction { pub fn tier(self) -> HotkeyTier { match self { - HotkeyAction::ToggleMute - | HotkeyAction::ToggleDeafen - | HotkeyAction::OpenSettings => HotkeyTier::AppWide, + HotkeyAction::ToggleMute | HotkeyAction::ToggleDeafen | HotkeyAction::OpenSettings => { + HotkeyTier::AppWide + } HotkeyAction::PushToTalk | HotkeyAction::LeaveRoom => HotkeyTier::RoomOnly, } } @@ -278,7 +278,10 @@ mod tests { #[test] fn parse_single_character_case_folds() { - assert_eq!(parse_binding("M"), Some(KeyBinding::Character("m".to_string()))); + assert_eq!( + parse_binding("M"), + Some(KeyBinding::Character("m".to_string())) + ); assert_eq!(format_binding(parse_binding("m").as_ref()), "M"); } } diff --git a/src/identity.rs b/src/identity.rs index 540acd3..7a0fd0c 100644 --- a/src/identity.rs +++ b/src/identity.rs @@ -41,7 +41,8 @@ pub fn identity_path() -> Option { /// A *missing* file (first ever run, or right after a reset) is the normal /// create path. pub fn load_or_create() -> Result { - let path = identity_path().context("could not determine a config directory for the identity key")?; + let path = + identity_path().context("could not determine a config directory for the identity key")?; load_or_create_at(&path) } @@ -49,7 +50,8 @@ pub fn load_or_create() -> Result { /// deliberate "Regenerate identity" / unlink action — the old id is discarded and /// unrecoverable, so callers should confirm with the user first. pub fn regenerate() -> Result { - let path = identity_path().context("could not determine a config directory for the identity key")?; + let path = + identity_path().context("could not determine a config directory for the identity key")?; let key = SecretKey::generate(); save_at(&path, &key)?; Ok(key) @@ -57,7 +59,8 @@ pub fn regenerate() -> Result { /// Atomic, `0600` write at the default identity path. See [`save_at`]. pub fn save(key: &SecretKey) -> Result<()> { - let path = identity_path().context("could not determine a config directory for the identity key")?; + let path = + identity_path().context("could not determine a config directory for the identity key")?; save_at(&path, key) } @@ -80,13 +83,15 @@ fn load_or_create_at(path: &std::path::Path) -> Result { /// perms are applied before the rename so the secret is never briefly /// world-readable. fn save_at(path: &std::path::Path, key: &SecretKey) -> Result<()> { - let parent = path.parent().context("identity path has no parent directory")?; + let parent = path + .parent() + .context("identity path has no parent directory")?; fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?; let tmp = parent.join(format!(".identity.key.tmp.{}", std::process::id())); { - let mut f = - fs::File::create(&tmp).with_context(|| format!("failed to create {}", tmp.display()))?; + let mut f = fs::File::create(&tmp) + .with_context(|| format!("failed to create {}", tmp.display()))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; diff --git a/src/lib.rs b/src/lib.rs index 8ee191b..355c693 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,27 +1,27 @@ -pub mod audio; -pub mod codec; -pub mod dsp; -pub mod network; -pub mod protocol; -pub mod core; pub mod app; -pub mod config; -pub mod identity; -pub mod friends; -pub mod presence; -pub mod presence_net; -pub mod theme; -pub mod notify; -pub mod screenshare; -pub mod sanitize; +pub mod audio; pub mod avatar; pub mod background; -pub mod recents; +pub mod codec; +pub mod config; +pub mod core; pub mod discovery; -pub mod hotkeys; +pub mod dsp; pub mod files; -pub mod playlist; +pub mod friends; pub mod game; +pub mod hotkeys; +pub mod identity; +pub mod network; +pub mod notify; +pub mod playlist; +pub mod presence; +pub mod presence_net; +pub mod protocol; +pub mod recents; +pub mod sanitize; +pub mod screenshare; +pub mod theme; pub mod widget; use std::fs::File; @@ -75,7 +75,8 @@ pub fn redact_for_log(value: &str) -> String { } pub fn short_bytes_hex(bytes: &[u8]) -> String { - bytes.iter() + bytes + .iter() .take(6) .map(|b| format!("{b:02x}")) .collect::>() @@ -83,7 +84,10 @@ pub fn short_bytes_hex(bytes: &[u8]) -> String { } fn rotated_log_path(path: &Path) -> PathBuf { - let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("peerspeak.log"); + let file_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("peerspeak.log"); path.with_file_name(format!("{file_name}.1")) } @@ -100,7 +104,10 @@ fn prepare_log_file_with_limit(path: &Path, max_bytes: u64) -> std::io::Result GossipPayload { let author = secret.public(); let sig = secret.sign(&signable_bytes(topic, &author, ts, &msg)); - GossipPayload { author, ts, msg, sig } + GossipPayload { + author, + ts, + msg, + sig, + } } /// Why a received gossip payload was rejected (logging + tests). @@ -233,7 +238,10 @@ impl ClockSkewMonitor { } fn drop_oldest_authors(&mut self) { - let remove_count = self.authors.len().saturating_sub(CLOCK_SKEW_AUTHORS_SOFT_CAP); + let remove_count = self + .authors + .len() + .saturating_sub(CLOCK_SKEW_AUTHORS_SOFT_CAP); let mut by_age: Vec<_> = self .authors .iter() @@ -339,7 +347,8 @@ fn verify_gossip( return Err(GossipReject::OutOfWindow); } if let GossipMessage::Announce(state) = &payload.msg - && state.addr.id != payload.author { + && state.addr.id != payload.author + { return Err(GossipReject::AnnounceAddressMismatch); } Ok(()) @@ -466,7 +475,8 @@ impl RoomState for IrohGossipState { // Make every dial target resolvable: the ticket host plus any retained // peers handed in (their addresses may have aged out of a fresh endpoint's // book even though the persistent lookup usually still holds them). - self.address_lookup.add_endpoint_info(ticket.host_addr.clone()); + self.address_lookup + .add_endpoint_info(ticket.host_addr.clone()); for addr in &extra_bootstrap { self.address_lookup.add_endpoint_info(addr.clone()); } @@ -478,7 +488,10 @@ impl RoomState for IrohGossipState { compute_bootstrap(self_state.addr.id, ticket.host_addr.id, &extra_bootstrap); crate::log_msg(&format!("Bootstrap peers for join: {:?}", bootstrap_peers)); - let gossip_topic = self.gossip.subscribe(topic_id, bootstrap_peers).await + let gossip_topic = self + .gossip + .subscribe(topic_id, bootstrap_peers) + .await .map_err(|e| { let err = format!("Failed to join gossip topic: {}", e); crate::log_msg(&err); @@ -505,32 +518,44 @@ impl RoomState for IrohGossipState { let topic_bytes = ticket.topic_id; let handle = tokio::spawn(async move { - crate::log_msg(&format!("Spawned gossip topic loop for self_id={:?}", self_id)); + crate::log_msg(&format!( + "Spawned gossip topic loop for self_id={:?}", + self_id + )); let mut state_mutations_seen = HashMap::new(); let mut clock_skew_monitor = ClockSkewMonitor::default(); // Broadcast initial state let initial_payload = { let guard = self_state_clone.lock().unwrap(); - guard.as_ref().map(|s| sign_gossip( - &secret_key, - &topic_bytes, - now_millis(), - GossipMessage::Announce(s.clone()), - )) + guard.as_ref().map(|s| { + sign_gossip( + &secret_key, + &topic_bytes, + now_millis(), + GossipMessage::Announce(s.clone()), + ) + }) }; if let Some(payload) = initial_payload - && let Ok(bytes) = serde_json::to_vec(&payload) { - crate::log_msg(&format!("Broadcasting initial state from self_id={:?}", self_id)); - let _ = gossip_sender_clone.broadcast(bytes.into()).await; - } + && let Ok(bytes) = serde_json::to_vec(&payload) + { + crate::log_msg(&format!( + "Broadcasting initial state from self_id={:?}", + self_id + )); + let _ = gossip_sender_clone.broadcast(bytes.into()).await; + } // Stream topic messages while let Some(res) = gossip_receiver.next().await { match res { Ok(iroh_gossip::api::Event::Received(msg)) => { - crate::log_msg(&format!("Gossip received Event::Received from delivery={:?}", msg.delivered_from)); + crate::log_msg(&format!( + "Gossip received Event::Received from delivery={:?}", + msg.delivered_from + )); // Reject oversized frames BEFORE deserializing: parsing // allocates, so a size check has to precede `from_slice` to // bound the memory a hostile peer can make us hold. @@ -577,13 +602,13 @@ impl RoomState for IrohGossipState { continue; } let our_id = { - self_state_clone.lock().unwrap() - .as_ref() - .map(|s| s.addr.id) + self_state_clone.lock().unwrap().as_ref().map(|s| s.addr.id) }; - + if Some(payload.author) == our_id { - crate::log_msg("Gossip Event::Received from ourselves; ignoring"); + crate::log_msg( + "Gossip Event::Received from ourselves; ignoring", + ); continue; } @@ -635,7 +660,9 @@ impl RoomState for IrohGossipState { // Screen-share tickets are capabilities and // peer-supplied: cap/validate once at ingest // so invalid offers never render a Watch button. - state.sharing = state.sharing.and_then(crate::screenshare::sanitize_ticket); + state.sharing = state + .sharing + .and_then(crate::screenshare::sanitize_ticket); // The game-presence label is untrusted // peer text like the name: sanitize + // length-cap at ingest (strip bidi/control, @@ -651,8 +678,11 @@ impl RoomState for IrohGossipState { // bounds a future fetch (reject anything // outside the attachment cap). state.music = state.music.and_then(|mut m| { - let name = crate::sanitize::sanitize_game_label(&m.name); - if name.is_empty() || !crate::files::size_within_cap(m.size) { + let name = + crate::sanitize::sanitize_game_label(&m.name); + if name.is_empty() + || !crate::files::size_within_cap(m.size) + { return None; } m.name = name; @@ -679,8 +709,10 @@ impl RoomState for IrohGossipState { // cleared ONLY once re-admitted — clearing it before // a possible reject would orphan its recovery state // (Tier C F-01 audit). - let is_reconnecting = - disconnected_peers.lock().unwrap().contains(&payload.author); + let is_reconnecting = disconnected_peers + .lock() + .unwrap() + .contains(&payload.author); let admitted = { let mut peer_map = peers.lock().unwrap(); let is_new = !peer_map.contains_key(&payload.author); @@ -689,11 +721,17 @@ impl RoomState for IrohGossipState { // memory/tasks/dials without bound (Tier C // F-01). Existing-peer updates and reconnects // are exempt; only brand-new authors are gated. - let subject_to_cap = announce_subject_to_cap(is_new, is_reconnecting); - if !admit_into_roster(peer_map.len(), subject_to_cap, MAX_ACTIVE_PEERS) { + let subject_to_cap = + announce_subject_to_cap(is_new, is_reconnecting); + if !admit_into_roster( + peer_map.len(), + subject_to_cap, + MAX_ACTIVE_PEERS, + ) { None } else { - let state_changed = peer_map.get(&payload.author) != Some(&state); + let state_changed = + peer_map.get(&payload.author) != Some(&state); if is_new || state_changed { peer_map.insert(payload.author, state.clone()); } @@ -723,35 +761,54 @@ impl RoomState for IrohGossipState { // address set, so leave/re-announce cycles // can't accumulate attacker-supplied history // (Tier C F-01). - let _ = address_lookup.set_endpoint_info(state.addr.clone()); - let _ = event_tx.send(RoomEvent::PeerJoined(payload.author, state)).await; + let _ = address_lookup + .set_endpoint_info(state.addr.clone()); + let _ = event_tx + .send(RoomEvent::PeerJoined(payload.author, state)) + .await; } else if state_changed { crate::log_msg(&format!( "Gossip peer state updated: {}, state: {}", crate::short_id(&payload.author.to_string()), peer_state_for_log(&state) )); - let _ = event_tx.send(RoomEvent::PeerUpdated(payload.author, state)).await; + let _ = event_tx + .send(RoomEvent::PeerUpdated(payload.author, state)) + .await; } } GossipMessage::Leave => { - crate::log_msg(&format!("Gossip peer leave request from author={:?}", payload.author)); + crate::log_msg(&format!( + "Gossip peer leave request from author={:?}", + payload.author + )); // Drop this id's address-lookup entry so cycling // distinct identities through Announce→Leave can't // grow the lookup for the room's lifetime (Tier C // F-01 audit). Re-announce re-populates it. let _ = address_lookup.remove_endpoint_info(payload.author); - let removed = peers.lock().unwrap().remove(&payload.author).is_some(); + let removed = + peers.lock().unwrap().remove(&payload.author).is_some(); let was_disconnected = disconnected_peers .lock() .unwrap() .remove(&payload.author); if removed || was_disconnected { - let _ = event_tx.send(RoomEvent::PeerLeft(payload.author)).await; + let _ = event_tx + .send(RoomEvent::PeerLeft(payload.author)) + .await; } } - GossipMessage::Chat { name, text, ts, attachment } => { - crate::log_msg(&format!("Gossip chat from author={:?}", payload.author)); + GossipMessage::Chat { + name, + text, + ts, + attachment, + } => { + crate::log_msg(&format!( + "Gossip chat from author={:?}", + payload.author + )); // Defensively normalize an untrusted attachment // descriptor: sanitize the filename and drop it // entirely if it declares an out-of-cap size. @@ -762,18 +819,23 @@ impl RoomState for IrohGossipState { a.name = crate::files::sanitize_filename(&a.name); Some(a) }); - let _ = event_tx.send(RoomEvent::ChatMessage { - from: payload.author, - name, - text, - ts, - attachment, - }).await; + let _ = event_tx + .send(RoomEvent::ChatMessage { + from: payload.author, + name, + text, + ts, + attachment, + }) + .await; } } } Err(e) => { - crate::log_msg(&format!("Gossip failed to deserialize payload: {:?}", e)); + crate::log_msg(&format!( + "Gossip failed to deserialize payload: {:?}", + e + )); } } } @@ -782,18 +844,24 @@ impl RoomState for IrohGossipState { // Resend state on new neighbor connection to guarantee synchronization let payload_opt = { let guard = self_state_clone.lock().unwrap(); - guard.as_ref().map(|state| sign_gossip( - &secret_key, - &topic_bytes, - now_millis(), - GossipMessage::Announce(state.clone()), - )) + guard.as_ref().map(|state| { + sign_gossip( + &secret_key, + &topic_bytes, + now_millis(), + GossipMessage::Announce(state.clone()), + ) + }) }; if let Some(payload) = payload_opt - && let Ok(bytes) = serde_json::to_vec(&payload) { - crate::log_msg(&format!("Broadcasting state to new neighbor={:?}", peer_id)); - let _ = gossip_sender_clone.broadcast(bytes.into()).await; - } + && let Ok(bytes) = serde_json::to_vec(&payload) + { + crate::log_msg(&format!( + "Broadcasting state to new neighbor={:?}", + peer_id + )); + let _ = gossip_sender_clone.broadcast(bytes.into()).await; + } } Ok(iroh_gossip::api::Event::NeighborDown(peer_id)) => { crate::log_msg(&format!("Gossip event: NeighborDown={:?}", peer_id)); @@ -807,7 +875,10 @@ impl RoomState for IrohGossipState { let removed = peers.lock().unwrap().remove(&peer_id).is_some(); if removed { disconnected_peers.lock().unwrap().insert(peer_id); - crate::log_msg(&format!("Peer connection lost (NeighborDown): {:?}", peer_id)); + crate::log_msg(&format!( + "Peer connection lost (NeighborDown): {:?}", + peer_id + )); let _ = event_tx.send(RoomEvent::PeerConnectionLost(peer_id)).await; } } @@ -844,7 +915,9 @@ impl RoomState for IrohGossipState { ); if let Ok(bytes) = serde_json::to_vec(&payload) { crate::log_msg("Broadcasting updated self state to gossip"); - sender.broadcast(bytes.into()).await + sender + .broadcast(bytes.into()) + .await .map_err(|e| NetError::Gossip(e.to_string()))?; } } @@ -909,10 +982,17 @@ impl RoomState for IrohGossipState { &self.secret_key, &topic, ts, - GossipMessage::Chat { name, text, ts, attachment }, + GossipMessage::Chat { + name, + text, + ts, + attachment, + }, ); if let Ok(bytes) = serde_json::to_vec(&payload) { - sender.broadcast(bytes.into()).await + sender + .broadcast(bytes.into()) + .await .map_err(|e| NetError::Gossip(e.to_string()))?; } } @@ -934,12 +1014,7 @@ impl RoomState for IrohGossipState { let sender_opt = self.active_sender.lock().unwrap().take(); if let (Some(sender), Some(topic)) = (sender_opt, topic_opt) { - let payload = sign_gossip( - &self.secret_key, - &topic, - now_millis(), - GossipMessage::Leave, - ); + let payload = sign_gossip(&self.secret_key, &topic, now_millis(), GossipMessage::Leave); if let Ok(bytes) = serde_json::to_vec(&payload) { crate::log_msg("Broadcasting Leave message to gossip"); let _ = sender.broadcast(bytes.into()).await; @@ -1060,7 +1135,9 @@ mod tests { #[test] fn reconnecting_and_existing_peers_are_exempt_from_the_cap() { // A brand-new author counts against the cap... - assert!(announce_subject_to_cap(/* is_new */ true, /* is_reconnecting */ false)); + assert!(announce_subject_to_cap( + /* is_new */ true, /* is_reconnecting */ false + )); // ...but an ordinary update from an in-roster peer does not... assert!(!announce_subject_to_cap(false, false)); // ...and neither does a re-announce from a peer mid-reconnect, even @@ -1070,7 +1147,11 @@ mod tests { // Combined with admit_into_roster: a reconnecting author passes at a full // roster, a brand-new one does not. assert!(admit_into_roster(3, announce_subject_to_cap(true, true), 3)); - assert!(!admit_into_roster(3, announce_subject_to_cap(true, false), 3)); + assert!(!admit_into_roster( + 3, + announce_subject_to_cap(true, false), + 3 + )); } #[test] @@ -1105,7 +1186,10 @@ mod tests { assert_eq!(monitor.observe(author, -122_000, 40_000), None); assert_eq!( monitor.observe(author, -123_000, 69_999), - Some(ClockSkewWarning { author, skew_ms: -123_000 }) + Some(ClockSkewWarning { + author, + skew_ms: -123_000 + }) ); assert_eq!(monitor.observe(author, -124_000, 70_000), None); } @@ -1125,7 +1209,10 @@ mod tests { assert_eq!(monitor.observe(author, 127_000, 319_999), None); assert_eq!( monitor.observe(author, 128_000, 320_000), - Some(ClockSkewWarning { author, skew_ms: 128_000 }) + Some(ClockSkewWarning { + author, + skew_ms: 128_000 + }) ); } @@ -1141,11 +1228,17 @@ mod tests { assert_eq!(monitor.observe(b, 121_000, 1_000), None); assert_eq!( monitor.observe(b, 121_000, 2_000), - Some(ClockSkewWarning { author: b, skew_ms: 121_000 }) + Some(ClockSkewWarning { + author: b, + skew_ms: 121_000 + }) ); assert_eq!( monitor.observe(a, -121_000, 2_000), - Some(ClockSkewWarning { author: a, skew_ms: -121_000 }) + Some(ClockSkewWarning { + author: a, + skew_ms: -121_000 + }) ); } @@ -1202,7 +1295,10 @@ mod tests { assert!(long.as_str().len() > MAX_RELAY_URL_LEN); let addr = EndpointAddr::from_parts( id, - [TransportAddr::Relay(short.clone()), TransportAddr::Relay(long)], + [ + TransportAddr::Relay(short.clone()), + TransportAddr::Relay(long), + ], ); let out = sanitize_endpoint_addr(&addr); let relays: Vec<_> = out.relay_urls().cloned().collect(); @@ -1222,7 +1318,12 @@ mod tests { let secret = SecretKey::generate(); let topic = [9u8; 32]; let peer_state = sample_peer_state_for(secret.public()); - let payload = sign_gossip(&secret, &topic, 1000, GossipMessage::Announce(peer_state.clone())); + let payload = sign_gossip( + &secret, + &topic, + 1000, + GossipMessage::Announce(peer_state.clone()), + ); let serialized = serde_json::to_string(&payload).unwrap(); let deserialized: GossipPayload = serde_json::from_str(&serialized).unwrap(); @@ -1252,7 +1353,13 @@ mod tests { }; let serialized = serde_json::to_string(&original).unwrap(); let deserialized: GossipMessage = serde_json::from_str(&serialized).unwrap(); - if let GossipMessage::Chat { name, text, ts, attachment } = deserialized { + if let GossipMessage::Chat { + name, + text, + ts, + attachment, + } = deserialized + { assert_eq!(name, "Alice"); assert_eq!(text, "Hello"); assert_eq!(ts, 123456789); @@ -1305,7 +1412,10 @@ mod tests { // defaulting the attachment to None (serde(default)). let legacy = r#"{"Chat":{"name":"Old","text":"hi","ts":7}}"#; let parsed: GossipMessage = serde_json::from_str(legacy).unwrap(); - if let GossipMessage::Chat { name, attachment, .. } = parsed { + if let GossipMessage::Chat { + name, attachment, .. + } = parsed + { assert_eq!(name, "Old"); assert_eq!(attachment, None); } else { @@ -1368,7 +1478,10 @@ mod tests { let secret = SecretKey::generate(); let topic = [1u8; 32]; let p = sign_gossip(&secret, &topic, 5_000, GossipMessage::Leave); - assert_eq!(verify_gossip(&p, &topic, 5_000, GOSSIP_FRESHNESS_MS), Ok(())); + assert_eq!( + verify_gossip(&p, &topic, 5_000, GOSSIP_FRESHNESS_MS), + Ok(()) + ); } #[test] @@ -1392,7 +1505,12 @@ mod tests { let secret = SecretKey::generate(); let topic = [4u8; 32]; let mut p = sign_gossip(&secret, &topic, 5_000, GossipMessage::Leave); - p.msg = GossipMessage::Chat { name: "x".into(), text: "y".into(), ts: 5_000, attachment: None }; + p.msg = GossipMessage::Chat { + name: "x".into(), + text: "y".into(), + ts: 5_000, + attachment: None, + }; assert_eq!( verify_gossip(&p, &topic, 5_000, GOSSIP_FRESHNESS_MS), Err(GossipReject::BadSignature) @@ -1418,16 +1536,34 @@ mod tests { let p = sign_gossip(&secret, &topic, 1_000_000, GossipMessage::Leave); // Far in the past relative to "now" → stale (replay). assert_eq!( - verify_gossip(&p, &topic, 1_000_000 + GOSSIP_FRESHNESS_MS + 1, GOSSIP_FRESHNESS_MS), + verify_gossip( + &p, + &topic, + 1_000_000 + GOSSIP_FRESHNESS_MS + 1, + GOSSIP_FRESHNESS_MS + ), Err(GossipReject::OutOfWindow) ); // Implausibly future. assert_eq!( - verify_gossip(&p, &topic, 1_000_000 - GOSSIP_FRESHNESS_MS - 1, GOSSIP_FRESHNESS_MS), + verify_gossip( + &p, + &topic, + 1_000_000 - GOSSIP_FRESHNESS_MS - 1, + GOSSIP_FRESHNESS_MS + ), Err(GossipReject::OutOfWindow) ); // Within the window (clock skew tolerance) → accepted. - assert!(verify_gossip(&p, &topic, 1_000_000 + GOSSIP_FRESHNESS_MS - 1, GOSSIP_FRESHNESS_MS).is_ok()); + assert!( + verify_gossip( + &p, + &topic, + 1_000_000 + GOSSIP_FRESHNESS_MS - 1, + GOSSIP_FRESHNESS_MS + ) + .is_ok() + ); } #[test] @@ -1449,10 +1585,30 @@ mod tests { let author = fresh_id(); let mut seen = HashMap::new(); - assert!(admit_state_mutation(&mut seen, author, &GossipMessage::Leave, 10)); - assert!(!admit_state_mutation(&mut seen, author, &GossipMessage::Leave, 10)); - assert!(!admit_state_mutation(&mut seen, author, &GossipMessage::Leave, 9)); - assert!(admit_state_mutation(&mut seen, author, &GossipMessage::Leave, 11)); + assert!(admit_state_mutation( + &mut seen, + author, + &GossipMessage::Leave, + 10 + )); + assert!(!admit_state_mutation( + &mut seen, + author, + &GossipMessage::Leave, + 10 + )); + assert!(!admit_state_mutation( + &mut seen, + author, + &GossipMessage::Leave, + 9 + )); + assert!(admit_state_mutation( + &mut seen, + author, + &GossipMessage::Leave, + 11 + )); let announce = GossipMessage::Announce(sample_peer_state_for(author)); assert!(admit_state_mutation(&mut seen, author, &announce, 10)); @@ -1465,13 +1621,26 @@ mod tests { fn state_mutation_replay_gate_leaves_chat_ordering_untouched() { let author = fresh_id(); let mut seen = HashMap::new(); - let later_chat = GossipMessage::Chat { name: "A".into(), text: "later".into(), ts: 200, attachment: None }; - let earlier_chat = GossipMessage::Chat { name: "A".into(), text: "earlier".into(), ts: 100, attachment: None }; + let later_chat = GossipMessage::Chat { + name: "A".into(), + text: "later".into(), + ts: 200, + attachment: None, + }; + let earlier_chat = GossipMessage::Chat { + name: "A".into(), + text: "earlier".into(), + ts: 100, + attachment: None, + }; assert!(admit_state_mutation(&mut seen, author, &later_chat, 200)); assert!(admit_state_mutation(&mut seen, author, &earlier_chat, 100)); assert!(admit_state_mutation(&mut seen, author, &later_chat, 200)); - assert!(seen.is_empty(), "chat must not populate the state-mutation replay map"); + assert!( + seen.is_empty(), + "chat must not populate the state-mutation replay map" + ); } #[test] @@ -1481,8 +1650,18 @@ mod tests { let mut seen = HashMap::new(); let announce = GossipMessage::Announce(sample_peer_state_for(author)); - assert!(admit_state_mutation(&mut seen, author, &GossipMessage::Leave, 5)); + assert!(admit_state_mutation( + &mut seen, + author, + &GossipMessage::Leave, + 5 + )); assert!(admit_state_mutation(&mut seen, author, &announce, 5)); - assert!(admit_state_mutation(&mut seen, other, &GossipMessage::Leave, 5)); + assert!(admit_state_mutation( + &mut seen, + other, + &GossipMessage::Leave, + 5 + )); } } diff --git a/src/network/iroh_impl.rs b/src/network/iroh_impl.rs index 0f51132..af9903c 100644 --- a/src/network/iroh_impl.rs +++ b/src/network/iroh_impl.rs @@ -1,16 +1,16 @@ -use crate::network::{NetworkTransport, NetError, ConnEvent}; -use iroh::{Endpoint, EndpointId}; -use iroh::endpoint::{Connection, ConnectionError, VarInt}; +use crate::network::{ConnEvent, NetError, NetworkTransport}; +use async_trait::async_trait; use bytes::Bytes; +use iroh::endpoint::{Connection, ConnectionError, VarInt}; +use iroh::{Endpoint, EndpointId}; +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex as StdMutex}; +use std::time::Duration; use tokio::sync::mpsc; use tokio::sync::mpsc::Receiver; -use std::sync::{Arc, Mutex as StdMutex}; -use std::collections::{HashMap, HashSet}; -use std::time::Duration; -use async_trait::async_trait; -use crate::protocol::{AUDIO_ALPN, FILES_ALPN}; use crate::files::{AttachmentId, ChatAttachment}; +use crate::protocol::{AUDIO_ALPN, FILES_ALPN}; /// Per-peer datagram send queue depth. Audio is real-time, so a backlog is /// useless latency — keep it shallow and drop the oldest frame when full. @@ -111,7 +111,13 @@ impl Shared { let shared = self.clone(); let supervisor = tokio::spawn(supervise(shared, peer_id, inbound_rx)); let inbound_tx_ret = inbound_tx.clone(); - peers.insert(peer_id, PeerHandle { supervisor, inbound_tx }); + peers.insert( + peer_id, + PeerHandle { + supervisor, + inbound_tx, + }, + ); crate::log_msg(&format!("Transport: supervising peer {:?}", peer_id)); inbound_tx_ret } @@ -123,7 +129,10 @@ impl Shared { self.addrs.lock().unwrap().remove(&peer_id); if let Some(handle) = self.peers.lock().await.remove(&peer_id) { handle.supervisor.abort(); - crate::log_msg(&format!("Transport: stopped supervising peer {:?}", peer_id)); + crate::log_msg(&format!( + "Transport: stopped supervising peer {:?}", + peer_id + )); } } @@ -208,12 +217,15 @@ async fn supervise( let mut backoff = INITIAL_BACKOFF; // Show "connecting" until the first link is actually up. - let _ = shared.conn_events_tx.try_send(ConnEvent::Connecting(peer_id)); + let _ = shared + .conn_events_tx + .try_send(ConnEvent::Connecting(peer_id)); - let mut conn = match obtain_conn(&shared, peer_id, is_dialer, &mut inbound_rx, &mut backoff).await { - Some(conn) => conn, - None => return, // retired before we ever connected - }; + let mut conn = + match obtain_conn(&shared, peer_id, is_dialer, &mut inbound_rx, &mut backoff).await { + Some(conn) => conn, + None => return, // retired before we ever connected + }; loop { // A healthy link resets the dialer's backoff for the next outage. @@ -223,8 +235,14 @@ async fn supervise( shared.senders.lock().unwrap().insert(peer_id, send_tx); // Publish the live connection so an intentional leave can close it with // the goodbye code. - shared.live_conns.lock().unwrap().insert(peer_id, conn.clone()); - let _ = shared.conn_events_tx.try_send(ConnEvent::Connected(peer_id)); + shared + .live_conns + .lock() + .unwrap() + .insert(peer_id, conn.clone()); + let _ = shared + .conn_events_tx + .try_send(ConnEvent::Connected(peer_id)); crate::log_msg(&format!("Transport: peer {:?} link up", peer_id)); // Run until the link dies, a replacement arrives, or we're retired. The @@ -272,21 +290,36 @@ async fn supervise( match wake { Wake::Shutdown => return, Wake::Replacement(new_conn) => { - crate::log_msg(&format!("Transport: peer {:?} replaced with new inbound link", peer_id)); - let _ = shared.conn_events_tx.try_send(ConnEvent::Connecting(peer_id)); + crate::log_msg(&format!( + "Transport: peer {:?} replaced with new inbound link", + peer_id + )); + let _ = shared + .conn_events_tx + .try_send(ConnEvent::Connecting(peer_id)); conn = new_conn; } Wake::Closed(reason) => { // A graceful application close means the peer left on purpose — // don't reconnect; tell the core to evict it now. if is_graceful_leave(&reason) { - crate::log_msg(&format!("Transport: peer {:?} left gracefully ({:?})", peer_id, reason)); + crate::log_msg(&format!( + "Transport: peer {:?} left gracefully ({:?})", + peer_id, reason + )); let _ = shared.conn_events_tx.try_send(ConnEvent::Left(peer_id)); return; } - crate::log_msg(&format!("Transport: peer {:?} link dropped; reconnecting", peer_id)); - let _ = shared.conn_events_tx.try_send(ConnEvent::Connecting(peer_id)); - conn = match obtain_conn(&shared, peer_id, is_dialer, &mut inbound_rx, &mut backoff).await { + crate::log_msg(&format!( + "Transport: peer {:?} link dropped; reconnecting", + peer_id + )); + let _ = shared + .conn_events_tx + .try_send(ConnEvent::Connecting(peer_id)); + conn = match obtain_conn(&shared, peer_id, is_dialer, &mut inbound_rx, &mut backoff) + .await + { Some(conn) => conn, None => return, // retired while reconnecting }; @@ -405,7 +438,10 @@ impl iroh::protocol::ProtocolHandler for AudioRouter { // only happens if links are churning, and the supervisor gets the next one. let inbound_tx = shared.ensure_supervisor(peer_id).await; if inbound_tx.try_send(connection).is_err() { - crate::log_msg(&format!("Transport: dropped inbound link from {:?} (queue full)", peer_id)); + crate::log_msg(&format!( + "Transport: dropped inbound link from {:?} (queue full)", + peer_id + )); } Ok(()) } @@ -545,7 +581,14 @@ impl IrohTransport { /// all supervisors so none linger redialing the about-to-close endpoint. /// Call this before shutting the router down. pub async fn leave(&self) { - let conns: Vec = self.shared.live_conns.lock().unwrap().drain().map(|(_, c)| c).collect(); + let conns: Vec = self + .shared + .live_conns + .lock() + .unwrap() + .drain() + .map(|(_, c)| c) + .collect(); for conn in &conns { conn.close(VarInt::from_u32(GOODBYE_CODE), b"leave"); } @@ -639,7 +682,9 @@ impl IrohTransport { .map_err(|_| NetError::Other("file fetch: read timed out".to_string()))? .map_err(|e| NetError::Other(format!("file fetch: read failed: {e}")))?; if bytes.is_empty() { - return Err(NetError::Other("file fetch: sender no longer has the file".to_string())); + return Err(NetError::Other( + "file fetch: sender no longer has the file".to_string(), + )); } Ok(bytes) } diff --git a/src/network/mod.rs b/src/network/mod.rs index c0b64b6..0fba1f2 100644 --- a/src/network/mod.rs +++ b/src/network/mod.rs @@ -1,10 +1,10 @@ -use iroh::{EndpointId, EndpointAddr}; +use async_trait::async_trait; use bytes::Bytes; +use iroh::{EndpointAddr, EndpointId}; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; use thiserror::Error; use tokio::sync::mpsc::Receiver; -use async_trait::async_trait; -use serde::{Serialize, Deserialize}; -use std::str::FromStr; #[derive(Error, Debug)] pub enum NetError { @@ -148,7 +148,10 @@ pub enum RoomEvent { /// A validly signed gossip payload was rejected only because its timestamp is /// outside the replay-protection window. The peer is not in the roster yet, /// so this surfaces as a room-level warning instead of a peer-card state. - ClockSkewSuspected { author: EndpointId, skew_ms: i64 }, + ClockSkewSuspected { + author: EndpointId, + skew_ms: i64, + }, /// A peer sent a room text-chat message. Carries the sender's id, their /// display name (embedded so it shows even without a presence entry), the /// text, and a sender-stamped millisecond timestamp. @@ -203,10 +206,12 @@ impl PeerSpeakTicket { /// is idempotent. pub fn restamp(ticket_str: &str, my_addr: iroh::EndpointAddr) -> String { match ticket_str.parse::() { - Ok(t) => { - PeerSpeakTicket { host_addr: my_addr, topic_id: t.topic_id, name: t.name } - .to_string() + Ok(t) => PeerSpeakTicket { + host_addr: my_addr, + topic_id: t.topic_id, + name: t.name, } + .to_string(), Err(_) => ticket_str.to_string(), } } @@ -215,7 +220,10 @@ impl PeerSpeakTicket { /// can't be parsed or carries no label. Pure; used to label the gathering both /// in the room UI and in the presence we report to friends. pub fn label_of(ticket_str: &str) -> String { - ticket_str.parse::().map(|t| t.name).unwrap_or_default() + ticket_str + .parse::() + .map(|t| t.name) + .unwrap_or_default() } /// The room's `topic_id` embedded in a ticket string, or `None` if the ticket @@ -223,7 +231,10 @@ impl PeerSpeakTicket { /// the recents list (the host address and label change between members/sessions, /// but the topic uniquely identifies the gathering). pub fn topic_of(ticket_str: &str) -> Option<[u8; 32]> { - ticket_str.parse::().ok().map(|t| t.topic_id) + ticket_str + .parse::() + .ok() + .map(|t| t.topic_id) } } @@ -246,8 +257,8 @@ impl FromStr for PeerSpeakTicket { fn from_str(s: &str) -> Result { let decoded = base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, s) .map_err(|e| NetError::InvalidTicket(e.to_string()))?; - let ticket: PeerSpeakTicket = serde_json::from_slice(&decoded) - .map_err(|e| NetError::InvalidTicket(e.to_string()))?; + let ticket: PeerSpeakTicket = + serde_json::from_slice(&decoded).map_err(|e| NetError::InvalidTicket(e.to_string()))?; Ok(ticket) } } @@ -327,13 +338,13 @@ pub trait RoomState: Send + Sync { async fn subscribe_events(&self) -> Result, NetError>; } -pub mod iroh_impl; pub mod gossip; +pub mod iroh_impl; #[cfg(test)] mod tests { use super::*; - use iroh::{SecretKey, EndpointAddr}; + use iroh::{EndpointAddr, SecretKey}; fn sample_peer_state() -> PeerState { let secret = SecretKey::generate(); @@ -371,9 +382,12 @@ mod tests { let host = SecretKey::generate().public(); let topic_id = [3u8; 32]; // A labelled ticket: restamp keeps the label, label_of reads it. - let labelled = - PeerSpeakTicket { host_addr: EndpointAddr::from(host), topic_id, name: "HangOut".into() } - .to_string(); + let labelled = PeerSpeakTicket { + host_addr: EndpointAddr::from(host), + topic_id, + name: "HangOut".into(), + } + .to_string(); assert_eq!(PeerSpeakTicket::label_of(&labelled), "HangOut"); let member = SecretKey::generate().public(); let restamped = PeerSpeakTicket::restamp(&labelled, EndpointAddr::from(member)); @@ -410,10 +424,8 @@ mod tests { // valid URL-safe-base64 that decodes to non-JSON bytes let bad_json = b"hello world"; - let encoded = base64::Engine::encode( - &base64::engine::general_purpose::URL_SAFE_NO_PAD, - bad_json, - ); + let encoded = + base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, bad_json); let res3 = encoded.parse::(); assert!(matches!(res3, Err(NetError::InvalidTicket(_)))); } @@ -424,9 +436,12 @@ mod tests { let host = SecretKey::generate().public(); let member = SecretKey::generate().public(); let topic_id = [42u8; 32]; - let original = - PeerSpeakTicket { host_addr: EndpointAddr::from(host), topic_id, name: "HangOut".into() } - .to_string(); + let original = PeerSpeakTicket { + host_addr: EndpointAddr::from(host), + topic_id, + name: "HangOut".into(), + } + .to_string(); let restamped_str = PeerSpeakTicket::restamp(&original, EndpointAddr::from(member)); let restamped = restamped_str.parse::().unwrap(); @@ -441,18 +456,27 @@ mod tests { fn test_restamp_is_idempotent_for_same_addr() { let me = SecretKey::generate().public(); let topic_id = [7u8; 32]; - let mine = - PeerSpeakTicket { host_addr: EndpointAddr::from(me), topic_id, name: String::new() } - .to_string(); + let mine = PeerSpeakTicket { + host_addr: EndpointAddr::from(me), + topic_id, + name: String::new(), + } + .to_string(); // Re-stamping my own ticket with my own addr changes nothing. - assert_eq!(PeerSpeakTicket::restamp(&mine, EndpointAddr::from(me)), mine); + assert_eq!( + PeerSpeakTicket::restamp(&mine, EndpointAddr::from(me)), + mine + ); } #[test] fn test_restamp_passes_through_unparseable() { let me = SecretKey::generate().public(); // A malformed ticket is returned unchanged (the join will fail anyway). - assert_eq!(PeerSpeakTicket::restamp("not-a-ticket", EndpointAddr::from(me)), "not-a-ticket"); + assert_eq!( + PeerSpeakTicket::restamp("not-a-ticket", EndpointAddr::from(me)), + "not-a-ticket" + ); } #[test] diff --git a/src/notify.rs b/src/notify.rs index 58355d9..ad97253 100644 --- a/src/notify.rs +++ b/src/notify.rs @@ -11,12 +11,16 @@ //! missing chime should never disrupt a call. use std::collections::HashMap; +use std::fs::OpenOptions; +use std::io::Write; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Mutex, OnceLock}; +use std::time::{SystemTime, UNIX_EPOCH}; static ENABLED: AtomicBool = AtomicBool::new(true); +static TEMP_WAV_COUNTER: AtomicU64 = AtomicU64::new(0); /// Per-sound enable flags (W6), indexed by `Sound::index`. The master `ENABLED` /// toggle gates everything; these silence individual events while the master @@ -57,7 +61,6 @@ pub fn should_play(master_enabled: bool, sound_enabled: bool) -> bool { master_enabled && sound_enabled } - /// A notification event with a distinct chime. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Sound { @@ -195,14 +198,38 @@ fn cached_path(sound: Sound) -> Option { if let Some(path) = guard.get(sound.name()) { return Some(path.clone()); } - let path = std::env::temp_dir().join(format!("peerspeak-{}.wav", sound.name())); - if std::fs::write(&path, sound.bytes()).is_err() { - return None; - } + let path = match write_private_wav(&std::env::temp_dir(), sound.name(), sound.bytes()) { + Ok(path) => path, + Err(_) => return None, + }; guard.insert(sound.name(), path.clone()); Some(path) } +fn write_private_wav(dir: &Path, stem: &str, bytes: &[u8]) -> std::io::Result { + let counter = TEMP_WAV_COUNTER.fetch_add(1, Ordering::Relaxed); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let path = dir.join(format!( + "peerspeak-{stem}-{}-{counter}-{nanos}.wav", + std::process::id() + )); + + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + + let mut file = options.open(&path)?; + file.write_all(bytes)?; + Ok(path) +} + #[cfg(any(windows, test))] fn escape_powershell_single_quoted(s: &str) -> String { s.replace('\'', "''") @@ -249,6 +276,19 @@ fn spawn_player(path: &Path) { mod tests { use super::*; + static TEST_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); + + fn temp_wav_dir(tag: &str) -> PathBuf { + let counter = TEST_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "peerspeak-notifytest-{}-{tag}-{counter}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + #[test] fn test_should_play_truth_table() { // Plays only when BOTH the master and the per-sound flag are on. @@ -264,10 +304,7 @@ mod tests { escape_powershell_single_quoted(r"C:\Users\O'Brien\chime.wav"), r"C:\Users\O''Brien\chime.wav" ); - assert_eq!( - escape_powershell_single_quoted("a'b'c"), - "a''b''c" - ); + assert_eq!(escape_powershell_single_quoted("a'b'c"), "a''b''c"); } #[test] @@ -297,7 +334,10 @@ mod tests { // bare `~` -> home dir assert_eq!(expand_tilde("~"), home); // `~/sub/dir/file.wav` -> home joined with `sub/dir/file.wav` - assert_eq!(expand_tilde("~/sub/dir/file.wav"), home.join("sub/dir/file.wav")); + assert_eq!( + expand_tilde("~/sub/dir/file.wav"), + home.join("sub/dir/file.wav") + ); } // absolute path (`/etc/foo.wav`) -> unchanged assert_eq!(expand_tilde("/etc/foo.wav"), PathBuf::from("/etc/foo.wav")); @@ -310,10 +350,19 @@ mod tests { // leading/trailing whitespace is trimmed if let Some(home) = dirs::home_dir() { assert_eq!(expand_tilde(" ~ "), home); - assert_eq!(expand_tilde(" ~/sub/dir/file.wav "), home.join("sub/dir/file.wav")); + assert_eq!( + expand_tilde(" ~/sub/dir/file.wav "), + home.join("sub/dir/file.wav") + ); } - assert_eq!(expand_tilde(" /etc/foo.wav "), PathBuf::from("/etc/foo.wav")); - assert_eq!(expand_tilde(" foo/bar.wav "), PathBuf::from("foo/bar.wav")); + assert_eq!( + expand_tilde(" /etc/foo.wav "), + PathBuf::from("/etc/foo.wav") + ); + assert_eq!( + expand_tilde(" foo/bar.wav "), + PathBuf::from("foo/bar.wav") + ); } #[test] @@ -332,4 +381,61 @@ mod tests { // a `~`-prefixed path that resolves to a non-existent file -> Some(false) assert_eq!(validate_custom_path("~/non/existent/file.wav"), Some(false)); } + + #[test] + fn write_private_wav_writes_exact_bytes() { + let dir = temp_wav_dir("writes"); + let bytes = b"RIFFpeerspeak-test"; + + let path = write_private_wav(&dir, "unit", bytes).unwrap(); + + assert!(path.exists()); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + let _ = std::fs::remove_dir_all(&dir); + } + + #[cfg(unix)] + #[test] + fn write_private_wav_creates_0600_file() { + use std::os::unix::fs::PermissionsExt; + + let dir = temp_wav_dir("mode"); + let path = write_private_wav(&dir, "unit", b"mode").unwrap(); + + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn write_private_wav_uses_unique_paths() { + let dir = temp_wav_dir("unique"); + + let first = write_private_wav(&dir, "same-stem", b"first").unwrap(); + let second = write_private_wav(&dir, "same-stem", b"second").unwrap(); + + assert_ne!(first, second); + assert!(first.exists()); + assert!(second.exists()); + assert_eq!(std::fs::read(&first).unwrap(), b"first"); + assert_eq!(std::fs::read(&second).unwrap(), b"second"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn create_new_refuses_existing_path() { + let dir = temp_wav_dir("create-new"); + let path = dir.join("preexisting.wav"); + std::fs::write(&path, b"original").unwrap(); + + let err = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .unwrap_err(); + + assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists); + assert_eq!(std::fs::read(&path).unwrap(), b"original"); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/src/playlist.rs b/src/playlist.rs index 9074794..0bd6b18 100644 --- a/src/playlist.rs +++ b/src/playlist.rs @@ -49,9 +49,7 @@ pub fn parse_playlist(contents: &str, base_dir: &Path, kind: PlaylistKind) -> Ve fn playlist_entry_path(entry: &str, base_dir: &Path) -> Option { let lower = entry.to_ascii_lowercase(); - if lower.starts_with("http://") - || lower.starts_with("https://") - || lower.starts_with("ftp://") + if lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("ftp://") { return None; } @@ -107,7 +105,10 @@ File3=/var/audio/two.MP3 #[test] fn playlist_kind_is_case_insensitive() { assert_eq!(playlist_kind(Path::new("mix.M3U")), Some(PlaylistKind::M3u)); - assert_eq!(playlist_kind(Path::new("mix.m3u8")), Some(PlaylistKind::M3u)); + assert_eq!( + playlist_kind(Path::new("mix.m3u8")), + Some(PlaylistKind::M3u) + ); assert_eq!(playlist_kind(Path::new("mix.PLS")), Some(PlaylistKind::Pls)); assert_eq!(playlist_kind(Path::new("mix.txt")), None); } diff --git a/src/presence.rs b/src/presence.rs index 9192242..04ba5d2 100644 --- a/src/presence.rs +++ b/src/presence.rs @@ -36,8 +36,11 @@ pub enum PresenceMode { impl PresenceMode { /// All postures, default first — the option list for the Settings/home picker. - pub const ALL: [PresenceMode; 3] = - [PresenceMode::Normal, PresenceMode::Invisible, PresenceMode::Discoverable]; + pub const ALL: [PresenceMode; 3] = [ + PresenceMode::Normal, + PresenceMode::Invisible, + PresenceMode::Discoverable, + ]; /// Whether this posture publishes to discovery (the only mode that does). pub fn publishes_to_discovery(self) -> bool { @@ -193,7 +196,11 @@ mod tests { assert!(!should_answer(&friend, &friends, PresenceMode::Invisible)); // Stranger is NEVER answered, in any mode. assert!(!should_answer(&stranger, &friends, PresenceMode::Normal)); - assert!(!should_answer(&stranger, &friends, PresenceMode::Discoverable)); + assert!(!should_answer( + &stranger, + &friends, + PresenceMode::Discoverable + )); assert!(!should_answer(&stranger, &friends, PresenceMode::Invisible)); } @@ -214,7 +221,10 @@ mod tests { ControlMsg::Ping, ControlMsg::Pong { room: None }, ControlMsg::Pong { - room: Some(RoomPresence { name: "HangOut".into(), ticket: "abc".into() }), + room: Some(RoomPresence { + name: "HangOut".into(), + ticket: "abc".into(), + }), }, ]; for msg in cases { @@ -245,19 +255,37 @@ mod tests { ); // Valid ticket -> InRoom with a sanitized name. let t = valid_ticket(friend); - let got = interpret_pong(&ControlMsg::Pong { - room: Some(RoomPresence { name: "HangOut".into(), ticket: t.clone() }), - }, friend); - assert_eq!(got, Some(FriendPresence::InRoom { name: "HangOut".into(), ticket: t })); + let got = interpret_pong( + &ControlMsg::Pong { + room: Some(RoomPresence { + name: "HangOut".into(), + ticket: t.clone(), + }), + }, + friend, + ); + assert_eq!( + got, + Some(FriendPresence::InRoom { + name: "HangOut".into(), + ticket: t + }) + ); } #[test] fn interpret_pong_downgrades_a_garbage_ticket_to_online() { // A friend reporting a room with an unparseable ticket is treated as just // Online — no dead/hostile Join button is surfaced. - let got = interpret_pong(&ControlMsg::Pong { - room: Some(RoomPresence { name: "Trap".into(), ticket: "not-a-ticket".into() }), - }, id()); + let got = interpret_pong( + &ControlMsg::Pong { + room: Some(RoomPresence { + name: "Trap".into(), + ticket: "not-a-ticket".into(), + }), + }, + id(), + ); assert_eq!(got, Some(FriendPresence::Online)); } @@ -266,9 +294,15 @@ mod tests { let friend = id(); let attacker = id(); let t = valid_ticket(attacker); - let got = interpret_pong(&ControlMsg::Pong { - room: Some(RoomPresence { name: "Redirect".into(), ticket: t }), - }, friend); + let got = interpret_pong( + &ControlMsg::Pong { + room: Some(RoomPresence { + name: "Redirect".into(), + ticket: t, + }), + }, + friend, + ); assert_eq!(got, Some(FriendPresence::Online)); } @@ -287,10 +321,18 @@ mod tests { let t = valid_ticket(friend); assert_eq!( presence_from_probe(Some(( - &ControlMsg::Pong { room: Some(RoomPresence { name: "Den".into(), ticket: t.clone() }) }, + &ControlMsg::Pong { + room: Some(RoomPresence { + name: "Den".into(), + ticket: t.clone() + }) + }, friend, ))), - FriendPresence::InRoom { name: "Den".into(), ticket: t } + FriendPresence::InRoom { + name: "Den".into(), + ticket: t + } ); // A non-reply (a stray Ping) is not a presence -> Offline, never a false Online. assert_eq!( @@ -304,9 +346,15 @@ mod tests { // Control/bidi characters in a peer-supplied name are stripped. let friend = id(); let t = valid_ticket(friend); - let got = interpret_pong(&ControlMsg::Pong { - room: Some(RoomPresence { name: "Hang\u{202e}Out\u{0007}".into(), ticket: t.clone() }), - }, friend); + let got = interpret_pong( + &ControlMsg::Pong { + room: Some(RoomPresence { + name: "Hang\u{202e}Out\u{0007}".into(), + ticket: t.clone(), + }), + }, + friend, + ); match got { Some(FriendPresence::InRoom { name, .. }) => { assert!(!name.contains('\u{202e}'), "bidi override must be stripped"); diff --git a/src/presence_net.rs b/src/presence_net.rs index 72160de..b421315 100644 --- a/src/presence_net.rs +++ b/src/presence_net.rs @@ -54,7 +54,10 @@ fn decode(bytes: &[u8]) -> Result { /// malformed) — the caller treats that as "appears offline". `peer` is usually a /// bare [`EndpointId`] (friends store the stable id); a full [`EndpointAddr`] is /// also accepted (and used by hermetic tests). -pub async fn probe(endpoint: &Endpoint, peer: impl Into) -> Result<(EndpointId, ControlMsg)> { +pub async fn probe( + endpoint: &Endpoint, + peer: impl Into, +) -> Result<(EndpointId, ControlMsg)> { let conn = tokio::time::timeout(IO_TIMEOUT, endpoint.connect(peer, FRIENDS_ALPN)) .await .context("timed out connecting to peer")? @@ -62,7 +65,10 @@ pub async fn probe(endpoint: &Endpoint, peer: impl Into) -> Result let from = conn.remote_id(); let io = async { - let (mut send, mut recv) = conn.open_bi().await.context("failed to open control stream")?; + let (mut send, mut recv) = conn + .open_bi() + .await + .context("failed to open control stream")?; send.write_all(&encode(&ControlMsg::Ping)?) .await .context("failed to write ping")?; @@ -118,7 +124,10 @@ async fn exchange(conn: &iroh::endpoint::Connection, handler: &Handler) -> Resul let io = async { let (mut send, mut recv) = conn.accept_bi().await.context("failed to accept stream")?; - let bytes = recv.read_to_end(MAX_MSG).await.context("failed to read ping")?; + let bytes = recv + .read_to_end(MAX_MSG) + .await + .context("failed to read ping")?; match decode(&bytes)? { ControlMsg::Ping => {} other => bail!("expected a ping, got {other:?}"), @@ -211,7 +220,10 @@ mod tests { let handler: Handler = Arc::new(move |from| { if from == allowed { Some(ControlMsg::Pong { - room: Some(RoomPresence { name: "HangOut".into(), ticket: "t".into() }), + room: Some(RoomPresence { + name: "HangOut".into(), + ticket: "t".into(), + }), }) } else { None // stranger -> no reply @@ -221,10 +233,11 @@ mod tests { let serve_task = tokio::spawn(async move { serve(server_ep, handler).await }); // The allowed prober gets a Pong with the room. - let (from, pong) = tokio::time::timeout(Duration::from_secs(15), probe(&prober, server_addr.clone())) - .await - .expect("probe timed out") - .expect("probe failed"); + let (from, pong) = + tokio::time::timeout(Duration::from_secs(15), probe(&prober, server_addr.clone())) + .await + .expect("probe timed out") + .expect("probe failed"); assert_eq!(from, server_addr.id); match pong { ControlMsg::Pong { room: Some(r) } => assert_eq!(r.name, "HangOut"), diff --git a/src/protocol.rs b/src/protocol.rs index e6a19fc..6eec650 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -83,10 +83,22 @@ mod tests { /// so a version bump can't silently forget to update the wire string. #[test] fn alpns_match_their_proto_versions() { - assert_eq!(AUDIO_ALPN, format!("peerspeak/audio/{AUDIO_PROTO}").as_bytes()); - assert_eq!(FRIENDS_ALPN, format!("peerspeak/friends/{FRIENDS_PROTO}").as_bytes()); - assert_eq!(FILES_ALPN, format!("peerspeak/files/{FILES_PROTO}").as_bytes()); - assert_eq!(GOSSIP_SIG_DOMAIN, format!("peerspeak-gossip-v{GOSSIP_PROTO}")); + assert_eq!( + AUDIO_ALPN, + format!("peerspeak/audio/{AUDIO_PROTO}").as_bytes() + ); + assert_eq!( + FRIENDS_ALPN, + format!("peerspeak/friends/{FRIENDS_PROTO}").as_bytes() + ); + assert_eq!( + FILES_ALPN, + format!("peerspeak/files/{FILES_PROTO}").as_bytes() + ); + assert_eq!( + GOSSIP_SIG_DOMAIN, + format!("peerspeak-gossip-v{GOSSIP_PROTO}") + ); } #[test] @@ -95,7 +107,11 @@ mod tests { let mut b = a; b[5] = 10; assert_eq!(versioned_topic(a), versioned_topic(a), "deterministic"); - assert_ne!(versioned_topic(a), versioned_topic(b), "distinct rooms stay distinct"); + assert_ne!( + versioned_topic(a), + versioned_topic(b), + "distinct rooms stay distinct" + ); } #[test] diff --git a/src/recents.rs b/src/recents.rs index 0586360..a7e7a3b 100644 --- a/src/recents.rs +++ b/src/recents.rs @@ -51,7 +51,14 @@ fn same_room(a: &str, b: &str) -> bool { /// supplies `now` (unix seconds) and persists the list afterwards. pub fn push_recent(list: &mut Vec, name: String, ticket: String, now: u64) { list.retain(|r| !same_room(&r.ticket, &ticket)); - list.insert(0, Recent { name, ticket, joined_at: now }); + list.insert( + 0, + Recent { + name, + ticket, + joined_at: now, + }, + ); list.truncate(RECENTS_MAX); } @@ -86,8 +93,12 @@ mod tests { /// Build a real, parseable ticket for a fresh room with the given label. fn ticket(name: &str, topic: [u8; 32]) -> String { let host = SecretKey::generate().public(); - PeerSpeakTicket { host_addr: EndpointAddr::from(host), topic_id: topic, name: name.into() } - .to_string() + PeerSpeakTicket { + host_addr: EndpointAddr::from(host), + topic_id: topic, + name: name.into(), + } + .to_string() } #[test] diff --git a/src/sanitize.rs b/src/sanitize.rs index a3dfd5a..3002070 100644 --- a/src/sanitize.rs +++ b/src/sanitize.rs @@ -85,7 +85,10 @@ pub enum Segment { /// Trailing characters commonly adjacent to a URL in prose that should NOT be /// part of the link (so "see http://x.com." or "(http://x.com)" linkify cleanly). fn is_url_trailing_punct(c: char) -> bool { - matches!(c, '.' | ',' | '!' | '?' | ';' | ':' | ')' | ']' | '}' | '>' | '"' | '\'') + matches!( + c, + '.' | ',' | '!' | '?' | ';' | ':' | ')' | ']' | '}' | '>' | '"' | '\'' + ) } /// Find the byte index of the earliest `http://` or `https://` scheme in `s`, @@ -144,7 +147,10 @@ mod tests { fn strips_control_chars_and_collapses_whitespace() { // NUL, CR/LF, TAB, and ANSI ESC are control chars → become spaces, then // collapse; ends trim. - assert_eq!(sanitize_name(" a\u{0}b\r\nc\td\u{1b}[31m "), "a b c d [31m"); + assert_eq!( + sanitize_name(" a\u{0}b\r\nc\td\u{1b}[31m "), + "a b c d [31m" + ); // A name that is only control/whitespace cleans to empty. assert_eq!(sanitize_name("\u{0}\r\n\t "), ""); } @@ -183,7 +189,10 @@ mod tests { let mid = "g".repeat(56); assert_eq!(sanitize_game_label(&mid).chars().count(), 56); let long = "g".repeat(GAME_LABEL_MAX_CHARS + 100); - assert_eq!(sanitize_game_label(&long).chars().count(), GAME_LABEL_MAX_CHARS); + assert_eq!( + sanitize_game_label(&long).chars().count(), + GAME_LABEL_MAX_CHARS + ); } #[test] @@ -220,7 +229,10 @@ mod tests { #[test] fn linkify_plain_text_has_no_links() { let segs = linkify("just a normal message, nothing here"); - assert_eq!(segs, vec![Segment::Text("just a normal message, nothing here".into())]); + assert_eq!( + segs, + vec![Segment::Text("just a normal message, nothing here".into())] + ); } #[test] @@ -280,7 +292,12 @@ mod tests { fn linkify_only_matches_http_schemes() { // Non-web schemes and bare domains are NOT linkified (conservative). let segs = linkify("email me@x.com or ftp://x.com or visit x.com"); - assert_eq!(segs, vec![Segment::Text("email me@x.com or ftp://x.com or visit x.com".into())]); + assert_eq!( + segs, + vec![Segment::Text( + "email me@x.com or ftp://x.com or visit x.com".into() + )] + ); } #[test] @@ -293,7 +310,11 @@ mod tests { "weird))) http://c.com]]] tail", "unicode 世界 http://d.com/路径 more 世界", ] { - assert_eq!(reassemble(&linkify(msg)), msg, "roundtrip failed for {msg:?}"); + assert_eq!( + reassemble(&linkify(msg)), + msg, + "roundtrip failed for {msg:?}" + ); } } } diff --git a/src/screenshare/mod.rs b/src/screenshare/mod.rs index 7d0213e..6a43122 100644 --- a/src/screenshare/mod.rs +++ b/src/screenshare/mod.rs @@ -158,9 +158,8 @@ pub fn host_args(audio_app: Option<&str>) -> Vec { /// "no valid app selected" — the caller then shares the whole desktop audio. pub fn sanitize_app_name(name: &str) -> Option { let name = name.trim(); - let ok = !name.is_empty() - && name.len() <= MAX_APP_NAME_LEN - && !name.chars().any(|c| c.is_control()); + let ok = + !name.is_empty() && name.len() <= MAX_APP_NAME_LEN && !name.chars().any(|c| c.is_control()); ok.then(|| name.to_string()) } @@ -599,7 +598,10 @@ mod tests { // i.e. after the `--` end-of-options guard, never parsed as a flag. let args = viewer_args("--malicious-flag"); assert_eq!(args.last().unwrap(), "--malicious-flag", "ticket is last"); - let guard = args.iter().position(|a| a == "--").expect("`--` guard present"); + let guard = args + .iter() + .position(|a| a == "--") + .expect("`--` guard present"); let ticket = args.len() - 1; assert!(guard < ticket, "ticket must follow the `--` guard"); // The real flags are parsed before the guard. @@ -629,7 +631,13 @@ mod tests { // `--strict-audio` so pixelpass never falls back to whole-desktop audio. assert_eq!( host_args(Some("Firefox")), - vec!["--host", "--output", "json", "--app=Firefox", "--strict-audio"] + vec![ + "--host", + "--output", + "json", + "--app=Firefox", + "--strict-audio" + ] ); // The hyphen-leading name is still bound to --app as a single token; // --strict-audio is the trailing flag. @@ -643,12 +651,18 @@ mod tests { // An empty / whitespace / control-laden selection is sanitized away, // falling back to whole-desktop capture rather than a broken flag. assert_eq!(host_args(Some(" ")), vec!["--host", "--output", "json"]); - assert_eq!(host_args(Some("bad\nname")), vec!["--host", "--output", "json"]); + assert_eq!( + host_args(Some("bad\nname")), + vec!["--host", "--output", "json"] + ); } #[test] fn sanitize_app_name_trims_and_rejects_garbage() { - assert_eq!(sanitize_app_name(" Firefox \n"), Some("Firefox".to_string())); + assert_eq!( + sanitize_app_name(" Firefox \n"), + Some("Firefox".to_string()) + ); assert_eq!(sanitize_app_name(""), None); assert_eq!(sanitize_app_name(" "), None); assert_eq!(sanitize_app_name("a\tb"), None); @@ -667,7 +681,11 @@ mod tests { ]"#; assert_eq!( parse_audio_apps(stdout), - vec!["Firefox".to_string(), "Spotify".to_string(), "mpv".to_string()] + vec![ + "Firefox".to_string(), + "Spotify".to_string(), + "mpv".to_string() + ] ); } @@ -754,13 +772,19 @@ Install hint: sudo apt install gstreamer1.0-plugins-bad #[test] fn sanitize_ticket_accepts_pixelpass_endpoint_ticket_shape() { let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm"; - assert_eq!(sanitize_ticket(format!(" {ticket}\n")), Some(ticket.to_string())); + assert_eq!( + sanitize_ticket(format!(" {ticket}\n")), + Some(ticket.to_string()) + ); } #[test] fn sanitize_ticket_rejects_oversized_or_garbage_ticket() { assert_eq!(sanitize_ticket("not-a-ticket".into()), None); - assert_eq!(sanitize_ticket(format!("endpoint{}", "a".repeat(MAX_TICKET_LEN))), None); + assert_eq!( + sanitize_ticket(format!("endpoint{}", "a".repeat(MAX_TICKET_LEN))), + None + ); assert_eq!(sanitize_ticket("endpointabc-def".into()), None); } @@ -784,7 +808,9 @@ Install hint: sudo apt install gstreamer1.0-plugins-bad fn parses_connected_url() { assert_eq!( parse_pixelpass_event(r#"{"event":"connected","url":"http://127.0.0.1:5500"}"#), - Some(PixelpassEvent::Connected("http://127.0.0.1:5500".to_string())) + Some(PixelpassEvent::Connected( + "http://127.0.0.1:5500".to_string() + )) ); } @@ -919,7 +945,10 @@ Install hint: sudo apt install gstreamer1.0-plugins-bad let dir = Path::new("bin"); let candidates: Vec = pixelpass_path_candidates(dir).into_iter().collect(); #[cfg(windows)] - assert_eq!(candidates, vec![dir.join("pixelpass"), dir.join("pixelpass.exe")]); + assert_eq!( + candidates, + vec![dir.join("pixelpass"), dir.join("pixelpass.exe")] + ); #[cfg(not(windows))] assert_eq!(candidates, vec![dir.join("pixelpass")]); } diff --git a/src/theme.rs b/src/theme.rs index 6b115ac..cf08736 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -211,7 +211,7 @@ impl AppTheme { overlay: hex(0x6272a4), text: hex(0xf8f8f2), subtext: hex(0xbdc0d4), - blue: hex(0xbd93f9), // Dracula's signature purple as the primary accent + blue: hex(0xbd93f9), // Dracula's signature purple as the primary accent lavender: hex(0x8be9fd), // cyan red: hex(0xff5555), maroon: hex(0xff79c6), // pink @@ -400,7 +400,11 @@ mod tests { for theme in AppTheme::ALL { let p = theme.palette(); let sub = contrast_ratio(p.subtext, p.base); - assert!(sub >= 3.0, "{}: subtext contrast {sub:.2} < 3.0", theme.label()); + assert!( + sub >= 3.0, + "{}: subtext contrast {sub:.2} < 3.0", + theme.label() + ); let accent = contrast_ratio(p.blue, p.base); assert!( accent >= 3.0, diff --git a/src/widget/context_input.rs b/src/widget/context_input.rs index 5e124b8..914138d 100644 --- a/src/widget/context_input.rs +++ b/src/widget/context_input.rs @@ -9,8 +9,8 @@ use iced::advanced::widget::{self, Widget}; use iced::advanced::{Layout, Shell}; use iced::widget::text_input; use iced::{ - alignment, Background, Border, Color, Element, Event, Length, Padding, - Pixels, Point, Rectangle, Shadow, Size, Vector, + Background, Border, Color, Element, Event, Length, Padding, Pixels, Point, Rectangle, Shadow, + Size, Vector, alignment, }; use std::rc::Rc; @@ -27,11 +27,7 @@ pub fn copy_selection(value: &str, start: usize, end: usize) -> Option { (start != end).then(|| value.select(start, end).to_string()) } -pub fn cut_selection( - value: &str, - start: usize, - end: usize, -) -> (Edit, Option) { +pub fn cut_selection(value: &str, start: usize, end: usize) -> (Edit, Option) { let mut value = text_input::Value::new(value); let (start, end) = normalized_range(&value, start, end); @@ -80,18 +76,13 @@ pub fn select_all_range(value: &str) -> (usize, usize) { (0, value.len()) } -fn normalized_range( - value: &text_input::Value, - start: usize, - end: usize, -) -> (usize, usize) { +fn normalized_range(value: &text_input::Value, start: usize, end: usize) -> (usize, usize) { let len = value.len(); (start.min(end).min(len), start.max(end).min(len)) } -type InputStyleFn<'a, Theme> = - Rc text_input::Style + 'a>; +type InputStyleFn<'a, Theme> = Rc text_input::Style + 'a>; pub fn context_input<'a, Message, Theme, Renderer>( placeholder: &str, @@ -119,12 +110,8 @@ where .locked(true) } -pub struct ContextInput< - 'a, - Message, - Theme = iced::Theme, - Renderer = iced::Renderer, -> where +pub struct ContextInput<'a, Message, Theme = iced::Theme, Renderer = iced::Renderer> +where Theme: text_input::Catalog, Renderer: text::Renderer, { @@ -137,8 +124,7 @@ pub struct ContextInput< style: Option>, } -impl<'a, Message, Theme, Renderer> - ContextInput<'a, Message, Theme, Renderer> +impl<'a, Message, Theme, Renderer> ContextInput<'a, Message, Theme, Renderer> where Message: Clone + 'a, Theme: text_input::Catalog + 'a, @@ -172,16 +158,13 @@ where self } - pub fn on_input( - mut self, - on_input: impl Fn(String) -> Message + 'a, - ) -> Self { - let on_input: Rc Message + 'a> = - Rc::new(on_input); + pub fn on_input(mut self, on_input: impl Fn(String) -> Message + 'a) -> Self { + let on_input: Rc Message + 'a> = Rc::new(on_input); let input_callback = Rc::clone(&on_input); - self.input = - self.input.on_input(move |value| input_callback.as_ref()(value)); + self.input = self + .input + .on_input(move |value| input_callback.as_ref()(value)); self.on_input = Some(on_input); self } @@ -196,16 +179,13 @@ where self } - pub fn on_paste( - mut self, - on_paste: impl Fn(String) -> Message + 'a, - ) -> Self { - let on_paste: Rc Message + 'a> = - Rc::new(on_paste); + pub fn on_paste(mut self, on_paste: impl Fn(String) -> Message + 'a) -> Self { + let on_paste: Rc Message + 'a> = Rc::new(on_paste); let paste_callback = Rc::clone(&on_paste); - self.input = - self.input.on_paste(move |value| paste_callback.as_ref()(value)); + self.input = self + .input + .on_paste(move |value| paste_callback.as_ref()(value)); self.on_paste = Some(on_paste); self } @@ -235,18 +215,12 @@ where self } - pub fn line_height( - mut self, - line_height: impl Into, - ) -> Self { + pub fn line_height(mut self, line_height: impl Into) -> Self { self.input = self.input.line_height(line_height); self } - pub fn align_x( - mut self, - alignment: impl Into, - ) -> Self { + pub fn align_x(mut self, alignment: impl Into) -> Self { self.input = self.input.align_x(alignment); self } @@ -379,11 +353,9 @@ where } }; - tree.state.downcast_mut::().menu = - cursor.position().map(|anchor| MenuState { - anchor, - selection, - }); + tree.state.downcast_mut::().menu = cursor + .position() + .map(|anchor| MenuState { anchor, selection }); shell.capture_event(); shell.request_redraw(); @@ -477,8 +449,7 @@ where } } -impl<'a, Message, Theme, Renderer> - From> +impl<'a, Message, Theme, Renderer> From> for Element<'a, Message, Theme, Renderer> where Message: Clone + 'a, @@ -516,12 +487,7 @@ enum MenuAction { } impl MenuAction { - const ALL: [Self; 4] = [ - Self::Cut, - Self::Copy, - Self::Paste, - Self::SelectAll, - ]; + const ALL: [Self; 4] = [Self::Cut, Self::Copy, Self::Paste, Self::SelectAll]; fn label(self) -> &'static str { match self { @@ -564,8 +530,7 @@ where cursor: mouse::Cursor, ) { let active_style = input_style(theme, self.style.as_ref(), text_input::Status::Active); - let hovered_style = - input_style(theme, self.style.as_ref(), text_input::Status::Hovered); + let hovered_style = input_style(theme, self.style.as_ref(), text_input::Status::Hovered); let bounds = layout.bounds(); let viewport = Rectangle::INFINITE; @@ -640,9 +605,7 @@ where ) { match event { Event::Keyboard(iced::keyboard::Event::KeyPressed { - key: iced::keyboard::Key::Named( - iced::keyboard::key::Named::Escape, - ), + key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape), .. }) => { self.close(shell); @@ -718,11 +681,7 @@ where ) } - fn hit_action( - &self, - bounds: Rectangle, - position: Point, - ) -> Option { + fn hit_action(&self, bounds: Rectangle, position: Point) -> Option { if !bounds.contains(position) { return None; } @@ -932,12 +891,48 @@ mod tests { #[test] fn locked_menu_allows_copy_and_select_all_only() { - assert!(!menu_action_enabled(MenuAction::Cut, true, true, false, true)); - assert!(menu_action_enabled(MenuAction::Copy, true, true, false, true)); - assert!(!menu_action_enabled(MenuAction::Paste, true, true, false, true)); - assert!(menu_action_enabled(MenuAction::SelectAll, true, true, false, true)); + assert!(!menu_action_enabled( + MenuAction::Cut, + true, + true, + false, + true + )); + assert!(menu_action_enabled( + MenuAction::Copy, + true, + true, + false, + true + )); + assert!(!menu_action_enabled( + MenuAction::Paste, + true, + true, + false, + true + )); + assert!(menu_action_enabled( + MenuAction::SelectAll, + true, + true, + false, + true + )); - assert!(!menu_action_enabled(MenuAction::Copy, false, true, false, true)); - assert!(!menu_action_enabled(MenuAction::SelectAll, false, false, false, true)); + assert!(!menu_action_enabled( + MenuAction::Copy, + false, + true, + false, + true + )); + assert!(!menu_action_enabled( + MenuAction::SelectAll, + false, + false, + false, + true + )); } } diff --git a/src/widget/selectable_text.rs b/src/widget/selectable_text.rs index c015bed..b3600fa 100644 --- a/src/widget/selectable_text.rs +++ b/src/widget/selectable_text.rs @@ -3,16 +3,15 @@ use iced::advanced::layout; use iced::advanced::mouse; use iced::advanced::renderer; use iced::advanced::text::{self as advanced_text, Paragraph, Span}; -use iced::advanced::widget::tree::{self, Tree}; use iced::advanced::widget::Widget; +use iced::advanced::widget::tree::{self, Tree}; use iced::advanced::{Layout, Shell}; use iced::widget::text::{ - self as widget_text, Alignment, Catalog, LineHeight, Shaping, Style, StyleFn, - Wrapping, + self as widget_text, Alignment, Catalog, LineHeight, Shaping, Style, StyleFn, Wrapping, }; use iced::{ - alignment, Background, Border, Color, Element, Event, Length, Pixels, Point, - Rectangle, Size, Vector, keyboard, + Background, Border, Color, Element, Event, Length, Pixels, Point, Rectangle, Size, Vector, + alignment, keyboard, }; const DRAG_THRESHOLD: f32 = 3.0; @@ -28,11 +27,7 @@ const HIT_SEARCH_STEPS: usize = 24; // widget's per-line offsets would stop being global and selection/copy across // lines would break — revisit then. -pub fn selected_substring( - text: &str, - anchor: usize, - cursor: usize, -) -> Option { +pub fn selected_substring(text: &str, anchor: usize, cursor: usize) -> Option { let (start, end) = normalized_byte_range(text, anchor, cursor); (start != end).then(|| text[start..end].to_owned()) @@ -42,11 +37,7 @@ pub fn select_all(text: &str) -> (usize, usize) { (0, text.len()) } -fn normalized_byte_range( - text: &str, - anchor: usize, - cursor: usize, -) -> (usize, usize) { +fn normalized_byte_range(text: &str, anchor: usize, cursor: usize) -> (usize, usize) { let start = clamp_to_char_boundary(text, anchor.min(cursor)); let end = clamp_to_char_boundary(text, anchor.max(cursor)); @@ -75,13 +66,8 @@ where SelectableRichText::with_spans(spans) } -pub struct SelectableRichText< - 'a, - Link, - Message, - Theme = iced::Theme, - Renderer = iced::Renderer, -> where +pub struct SelectableRichText<'a, Link, Message, Theme = iced::Theme, Renderer = iced::Renderer> +where Link: Clone + 'static, Theme: Catalog, Renderer: advanced_text::Renderer, @@ -101,8 +87,7 @@ pub struct SelectableRichText< selection_color: Color, } -impl<'a, Link, Message, Theme, Renderer> - SelectableRichText<'a, Link, Message, Theme, Renderer> +impl<'a, Link, Message, Theme, Renderer> SelectableRichText<'a, Link, Message, Theme, Renderer> where Link: Clone + 'static, Theme: Catalog, @@ -127,9 +112,7 @@ where } } - pub fn with_spans( - spans: impl AsRef<[Span<'a, Link, Renderer::Font>]> + 'a, - ) -> Self { + pub fn with_spans(spans: impl AsRef<[Span<'a, Link, Renderer::Font>]> + 'a) -> Self { Self { spans: Box::new(spans), ..Self::new() @@ -166,10 +149,7 @@ where self } - pub fn align_y( - mut self, - alignment: impl Into, - ) -> Self { + pub fn align_y(mut self, alignment: impl Into) -> Self { self.align_y = alignment.into(); self } @@ -179,10 +159,7 @@ where self } - pub fn on_link_click( - mut self, - on_link_click: impl Fn(Link) -> Message + 'a, - ) -> Self { + pub fn on_link_click(mut self, on_link_click: impl Fn(Link) -> Message + 'a) -> Self { self.on_link_click = Some(Box::new(on_link_click)); self } @@ -356,26 +333,16 @@ where } for (index, span) in spans.iter().enumerate() { - let is_hovered_link = self.on_link_click.is_some() - && Some(index) == self.hovered_link; + let is_hovered_link = self.on_link_click.is_some() && Some(index) == self.hovered_link; - if span.highlight.is_some() - || span.underline - || span.strikethrough - || is_hovered_link - { + if span.highlight.is_some() || span.underline || span.strikethrough || is_hovered_link { let regions = state.paragraph.span_bounds(index); if let Some(highlight) = span.highlight { for bounds in ®ions { let bounds = Rectangle::new( - bounds.position() - - Vector::new( - span.padding.left, - span.padding.top, - ), - bounds.size() - + Size::new(span.padding.x(), span.padding.y()), + bounds.position() - Vector::new(span.padding.left, span.padding.top), + bounds.size() + Size::new(span.padding.x(), span.padding.y()), ); renderer.fill_quad( @@ -390,26 +357,17 @@ where } if span.underline || span.strikethrough || is_hovered_link { - let size = span - .size - .or(self.size) - .unwrap_or(renderer.default_size()); + let size = span.size.or(self.size).unwrap_or(renderer.default_size()); let line_height = span .line_height .unwrap_or(self.line_height) .to_absolute(size); - let color = span - .color - .or(style.color) - .unwrap_or(defaults.text_color); + let color = span.color.or(style.color).unwrap_or(defaults.text_color); - let baseline = translation - + Vector::new( - 0.0, - size.0 + (line_height.0 - size.0) / 2.0, - ); + let baseline = + translation + Vector::new(0.0, size.0 + (line_height.0 - size.0) / 2.0); if span.underline || is_hovered_link { for bounds in ®ions { @@ -497,13 +455,10 @@ where state.dragging = true; state.press_position = Some(position); state.span_pressed = self.hovered_link; - state.selection = state - .paragraph - .hit_test(position) - .map(|hit| { - let offset = hit.cursor().min(flat_text.len()); - (offset, offset) - }); + state.selection = state.paragraph.hit_test(position).map(|hit| { + let offset = hit.cursor().min(flat_text.len()); + (offset, offset) + }); shell.capture_event(); shell.request_redraw(); } else if state.active || state.selection.is_some() { @@ -521,8 +476,7 @@ where && let Some(hit) = state.paragraph.hit_test(position) && let Some((anchor, _)) = state.selection { - state.selection = - Some((anchor, hit.cursor().min(flat_text.len()))); + state.selection = Some((anchor, hit.cursor().min(flat_text.len()))); shell.request_redraw(); } } @@ -540,16 +494,14 @@ where && let Some(hit) = state.paragraph.hit_test(position) && let Some((anchor, _)) = state.selection { - state.selection = - Some((anchor, hit.cursor().min(flat_text.len()))); + state.selection = Some((anchor, hit.cursor().min(flat_text.len()))); } if !dragged { if let (Some(on_link_clicked), Some(span)) = (&self.on_link_click, state.span_pressed) && Some(span) == self.hovered_link - && let Some(link) = - spans.get(span).and_then(|span| span.link.clone()) + && let Some(link) = spans.get(span).and_then(|span| span.link.clone()) { shell.publish(on_link_clicked(link)); } @@ -570,25 +522,22 @@ where physical_key, modifiers, .. - }) if state.active && modifiers.command() => { - match key.to_latin(*physical_key) { - Some('c') | Some('C') => { - if let Some((anchor, cursor)) = state.selection - && let Some(selected) = - selected_substring(&flat_text, anchor, cursor) - { - clipboard.write(clipboard::Kind::Standard, selected); - shell.capture_event(); - } - } - Some('a') | Some('A') => { - state.selection = Some(select_all(&flat_text)); + }) if state.active && modifiers.command() => match key.to_latin(*physical_key) { + Some('c') | Some('C') => { + if let Some((anchor, cursor)) = state.selection + && let Some(selected) = selected_substring(&flat_text, anchor, cursor) + { + clipboard.write(clipboard::Kind::Standard, selected); shell.capture_event(); - shell.request_redraw(); } - _ => {} } - } + Some('a') | Some('A') => { + state.selection = Some(select_all(&flat_text)); + shell.capture_event(); + shell.request_redraw(); + } + _ => {} + }, _ => {} } } @@ -657,14 +606,8 @@ where }; if state.spans != config.spans { - state.paragraph = - Renderer::Paragraph::with_spans(text_with_spans()); - state.spans = config - .spans - .iter() - .cloned() - .map(Span::to_static) - .collect(); + state.paragraph = Renderer::Paragraph::with_spans(text_with_spans()); + state.spans = config.spans.iter().cloned().map(Span::to_static).collect(); } else { match state.paragraph.compare(advanced_text::Text { content: (), @@ -682,8 +625,7 @@ where state.paragraph.resize(bounds); } advanced_text::Difference::Shape => { - state.paragraph = - Renderer::Paragraph::with_spans(text_with_spans()); + state.paragraph = Renderer::Paragraph::with_spans(text_with_spans()); } } } @@ -761,13 +703,7 @@ fn selection_rect_for_line( }) } -fn x_for_offset( - paragraph: &P, - y: f32, - offset: usize, - left: f32, - right: f32, -) -> f32 { +fn x_for_offset(paragraph: &P, y: f32, offset: usize, left: f32, right: f32) -> f32 { let mut low = left; let mut high = right.max(left); @@ -787,10 +723,7 @@ fn x_for_offset( high } -fn visual_lines( - paragraph: &P, - span_count: usize, -) -> Vec { +fn visual_lines(paragraph: &P, span_count: usize) -> Vec { let mut lines: Vec = Vec::new(); for span in 0..span_count { @@ -820,10 +753,7 @@ fn union(a: Rectangle, b: Rectangle) -> Rectangle { let right = (a.x + a.width).max(b.x + b.width); let bottom = (a.y + a.height).max(b.y + b.height); - Rectangle::new( - Point::new(left, top), - Size::new(right - left, bottom - top), - ) + Rectangle::new(Point::new(left, top), Size::new(right - left, bottom - top)) } fn clamped_position(cursor: mouse::Cursor, bounds: Rectangle) -> Option { diff --git a/tests/file_transfer_loopback.rs b/tests/file_transfer_loopback.rs index 7d3dedd..21f3863 100644 --- a/tests/file_transfer_loopback.rs +++ b/tests/file_transfer_loopback.rs @@ -21,7 +21,7 @@ use iroh::endpoint::presets; use iroh::protocol::Router; use iroh::{Endpoint, RelayMode}; -use peerspeak::files::{ChatAttachment, AttachmentKind}; +use peerspeak::files::{AttachmentKind, ChatAttachment}; use peerspeak::network::NetworkTransport; use peerspeak::network::iroh_impl::{FileRouter, IrohTransport}; use peerspeak::protocol::FILES_ALPN; @@ -52,7 +52,12 @@ async fn spawn_node() -> Node { .accept(FILES_ALPN, file_router) .spawn(); - Node { endpoint, transport, _router: router, lookup } + Node { + endpoint, + transport, + _router: router, + lookup, + } } /// A pseudo-random-ish multi-megabyte payload spanning many QUIC packets, so a @@ -86,7 +91,9 @@ async fn loopback_attachment_round_trips_intact() { let blob = big_blob(); let id = [42u8; 32]; - server.transport.serve_attachment(id, Arc::new(blob.clone())); + server + .transport + .serve_attachment(id, Arc::new(blob.clone())); let att = ChatAttachment { name: "exterior-landscape.jpg".to_string(), diff --git a/tests/reconnect_eviction.rs b/tests/reconnect_eviction.rs index db3d952..9ae61cc 100644 --- a/tests/reconnect_eviction.rs +++ b/tests/reconnect_eviction.rs @@ -62,7 +62,7 @@ async fn evicted_within( Ok(Some(UiEvent::PeerConnectionFailed { id })) if id == peer => return true, Ok(Some(_)) => continue, // ignore PeerConnecting / PeerConnected / PeerLeft Ok(None) => return false, // channel closed - Err(_) => return false, // timed out — no eviction + Err(_) => return false, // timed out — no eviction } } } @@ -80,7 +80,7 @@ async fn left_within( Ok(Some(UiEvent::PeerLeft { id })) if id == peer => return true, Ok(Some(_)) => continue, // ignore PeerConnecting / PeerConnected Ok(None) => return false, // channel closed - Err(_) => return false, // timed out — no leave + Err(_) => return false, // timed out — no leave } } } diff --git a/tests/transport_loopback.rs b/tests/transport_loopback.rs index 3efc08a..d2cea47 100644 --- a/tests/transport_loopback.rs +++ b/tests/transport_loopback.rs @@ -23,8 +23,8 @@ use iroh::protocol::{AcceptError, ProtocolHandler}; use peerspeak::codec::AudioEncoder; use peerspeak::codec::opus_impl::OpusEncoder; use peerspeak::core::jitter::{FRAME_SAMPLES, JitterBuffer}; -use peerspeak::network::{ConnEvent, NetworkTransport}; use peerspeak::network::iroh_impl::{AudioRouter, IrohTransport}; +use peerspeak::network::{ConnEvent, NetworkTransport}; use peerspeak::protocol::AUDIO_ALPN; struct Node { @@ -91,7 +91,12 @@ async fn spawn_capture_peer(secret: iroh::SecretKey) -> CapturePeer { .accept(AUDIO_ALPN, CaptureProtocol { conns_tx }) .spawn(); - CapturePeer { endpoint, _router: router, lookup, conns_rx } + CapturePeer { + endpoint, + _router: router, + lookup, + conns_rx, + } } /// Spawn a node with a specific secret key. Reusing a key gives the respawned @@ -208,7 +213,11 @@ async fn loopback_sequenced_audio_reaches_peer_and_decodes() { received += 1; if let Some(frame) = jitter.pop_frame() { - assert_eq!(frame.len(), FRAME_SAMPLES, "decoded frame is one 20ms frame"); + assert_eq!( + frame.len(), + FRAME_SAMPLES, + "decoded frame is one 20ms frame" + ); decoded_frames += 1; } if received >= N { @@ -290,7 +299,11 @@ async fn dialer_reconnects_after_link_drops() { .expect("timed out awaiting initial connection") .expect("connection channel closed"); assert!( - await_reconnect(&mut conn_events, tokio::time::Instant::now() + Duration::from_secs(10)).await, + await_reconnect( + &mut conn_events, + tokio::time::Instant::now() + Duration::from_secs(10) + ) + .await, "initial link should report Connecting then Connected" ); @@ -306,7 +319,11 @@ async fn dialer_reconnects_after_link_drops() { .expect("timed out awaiting reconnect") .expect("connection channel closed"); assert!( - await_reconnect(&mut conn_events, tokio::time::Instant::now() + Duration::from_secs(15)).await, + await_reconnect( + &mut conn_events, + tokio::time::Instant::now() + Duration::from_secs(15) + ) + .await, "dropped link should report Connecting (down) then Connected (recovered)" ); @@ -319,7 +336,12 @@ async fn dialer_reconnects_after_link_drops() { tokio::time::sleep(Duration::from_millis(5)).await; } - let received = count_audio(&conn2, 25, tokio::time::Instant::now() + Duration::from_secs(3)).await; + let received = count_audio( + &conn2, + 25, + tokio::time::Instant::now() + Duration::from_secs(3), + ) + .await; assert!( received >= 20, "audio should resume after reconnect; got {received} frames" @@ -359,7 +381,11 @@ async fn dialer_reports_left_on_graceful_close() { .expect("timed out awaiting initial connection") .expect("connection channel closed"); assert!( - await_reconnect(&mut conn_events, tokio::time::Instant::now() + Duration::from_secs(10)).await, + await_reconnect( + &mut conn_events, + tokio::time::Instant::now() + Duration::from_secs(10) + ) + .await, "initial link should report Connecting then Connected" ); @@ -384,7 +410,10 @@ async fn dialer_reports_left_on_graceful_close() { // And no re-dial reaches the peer within a short window. let redial = tokio::time::timeout(Duration::from_secs(2), peer.conns_rx.recv()).await; - assert!(redial.is_err(), "supervisor must not re-dial after a graceful leave"); + assert!( + redial.is_err(), + "supervisor must not re-dial after a graceful leave" + ); } #[tokio::test] @@ -429,7 +458,11 @@ async fn dialer_connects_and_reconnects_without_an_address_lookup() { .expect("timed out awaiting initial connection (retained address path)") .expect("connection channel closed"); assert!( - await_reconnect(&mut conn_events, tokio::time::Instant::now() + Duration::from_secs(10)).await, + await_reconnect( + &mut conn_events, + tokio::time::Instant::now() + Duration::from_secs(10) + ) + .await, "initial link should report Connecting then Connected" ); @@ -443,7 +476,11 @@ async fn dialer_connects_and_reconnects_without_an_address_lookup() { .expect("timed out awaiting reconnect (retained address path)") .expect("connection channel closed"); assert!( - await_reconnect(&mut conn_events, tokio::time::Instant::now() + Duration::from_secs(15)).await, + await_reconnect( + &mut conn_events, + tokio::time::Instant::now() + Duration::from_secs(15) + ) + .await, "reconnect should report Connecting then Connected with no lookup at all" ); @@ -455,7 +492,12 @@ async fn dialer_connects_and_reconnects_without_an_address_lookup() { tokio::time::sleep(Duration::from_millis(5)).await; } - let received = count_audio(&conn2, 25, tokio::time::Instant::now() + Duration::from_secs(3)).await; + let received = count_audio( + &conn2, + 25, + tokio::time::Instant::now() + Duration::from_secs(3), + ) + .await; assert!( received >= 20, "audio should resume after reconnecting via the retained address; got {received} frames"