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::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();
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
+183
-50
@@ -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;
|
||||||
let apps = if app_audio_supported {
|
pixelpass_capabilities =
|
||||||
|
Some(crate::screenshare::ProbedPixelpassCapabilities {
|
||||||
|
binary: bin,
|
||||||
|
capabilities,
|
||||||
|
});
|
||||||
|
let apps = if capabilities.strict_app_audio {
|
||||||
crate::screenshare::list_audio_apps().await
|
crate::screenshare::list_audio_apps().await
|
||||||
} else {
|
} else {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
};
|
};
|
||||||
|
(capabilities, apps)
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
pixelpass_capabilities = None;
|
||||||
|
(
|
||||||
|
crate::screenshare::PixelpassCapabilities::default(),
|
||||||
|
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.
|
||||||
|
|||||||
@@ -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
@@ -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!(
|
||||||
|
|||||||
@@ -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(),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user