feat(screenshare): integrate desktop audio exclusion
This commit is contained in:
+228
-40
@@ -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<String>),
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
/// 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<AppMessage> {
|
||||
// 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> {
|
||||
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<AppMessage> {
|
||||
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<AppMessage> {
|
||||
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<AppMessage> {
|
||||
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<AppMessage> {
|
||||
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<AppMessage> {
|
||||
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();
|
||||
|
||||
@@ -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::<u64>().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::<u64>()
|
||||
.map_err(|_| format!("unexpected pactl output: {raw_module_index:?}"))?;
|
||||
let guard = EchoCancelGuard {
|
||||
module_index,
|
||||
source_name,
|
||||
|
||||
+11
-10
@@ -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<String>,
|
||||
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<CoalesceKey> {
|
||||
| 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<String>,
|
||||
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.
|
||||
|
||||
+186
-53
@@ -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(
|
||||
mut reliable_rx: mpsc::UnboundedReceiver<CoreCommand>,
|
||||
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<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 {
|
||||
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::<crate::screenshare::HostNotice>();
|
||||
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.
|
||||
|
||||
@@ -305,10 +305,6 @@ pub(super) struct ScreenshareTeardown<C: ChildProcess, G> {
|
||||
///
|
||||
/// ⚠️ **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>,
|
||||
}
|
||||
|
||||
@@ -325,6 +321,13 @@ impl<C: ChildProcess, G> ScreenshareTeardown<C, G> {
|
||||
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"));
|
||||
}
|
||||
|
||||
+399
-19
@@ -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<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
|
||||
/// `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<PixelpassEvent> {
|
||||
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<u64> {
|
||||
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=<name> --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<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 {
|
||||
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<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<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)
|
||||
.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<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
|
||||
/// 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<u64>,
|
||||
settings: &ScreenShareSettings,
|
||||
quality: ShareQuality,
|
||||
notices: tokio::sync::mpsc::UnboundedSender<HostNotice>,
|
||||
) -> 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 <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]
|
||||
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!(
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user