From 2c2b861516916eacd102b926e97b088fa36c9fae Mon Sep 17 00:00:00 2001 From: Mollusk Date: Fri, 21 Aug 2026 22:12:48 -0400 Subject: [PATCH] feat(screenshare): integrate desktop audio exclusion --- src/app/mod.rs | 268 +++++++++++++++++--- src/audio/echo_cancel.rs | 17 +- src/core/messages.rs | 21 +- src/core/mod.rs | 239 ++++++++++++++---- src/core/teardown.rs | 11 +- src/screenshare/mod.rs | 418 ++++++++++++++++++++++++++++++-- tests/screenshare_host_fault.rs | 11 +- 7 files changed, 848 insertions(+), 137 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index b610c71..60becc4 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -16,6 +16,7 @@ use crate::hotkeys::{HotkeyAction, HotkeyContext, KeyBinding, format_binding}; use crate::network::PeerState; use crate::notify::{self, Sound}; use crate::presence::PresenceMode; +use crate::screenshare::{AudioExclusionStatus, ShareAudioSelection}; use crate::theme::{AppTheme, Palette}; use crate::widget::context_input::{context_input, locked_value}; use crate::widget::selectable_text::selectable_rich_text; @@ -901,9 +902,8 @@ pub enum AppMessage { ToggleScreenShare, /// Close the screen-share audio picker without sharing. CloseSharePicker, - /// Select which app's audio to share in the picker: `Some(name)` for one app, - /// `None` for the whole desktop ("All system audio"). - SelectShareAudioApp(Option), + /// Select legacy desktop, desktop-excluding, or strict per-app audio. + SelectShareAudio(ShareAudioSelection), /// Session-only quality preset for the next share start. SelectShareQualityOverride(ShareQuality), /// Confirm the picker: start the share with the currently selected audio app. @@ -1150,9 +1150,10 @@ pub struct AppState { /// Apps currently producing audio, shown in the share picker. Populated from /// `UiEvent::AudioAppsListed` after the picker requests an enumeration. share_audio_apps: Vec, - /// The picker's current selection: `Some(name)` = capture that app's audio, - /// `None` = "All system audio" (whole desktop; may echo the call). - share_audio_selection: Option, + /// The picker's current typed selection. Desktop-shared remains the legacy + /// fallback; desktop-excluding is shown only when the resolved PixelPass + /// advertises it. + share_audio_selection: ShareAudioSelection, /// Session-only quality override for the next screen-share start. share_quality_selection: ShareQuality, /// A share start is in flight: `ConfirmShareScreen` was sent but the core @@ -1171,11 +1172,18 @@ pub struct AppState { /// just-killed host can't flip the warning on a new whole-desktop share or /// after stop (audit P3, unscoped events). share_audio_app_active: bool, + /// Whether the current share is the desktop-excluding mode. This gates its + /// status events so late notices cannot affect another share mode. + share_desktop_excluding_active: bool, + /// Persistent explanation from PixelPass while desktop exclusion is active. + share_audio_exclusion_warning: Option, /// Whether the resolved pixelpass supports `--strict-audio` (per-app audio). /// `false` ⇒ the picker offers whole-desktop only, because a per-app share /// would pass a flag an older pixelpass rejects (audit P2). Optimistic `true` /// until the core's `AudioAppsListed` reports otherwise. share_app_audio_supported: bool, + /// Whether the resolved PixelPass advertises desktop audio exclusion. + share_desktop_audio_exclusion_supported: bool, /// Room-level warning for a validly signed peer whose gossip timestamp falls /// outside the replay freshness window. The peer is not yet in the roster, so /// this is not attached to a participant card. @@ -1272,12 +1280,15 @@ impl AppState { self.self_sharing = false; self.share_picker_open = false; self.share_audio_apps.clear(); - self.share_audio_selection = None; + self.share_audio_selection = ShareAudioSelection::DesktopShared; self.share_quality_selection = self.config.screen_share.quality; self.share_starting = false; self.share_audio_dropped = false; self.share_audio_app_active = false; + self.share_desktop_excluding_active = false; + self.share_audio_exclusion_warning = None; self.share_app_audio_supported = true; + self.share_desktop_audio_exclusion_supported = false; self.clock_skew_warning = None; } @@ -1580,12 +1591,15 @@ impl Default for AppState { pixelpass_help_open: false, share_picker_open: false, share_audio_apps: Vec::new(), - share_audio_selection: None, + share_audio_selection: ShareAudioSelection::DesktopShared, share_quality_selection, share_starting: false, share_audio_dropped: false, share_audio_app_active: false, + share_desktop_excluding_active: false, + share_audio_exclusion_warning: None, share_app_audio_supported: true, + share_desktop_audio_exclusion_supported: false, clock_skew_warning: None, drawer_chat_open: false, playlist_drawer_open: false, @@ -2172,12 +2186,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { // Open the audio picker instead of sharing immediately, so the // user chooses which app's audio to capture rather than the whole // desktop (which echoes the call back to viewers, A23). Default - // selection is "All system audio" (None). Kick off a fresh + // selection is legacy "All system audio". Kick off a fresh // enumeration so the list reflects what's playing right now. // Suppressed while a start is already in flight (`share_starting`) // so the picker can't be reopened during the startup window. state.share_picker_open = true; - state.share_audio_selection = None; + state.share_audio_selection = ShareAudioSelection::DesktopShared; // NB: do NOT reset `share_quality_selection` here. It is the // per-call override set by the inline quality dropdown next to // the Share button, and the picker has no quality control of its @@ -2190,8 +2204,8 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { AppMessage::CloseSharePicker => { state.share_picker_open = false; } - AppMessage::SelectShareAudioApp(app) => { - state.share_audio_selection = app; + AppMessage::SelectShareAudio(audio) => { + state.share_audio_selection = audio; } AppMessage::SelectShareQualityOverride(quality) => { state.share_quality_selection = quality; @@ -2203,11 +2217,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { if state.share_picker_open && !state.share_starting { state.share_picker_open = false; state.share_starting = true; - let audio_app = state.share_audio_selection.clone(); + state.share_audio_exclusion_warning = None; + let audio = state.share_audio_selection.clone(); let settings = state.config.screen_share.clone(); let quality = state.share_quality_selection; let _ = state.controller.send(CoreCommand::StartScreenShare { - audio_app, + audio, settings, quality, }); @@ -2513,25 +2528,42 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { UiEvent::AudioAppsListed { apps, app_audio_supported, + desktop_audio_exclusion_supported, } => { // Only meaningful while the picker is open; if the user // already cancelled, drop it. if state.share_picker_open { state.share_app_audio_supported = app_audio_supported; + state.share_desktop_audio_exclusion_supported = + desktop_audio_exclusion_supported; if app_audio_supported { // Keep the current selection if it still exists in the // refreshed list, else fall back to "All system audio". - if let Some(sel) = &state.share_audio_selection + if let ShareAudioSelection::Application(sel) = + &state.share_audio_selection && !apps.iter().any(|a| a == sel) { - state.share_audio_selection = None; + state.share_audio_selection = ShareAudioSelection::DesktopShared; } state.share_audio_apps = apps; } else { // Older pixelpass: per-app capture would hard-fail // (--strict-audio unknown). Force whole-desktop only. state.share_audio_apps.clear(); - state.share_audio_selection = None; + if matches!( + state.share_audio_selection, + ShareAudioSelection::Application(_) + ) { + state.share_audio_selection = ShareAudioSelection::DesktopShared; + } + } + if !desktop_audio_exclusion_supported + && matches!( + state.share_audio_selection, + ShareAudioSelection::DesktopExcluding + ) + { + state.share_audio_selection = ShareAudioSelection::DesktopShared; } } } @@ -2541,7 +2573,14 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.share_audio_dropped = false; // Remember whether this share captures a specific app, so we // only apply `app_audio` warnings to app shares (P3). - state.share_audio_app_active = state.share_audio_selection.is_some(); + state.share_audio_app_active = matches!( + state.share_audio_selection, + ShareAudioSelection::Application(_) + ); + state.share_desktop_excluding_active = matches!( + state.share_audio_selection, + ShareAudioSelection::DesktopExcluding + ); // Defensive: ensure no picker lingers across a successful start. state.share_picker_open = false; state.status_message = "Sharing your screen".to_string(); @@ -2551,6 +2590,8 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.share_starting = false; state.share_audio_dropped = false; state.share_audio_app_active = false; + state.share_desktop_excluding_active = false; + state.share_audio_exclusion_warning = None; state.status_message = "Screen share stopped".to_string(); } UiEvent::ShareAudioActive(active) => { @@ -2562,6 +2603,22 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.share_audio_dropped = !active; } } + UiEvent::ShareAudioExclusionStatus(status) => { + // A status can race the start acknowledgement because the + // host drain and core loop use cloned UI senders. Accept it + // during an in-flight excluding start as well as the active + // share, but ignore late notices for other modes. + let excluding_starting = state.share_starting + && matches!( + state.share_audio_selection, + ShareAudioSelection::DesktopExcluding + ); + if state.share_desktop_excluding_active || excluding_starting { + let message = audio_exclusion_status_message(&status); + state.status_message = message.clone(); + state.share_audio_exclusion_warning = Some(message); + } + } UiEvent::ClockSkewWarning { skew_secs, peer_ahead, @@ -6656,20 +6713,20 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { ] .spacing(6) .align_y(iced::alignment::Vertical::Center); + let mut details = column![badge].spacing(3); if state.share_audio_dropped { - column![ - badge, + details = details.push( text( "⚠ Shared app isn't sending audio — viewers hear silence until it plays" ) .size(11) .color(color_yellow), - ] - .spacing(3) - .into() - } else { - badge.into() + ); } + if let Some(warning) = &state.share_audio_exclusion_warning { + details = details.push(text(warning).size(11).color(color_yellow)); + } + details.into() } else { iced::widget::Space::new().width(0.0).height(0.0).into() }; @@ -8757,6 +8814,22 @@ fn with_pixelpass_help<'a>( .into() } +fn audio_exclusion_status_message(status: &AudioExclusionStatus) -> String { + match status { + AudioExclusionStatus::StreamUnsupported { reason, .. } => format!( + "Some system audio could not be shared safely ({reason}); it was left out." + ), + AudioExclusionStatus::AecFailed { .. } => + "Audio exclusion could not verify PeerSpeak's echo canceller; viewers hear silence." + .to_string(), + AudioExclusionStatus::AecRevoked { .. } => + "Audio exclusion stopped because PeerSpeak's echo canceller disappeared; viewers hear silence." + .to_string(), + AudioExclusionStatus::ForeignAecWarning { .. } => + "Another echo-cancel stream was kept out of the screen share.".to_string(), + } +} + /// Overlay the screen-share **audio picker** when open (A23). Lets the user /// capture a single app's audio instead of the whole desktop sink — the default /// whole-desktop capture contains our own call playout, so a viewer would @@ -8828,22 +8901,39 @@ fn with_share_picker<'a>( }) }; - // "All system audio" first (the whole-desktop default — carries the echo - // warning), then each currently-playing app. + // Keep the legacy whole-desktop choice visible with its echo warning. The + // new exclusion row exists only when this exact PixelPass advertised it. let mut options = column![opt_row( - state.share_audio_selection.is_none(), + matches!( + state.share_audio_selection, + ShareAudioSelection::DesktopShared + ), "All system audio".to_string(), Some("⚠ may echo the call back to viewers"), - AppMessage::SelectShareAudioApp(None), + AppMessage::SelectShareAudio(ShareAudioSelection::DesktopShared), )] .spacing(4); + if state.share_desktop_audio_exclusion_supported { + options = options.push(opt_row( + matches!( + state.share_audio_selection, + ShareAudioSelection::DesktopExcluding + ), + "System audio except PeerSpeak".to_string(), + Some("Excludes call and watched-share playback"), + AppMessage::SelectShareAudio(ShareAudioSelection::DesktopExcluding), + )); + } for app in &state.share_audio_apps { - let selected = state.share_audio_selection.as_deref() == Some(app.as_str()); + let selected = matches!( + &state.share_audio_selection, + ShareAudioSelection::Application(selected) if selected == app + ); options = options.push(opt_row( selected, app.clone(), None, - AppMessage::SelectShareAudioApp(Some(app.clone())), + AppMessage::SelectShareAudio(ShareAudioSelection::Application(app.clone())), )); } @@ -9668,9 +9758,9 @@ mod tests { use super::PendingSend; use super::sendqueue::{self, LocalSend, SendStatus}; use super::{ - AppConfig, AppMessage, AppState, AttachmentCache, AttachmentState, + AppConfig, AppMessage, AppState, AttachmentCache, AttachmentState, AudioExclusionStatus, CLOCK_SKEW_WARNING_VISIBLE_SECS, ChatEntry, ClockSkewBanner, GateMeter, METER_MAX, Screen, - ScreenBounds, UiEvent, attachment_default_name, clamp_window_position, + ScreenBounds, ShareAudioSelection, UiEvent, attachment_default_name, clamp_window_position, clear_expired_clock_skew_warning, format_clock_skew_duration, format_duration, format_relative_ago, friend_presence_notification, initial_window_position, now_playing_label, reconnect_attempt_chime, reconnected_chime, selected_wav_path, @@ -9970,11 +10060,14 @@ mod tests { state.self_sharing = true; state.share_picker_open = true; state.share_audio_apps = vec!["Firefox".to_string()]; - state.share_audio_selection = Some("Firefox".to_string()); + state.share_audio_selection = ShareAudioSelection::Application("Firefox".to_string()); state.share_starting = true; state.share_audio_dropped = true; state.share_audio_app_active = true; + state.share_desktop_excluding_active = true; + state.share_audio_exclusion_warning = Some("warning".to_string()); state.share_app_audio_supported = false; + state.share_desktop_audio_exclusion_supported = true; state.clock_skew_warning = Some(ClockSkewBanner { skew_secs: 180, peer_ahead: true, @@ -10011,7 +10104,10 @@ mod tests { assert!(!state.self_sharing); assert!(!state.share_picker_open); assert!(state.share_audio_apps.is_empty()); - assert!(state.share_audio_selection.is_none()); + assert_eq!( + state.share_audio_selection, + ShareAudioSelection::DesktopShared + ); assert_eq!( state.share_quality_selection, state.config.screen_share.quality @@ -10019,10 +10115,13 @@ mod tests { assert!(!state.share_starting); assert!(!state.share_audio_dropped); assert!(!state.share_audio_app_active); + assert!(!state.share_desktop_excluding_active); + assert!(state.share_audio_exclusion_warning.is_none()); assert!( state.share_app_audio_supported, "reset is optimistic by default" ); + assert!(!state.share_desktop_audio_exclusion_supported); assert!(state.clock_skew_warning.is_none()); assert!(state.music_broadcast_id.is_none()); assert!(state.music_broadcast_next.is_none()); @@ -10135,7 +10234,7 @@ mod tests { // Picker open, user confirms a selection. let mut state = AppState { share_picker_open: true, - share_audio_selection: Some("mpv".to_string()), + share_audio_selection: ShareAudioSelection::Application("mpv".to_string()), ..Default::default() }; let _ = update(&mut state, AppMessage::ConfirmShareScreen); @@ -10233,7 +10332,7 @@ mod tests { // flag; start and stop both reset it so it can't linger across sessions. // A specific app was chosen in the picker, so the share is app-specific. let mut state = AppState { - share_audio_selection: Some("mpv".to_string()), + share_audio_selection: ShareAudioSelection::Application("mpv".to_string()), ..Default::default() }; @@ -10300,7 +10399,7 @@ mod tests { // (b) After stop: a straggling event can't resurrect the warning. let mut state = AppState { - share_audio_selection: Some("mpv".to_string()), + share_audio_selection: ShareAudioSelection::Application("mpv".to_string()), ..Default::default() }; let _ = update( @@ -10318,6 +10417,53 @@ mod tests { assert!(!state.share_audio_dropped, "post-stop event is ignored"); } + #[test] + fn desktop_exclusion_status_reaches_visible_state_and_clears_on_stop() { + let mut state = AppState { + share_starting: true, + share_audio_selection: ShareAudioSelection::DesktopExcluding, + ..Default::default() + }; + + // The status may beat ScreenShareStarted because the host drain uses a + // cloned UI sender. It must still be retained and shown. + let _ = update( + &mut state, + AppMessage::UiEventReceived(UiEvent::ShareAudioExclusionStatus( + AudioExclusionStatus::AecFailed { module_index: 77 }, + )), + ); + assert!( + state + .share_audio_exclusion_warning + .as_deref() + .is_some_and(|message| message.contains("viewers hear silence")) + ); + + let _ = update( + &mut state, + AppMessage::UiEventReceived(UiEvent::ScreenShareStarted), + ); + assert!(state.share_desktop_excluding_active); + assert!(state.share_audio_exclusion_warning.is_some()); + + let _ = update( + &mut state, + AppMessage::UiEventReceived(UiEvent::ScreenShareStopped), + ); + assert!(!state.share_desktop_excluding_active); + assert!(state.share_audio_exclusion_warning.is_none()); + + // A stale notice after stop cannot resurrect the warning. + let _ = update( + &mut state, + AppMessage::UiEventReceived(UiEvent::ShareAudioExclusionStatus( + AudioExclusionStatus::AecRevoked { module_index: 77 }, + )), + ); + assert!(state.share_audio_exclusion_warning.is_none()); + } + #[test] fn old_pixelpass_picker_offers_whole_desktop_only() { // P2 (version skew): when the resolved pixelpass lacks --strict-audio, the @@ -10325,7 +10471,7 @@ mod tests { // so a per-app share (which would pass the unknown flag) can't be started. let mut state = AppState { share_picker_open: true, - share_audio_selection: Some("Firefox".to_string()), + share_audio_selection: ShareAudioSelection::Application("Firefox".to_string()), share_audio_apps: vec!["Firefox".to_string(), "mpv".to_string()], ..Default::default() }; @@ -10334,12 +10480,14 @@ mod tests { AppMessage::UiEventReceived(UiEvent::AudioAppsListed { apps: vec!["Firefox".to_string(), "mpv".to_string()], app_audio_supported: false, + desktop_audio_exclusion_supported: false, }), ); assert!(!state.share_app_audio_supported); + assert!(!state.share_desktop_audio_exclusion_supported); assert!(state.share_audio_apps.is_empty(), "no per-app rows offered"); assert!( - state.share_audio_selection.is_none(), + state.share_audio_selection == ShareAudioSelection::DesktopShared, "forced to whole-desktop" ); @@ -10349,12 +10497,52 @@ mod tests { AppMessage::UiEventReceived(UiEvent::AudioAppsListed { apps: vec!["Firefox".to_string(), "mpv".to_string()], app_audio_supported: true, + desktop_audio_exclusion_supported: true, }), ); assert!(state.share_app_audio_supported); + assert!(state.share_desktop_audio_exclusion_supported); assert_eq!(state.share_audio_apps.len(), 2); } + #[test] + fn picker_treats_per_app_and_desktop_exclusion_as_independent_capabilities() { + let mut state = AppState { + share_picker_open: true, + share_audio_selection: ShareAudioSelection::DesktopExcluding, + ..Default::default() + }; + let _ = update( + &mut state, + AppMessage::UiEventReceived(UiEvent::AudioAppsListed { + apps: vec!["Firefox".to_string()], + app_audio_supported: false, + desktop_audio_exclusion_supported: true, + }), + ); + assert!(!state.share_app_audio_supported); + assert!(state.share_desktop_audio_exclusion_supported); + assert_eq!( + state.share_audio_selection, + ShareAudioSelection::DesktopExcluding, + "lack of per-app support must not hide the independent exclusion mode" + ); + + let _ = update( + &mut state, + AppMessage::UiEventReceived(UiEvent::AudioAppsListed { + apps: Vec::new(), + app_audio_supported: false, + desktop_audio_exclusion_supported: false, + }), + ); + assert_eq!( + state.share_audio_selection, + ShareAudioSelection::DesktopShared, + "old PixelPass must remove the unavailable exclusion selection" + ); + } + #[test] fn peer_gate_persists_when_on_and_clears_when_off() { let mut config = AppConfig::default(); diff --git a/src/audio/echo_cancel.rs b/src/audio/echo_cancel.rs index ad51a7e..d8ba3f0 100644 --- a/src/audio/echo_cancel.rs +++ b/src/audio/echo_cancel.rs @@ -33,12 +33,17 @@ const NODE_READY_TIMEOUT: Duration = Duration::from_secs(3); /// Owns a loaded `module-echo-cancel` instance; unloads it on drop so the virtual /// nodes never leak past the call that created them. pub struct EchoCancelGuard { - module_index: String, + module_index: u64, source_name: String, sink_name: String, } impl EchoCancelGuard { + /// The pactl module identity PixelPass validates in desktop-excluding mode. + pub fn module_index(&self) -> u64 { + self.module_index + } + pub fn source_name(&self) -> &str { &self.source_name } @@ -52,7 +57,7 @@ impl Drop for EchoCancelGuard { fn drop(&mut self) { let _ = Command::new("pactl") .arg("unload-module") - .arg(&self.module_index) + .arg(self.module_index.to_string()) .output(); crate::log_msg(&format!( "Echo cancel: unloaded module {}", @@ -103,10 +108,10 @@ pub fn enable( )); } - let module_index = String::from_utf8_lossy(&out.stdout).trim().to_string(); - if module_index.parse::().is_err() { - return Err(format!("unexpected pactl output: {module_index:?}")); - } + let raw_module_index = String::from_utf8_lossy(&out.stdout).trim().to_string(); + let module_index = raw_module_index + .parse::() + .map_err(|_| format!("unexpected pactl output: {raw_module_index:?}"))?; let guard = EchoCancelGuard { module_index, source_name, diff --git a/src/core/messages.rs b/src/core/messages.rs index fe42575..607edd3 100644 --- a/src/core/messages.rs +++ b/src/core/messages.rs @@ -126,11 +126,10 @@ pub enum CoreCommand { ListAudioApps, /// Start sharing our screen: spawn a pixelpass host and announce its ticket /// on our presence so the room can watch. No-op when not in a call. - /// `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). + /// `audio` is typed so legacy whole-desktop, desktop-excluding, and strict + /// per-app capture remain distinct across the UI/core boundary. StartScreenShare { - audio_app: Option, + audio: crate::screenshare::ShareAudioSelection, settings: ScreenShareSettings, quality: ShareQuality, }, @@ -276,7 +275,7 @@ pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass { | CoreCommand::SetPixelpassPath(_) | CoreCommand::ListAudioApps | CoreCommand::StartScreenShare { - audio_app: _, + audio: _, settings: _, quality: _, } @@ -369,7 +368,7 @@ pub fn coalesce_key(cmd: &CoreCommand) -> Option { | CoreCommand::SetPixelpassPath(_) | CoreCommand::ListAudioApps | CoreCommand::StartScreenShare { - audio_app: _, + audio: _, settings: _, quality: _, } @@ -511,13 +510,12 @@ pub enum UiEvent { }, /// 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). + /// playing or enumeration isn't available. The two support bits are + /// independent and belong to the exact resolved PixelPass binary. AudioAppsListed { apps: Vec, app_audio_supported: bool, + desktop_audio_exclusion_supported: bool, }, /// Our own screen share started; the UI flips the Share button to "Stop". ScreenShareStarted, @@ -528,6 +526,9 @@ pub enum UiEvent { /// run viewers currently hear silence. The UI shows a transient warning while /// `false`. Only meaningful while sharing a specific app (not whole-desktop). ShareAudioActive(bool), + /// One desktop-excluding status parsed from PixelPass and forwarded without + /// translating it into a separately maintained PeerSpeak enum. + ShareAudioExclusionStatus(crate::screenshare::AudioExclusionStatus), /// 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. diff --git a/src/core/mod.rs b/src/core/mod.rs index 280209a..8fa1e02 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1284,6 +1284,42 @@ async fn probe_friends_once( } } +fn ui_event_from_pixelpass_event(event: crate::screenshare::PixelpassEvent) -> Option { + match event { + crate::screenshare::PixelpassEvent::AppAudioRouted => Some(UiEvent::ShareAudioActive(true)), + crate::screenshare::PixelpassEvent::AppAudioLost => Some(UiEvent::ShareAudioActive(false)), + crate::screenshare::PixelpassEvent::AudioExclusion(status) => { + Some(UiEvent::ShareAudioExclusionStatus(status)) + } + _ => None, + } +} + +/// Carry the actual parsed PixelPass event value across the notice channel to +/// the UI. `Eof` remains a generation-scoped core fault rather than a UI event. +async fn forward_host_notices( + mut notices: mpsc::UnboundedReceiver, + ui_tx: mpsc::Sender, + fault_tx: mpsc::UnboundedSender, + generation: u64, +) { + while let Some(notice) = notices.recv().await { + match notice { + crate::screenshare::HostNotice::Event(event) => { + if let Some(event) = ui_event_from_pixelpass_event(event) + && ui_tx.send(event).await.is_err() + { + break; + } + } + crate::screenshare::HostNotice::Eof => { + let _ = fault_tx.send(generation); + break; + } + } + } +} + async fn run_core_loop( mut reliable_rx: mpsc::UnboundedReceiver, coalesce: CoalesceStore, @@ -1404,6 +1440,10 @@ async fn run_core_loop( // generation must appear and vanish together, or a stale fault could tear // down a share it doesn't belong to. let mut pixelpass_override: Option = None; + // Capability results are meaningful only for the exact resolved binary + // path that produced them. A changed override/PATH resolution must be + // re-probed before any capability-gated argv is constructed. + let mut pixelpass_capabilities: Option = None; struct ActiveShare { generation: u64, ticket: String, @@ -3413,39 +3453,53 @@ async fn run_core_loop( CoreCommand::SetPixelpassPath(path) => { pixelpass_override = path.filter(|p| !p.trim().is_empty()); + pixelpass_capabilities = None; } CoreCommand::ListAudioApps => { - // Probe whether this pixelpass supports `--strict-audio` before - // offering per-app capture: an older binary would reject the flag - // and hard-fail the share (audit P2). When unsupported (or - // pixelpass is missing), skip enumeration and let the picker show - // whole-desktop audio only — never a best-effort `--app` that - // would reopen the A23 echo. - let app_audio_supported = + // Probe the versioned response from the exact binary selected + // for this picker. The help fallback can recover legacy strict + // per-app support, but never desktop exclusion. + let (capabilities, apps) = match crate::screenshare::pixelpass_path(pixelpass_override.as_deref()) { - Some(bin) => crate::screenshare::supports_strict_audio(&bin).await, - None => false, + Some(bin) => { + let capabilities = + crate::screenshare::probe_pixelpass_capabilities(&bin).await; + pixelpass_capabilities = + Some(crate::screenshare::ProbedPixelpassCapabilities { + binary: bin, + capabilities, + }); + let apps = if capabilities.strict_app_audio { + crate::screenshare::list_audio_apps().await + } else { + Vec::new() + }; + (capabilities, apps) + } + None => { + pixelpass_capabilities = None; + ( + crate::screenshare::PixelpassCapabilities::default(), + Vec::new(), + ) + } }; - let apps = if app_audio_supported { - crate::screenshare::list_audio_apps().await - } else { - Vec::new() - }; let _ = ui_tx .send(UiEvent::AudioAppsListed { apps, - app_audio_supported, + app_audio_supported: capabilities.strict_app_audio, + desktop_audio_exclusion_supported: capabilities.desktop_audio_exclusion, }) .await; } CoreCommand::StartScreenShare { - audio_app, + audio, settings, quality, } => { - let Some(session) = &mut active_session else { + let Some(session) = active_session.as_ref() else { let _ = ui_tx .send(UiEvent::Error( "Join a call before sharing your screen".into(), @@ -3467,49 +3521,72 @@ async fn run_core_loop( continue; } }; + // The picker probe is bound to its resolved binary. If the + // override/PATH now resolves elsewhere, immediately re-probe + // before constructing any capability-gated argv and fail closed + // when the selected feature is absent. + if !matches!( + audio, + crate::screenshare::ShareAudioSelection::DesktopShared + ) { + let capabilities = crate::screenshare::capabilities_for_resolved_binary( + &bin, + &mut pixelpass_capabilities, + ) + .await; + let unsupported = match &audio { + crate::screenshare::ShareAudioSelection::Application(_) + if !capabilities.strict_app_audio => + { + Some( + "This PixelPass does not support strict per-app audio. Reopen the picker or update PixelPass.", + ) + } + crate::screenshare::ShareAudioSelection::DesktopExcluding + if !capabilities.desktop_audio_exclusion => + { + Some( + "This PixelPass does not support desktop audio exclusion. Update PixelPass or choose another audio source.", + ) + } + _ => None, + }; + if let Some(message) = unsupported { + let _ = ui_tx.send(UiEvent::Error(message.into())).await; + continue; + } + } + + let session = active_session + .as_mut() + .expect("session presence checked before PixelPass probe"); + let aec_module_index = matches!( + audio, + crate::screenshare::ShareAudioSelection::DesktopExcluding + ) + .then(|| { + session + .teardown + .echo_cancel() + .map(crate::audio::echo_cancel::EchoCancelGuard::module_index) + }) + .flatten(); // Every share gets a notice forwarder — not just app-audio ones. - // pixelpass `app_audio` events (only emitted when an app is - // selected) become UI warnings, and the drain's terminal `Eof` - // becomes a host fault scoped to this spawn's generation, so a - // host that dies is torn down instead of staying advertised in - // presence forever. On a failed spawn the sender is dropped - // before the drain ever runs, so the forwarder just ends and no - // fault is sent (the spawn error carries the news instead). + // App-audio and desktop-exclusion events become UI state, and + // the drain's terminal `Eof` becomes a generation-scoped fault. share_generations += 1; let generation = share_generations; - let (notices_tx, mut notices_rx) = + let (notices_tx, notices_rx) = tokio::sync::mpsc::unbounded_channel::(); let ui_tx_notices = ui_tx.clone(); let fault_tx = host_fault_tx.clone(); tokio::spawn(async move { - while let Some(notice) = notices_rx.recv().await { - match notice { - crate::screenshare::HostNotice::Event(ev) => { - let active = match ev { - crate::screenshare::PixelpassEvent::AppAudioRouted => true, - crate::screenshare::PixelpassEvent::AppAudioLost => false, - _ => continue, - }; - if ui_tx_notices - .send(UiEvent::ShareAudioActive(active)) - .await - .is_err() - { - break; - } - } - // Terminal by contract: nothing follows on the - // channel, so the task ends here. - crate::screenshare::HostNotice::Eof => { - let _ = fault_tx.send(generation); - break; - } - } - } + forward_host_notices(notices_rx, ui_tx_notices, fault_tx, generation).await; }); match crate::screenshare::spawn_host( &bin, - audio_app.as_deref(), + &audio, + aec_module_index, &settings, quality, notices_tx, @@ -3705,10 +3782,11 @@ mod tests { KnownPeers, MAX_OPUS_PAYLOAD, MAX_RETAINED_PEERS, MIC_LEVEL_REPORT_SAMPLES, MicLevelMeter, NetworkMode, PLAYBACK_HANDOFF_QUEUE_FRAMES, 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, rebuild_with_fallback, - replace_viewer_index, send_playback_frame, should_auto_fetch, stereo_to_mono, + forward_host_notices, frame_level, mix_frames, mix_stereo_frames, next_game_change, + rebuild_with_fallback, replace_viewer_index, send_playback_frame, should_auto_fetch, + stereo_to_mono, }; - use crate::core::messages::{CoalesceKey, CoreCommand, coalesce_key}; + use crate::core::messages::{CoalesceKey, CoreCommand, UiEvent, coalesce_key}; use std::collections::{HashMap, HashSet}; use std::sync::mpsc::sync_channel; use std::time::Duration; @@ -3717,6 +3795,61 @@ mod tests { iroh::SecretKey::generate().public() } + #[tokio::test] + async fn all_exclusion_events_causally_cross_the_host_notice_channel_to_ui() { + use crate::screenshare::{AudioExclusionStatus, HostNotice, parse_pixelpass_event}; + + let lines = [ + r#"{"event":"stream_unsupported","version":1,"stream_serial":4294967303,"reason":"port-exclusive"}"#, + r#"{"event":"aec_failed","version":1,"module_index":536870919}"#, + r#"{"event":"aec_revoked","version":1,"module_index":536870919}"#, + r#"{"event":"foreign_aec_warning","version":1,"link_group":"echo-cancel-9999-13"}"#, + ]; + let (notice_tx, notice_rx) = tokio::sync::mpsc::unbounded_channel(); + for line in lines { + notice_tx + .send(HostNotice::Event( + parse_pixelpass_event(line).expect("PixelPass event must parse"), + )) + .unwrap(); + } + drop(notice_tx); + + let (ui_tx, mut ui_rx) = tokio::sync::mpsc::channel(8); + let (fault_tx, mut fault_rx) = tokio::sync::mpsc::unbounded_channel(); + forward_host_notices(notice_rx, ui_tx, fault_tx, 17).await; + + let mut statuses = Vec::new(); + while let Some(event) = ui_rx.recv().await { + match event { + UiEvent::ShareAudioExclusionStatus(status) => statuses.push(status), + other => panic!("unexpected forwarded UI event: {other:?}"), + } + } + assert_eq!( + statuses, + vec![ + AudioExclusionStatus::StreamUnsupported { + stream_serial: 4_294_967_303, + reason: "port-exclusive".to_string(), + }, + AudioExclusionStatus::AecFailed { + module_index: 536_870_919, + }, + AudioExclusionStatus::AecRevoked { + module_index: 536_870_919, + }, + AudioExclusionStatus::ForeignAecWarning { + link_group: "echo-cancel-9999-13".to_string(), + }, + ] + ); + assert!( + fault_rx.try_recv().is_err(), + "ordinary status events must not synthesize a host fault" + ); + } + #[test] fn re_watch_replaces_existing_viewer_for_same_ticket() { // The value type stands in for a viewer Child; only the ticket matters. diff --git a/src/core/teardown.rs b/src/core/teardown.rs index 2a1caa3..a591788 100644 --- a/src/core/teardown.rs +++ b/src/core/teardown.rs @@ -305,10 +305,6 @@ pub(super) struct ScreenshareTeardown { /// /// ⚠️ **LAST FIELD ON PURPOSE** — see the module docs and the struct note. /// - /// Never read, and that is the design: the guard is held only so that its - /// `Drop` runs, and only so that it runs *here*, last. `dead_code` is right - /// that nothing reads it and wrong that it does nothing. - #[allow(dead_code)] echo_cancel: Option, } @@ -325,6 +321,13 @@ impl ScreenshareTeardown { self.host.is_some() } + /// Borrow the session-owned AEC guard without disturbing its load-bearing + /// last-field drop order. Phase 8 uses this only to pass the module identity + /// to PixelPass while the guard remains owned here. + pub(super) fn echo_cancel(&self) -> Option<&G> { + self.echo_cancel.as_ref() + } + pub(super) fn set_host(&mut self, child: C) { self.host = Some(ReapOnDrop::new(child, "screen-share host")); } diff --git a/src/screenshare/mod.rs b/src/screenshare/mod.rs index 982fbd0..4f9620c 100644 --- a/src/screenshare/mod.rs +++ b/src/screenshare/mod.rs @@ -60,6 +60,34 @@ const LOW_LATENCY_CACHE_CAP_MB: u32 = 1; /// is only a safety net so a hung pixelpass can't wedge the caller forever. const STARTUP_TIMEOUT: Duration = Duration::from_secs(20); +/// The audio source selected for one hosted screen share. +/// +/// This is shared by the picker and the core so the new desktop-excluding +/// choice cannot collapse back into the legacy `Option` representation +/// (where `None` could only mean whole-desktop audio). +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum ShareAudioSelection { + /// PixelPass's legacy whole-desktop monitor capture. + #[default] + DesktopShared, + /// Whole-desktop audio with PeerSpeak-owned playback excluded. + DesktopExcluding, + /// Strict capture of one locally selected application. + Application(String), +} + +/// One version-1 desktop-audio-exclusion status from PixelPass. +/// +/// The parsed value itself crosses the core/UI boundary; PeerSpeak does not +/// define a second matching enum that could silently drift from the wire. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AudioExclusionStatus { + StreamUnsupported { stream_serial: u64, reason: String }, + AecFailed { module_index: u64 }, + AecRevoked { module_index: u64 }, + ForeignAecWarning { link_group: String }, +} + /// One parsed line from pixelpass's `--output json` stdout stream. Mirrors the /// `event` tags in pixelpass's `src/common/output.rs`. Recognized-but-unused /// events collapse to [`PixelpassEvent::Other`]; blank or non-JSON lines parse @@ -86,6 +114,9 @@ pub enum PixelpassEvent { /// our `--strict-audio` run this means viewers now hear silence (not the call /// echo) until the app produces audio again — we surface it as a warning. AppAudioLost, + /// Host (desktop-excluding audio): a versioned status from the fail-closed + /// fan-out controller. + AudioExclusion(AudioExclusionStatus), /// A recognized event we don't act on (e.g. `host_info`). Other, } @@ -140,6 +171,27 @@ pub fn parse_pixelpass_event(line: &str) -> Option { Some("lost") => PixelpassEvent::AppAudioLost, _ => PixelpassEvent::Other, }, + "stream_unsupported" if json_u64(&v, "version") == Some(1) => { + PixelpassEvent::AudioExclusion(AudioExclusionStatus::StreamUnsupported { + stream_serial: json_u64(&v, "stream_serial")?, + reason: v.get("reason")?.as_str()?.to_string(), + }) + } + "aec_failed" if json_u64(&v, "version") == Some(1) => { + PixelpassEvent::AudioExclusion(AudioExclusionStatus::AecFailed { + module_index: json_u64(&v, "module_index")?, + }) + } + "aec_revoked" if json_u64(&v, "version") == Some(1) => { + PixelpassEvent::AudioExclusion(AudioExclusionStatus::AecRevoked { + module_index: json_u64(&v, "module_index")?, + }) + } + "foreign_aec_warning" if json_u64(&v, "version") == Some(1) => { + PixelpassEvent::AudioExclusion(AudioExclusionStatus::ForeignAecWarning { + link_group: v.get("link_group")?.as_str()?.to_string(), + }) + } _ => PixelpassEvent::Other, }; Some(ev) @@ -149,6 +201,10 @@ fn json_u32(v: &serde_json::Value, key: &str) -> u32 { v.get(key).and_then(|x| x.as_u64()).unwrap_or(0) as u32 } +fn json_u64(v: &serde_json::Value, key: &str) -> Option { + v.get(key).and_then(|x| x.as_u64()) +} + /// Build the argv for a pixelpass *host*. Always `--host --output json`; when /// `audio_app` is `Some`, append `--app= --strict-audio` so pixelpass /// captures only that app's audio instead of the whole desktop sink monitor @@ -180,6 +236,48 @@ pub fn host_args( args.push(format!("--app={name}")); args.push("--strict-audio".to_string()); } + append_host_settings(&mut args, settings, quality); + args +} + +/// Build host argv from the picker's typed audio selection. +/// +/// The existing desktop-shared and application arms deliberately delegate to +/// [`host_args`] so their argv stays byte-for-byte compatible. Only the new +/// desktop-excluding arm emits the public PixelPass protocol pair, and it +/// always includes an explicit AEC state: `off` when this PeerSpeak session did +/// not load an echo-cancel module, otherwise the exact pactl module index. +pub fn host_args_for_selection( + audio: &ShareAudioSelection, + aec_module_index: Option, + settings: &ScreenShareSettings, + quality: ShareQuality, +) -> Vec { + match audio { + ShareAudioSelection::DesktopShared => host_args(None, settings, quality), + ShareAudioSelection::Application(name) => host_args(Some(name), settings, quality), + ShareAudioSelection::DesktopExcluding => { + let mut args = vec![ + "--host".to_string(), + "--output".to_string(), + "json".to_string(), + "--audio-mode=desktop-excluding".to_string(), + match aec_module_index { + Some(index) => format!("--aec=pulse-module:{index}"), + None => "--aec=off".to_string(), + }, + ]; + append_host_settings(&mut args, settings, quality); + args + } + } +} + +fn append_host_settings( + args: &mut Vec, + settings: &ScreenShareSettings, + quality: ShareQuality, +) { if quality != ShareQuality::Auto { args.push(format!("--quality={}", pixelpass_quality(quality))); } @@ -199,7 +297,6 @@ pub fn host_args( args.push(format!("--max-viewers={max}")); } args.extend(split_extra_args(&settings.extra_host_args)); - args } fn pixelpass_quality(quality: ShareQuality) -> &'static str { @@ -259,22 +356,67 @@ pub async fn list_audio_apps() -> Vec { } } -/// Hard cap on the capability probe (`pixelpass --help`). Conservative: a slow or -/// hung pixelpass degrades to "strict audio unsupported" → whole-desktop-only -/// picker (safe), never a stalled core loop. +/// Hard cap on each PixelPass capability probe. Conservative: a slow or hung +/// binary degrades to the legacy capability set, never a stalled core loop. const HELP_PROBE_TIMEOUT: Duration = Duration::from_secs(2); -/// Whether the resolved pixelpass understands `--strict-audio` (added in pixelpass -/// `85fdebe`). peerspeak only offers per-app audio capture when it does: a per-app -/// share always appends `--strict-audio`, and an **older** pixelpass would have -/// clap reject the unknown flag → the host spawn hard-fails and the share is -/// broken (audit P2, version skew). When unsupported the picker degrades to -/// whole-desktop audio only — we never silently drop to best-effort `--app`, which -/// would reintroduce the call echo (A23). -/// -/// Any probe failure/timeout returns `false` (degrade to the safe path). The -/// `--help` child is `kill_on_drop` so a hung pixelpass can't linger. -pub async fn supports_strict_audio(bin: &Path) -> bool { +/// Capabilities PeerSpeak consumes from PixelPass's versioned response. +/// Strict per-app capture and desktop exclusion are independent by contract. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct PixelpassCapabilities { + pub strict_app_audio: bool, + pub desktop_audio_exclusion: bool, +} + +/// A capability result tied to the exact resolved executable that produced it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProbedPixelpassCapabilities { + pub binary: PathBuf, + pub capabilities: PixelpassCapabilities, +} + +#[derive(serde::Deserialize)] +struct CapabilityResponse { + schema_version: u64, + capabilities: CapabilityFlags, +} + +#[derive(serde::Deserialize)] +struct CapabilityFlags { + strict_app_audio: bool, + desktop_audio_exclusion: bool, +} + +/// Parse the schema-1 response from `pixelpass --capabilities`. Unsupported +/// schemas and malformed output return `None`, which selects the legacy help +/// fallback rather than guessing at a new protocol. +pub fn parse_pixelpass_capabilities(stdout: &[u8]) -> Option { + let response: CapabilityResponse = serde_json::from_slice(stdout).ok()?; + (response.schema_version == 1).then_some(PixelpassCapabilities { + strict_app_audio: response.capabilities.strict_app_audio, + desktop_audio_exclusion: response.capabilities.desktop_audio_exclusion, + }) +} + +/// Probe one resolved PixelPass binary. The versioned machine response is the +/// primary contract. `--help` survives only as a compatibility fallback for an +/// older PixelPass that predates `--capabilities`; it can recover strict per-app +/// support but can never advertise desktop exclusion. +pub async fn probe_pixelpass_capabilities(bin: &Path) -> PixelpassCapabilities { + let primary = Command::new(bin) + .arg("--capabilities") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .output(); + if let Ok(Ok(output)) = tokio::time::timeout(HELP_PROBE_TIMEOUT, primary).await + && output.status.success() + && let Some(capabilities) = parse_pixelpass_capabilities(&output.stdout) + { + return capabilities; + } + let run = Command::new(bin) .arg("--help") .stdin(Stdio::null()) @@ -282,12 +424,42 @@ pub async fn supports_strict_audio(bin: &Path) -> bool { .stderr(Stdio::null()) .kill_on_drop(true) .output(); - match tokio::time::timeout(HELP_PROBE_TIMEOUT, run).await { - Ok(Ok(o)) => help_mentions_strict_audio(&o.stdout), + let strict_app_audio = match tokio::time::timeout(HELP_PROBE_TIMEOUT, run).await { + Ok(Ok(output)) => help_mentions_strict_audio(&output.stdout), _ => false, + }; + PixelpassCapabilities { + strict_app_audio, + desktop_audio_exclusion: false, } } +/// Return capabilities for `bin`, re-probing and replacing `cached` whenever +/// the resolved executable path differs. This is the start-time skew guard: +/// capability-gated argv must never be built from a probe of another binary. +pub async fn capabilities_for_resolved_binary( + bin: &Path, + cached: &mut Option, +) -> PixelpassCapabilities { + if let Some(probe) = cached.as_ref() + && probe.binary == bin + { + return probe.capabilities; + } + let capabilities = probe_pixelpass_capabilities(bin).await; + *cached = Some(ProbedPixelpassCapabilities { + binary: bin.to_path_buf(), + capabilities, + }); + capabilities +} + +/// Compatibility helper retained for callers that only need the pre-Phase-8 +/// per-app bit. +pub async fn supports_strict_audio(bin: &Path) -> bool { + probe_pixelpass_capabilities(bin).await.strict_app_audio +} + /// Pure check: does `pixelpass --help` advertise `--strict-audio`? Matches the /// flag token rather than a whole line, since clap may wrap/realign help text. pub fn help_mentions_strict_audio(help_stdout: &[u8]) -> bool { @@ -393,12 +565,13 @@ pub fn is_available(config_override: Option<&str>) -> bool { /// them, so pixelpass keeps its own defaults in the common case. pub async fn spawn_host( bin: &Path, - audio_app: Option<&str>, + audio: &ShareAudioSelection, + aec_module_index: Option, settings: &ScreenShareSettings, quality: ShareQuality, notices: tokio::sync::mpsc::UnboundedSender, ) -> std::io::Result<(Child, String)> { - let args = host_args(audio_app, settings, quality); + let args = host_args_for_selection(audio, aec_module_index, settings, quality); // Log the exact argv we hand pixelpass so a field log can confirm which // encode/quality flags (e.g. --bitrate) actually reached the host — these // are local flags with no ticket/secret, so logging them verbatim is safe. @@ -633,6 +806,7 @@ fn event_for_log(ev: &PixelpassEvent) -> String { PixelpassEvent::CaptureStopped => "capture_stopped".to_string(), PixelpassEvent::AppAudioRouted => "app_audio_routed".to_string(), PixelpassEvent::AppAudioLost => "app_audio_lost".to_string(), + PixelpassEvent::AudioExclusion(status) => format!("audio_exclusion {status:?}"), PixelpassEvent::Other => "other".to_string(), } } @@ -913,6 +1087,64 @@ mod tests { assert_eq!(args[4], "--strict-audio"); } + #[test] + fn desktop_excluding_argv_requires_both_public_mode_and_explicit_aec() { + let settings = ScreenShareSettings::default(); + assert_eq!( + host_args_for_selection( + &ShareAudioSelection::DesktopExcluding, + Some(536_870_919), + &settings, + ShareQuality::Auto, + ), + vec![ + "--host", + "--output", + "json", + "--audio-mode=desktop-excluding", + "--aec=pulse-module:536870919", + ] + ); + assert_eq!( + host_args_for_selection( + &ShareAudioSelection::DesktopExcluding, + None, + &settings, + ShareQuality::Auto, + ), + vec![ + "--host", + "--output", + "json", + "--audio-mode=desktop-excluding", + "--aec=off", + ] + ); + } + + #[test] + fn typed_legacy_selections_keep_existing_argv_byte_identical() { + let settings = ScreenShareSettings::default(); + assert_eq!( + host_args_for_selection( + &ShareAudioSelection::DesktopShared, + Some(42), + &settings, + ShareQuality::Auto, + ), + host_args(None, &settings, ShareQuality::Auto), + ); + assert_eq!( + host_args_for_selection( + &ShareAudioSelection::Application("Firefox".to_string()), + Some(42), + &settings, + ShareQuality::Auto, + ), + host_args(Some("Firefox"), &settings, ShareQuality::Auto), + ); + } + #[test] fn host_args_blank_or_control_app_is_dropped() { // An empty / whitespace / control-laden selection is sanitized away, @@ -1217,6 +1449,106 @@ Install hint: sudo apt install gstreamer1.0-plugins-bad assert!(!help_mentions_strict_audio(&[0xff, 0xfe, 0x00])); } + #[test] + fn capability_schema_keeps_strict_and_desktop_exclusion_independent() { + assert_eq!( + parse_pixelpass_capabilities( + br#"{"schema_version":1,"capabilities":{"strict_app_audio":true,"desktop_audio_exclusion":false}}"#, + ), + Some(PixelpassCapabilities { + strict_app_audio: true, + desktop_audio_exclusion: false, + }) + ); + assert_eq!( + parse_pixelpass_capabilities( + br#"{"schema_version":1,"capabilities":{"strict_app_audio":false,"desktop_audio_exclusion":true}}"#, + ), + Some(PixelpassCapabilities { + strict_app_audio: false, + desktop_audio_exclusion: true, + }) + ); + assert!( + parse_pixelpass_capabilities( + br#"{"schema_version":2,"capabilities":{"strict_app_audio":true,"desktop_audio_exclusion":true}}"#, + ) + .is_none(), + "an unknown schema must not advertise the new mode" + ); + } + + #[cfg(unix)] + fn write_fake_pixelpass(dir: &Path, name: &str, body: &str) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + + let path = dir.join(name); + std::fs::write(&path, body).unwrap(); + let mut permissions = std::fs::metadata(&path).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&path, permissions).unwrap(); + path + } + + #[cfg(unix)] + #[tokio::test] + async fn old_pixelpass_help_fallback_cannot_advertise_desktop_exclusion() { + let dir = std::env::temp_dir().join(format!( + "peerspeak-phase8-old-pixelpass-{}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + let bin = write_fake_pixelpass( + &dir, + "pixelpass-old", + "#!/bin/sh\nif [ \"$1\" = \"--capabilities\" ]; then exit 2; fi\nprintf '%s\\n' 'Options: --app --strict-audio --output '\n", + ); + + let capabilities = probe_pixelpass_capabilities(&bin).await; + assert!(capabilities.strict_app_audio); + assert!(!capabilities.desktop_audio_exclusion); + assert_eq!( + host_args_for_selection( + &ShareAudioSelection::DesktopShared, + None, + &ScreenShareSettings::default(), + ShareQuality::Auto, + ), + vec!["--host", "--output", "json"], + "old-PixelPass fallback must emit no new flags" + ); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[cfg(unix)] + #[tokio::test] + async fn capability_cache_reprobes_when_the_resolved_binary_changes() { + let dir = + std::env::temp_dir().join(format!("peerspeak-phase8-rebind-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let new_bin = write_fake_pixelpass( + &dir, + "pixelpass-new", + "#!/bin/sh\nprintf '%s\\n' '{\"schema_version\":1,\"capabilities\":{\"strict_app_audio\":true,\"desktop_audio_exclusion\":true}}'\n", + ); + let old_bin = write_fake_pixelpass( + &dir, + "pixelpass-old", + "#!/bin/sh\nif [ \"$1\" = \"--capabilities\" ]; then exit 2; fi\nprintf '%s\\n' 'Options: --output '\n", + ); + + let mut cached = None; + let first = capabilities_for_resolved_binary(&new_bin, &mut cached).await; + assert!(first.desktop_audio_exclusion); + assert_eq!(cached.as_ref().unwrap().binary, new_bin); + + let rebound = capabilities_for_resolved_binary(&old_bin, &mut cached).await; + assert!(!rebound.desktop_audio_exclusion); + assert!(!rebound.strict_app_audio); + assert_eq!(cached.as_ref().unwrap().binary, old_bin); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn sanitize_ticket_accepts_pixelpass_endpoint_ticket_shape() { let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm"; @@ -1334,6 +1666,54 @@ Install hint: sudo apt install gstreamer1.0-plugins-bad ); } + #[test] + fn parses_all_version_one_audio_exclusion_statuses_exactly() { + assert_eq!( + parse_pixelpass_event( + r#"{"event":"stream_unsupported","version":1,"stream_serial":4294967303,"reason":"port-exclusive"}"#, + ), + Some(PixelpassEvent::AudioExclusion( + AudioExclusionStatus::StreamUnsupported { + stream_serial: 4_294_967_303, + reason: "port-exclusive".to_string(), + } + )) + ); + assert_eq!( + parse_pixelpass_event(r#"{"event":"aec_failed","version":1,"module_index":536870919}"#,), + Some(PixelpassEvent::AudioExclusion( + AudioExclusionStatus::AecFailed { + module_index: 536_870_919, + } + )) + ); + assert_eq!( + parse_pixelpass_event( + r#"{"event":"aec_revoked","version":1,"module_index":536870919}"#, + ), + Some(PixelpassEvent::AudioExclusion( + AudioExclusionStatus::AecRevoked { + module_index: 536_870_919, + } + )) + ); + assert_eq!( + parse_pixelpass_event( + r#"{"event":"foreign_aec_warning","version":1,"link_group":"echo-cancel-9999-13"}"#, + ), + Some(PixelpassEvent::AudioExclusion( + AudioExclusionStatus::ForeignAecWarning { + link_group: "echo-cancel-9999-13".to_string(), + } + )) + ); + assert_eq!( + parse_pixelpass_event(r#"{"event":"aec_failed","version":2,"module_index":536870919}"#,), + Some(PixelpassEvent::Other), + "an unknown wire version must not be misinterpreted as version 1" + ); + } + #[test] fn recognized_but_unused_event_is_other() { assert_eq!( diff --git a/tests/screenshare_host_fault.rs b/tests/screenshare_host_fault.rs index f70aa3d..fe0e7a7 100644 --- a/tests/screenshare_host_fault.rs +++ b/tests/screenshare_host_fault.rs @@ -22,6 +22,7 @@ use std::time::Duration; use peerspeak::core::CoreController; use peerspeak::core::messages::{CoreCommand, UiEvent}; +use peerspeak::screenshare::ShareAudioSelection; const EVENT_TIMEOUT: Duration = Duration::from_secs(20); /// How long to listen for events that must NOT arrive. Comfortably past the @@ -117,7 +118,7 @@ async fn a_dead_host_is_torn_down_and_a_clean_stop_stays_clean() { // ── Half 1: the host dies mid-share ───────────────────────────────────── assert!(controller.send(CoreCommand::StartScreenShare { - audio_app: None, + audio: ShareAudioSelection::DesktopShared, settings: Default::default(), quality: Default::default(), })); @@ -159,7 +160,7 @@ async fn a_dead_host_is_torn_down_and_a_clean_stop_stays_clean() { living_host.to_string_lossy().into_owned() )))); assert!(controller.send(CoreCommand::StartScreenShare { - audio_app: None, + audio: ShareAudioSelection::DesktopShared, settings: Default::default(), quality: Default::default(), })); @@ -211,7 +212,7 @@ async fn a_dead_host_is_torn_down_and_a_clean_stop_stays_clean() { // killed host's EOF passes the staleness gate and a spurious "ended // unexpectedly" lands on top of the ticket error (Gemini review, P2-1). assert!(controller.send(CoreCommand::StartScreenShare { - audio_app: None, + audio: ShareAudioSelection::DesktopShared, settings: Default::default(), quality: Default::default(), })); @@ -361,7 +362,7 @@ async fn a_host_fault_pulls_the_ticket_off_presence_within_the_grace() { .await; assert!(controller.send(CoreCommand::StartScreenShare { - audio_app: None, + audio: ShareAudioSelection::DesktopShared, settings: Default::default(), quality: Default::default(), })); @@ -534,7 +535,7 @@ async fn stop_share_ends_the_real_host_via_sigint_within_the_grace() { // after its ticket (capture starts on first viewer) — exactly the state a // Stop Share most often hits. assert!(controller.send(CoreCommand::StartScreenShare { - audio_app: None, + audio: ShareAudioSelection::DesktopShared, settings: Default::default(), quality: Default::default(), }));