feat(screenshare): integrate desktop audio exclusion

This commit is contained in:
2026-08-21 22:12:48 -04:00
parent ba96e59db0
commit 2c2b861516
7 changed files with 848 additions and 137 deletions
+228 -40
View File
@@ -16,6 +16,7 @@ use crate::hotkeys::{HotkeyAction, HotkeyContext, KeyBinding, format_binding};
use crate::network::PeerState; use crate::network::PeerState;
use crate::notify::{self, Sound}; use crate::notify::{self, Sound};
use crate::presence::PresenceMode; use crate::presence::PresenceMode;
use crate::screenshare::{AudioExclusionStatus, ShareAudioSelection};
use crate::theme::{AppTheme, Palette}; use crate::theme::{AppTheme, Palette};
use crate::widget::context_input::{context_input, locked_value}; use crate::widget::context_input::{context_input, locked_value};
use crate::widget::selectable_text::selectable_rich_text; use crate::widget::selectable_text::selectable_rich_text;
@@ -901,9 +902,8 @@ pub enum AppMessage {
ToggleScreenShare, ToggleScreenShare,
/// Close the screen-share audio picker without sharing. /// Close the screen-share audio picker without sharing.
CloseSharePicker, CloseSharePicker,
/// Select which app's audio to share in the picker: `Some(name)` for one app, /// Select legacy desktop, desktop-excluding, or strict per-app audio.
/// `None` for the whole desktop ("All system audio"). SelectShareAudio(ShareAudioSelection),
SelectShareAudioApp(Option<String>),
/// Session-only quality preset for the next share start. /// Session-only quality preset for the next share start.
SelectShareQualityOverride(ShareQuality), SelectShareQualityOverride(ShareQuality),
/// Confirm the picker: start the share with the currently selected audio app. /// 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 /// Apps currently producing audio, shown in the share picker. Populated from
/// `UiEvent::AudioAppsListed` after the picker requests an enumeration. /// `UiEvent::AudioAppsListed` after the picker requests an enumeration.
share_audio_apps: Vec<String>, share_audio_apps: Vec<String>,
/// The picker's current selection: `Some(name)` = capture that app's audio, /// The picker's current typed selection. Desktop-shared remains the legacy
/// `None` = "All system audio" (whole desktop; may echo the call). /// fallback; desktop-excluding is shown only when the resolved PixelPass
share_audio_selection: Option<String>, /// advertises it.
share_audio_selection: ShareAudioSelection,
/// Session-only quality override for the next screen-share start. /// Session-only quality override for the next screen-share start.
share_quality_selection: ShareQuality, share_quality_selection: ShareQuality,
/// A share start is in flight: `ConfirmShareScreen` was sent but the core /// 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 /// just-killed host can't flip the warning on a new whole-desktop share or
/// after stop (audit P3, unscoped events). /// after stop (audit P3, unscoped events).
share_audio_app_active: bool, 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<String>,
/// Whether the resolved pixelpass supports `--strict-audio` (per-app audio). /// Whether the resolved pixelpass supports `--strict-audio` (per-app audio).
/// `false` ⇒ the picker offers whole-desktop only, because a per-app share /// `false` ⇒ the picker offers whole-desktop only, because a per-app share
/// would pass a flag an older pixelpass rejects (audit P2). Optimistic `true` /// would pass a flag an older pixelpass rejects (audit P2). Optimistic `true`
/// until the core's `AudioAppsListed` reports otherwise. /// until the core's `AudioAppsListed` reports otherwise.
share_app_audio_supported: bool, 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 /// 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 /// outside the replay freshness window. The peer is not yet in the roster, so
/// this is not attached to a participant card. /// this is not attached to a participant card.
@@ -1272,12 +1280,15 @@ impl AppState {
self.self_sharing = false; self.self_sharing = false;
self.share_picker_open = false; self.share_picker_open = false;
self.share_audio_apps.clear(); 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_quality_selection = self.config.screen_share.quality;
self.share_starting = false; self.share_starting = false;
self.share_audio_dropped = false; self.share_audio_dropped = false;
self.share_audio_app_active = 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_app_audio_supported = true;
self.share_desktop_audio_exclusion_supported = false;
self.clock_skew_warning = None; self.clock_skew_warning = None;
} }
@@ -1580,12 +1591,15 @@ impl Default for AppState {
pixelpass_help_open: false, pixelpass_help_open: false,
share_picker_open: false, share_picker_open: false,
share_audio_apps: Vec::new(), share_audio_apps: Vec::new(),
share_audio_selection: None, share_audio_selection: ShareAudioSelection::DesktopShared,
share_quality_selection, share_quality_selection,
share_starting: false, share_starting: false,
share_audio_dropped: false, share_audio_dropped: false,
share_audio_app_active: false, share_audio_app_active: false,
share_desktop_excluding_active: false,
share_audio_exclusion_warning: None,
share_app_audio_supported: true, share_app_audio_supported: true,
share_desktop_audio_exclusion_supported: false,
clock_skew_warning: None, clock_skew_warning: None,
drawer_chat_open: false, drawer_chat_open: false,
playlist_drawer_open: false, playlist_drawer_open: false,
@@ -2172,12 +2186,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
// Open the audio picker instead of sharing immediately, so the // Open the audio picker instead of sharing immediately, so the
// user chooses which app's audio to capture rather than the whole // user chooses which app's audio to capture rather than the whole
// desktop (which echoes the call back to viewers, A23). Default // 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. // enumeration so the list reflects what's playing right now.
// Suppressed while a start is already in flight (`share_starting`) // Suppressed while a start is already in flight (`share_starting`)
// so the picker can't be reopened during the startup window. // so the picker can't be reopened during the startup window.
state.share_picker_open = true; 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 // NB: do NOT reset `share_quality_selection` here. It is the
// per-call override set by the inline quality dropdown next to // per-call override set by the inline quality dropdown next to
// the Share button, and the picker has no quality control of its // 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> {
AppMessage::CloseSharePicker => { AppMessage::CloseSharePicker => {
state.share_picker_open = false; state.share_picker_open = false;
} }
AppMessage::SelectShareAudioApp(app) => { AppMessage::SelectShareAudio(audio) => {
state.share_audio_selection = app; state.share_audio_selection = audio;
} }
AppMessage::SelectShareQualityOverride(quality) => { AppMessage::SelectShareQualityOverride(quality) => {
state.share_quality_selection = quality; state.share_quality_selection = quality;
@@ -2203,11 +2217,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
if state.share_picker_open && !state.share_starting { if state.share_picker_open && !state.share_starting {
state.share_picker_open = false; state.share_picker_open = false;
state.share_starting = true; 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 settings = state.config.screen_share.clone();
let quality = state.share_quality_selection; let quality = state.share_quality_selection;
let _ = state.controller.send(CoreCommand::StartScreenShare { let _ = state.controller.send(CoreCommand::StartScreenShare {
audio_app, audio,
settings, settings,
quality, quality,
}); });
@@ -2513,25 +2528,42 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
UiEvent::AudioAppsListed { UiEvent::AudioAppsListed {
apps, apps,
app_audio_supported, app_audio_supported,
desktop_audio_exclusion_supported,
} => { } => {
// Only meaningful while the picker is open; if the user // Only meaningful while the picker is open; if the user
// already cancelled, drop it. // already cancelled, drop it.
if state.share_picker_open { if state.share_picker_open {
state.share_app_audio_supported = app_audio_supported; state.share_app_audio_supported = app_audio_supported;
state.share_desktop_audio_exclusion_supported =
desktop_audio_exclusion_supported;
if app_audio_supported { if app_audio_supported {
// Keep the current selection if it still exists in the // Keep the current selection if it still exists in the
// refreshed list, else fall back to "All system audio". // 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) && !apps.iter().any(|a| a == sel)
{ {
state.share_audio_selection = None; state.share_audio_selection = ShareAudioSelection::DesktopShared;
} }
state.share_audio_apps = apps; state.share_audio_apps = apps;
} else { } else {
// Older pixelpass: per-app capture would hard-fail // Older pixelpass: per-app capture would hard-fail
// (--strict-audio unknown). Force whole-desktop only. // (--strict-audio unknown). Force whole-desktop only.
state.share_audio_apps.clear(); 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<AppMessage> {
state.share_audio_dropped = false; state.share_audio_dropped = false;
// Remember whether this share captures a specific app, so we // Remember whether this share captures a specific app, so we
// only apply `app_audio` warnings to app shares (P3). // 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. // Defensive: ensure no picker lingers across a successful start.
state.share_picker_open = false; state.share_picker_open = false;
state.status_message = "Sharing your screen".to_string(); state.status_message = "Sharing your screen".to_string();
@@ -2551,6 +2590,8 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.share_starting = false; state.share_starting = false;
state.share_audio_dropped = false; state.share_audio_dropped = false;
state.share_audio_app_active = 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(); state.status_message = "Screen share stopped".to_string();
} }
UiEvent::ShareAudioActive(active) => { UiEvent::ShareAudioActive(active) => {
@@ -2562,6 +2603,22 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.share_audio_dropped = !active; 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 { UiEvent::ClockSkewWarning {
skew_secs, skew_secs,
peer_ahead, peer_ahead,
@@ -6656,20 +6713,20 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
] ]
.spacing(6) .spacing(6)
.align_y(iced::alignment::Vertical::Center); .align_y(iced::alignment::Vertical::Center);
let mut details = column![badge].spacing(3);
if state.share_audio_dropped { if state.share_audio_dropped {
column![ details = details.push(
badge,
text( text(
"⚠ Shared app isn't sending audio — viewers hear silence until it plays" "⚠ Shared app isn't sending audio — viewers hear silence until it plays"
) )
.size(11) .size(11)
.color(color_yellow), .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 { } else {
iced::widget::Space::new().width(0.0).height(0.0).into() iced::widget::Space::new().width(0.0).height(0.0).into()
}; };
@@ -8757,6 +8814,22 @@ fn with_pixelpass_help<'a>(
.into() .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 /// 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 /// 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 /// 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 // Keep the legacy whole-desktop choice visible with its echo warning. The
// warning), then each currently-playing app. // new exclusion row exists only when this exact PixelPass advertised it.
let mut options = column![opt_row( let mut options = column![opt_row(
state.share_audio_selection.is_none(), matches!(
state.share_audio_selection,
ShareAudioSelection::DesktopShared
),
"All system audio".to_string(), "All system audio".to_string(),
Some("⚠ may echo the call back to viewers"), Some("⚠ may echo the call back to viewers"),
AppMessage::SelectShareAudioApp(None), AppMessage::SelectShareAudio(ShareAudioSelection::DesktopShared),
)] )]
.spacing(4); .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 { 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( options = options.push(opt_row(
selected, selected,
app.clone(), app.clone(),
None, None,
AppMessage::SelectShareAudioApp(Some(app.clone())), AppMessage::SelectShareAudio(ShareAudioSelection::Application(app.clone())),
)); ));
} }
@@ -9668,9 +9758,9 @@ mod tests {
use super::PendingSend; use super::PendingSend;
use super::sendqueue::{self, LocalSend, SendStatus}; use super::sendqueue::{self, LocalSend, SendStatus};
use super::{ use super::{
AppConfig, AppMessage, AppState, AttachmentCache, AttachmentState, AppConfig, AppMessage, AppState, AttachmentCache, AttachmentState, AudioExclusionStatus,
CLOCK_SKEW_WARNING_VISIBLE_SECS, ChatEntry, ClockSkewBanner, GateMeter, METER_MAX, Screen, 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, clear_expired_clock_skew_warning, format_clock_skew_duration, format_duration,
format_relative_ago, friend_presence_notification, initial_window_position, format_relative_ago, friend_presence_notification, initial_window_position,
now_playing_label, reconnect_attempt_chime, reconnected_chime, selected_wav_path, now_playing_label, reconnect_attempt_chime, reconnected_chime, selected_wav_path,
@@ -9970,11 +10060,14 @@ mod tests {
state.self_sharing = true; state.self_sharing = true;
state.share_picker_open = true; state.share_picker_open = true;
state.share_audio_apps = vec!["Firefox".to_string()]; 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_starting = true;
state.share_audio_dropped = true; state.share_audio_dropped = true;
state.share_audio_app_active = 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_app_audio_supported = false;
state.share_desktop_audio_exclusion_supported = true;
state.clock_skew_warning = Some(ClockSkewBanner { state.clock_skew_warning = Some(ClockSkewBanner {
skew_secs: 180, skew_secs: 180,
peer_ahead: true, peer_ahead: true,
@@ -10011,7 +10104,10 @@ mod tests {
assert!(!state.self_sharing); assert!(!state.self_sharing);
assert!(!state.share_picker_open); assert!(!state.share_picker_open);
assert!(state.share_audio_apps.is_empty()); assert!(state.share_audio_apps.is_empty());
assert!(state.share_audio_selection.is_none()); assert_eq!(
state.share_audio_selection,
ShareAudioSelection::DesktopShared
);
assert_eq!( assert_eq!(
state.share_quality_selection, state.share_quality_selection,
state.config.screen_share.quality state.config.screen_share.quality
@@ -10019,10 +10115,13 @@ mod tests {
assert!(!state.share_starting); assert!(!state.share_starting);
assert!(!state.share_audio_dropped); assert!(!state.share_audio_dropped);
assert!(!state.share_audio_app_active); assert!(!state.share_audio_app_active);
assert!(!state.share_desktop_excluding_active);
assert!(state.share_audio_exclusion_warning.is_none());
assert!( assert!(
state.share_app_audio_supported, state.share_app_audio_supported,
"reset is optimistic by default" "reset is optimistic by default"
); );
assert!(!state.share_desktop_audio_exclusion_supported);
assert!(state.clock_skew_warning.is_none()); assert!(state.clock_skew_warning.is_none());
assert!(state.music_broadcast_id.is_none()); assert!(state.music_broadcast_id.is_none());
assert!(state.music_broadcast_next.is_none()); assert!(state.music_broadcast_next.is_none());
@@ -10135,7 +10234,7 @@ mod tests {
// Picker open, user confirms a selection. // Picker open, user confirms a selection.
let mut state = AppState { let mut state = AppState {
share_picker_open: true, share_picker_open: true,
share_audio_selection: Some("mpv".to_string()), share_audio_selection: ShareAudioSelection::Application("mpv".to_string()),
..Default::default() ..Default::default()
}; };
let _ = update(&mut state, AppMessage::ConfirmShareScreen); 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. // 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. // A specific app was chosen in the picker, so the share is app-specific.
let mut state = AppState { let mut state = AppState {
share_audio_selection: Some("mpv".to_string()), share_audio_selection: ShareAudioSelection::Application("mpv".to_string()),
..Default::default() ..Default::default()
}; };
@@ -10300,7 +10399,7 @@ mod tests {
// (b) After stop: a straggling event can't resurrect the warning. // (b) After stop: a straggling event can't resurrect the warning.
let mut state = AppState { let mut state = AppState {
share_audio_selection: Some("mpv".to_string()), share_audio_selection: ShareAudioSelection::Application("mpv".to_string()),
..Default::default() ..Default::default()
}; };
let _ = update( let _ = update(
@@ -10318,6 +10417,53 @@ mod tests {
assert!(!state.share_audio_dropped, "post-stop event is ignored"); 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] #[test]
fn old_pixelpass_picker_offers_whole_desktop_only() { fn old_pixelpass_picker_offers_whole_desktop_only() {
// P2 (version skew): when the resolved pixelpass lacks --strict-audio, the // 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. // so a per-app share (which would pass the unknown flag) can't be started.
let mut state = AppState { let mut state = AppState {
share_picker_open: true, 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()], share_audio_apps: vec!["Firefox".to_string(), "mpv".to_string()],
..Default::default() ..Default::default()
}; };
@@ -10334,12 +10480,14 @@ mod tests {
AppMessage::UiEventReceived(UiEvent::AudioAppsListed { AppMessage::UiEventReceived(UiEvent::AudioAppsListed {
apps: vec!["Firefox".to_string(), "mpv".to_string()], apps: vec!["Firefox".to_string(), "mpv".to_string()],
app_audio_supported: false, app_audio_supported: false,
desktop_audio_exclusion_supported: false,
}), }),
); );
assert!(!state.share_app_audio_supported); 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_apps.is_empty(), "no per-app rows offered");
assert!( assert!(
state.share_audio_selection.is_none(), state.share_audio_selection == ShareAudioSelection::DesktopShared,
"forced to whole-desktop" "forced to whole-desktop"
); );
@@ -10349,12 +10497,52 @@ mod tests {
AppMessage::UiEventReceived(UiEvent::AudioAppsListed { AppMessage::UiEventReceived(UiEvent::AudioAppsListed {
apps: vec!["Firefox".to_string(), "mpv".to_string()], apps: vec!["Firefox".to_string(), "mpv".to_string()],
app_audio_supported: true, app_audio_supported: true,
desktop_audio_exclusion_supported: true,
}), }),
); );
assert!(state.share_app_audio_supported); assert!(state.share_app_audio_supported);
assert!(state.share_desktop_audio_exclusion_supported);
assert_eq!(state.share_audio_apps.len(), 2); 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] #[test]
fn peer_gate_persists_when_on_and_clears_when_off() { fn peer_gate_persists_when_on_and_clears_when_off() {
let mut config = AppConfig::default(); let mut config = AppConfig::default();
+11 -6
View File
@@ -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 /// Owns a loaded `module-echo-cancel` instance; unloads it on drop so the virtual
/// nodes never leak past the call that created them. /// nodes never leak past the call that created them.
pub struct EchoCancelGuard { pub struct EchoCancelGuard {
module_index: String, module_index: u64,
source_name: String, source_name: String,
sink_name: String, sink_name: String,
} }
impl EchoCancelGuard { 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 { pub fn source_name(&self) -> &str {
&self.source_name &self.source_name
} }
@@ -52,7 +57,7 @@ impl Drop for EchoCancelGuard {
fn drop(&mut self) { fn drop(&mut self) {
let _ = Command::new("pactl") let _ = Command::new("pactl")
.arg("unload-module") .arg("unload-module")
.arg(&self.module_index) .arg(self.module_index.to_string())
.output(); .output();
crate::log_msg(&format!( crate::log_msg(&format!(
"Echo cancel: unloaded module {}", "Echo cancel: unloaded module {}",
@@ -103,10 +108,10 @@ pub fn enable(
)); ));
} }
let module_index = String::from_utf8_lossy(&out.stdout).trim().to_string(); let raw_module_index = String::from_utf8_lossy(&out.stdout).trim().to_string();
if module_index.parse::<u64>().is_err() { let module_index = raw_module_index
return Err(format!("unexpected pactl output: {module_index:?}")); .parse::<u64>()
} .map_err(|_| format!("unexpected pactl output: {raw_module_index:?}"))?;
let guard = EchoCancelGuard { let guard = EchoCancelGuard {
module_index, module_index,
source_name, source_name,
+11 -10
View File
@@ -126,11 +126,10 @@ pub enum CoreCommand {
ListAudioApps, ListAudioApps,
/// Start sharing our screen: spawn a pixelpass host and announce its ticket /// 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. /// 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 /// `audio` is typed so legacy whole-desktop, desktop-excluding, and strict
/// only that app (avoiding the call-loopback echo, A23); `None` shares the /// per-app capture remain distinct across the UI/core boundary.
/// whole desktop audio (the legacy behavior).
StartScreenShare { StartScreenShare {
audio_app: Option<String>, audio: crate::screenshare::ShareAudioSelection,
settings: ScreenShareSettings, settings: ScreenShareSettings,
quality: ShareQuality, quality: ShareQuality,
}, },
@@ -276,7 +275,7 @@ pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass {
| CoreCommand::SetPixelpassPath(_) | CoreCommand::SetPixelpassPath(_)
| CoreCommand::ListAudioApps | CoreCommand::ListAudioApps
| CoreCommand::StartScreenShare { | CoreCommand::StartScreenShare {
audio_app: _, audio: _,
settings: _, settings: _,
quality: _, quality: _,
} }
@@ -369,7 +368,7 @@ pub fn coalesce_key(cmd: &CoreCommand) -> Option<CoalesceKey> {
| CoreCommand::SetPixelpassPath(_) | CoreCommand::SetPixelpassPath(_)
| CoreCommand::ListAudioApps | CoreCommand::ListAudioApps
| CoreCommand::StartScreenShare { | CoreCommand::StartScreenShare {
audio_app: _, audio: _,
settings: _, settings: _,
quality: _, quality: _,
} }
@@ -511,13 +510,12 @@ pub enum UiEvent {
}, },
/// The apps currently producing audio, for the screen-share audio picker /// The apps currently producing audio, for the screen-share audio picker
/// (A23). Sorted, deduplicated `application.name`s; empty when nothing is /// (A23). Sorted, deduplicated `application.name`s; empty when nothing is
/// playing or enumeration isn't available. `app_audio_supported` reports /// playing or enumeration isn't available. The two support bits are
/// whether the resolved pixelpass understands `--strict-audio`: when `false` /// independent and belong to the exact resolved PixelPass binary.
/// (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 { AudioAppsListed {
apps: Vec<String>, apps: Vec<String>,
app_audio_supported: bool, app_audio_supported: bool,
desktop_audio_exclusion_supported: bool,
}, },
/// Our own screen share started; the UI flips the Share button to "Stop". /// Our own screen share started; the UI flips the Share button to "Stop".
ScreenShareStarted, ScreenShareStarted,
@@ -528,6 +526,9 @@ pub enum UiEvent {
/// run viewers currently hear silence. The UI shows a transient warning while /// run viewers currently hear silence. The UI shows a transient warning while
/// `false`. Only meaningful while sharing a specific app (not whole-desktop). /// `false`. Only meaningful while sharing a specific app (not whole-desktop).
ShareAudioActive(bool), 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 /// A validly signed peer cannot be admitted because its gossip timestamp is
/// outside the replay freshness window. `peer_ahead` describes the peer's /// outside the replay freshness window. `peer_ahead` describes the peer's
/// sender-stamped timestamp relative to this machine's clock. /// sender-stamped timestamp relative to this machine's clock.
+186 -53
View File
@@ -1284,6 +1284,42 @@ async fn probe_friends_once(
} }
} }
fn ui_event_from_pixelpass_event(event: crate::screenshare::PixelpassEvent) -> Option<UiEvent> {
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<crate::screenshare::HostNotice>,
ui_tx: mpsc::Sender<UiEvent>,
fault_tx: mpsc::UnboundedSender<u64>,
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( async fn run_core_loop(
mut reliable_rx: mpsc::UnboundedReceiver<CoreCommand>, mut reliable_rx: mpsc::UnboundedReceiver<CoreCommand>,
coalesce: CoalesceStore, coalesce: CoalesceStore,
@@ -1404,6 +1440,10 @@ async fn run_core_loop(
// generation must appear and vanish together, or a stale fault could tear // generation must appear and vanish together, or a stale fault could tear
// down a share it doesn't belong to. // down a share it doesn't belong to.
let mut pixelpass_override: Option<String> = None; let mut pixelpass_override: Option<String> = 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<crate::screenshare::ProbedPixelpassCapabilities> = None;
struct ActiveShare { struct ActiveShare {
generation: u64, generation: u64,
ticket: String, ticket: String,
@@ -3413,39 +3453,53 @@ async fn run_core_loop(
CoreCommand::SetPixelpassPath(path) => { CoreCommand::SetPixelpassPath(path) => {
pixelpass_override = path.filter(|p| !p.trim().is_empty()); pixelpass_override = path.filter(|p| !p.trim().is_empty());
pixelpass_capabilities = None;
} }
CoreCommand::ListAudioApps => { CoreCommand::ListAudioApps => {
// Probe whether this pixelpass supports `--strict-audio` before // Probe the versioned response from the exact binary selected
// offering per-app capture: an older binary would reject the flag // for this picker. The help fallback can recover legacy strict
// and hard-fail the share (audit P2). When unsupported (or // per-app support, but never desktop exclusion.
// pixelpass is missing), skip enumeration and let the picker show let (capabilities, apps) =
// whole-desktop audio only — never a best-effort `--app` that
// would reopen the A23 echo.
let app_audio_supported =
match crate::screenshare::pixelpass_path(pixelpass_override.as_deref()) { match crate::screenshare::pixelpass_path(pixelpass_override.as_deref()) {
Some(bin) => crate::screenshare::supports_strict_audio(&bin).await, Some(bin) => {
None => false, 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 let _ = ui_tx
.send(UiEvent::AudioAppsListed { .send(UiEvent::AudioAppsListed {
apps, apps,
app_audio_supported, app_audio_supported: capabilities.strict_app_audio,
desktop_audio_exclusion_supported: capabilities.desktop_audio_exclusion,
}) })
.await; .await;
} }
CoreCommand::StartScreenShare { CoreCommand::StartScreenShare {
audio_app, audio,
settings, settings,
quality, quality,
} => { } => {
let Some(session) = &mut active_session else { let Some(session) = active_session.as_ref() else {
let _ = ui_tx let _ = ui_tx
.send(UiEvent::Error( .send(UiEvent::Error(
"Join a call before sharing your screen".into(), "Join a call before sharing your screen".into(),
@@ -3467,49 +3521,72 @@ async fn run_core_loop(
continue; 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. // Every share gets a notice forwarder — not just app-audio ones.
// pixelpass `app_audio` events (only emitted when an app is // App-audio and desktop-exclusion events become UI state, and
// selected) become UI warnings, and the drain's terminal `Eof` // the drain's terminal `Eof` becomes a generation-scoped fault.
// 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).
share_generations += 1; share_generations += 1;
let generation = share_generations; let generation = share_generations;
let (notices_tx, mut notices_rx) = let (notices_tx, notices_rx) =
tokio::sync::mpsc::unbounded_channel::<crate::screenshare::HostNotice>(); tokio::sync::mpsc::unbounded_channel::<crate::screenshare::HostNotice>();
let ui_tx_notices = ui_tx.clone(); let ui_tx_notices = ui_tx.clone();
let fault_tx = host_fault_tx.clone(); let fault_tx = host_fault_tx.clone();
tokio::spawn(async move { tokio::spawn(async move {
while let Some(notice) = notices_rx.recv().await { forward_host_notices(notices_rx, ui_tx_notices, fault_tx, generation).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;
}
}
}
}); });
match crate::screenshare::spawn_host( match crate::screenshare::spawn_host(
&bin, &bin,
audio_app.as_deref(), &audio,
aec_module_index,
&settings, &settings,
quality, quality,
notices_tx, notices_tx,
@@ -3705,10 +3782,11 @@ mod tests {
KnownPeers, MAX_OPUS_PAYLOAD, MAX_RETAINED_PEERS, MIC_LEVEL_REPORT_SAMPLES, MicLevelMeter, KnownPeers, MAX_OPUS_PAYLOAD, MAX_RETAINED_PEERS, MIC_LEVEL_REPORT_SAMPLES, MicLevelMeter,
NetworkMode, PLAYBACK_HANDOFF_QUEUE_FRAMES, PeerSpeakTicket, admit_retained, NetworkMode, PLAYBACK_HANDOFF_QUEUE_FRAMES, PeerSpeakTicket, admit_retained,
apply_peer_volume, apply_volume, audio_datagram_len_ok, coalesce_insert, coalesce_pop, 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, forward_host_notices, frame_level, mix_frames, mix_stereo_frames, next_game_change,
replace_viewer_index, send_playback_frame, should_auto_fetch, stereo_to_mono, 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::collections::{HashMap, HashSet};
use std::sync::mpsc::sync_channel; use std::sync::mpsc::sync_channel;
use std::time::Duration; use std::time::Duration;
@@ -3717,6 +3795,61 @@ mod tests {
iroh::SecretKey::generate().public() 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] #[test]
fn re_watch_replaces_existing_viewer_for_same_ticket() { fn re_watch_replaces_existing_viewer_for_same_ticket() {
// The value type stands in for a viewer Child; only the ticket matters. // The value type stands in for a viewer Child; only the ticket matters.
+7 -4
View File
@@ -305,10 +305,6 @@ pub(super) struct ScreenshareTeardown<C: ChildProcess, G> {
/// ///
/// ⚠️ **LAST FIELD ON PURPOSE** — see the module docs and the struct note. /// ⚠️ **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<G>, echo_cancel: Option<G>,
} }
@@ -325,6 +321,13 @@ impl<C: ChildProcess, G> ScreenshareTeardown<C, G> {
self.host.is_some() 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) { pub(super) fn set_host(&mut self, child: C) {
self.host = Some(ReapOnDrop::new(child, "screen-share host")); self.host = Some(ReapOnDrop::new(child, "screen-share host"));
} }
+399 -19
View File
@@ -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. /// is only a safety net so a hung pixelpass can't wedge the caller forever.
const STARTUP_TIMEOUT: Duration = Duration::from_secs(20); 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<String>` 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 /// One parsed line from pixelpass's `--output json` stdout stream. Mirrors the
/// `event` tags in pixelpass's `src/common/output.rs`. Recognized-but-unused /// `event` tags in pixelpass's `src/common/output.rs`. Recognized-but-unused
/// events collapse to [`PixelpassEvent::Other`]; blank or non-JSON lines parse /// 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 /// 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. /// echo) until the app produces audio again — we surface it as a warning.
AppAudioLost, 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`). /// A recognized event we don't act on (e.g. `host_info`).
Other, Other,
} }
@@ -140,6 +171,27 @@ pub fn parse_pixelpass_event(line: &str) -> Option<PixelpassEvent> {
Some("lost") => PixelpassEvent::AppAudioLost, Some("lost") => PixelpassEvent::AppAudioLost,
_ => PixelpassEvent::Other, _ => 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, _ => PixelpassEvent::Other,
}; };
Some(ev) 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 v.get(key).and_then(|x| x.as_u64()).unwrap_or(0) as u32
} }
fn json_u64(v: &serde_json::Value, key: &str) -> Option<u64> {
v.get(key).and_then(|x| x.as_u64())
}
/// Build the argv for a pixelpass *host*. Always `--host --output json`; when /// Build the argv for a pixelpass *host*. Always `--host --output json`; when
/// `audio_app` is `Some`, append `--app=<name> --strict-audio` so pixelpass /// `audio_app` is `Some`, append `--app=<name> --strict-audio` so pixelpass
/// captures only that app's audio instead of the whole desktop sink monitor /// 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(format!("--app={name}"));
args.push("--strict-audio".to_string()); 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<u64>,
settings: &ScreenShareSettings,
quality: ShareQuality,
) -> Vec<String> {
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<String>,
settings: &ScreenShareSettings,
quality: ShareQuality,
) {
if quality != ShareQuality::Auto { if quality != ShareQuality::Auto {
args.push(format!("--quality={}", pixelpass_quality(quality))); args.push(format!("--quality={}", pixelpass_quality(quality)));
} }
@@ -199,7 +297,6 @@ pub fn host_args(
args.push(format!("--max-viewers={max}")); args.push(format!("--max-viewers={max}"));
} }
args.extend(split_extra_args(&settings.extra_host_args)); args.extend(split_extra_args(&settings.extra_host_args));
args
} }
fn pixelpass_quality(quality: ShareQuality) -> &'static str { fn pixelpass_quality(quality: ShareQuality) -> &'static str {
@@ -259,22 +356,67 @@ pub async fn list_audio_apps() -> Vec<String> {
} }
} }
/// Hard cap on the capability probe (`pixelpass --help`). Conservative: a slow or /// Hard cap on each PixelPass capability probe. Conservative: a slow or hung
/// hung pixelpass degrades to "strict audio unsupported" → whole-desktop-only /// binary degrades to the legacy capability set, never a stalled core loop.
/// picker (safe), never a stalled core loop.
const HELP_PROBE_TIMEOUT: Duration = Duration::from_secs(2); const HELP_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
/// Whether the resolved pixelpass understands `--strict-audio` (added in pixelpass /// Capabilities PeerSpeak consumes from PixelPass's versioned response.
/// `85fdebe`). peerspeak only offers per-app audio capture when it does: a per-app /// Strict per-app capture and desktop exclusion are independent by contract.
/// share always appends `--strict-audio`, and an **older** pixelpass would have #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
/// clap reject the unknown flag → the host spawn hard-fails and the share is pub struct PixelpassCapabilities {
/// broken (audit P2, version skew). When unsupported the picker degrades to pub strict_app_audio: bool,
/// whole-desktop audio only — we never silently drop to best-effort `--app`, which pub desktop_audio_exclusion: bool,
/// would reintroduce the call echo (A23). }
///
/// Any probe failure/timeout returns `false` (degrade to the safe path). The /// A capability result tied to the exact resolved executable that produced it.
/// `--help` child is `kill_on_drop` so a hung pixelpass can't linger. #[derive(Debug, Clone, PartialEq, Eq)]
pub async fn supports_strict_audio(bin: &Path) -> bool { 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<PixelpassCapabilities> {
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) let run = Command::new(bin)
.arg("--help") .arg("--help")
.stdin(Stdio::null()) .stdin(Stdio::null())
@@ -282,12 +424,42 @@ pub async fn supports_strict_audio(bin: &Path) -> bool {
.stderr(Stdio::null()) .stderr(Stdio::null())
.kill_on_drop(true) .kill_on_drop(true)
.output(); .output();
match tokio::time::timeout(HELP_PROBE_TIMEOUT, run).await { let strict_app_audio = match tokio::time::timeout(HELP_PROBE_TIMEOUT, run).await {
Ok(Ok(o)) => help_mentions_strict_audio(&o.stdout), Ok(Ok(output)) => help_mentions_strict_audio(&output.stdout),
_ => false, _ => 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<ProbedPixelpassCapabilities>,
) -> 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 /// Pure check: does `pixelpass --help` advertise `--strict-audio`? Matches the
/// flag token rather than a whole line, since clap may wrap/realign help text. /// flag token rather than a whole line, since clap may wrap/realign help text.
pub fn help_mentions_strict_audio(help_stdout: &[u8]) -> bool { 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. /// them, so pixelpass keeps its own defaults in the common case.
pub async fn spawn_host( pub async fn spawn_host(
bin: &Path, bin: &Path,
audio_app: Option<&str>, audio: &ShareAudioSelection,
aec_module_index: Option<u64>,
settings: &ScreenShareSettings, settings: &ScreenShareSettings,
quality: ShareQuality, quality: ShareQuality,
notices: tokio::sync::mpsc::UnboundedSender<HostNotice>, notices: tokio::sync::mpsc::UnboundedSender<HostNotice>,
) -> std::io::Result<(Child, String)> { ) -> 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 // 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 // encode/quality flags (e.g. --bitrate) actually reached the host — these
// are local flags with no ticket/secret, so logging them verbatim is safe. // 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::CaptureStopped => "capture_stopped".to_string(),
PixelpassEvent::AppAudioRouted => "app_audio_routed".to_string(), PixelpassEvent::AppAudioRouted => "app_audio_routed".to_string(),
PixelpassEvent::AppAudioLost => "app_audio_lost".to_string(), PixelpassEvent::AppAudioLost => "app_audio_lost".to_string(),
PixelpassEvent::AudioExclusion(status) => format!("audio_exclusion {status:?}"),
PixelpassEvent::Other => "other".to_string(), PixelpassEvent::Other => "other".to_string(),
} }
} }
@@ -913,6 +1087,64 @@ mod tests {
assert_eq!(args[4], "--strict-audio"); 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] #[test]
fn host_args_blank_or_control_app_is_dropped() { fn host_args_blank_or_control_app_is_dropped() {
// An empty / whitespace / control-laden selection is sanitized away, // 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])); 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 <APP> --strict-audio --output <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 <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] #[test]
fn sanitize_ticket_accepts_pixelpass_endpoint_ticket_shape() { fn sanitize_ticket_accepts_pixelpass_endpoint_ticket_shape() {
let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm"; 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] #[test]
fn recognized_but_unused_event_is_other() { fn recognized_but_unused_event_is_other() {
assert_eq!( assert_eq!(
+6 -5
View File
@@ -22,6 +22,7 @@ use std::time::Duration;
use peerspeak::core::CoreController; use peerspeak::core::CoreController;
use peerspeak::core::messages::{CoreCommand, UiEvent}; use peerspeak::core::messages::{CoreCommand, UiEvent};
use peerspeak::screenshare::ShareAudioSelection;
const EVENT_TIMEOUT: Duration = Duration::from_secs(20); const EVENT_TIMEOUT: Duration = Duration::from_secs(20);
/// How long to listen for events that must NOT arrive. Comfortably past the /// 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 ───────────────────────────────────── // ── Half 1: the host dies mid-share ─────────────────────────────────────
assert!(controller.send(CoreCommand::StartScreenShare { assert!(controller.send(CoreCommand::StartScreenShare {
audio_app: None, audio: ShareAudioSelection::DesktopShared,
settings: Default::default(), settings: Default::default(),
quality: 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() living_host.to_string_lossy().into_owned()
)))); ))));
assert!(controller.send(CoreCommand::StartScreenShare { assert!(controller.send(CoreCommand::StartScreenShare {
audio_app: None, audio: ShareAudioSelection::DesktopShared,
settings: Default::default(), settings: Default::default(),
quality: 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 // killed host's EOF passes the staleness gate and a spurious "ended
// unexpectedly" lands on top of the ticket error (Gemini review, P2-1). // unexpectedly" lands on top of the ticket error (Gemini review, P2-1).
assert!(controller.send(CoreCommand::StartScreenShare { assert!(controller.send(CoreCommand::StartScreenShare {
audio_app: None, audio: ShareAudioSelection::DesktopShared,
settings: Default::default(), settings: Default::default(),
quality: Default::default(), quality: Default::default(),
})); }));
@@ -361,7 +362,7 @@ async fn a_host_fault_pulls_the_ticket_off_presence_within_the_grace() {
.await; .await;
assert!(controller.send(CoreCommand::StartScreenShare { assert!(controller.send(CoreCommand::StartScreenShare {
audio_app: None, audio: ShareAudioSelection::DesktopShared,
settings: Default::default(), settings: Default::default(),
quality: 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 // after its ticket (capture starts on first viewer) — exactly the state a
// Stop Share most often hits. // Stop Share most often hits.
assert!(controller.send(CoreCommand::StartScreenShare { assert!(controller.send(CoreCommand::StartScreenShare {
audio_app: None, audio: ShareAudioSelection::DesktopShared,
settings: Default::default(), settings: Default::default(),
quality: Default::default(), quality: Default::default(),
})); }));