fix(screenshare): per-app audio capture to kill call-loopback echo (A23)
Screen sharing spawned pixelpass with a bare `--host`, so pixelpass
captured the whole default-sink monitor — which contains peerspeak's own
call playout. A viewer therefore heard their OWN voice echoed back out of
the sharer's machine (confirmed live, backlog A23).
pixelpass already supports `--app <NAME>` (capture only one app's audio,
per-app PipeWire routing); peerspeak just never passed it. This wires that
flag through, peerspeak-side only — no pixelpass change.
- screenshare: pure `host_args(audio_app)` builds the host argv, appending
`--app=<name>` (single-token form so a hyphen-leading name can't be
reparsed as a flag) when an app is chosen; `sanitize_app_name` guards the
locally-chosen value; `list_audio_apps`/`parse_audio_apps` enumerate
currently-playing apps via `pactl -f json list sink-inputs` (mirroring how
pixelpass builds its own picker, so the names match what `--app` matches).
- core: `StartScreenShare { audio_app }` + `ListAudioApps`/`AudioAppsListed`.
- GUI: Share Screen now opens a small audio picker (radio-style modal) listing
the playing apps + "All system audio" (warned, = legacy whole-desktop);
picking one starts the share with `--app=<name>`. Reset on room leave.
+6 unit tests (host_args with/without/blank app, sanitize_app_name,
parse_audio_apps dedup + garbage). 430 lib tests, clippy --all-targets clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+240
-4
@@ -426,8 +426,16 @@ pub enum AppMessage {
|
|||||||
SetBackgroundDim(f32),
|
SetBackgroundDim(f32),
|
||||||
/// Toggle the Chat drawer open/closed (drawer layout).
|
/// Toggle the Chat drawer open/closed (drawer layout).
|
||||||
ToggleDrawerChat,
|
ToggleDrawerChat,
|
||||||
/// Start/stop sharing our own screen (spawns/kills a pixelpass host).
|
/// Start/stop sharing our own screen. When not sharing, opens the audio
|
||||||
|
/// picker (A23) instead of starting immediately; when sharing, stops.
|
||||||
ToggleScreenShare,
|
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>),
|
||||||
|
/// Confirm the picker: start the share with the currently selected audio app.
|
||||||
|
ConfirmShareScreen,
|
||||||
/// Watch a peer's screen share, identified by their pixelpass ticket.
|
/// Watch a peer's screen share, identified by their pixelpass ticket.
|
||||||
WatchShare(String),
|
WatchShare(String),
|
||||||
/// Result of asynchronously enqueueing the core shutdown command.
|
/// Result of asynchronously enqueueing the core shutdown command.
|
||||||
@@ -565,6 +573,16 @@ pub struct AppState {
|
|||||||
hotkey_info_open: bool,
|
hotkey_info_open: bool,
|
||||||
/// Whether the pixelpass screen-share explainer popup is open (A11).
|
/// Whether the pixelpass screen-share explainer popup is open (A11).
|
||||||
pixelpass_help_open: bool,
|
pixelpass_help_open: bool,
|
||||||
|
/// Whether the screen-share audio picker is open (A23). Opened by Share
|
||||||
|
/// Screen when not already sharing; lets the user capture one app's audio
|
||||||
|
/// instead of the whole desktop (which echoes the call back to viewers).
|
||||||
|
share_picker_open: bool,
|
||||||
|
/// 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>,
|
||||||
/// Whether the Chat drawer is open (drawer layout only).
|
/// Whether the Chat drawer is open (drawer layout only).
|
||||||
drawer_chat_open: bool,
|
drawer_chat_open: bool,
|
||||||
/// Raw mic level (normalized RMS, `0.0..=1.0`) for the settings meter.
|
/// Raw mic level (normalized RMS, `0.0..=1.0`) for the settings meter.
|
||||||
@@ -634,6 +652,9 @@ impl AppState {
|
|||||||
self.call_started = None;
|
self.call_started = None;
|
||||||
self.mic_level = 0.0;
|
self.mic_level = 0.0;
|
||||||
self.self_sharing = false;
|
self.self_sharing = false;
|
||||||
|
self.share_picker_open = false;
|
||||||
|
self.share_audio_apps.clear();
|
||||||
|
self.share_audio_selection = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn custom_sound_path(&self, sound: Sound) -> &str {
|
fn custom_sound_path(&self, sound: Sound) -> &str {
|
||||||
@@ -755,6 +776,9 @@ impl Default for AppState {
|
|||||||
layout_picker_open: false,
|
layout_picker_open: false,
|
||||||
hotkey_info_open: false,
|
hotkey_info_open: false,
|
||||||
pixelpass_help_open: false,
|
pixelpass_help_open: false,
|
||||||
|
share_picker_open: false,
|
||||||
|
share_audio_apps: Vec::new(),
|
||||||
|
share_audio_selection: None,
|
||||||
drawer_chat_open: false,
|
drawer_chat_open: false,
|
||||||
mic_level: 0.0,
|
mic_level: 0.0,
|
||||||
mic_test_active: false,
|
mic_test_active: false,
|
||||||
@@ -1139,10 +1163,31 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
if state.self_sharing {
|
if state.self_sharing {
|
||||||
let _ = state.controller.send(CoreCommand::StopScreenShare);
|
let _ = state.controller.send(CoreCommand::StopScreenShare);
|
||||||
} else {
|
} else {
|
||||||
let _ = state.controller.send(CoreCommand::StartScreenShare);
|
// Open the audio picker instead of sharing immediately, so the
|
||||||
state.status_message = "Starting screen share…".to_string();
|
// 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
|
||||||
|
// enumeration so the list reflects what's playing right now.
|
||||||
|
state.share_picker_open = true;
|
||||||
|
state.share_audio_selection = None;
|
||||||
|
state.share_audio_apps.clear();
|
||||||
|
let _ = state.controller.send(CoreCommand::ListAudioApps);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AppMessage::CloseSharePicker => {
|
||||||
|
state.share_picker_open = false;
|
||||||
|
}
|
||||||
|
AppMessage::SelectShareAudioApp(app) => {
|
||||||
|
state.share_audio_selection = app;
|
||||||
|
}
|
||||||
|
AppMessage::ConfirmShareScreen => {
|
||||||
|
state.share_picker_open = false;
|
||||||
|
let audio_app = state.share_audio_selection.clone();
|
||||||
|
let _ = state
|
||||||
|
.controller
|
||||||
|
.send(CoreCommand::StartScreenShare { audio_app });
|
||||||
|
state.status_message = "Starting screen share…".to_string();
|
||||||
|
}
|
||||||
AppMessage::WatchShare(ticket) => {
|
AppMessage::WatchShare(ticket) => {
|
||||||
let _ = state.controller.send(CoreCommand::ViewShare(ticket));
|
let _ = state.controller.send(CoreCommand::ViewShare(ticket));
|
||||||
state.status_message = "Opening screen share…".to_string();
|
state.status_message = "Opening screen share…".to_string();
|
||||||
@@ -1298,6 +1343,20 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
state.attachments.insert(key, AttachmentState::Failed(error.clone()), None);
|
state.attachments.insert(key, AttachmentState::Failed(error.clone()), None);
|
||||||
state.status_message = format!("Attachment failed: {error}");
|
state.status_message = format!("Attachment failed: {error}");
|
||||||
}
|
}
|
||||||
|
UiEvent::AudioAppsListed(apps) => {
|
||||||
|
// Only meaningful while the picker is open; if the user
|
||||||
|
// already cancelled, drop it. Keep the current selection if it
|
||||||
|
// still exists in the refreshed list, else fall back to "All
|
||||||
|
// system audio".
|
||||||
|
if state.share_picker_open {
|
||||||
|
if let Some(sel) = &state.share_audio_selection
|
||||||
|
&& !apps.iter().any(|a| a == sel)
|
||||||
|
{
|
||||||
|
state.share_audio_selection = None;
|
||||||
|
}
|
||||||
|
state.share_audio_apps = apps;
|
||||||
|
}
|
||||||
|
}
|
||||||
UiEvent::ScreenShareStarted => {
|
UiEvent::ScreenShareStarted => {
|
||||||
state.self_sharing = true;
|
state.self_sharing = true;
|
||||||
state.status_message = "Sharing your screen".to_string();
|
state.status_message = "Sharing your screen".to_string();
|
||||||
@@ -4753,7 +4812,10 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
.style(c_style(root_bg, Color::TRANSPARENT, 0.0));
|
.style(c_style(root_bg, Color::TRANSPARENT, 0.0));
|
||||||
|
|
||||||
with_hotkey_info(
|
with_hotkey_info(
|
||||||
with_pixelpass_help(with_layout_picker(room.into(), state), state),
|
with_share_picker(
|
||||||
|
with_pixelpass_help(with_layout_picker(room.into(), state), state),
|
||||||
|
state,
|
||||||
|
),
|
||||||
state,
|
state,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -5302,6 +5364,174 @@ fn with_pixelpass_help<'a>(
|
|||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// otherwise hear themselves echoed back. "All system audio" keeps the legacy
|
||||||
|
/// behavior (with a warning); picking an app passes `--app=<name>` to pixelpass.
|
||||||
|
fn with_share_picker<'a>(
|
||||||
|
base: Element<'a, AppMessage>,
|
||||||
|
state: &'a AppState,
|
||||||
|
) -> Element<'a, AppMessage> {
|
||||||
|
if !state.share_picker_open {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
let pal = state.config.theme.palette();
|
||||||
|
let crust = pal.crust;
|
||||||
|
let mantle = pal.mantle;
|
||||||
|
let surface = pal.surface;
|
||||||
|
let text_c = pal.text;
|
||||||
|
let subtext = pal.subtext;
|
||||||
|
let blue = pal.blue;
|
||||||
|
let yellow = pal.yellow;
|
||||||
|
|
||||||
|
let backdrop = mouse_area(
|
||||||
|
container(horizontal_space())
|
||||||
|
.width(iced::Length::Fill)
|
||||||
|
.height(iced::Length::Fill)
|
||||||
|
.style(move |_t: &Theme| container::Style {
|
||||||
|
background: Some(Background::Color(Color { a: 0.55, ..crust })),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.on_press(AppMessage::CloseSharePicker);
|
||||||
|
|
||||||
|
// One selectable row: a radio-style dot + label. Highlighted when selected.
|
||||||
|
let opt_row = |selected: bool, label: String, sublabel: Option<&'static str>, msg: AppMessage| {
|
||||||
|
let dot = text(if selected { "●" } else { "○" })
|
||||||
|
.size(14)
|
||||||
|
.color(if selected { blue } else { subtext });
|
||||||
|
let mut labels = column![text(label).size(13).color(text_c)].spacing(2);
|
||||||
|
if let Some(s) = sublabel {
|
||||||
|
labels = labels.push(text(s).size(11).color(yellow));
|
||||||
|
}
|
||||||
|
button(
|
||||||
|
row![dot, labels]
|
||||||
|
.spacing(10)
|
||||||
|
.align_y(iced::alignment::Vertical::Center),
|
||||||
|
)
|
||||||
|
.on_press(msg)
|
||||||
|
.width(iced::Length::Fill)
|
||||||
|
.padding(8)
|
||||||
|
.style(move |_t: &Theme, status: button::Status| {
|
||||||
|
let bg = if selected {
|
||||||
|
Some(Background::Color(surface))
|
||||||
|
} else if matches!(status, button::Status::Hovered) {
|
||||||
|
Some(Background::Color(Color { a: 0.5, ..surface }))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
button::Style {
|
||||||
|
background: bg,
|
||||||
|
text_color: text_c,
|
||||||
|
border: Border { color: Color::TRANSPARENT, width: 0.0, radius: 6.0.into() },
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// "All system audio" first (the whole-desktop default — carries the echo
|
||||||
|
// warning), then each currently-playing app.
|
||||||
|
let mut options = column![opt_row(
|
||||||
|
state.share_audio_selection.is_none(),
|
||||||
|
"All system audio".to_string(),
|
||||||
|
Some("⚠ may echo the call back to viewers"),
|
||||||
|
AppMessage::SelectShareAudioApp(None),
|
||||||
|
)]
|
||||||
|
.spacing(4);
|
||||||
|
for app in &state.share_audio_apps {
|
||||||
|
let selected = state.share_audio_selection.as_deref() == Some(app.as_str());
|
||||||
|
options = options.push(opt_row(
|
||||||
|
selected,
|
||||||
|
app.clone(),
|
||||||
|
None,
|
||||||
|
AppMessage::SelectShareAudioApp(Some(app.clone())),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let list: Element<'_, AppMessage> = if state.share_audio_apps.is_empty() {
|
||||||
|
column![
|
||||||
|
options,
|
||||||
|
text("No other apps are playing audio right now.")
|
||||||
|
.size(11)
|
||||||
|
.color(subtext),
|
||||||
|
]
|
||||||
|
.spacing(8)
|
||||||
|
.into()
|
||||||
|
} else {
|
||||||
|
scrollable(options).height(iced::Length::Shrink).into()
|
||||||
|
};
|
||||||
|
|
||||||
|
let cancel_btn = button(text("Cancel").size(13).color(text_c))
|
||||||
|
.on_press(AppMessage::CloseSharePicker)
|
||||||
|
.style(move |_t: &Theme, status: button::Status| button::Style {
|
||||||
|
background: Some(Background::Color(match status {
|
||||||
|
button::Status::Hovered => surface,
|
||||||
|
_ => mantle,
|
||||||
|
})),
|
||||||
|
text_color: text_c,
|
||||||
|
border: Border { color: surface, width: 1.0, radius: 6.0.into() },
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.padding(8);
|
||||||
|
let share_btn = button(text("Share").size(13).color(crust))
|
||||||
|
.on_press(AppMessage::ConfirmShareScreen)
|
||||||
|
.style(move |_t: &Theme, status: button::Status| button::Style {
|
||||||
|
background: Some(Background::Color(match status {
|
||||||
|
button::Status::Hovered => pal.lavender,
|
||||||
|
_ => blue,
|
||||||
|
})),
|
||||||
|
text_color: crust,
|
||||||
|
border: Border { color: Color::TRANSPARENT, width: 0.0, radius: 6.0.into() },
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.padding(8);
|
||||||
|
|
||||||
|
let dialog = container(
|
||||||
|
column![
|
||||||
|
row![
|
||||||
|
text("Share screen audio").size(16).color(blue),
|
||||||
|
horizontal_space(),
|
||||||
|
button(text("✕").size(16).color(subtext))
|
||||||
|
.on_press(AppMessage::CloseSharePicker)
|
||||||
|
.style(|_t: &Theme, _s: button::Status| button::Style {
|
||||||
|
background: None,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.padding(2),
|
||||||
|
]
|
||||||
|
.align_y(iced::alignment::Vertical::Center),
|
||||||
|
text("Capture audio from:").size(13).color(text_c),
|
||||||
|
list,
|
||||||
|
row![
|
||||||
|
horizontal_space(),
|
||||||
|
cancel_btn,
|
||||||
|
share_btn,
|
||||||
|
]
|
||||||
|
.spacing(8),
|
||||||
|
]
|
||||||
|
.spacing(14),
|
||||||
|
)
|
||||||
|
.style(move |_t: &Theme| container::Style {
|
||||||
|
text_color: Some(text_c),
|
||||||
|
background: Some(Background::Color(mantle)),
|
||||||
|
border: Border { color: surface, width: 1.0, radius: 12.0.into() },
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.padding(20)
|
||||||
|
.width(iced::Length::Fixed(420.0))
|
||||||
|
.max_height(460.0);
|
||||||
|
|
||||||
|
stack![
|
||||||
|
base,
|
||||||
|
backdrop,
|
||||||
|
container(dialog)
|
||||||
|
.center_x(iced::Length::Fill)
|
||||||
|
.center_y(iced::Length::Fill),
|
||||||
|
]
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
/// Overlay the "Regenerate identity?" confirm dialog when open (W7). A
|
/// Overlay the "Regenerate identity?" confirm dialog when open (W7). A
|
||||||
/// destructive action — minting a new id discards the old one — so it's gated
|
/// destructive action — minting a new id discards the old one — so it's gated
|
||||||
/// behind an explicit confirm with a clear warning.
|
/// behind an explicit confirm with a clear warning.
|
||||||
@@ -6059,6 +6289,9 @@ mod tests {
|
|||||||
state.call_started = Some(now);
|
state.call_started = Some(now);
|
||||||
state.mic_level = 0.75;
|
state.mic_level = 0.75;
|
||||||
state.self_sharing = true;
|
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.clip_status.lock().unwrap().playing_id = Some(attachment_id);
|
state.clip_status.lock().unwrap().playing_id = Some(attachment_id);
|
||||||
|
|
||||||
state.reset_room_state();
|
state.reset_room_state();
|
||||||
@@ -6079,6 +6312,9 @@ mod tests {
|
|||||||
assert!(state.call_started.is_none());
|
assert!(state.call_started.is_none());
|
||||||
assert_eq!(state.mic_level, 0.0);
|
assert_eq!(state.mic_level, 0.0);
|
||||||
assert!(!state.self_sharing);
|
assert!(!state.self_sharing);
|
||||||
|
assert!(!state.share_picker_open);
|
||||||
|
assert!(state.share_audio_apps.is_empty());
|
||||||
|
assert!(state.share_audio_selection.is_none());
|
||||||
|
|
||||||
for _ in 0..50 {
|
for _ in 0..50 {
|
||||||
if crate::audio::clip_player::status_snapshot(&state.clip_status).playing_id.is_none() {
|
if crate::audio::clip_player::status_snapshot(&state.clip_status).playing_id.is_none() {
|
||||||
|
|||||||
+12
-1
@@ -64,9 +64,16 @@ pub enum CoreCommand {
|
|||||||
/// Set the pixelpass binary location (config override, empty = use `$PATH`).
|
/// Set the pixelpass binary location (config override, empty = use `$PATH`).
|
||||||
/// Sent at startup so screen-share can resolve the binary.
|
/// Sent at startup so screen-share can resolve the binary.
|
||||||
SetPixelpassPath(Option<String>),
|
SetPixelpassPath(Option<String>),
|
||||||
|
/// Enumerate apps currently producing audio (for the screen-share audio
|
||||||
|
/// picker, A23). Replies with [`UiEvent::AudioAppsListed`]. Cheap shell-out;
|
||||||
|
/// safe to call each time the picker opens.
|
||||||
|
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.
|
||||||
StartScreenShare,
|
/// `audio_app` selects which app's audio to capture: `Some(name)` captures
|
||||||
|
/// only that app (avoiding the call-loopback echo, A23); `None` shares the
|
||||||
|
/// whole desktop audio (the legacy behavior).
|
||||||
|
StartScreenShare { audio_app: Option<String> },
|
||||||
/// Stop sharing our screen: kill the pixelpass host and clear the presence
|
/// Stop sharing our screen: kill the pixelpass host and clear the presence
|
||||||
/// ticket. No-op when not sharing.
|
/// ticket. No-op when not sharing.
|
||||||
StopScreenShare,
|
StopScreenShare,
|
||||||
@@ -140,6 +147,10 @@ pub enum UiEvent {
|
|||||||
AttachmentReady { from: EndpointId, id: crate::files::AttachmentId, data: Vec<u8> },
|
AttachmentReady { from: EndpointId, id: crate::files::AttachmentId, data: Vec<u8> },
|
||||||
/// An attachment fetch failed (sender gone, too large, decode error, etc.).
|
/// An attachment fetch failed (sender gone, too large, decode error, etc.).
|
||||||
AttachmentFailed { from: EndpointId, id: crate::files::AttachmentId, error: String },
|
AttachmentFailed { from: EndpointId, id: crate::files::AttachmentId, error: String },
|
||||||
|
/// 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.
|
||||||
|
AudioAppsListed(Vec<String>),
|
||||||
/// 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,
|
||||||
/// Our own screen share stopped (or failed to start).
|
/// Our own screen share stopped (or failed to start).
|
||||||
|
|||||||
+7
-2
@@ -2634,7 +2634,12 @@ async fn run_core_loop(
|
|||||||
pixelpass_override = path.filter(|p| !p.trim().is_empty());
|
pixelpass_override = path.filter(|p| !p.trim().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
CoreCommand::StartScreenShare => {
|
CoreCommand::ListAudioApps => {
|
||||||
|
let apps = crate::screenshare::list_audio_apps().await;
|
||||||
|
let _ = ui_tx.send(UiEvent::AudioAppsListed(apps)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
CoreCommand::StartScreenShare { audio_app } => {
|
||||||
let Some(session) = &mut active_session else {
|
let Some(session) = &mut active_session else {
|
||||||
let _ = ui_tx
|
let _ = ui_tx
|
||||||
.send(UiEvent::Error("Join a call before sharing your screen".into()))
|
.send(UiEvent::Error("Join a call before sharing your screen".into()))
|
||||||
@@ -2655,7 +2660,7 @@ async fn run_core_loop(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
match crate::screenshare::spawn_host(&bin).await {
|
match crate::screenshare::spawn_host(&bin, audio_app.as_deref()).await {
|
||||||
Ok((child, ticket)) => {
|
Ok((child, ticket)) => {
|
||||||
crate::log_msg("Screen share host started");
|
crate::log_msg("Screen share host started");
|
||||||
session.screenshare_host = Some(child);
|
session.screenshare_host = Some(child);
|
||||||
|
|||||||
+154
-6
@@ -39,6 +39,10 @@ fn pixelpass_path_candidates(dir: &Path) -> [PathBuf; 1] {
|
|||||||
/// growth, but reject unbounded gossip payloads before the UI offers "Watch".
|
/// growth, but reject unbounded gossip payloads before the UI offers "Watch".
|
||||||
const MAX_TICKET_LEN: usize = 512;
|
const MAX_TICKET_LEN: usize = 512;
|
||||||
|
|
||||||
|
/// Upper bound on a PipeWire `application.name` we'll pass to `--app`. Real names
|
||||||
|
/// are short ("Firefox", "mpv"); this only guards against a pathological value.
|
||||||
|
const MAX_APP_NAME_LEN: usize = 256;
|
||||||
|
|
||||||
/// How long to wait for the host to emit its ticket / the viewer to connect
|
/// How long to wait for the host to emit its ticket / the viewer to connect
|
||||||
/// before giving up and killing the child. Startup is normally sub-second; this
|
/// before giving up and killing the child. Startup is normally sub-second; this
|
||||||
/// 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.
|
||||||
@@ -107,6 +111,88 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build the argv for a pixelpass *host*. Always `--host --output json`; when
|
||||||
|
/// `audio_app` is `Some`, append `--app=<name>` so pixelpass captures only that
|
||||||
|
/// app's audio instead of the whole desktop sink monitor (which contains our own
|
||||||
|
/// call playout → the viewer would hear themselves echoed back, backlog A23).
|
||||||
|
///
|
||||||
|
/// The name is passed in the single-token `--app=<name>` form so a value that
|
||||||
|
/// happens to begin with `-` can never be reparsed as a pixelpass flag (clap
|
||||||
|
/// otherwise rejects hyphen-leading option values). The name is locally chosen
|
||||||
|
/// (our own enumeration / the user's pick), not peer-supplied, but is still
|
||||||
|
/// sanitized via [`sanitize_app_name`] before reaching here. Pure: no I/O.
|
||||||
|
pub fn host_args(audio_app: Option<&str>) -> Vec<String> {
|
||||||
|
let mut args = vec![
|
||||||
|
"--host".to_string(),
|
||||||
|
"--output".to_string(),
|
||||||
|
"json".to_string(),
|
||||||
|
];
|
||||||
|
if let Some(name) = audio_app.and_then(sanitize_app_name) {
|
||||||
|
args.push(format!("--app={name}"));
|
||||||
|
}
|
||||||
|
args
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a locally-chosen audio app name before it becomes a `--app` value:
|
||||||
|
/// trim, reject empty / overlong, and reject names carrying control characters
|
||||||
|
/// (newlines etc.) that have no place in a real `application.name`. `None` means
|
||||||
|
/// "no valid app selected" — the caller then shares the whole desktop audio.
|
||||||
|
pub fn sanitize_app_name(name: &str) -> Option<String> {
|
||||||
|
let name = name.trim();
|
||||||
|
let ok = !name.is_empty()
|
||||||
|
&& name.len() <= MAX_APP_NAME_LEN
|
||||||
|
&& !name.chars().any(|c| c.is_control());
|
||||||
|
ok.then(|| name.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enumerate the apps currently sending audio to a sink, deduplicated by
|
||||||
|
/// `application.name`. Mirrors how pixelpass itself builds its interactive
|
||||||
|
/// picker (`pactl -f json list sink-inputs`), so the names we return are exactly
|
||||||
|
/// the ones `--app` matches against. Returns an empty list on any error (pactl
|
||||||
|
/// missing, non-PipeWire host, nothing playing) — a normal, handled state that
|
||||||
|
/// leaves the picker showing only "All system audio".
|
||||||
|
pub async fn list_audio_apps() -> Vec<String> {
|
||||||
|
let output = Command::new("pactl")
|
||||||
|
.args(["-f", "json", "list", "sink-inputs"])
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
match output {
|
||||||
|
Ok(o) if o.status.success() => parse_audio_apps(&o.stdout),
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse `pactl -f json list sink-inputs` stdout into a sorted, deduplicated list
|
||||||
|
/// of `application.name`s. Pure: no I/O. Unparseable input yields an empty list.
|
||||||
|
pub fn parse_audio_apps(stdout: &[u8]) -> Vec<String> {
|
||||||
|
let Ok(entries) = serde_json::from_slice::<Vec<SinkInput>>(stdout) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let mut names: Vec<String> = entries
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|e| e.properties.application_name)
|
||||||
|
.map(|n| n.trim().to_string())
|
||||||
|
.filter(|n| !n.is_empty())
|
||||||
|
.collect();
|
||||||
|
names.sort_unstable();
|
||||||
|
names.dedup();
|
||||||
|
names
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct SinkInput {
|
||||||
|
properties: SinkInputProperties,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct SinkInputProperties {
|
||||||
|
#[serde(rename = "application.name")]
|
||||||
|
application_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the argv for a pixelpass *viewer*. The `ticket` is peer-supplied (it
|
/// Build the argv for a pixelpass *viewer*. The `ticket` is peer-supplied (it
|
||||||
/// rides gossip presence, which is untrusted and spoofable), so flags come first
|
/// rides gossip presence, which is untrusted and spoofable), so flags come first
|
||||||
/// and the ticket is passed as a positional **after a `--` end-of-options
|
/// and the ticket is passed as a positional **after a `--` end-of-options
|
||||||
@@ -162,17 +248,17 @@ pub fn is_available(config_override: Option<&str>) -> bool {
|
|||||||
pixelpass_path(config_override).is_some()
|
pixelpass_path(config_override).is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn a pixelpass host (`pixelpass --host --output json`), wait for its
|
/// Spawn a pixelpass host (`pixelpass --host --output json [--app=<name>]`), wait
|
||||||
/// startup ticket, and return the live child plus the ticket. The child keeps
|
/// for its startup ticket, and return the live child plus the ticket. When
|
||||||
|
/// `audio_app` is `Some`, pixelpass captures only that app's audio instead of the
|
||||||
|
/// whole desktop sink, which avoids the call-loopback echo (A23). The child keeps
|
||||||
/// running (streaming to viewers) until killed or dropped; remaining stdout is
|
/// running (streaming to viewers) until killed or dropped; remaining stdout is
|
||||||
/// drained in a background task so a full pipe can't stall the host. We do
|
/// drained in a background task so a full pipe can't stall the host. We do
|
||||||
/// **not** pass `--max-viewers`: pixelpass bandwidth-measures its own safe cap,
|
/// **not** pass `--max-viewers`: pixelpass bandwidth-measures its own safe cap,
|
||||||
/// protecting the sharer's uplink, and refuses extras with `viewer_refused`.
|
/// protecting the sharer's uplink, and refuses extras with `viewer_refused`.
|
||||||
pub async fn spawn_host(bin: &Path) -> std::io::Result<(Child, String)> {
|
pub async fn spawn_host(bin: &Path, audio_app: Option<&str>) -> std::io::Result<(Child, String)> {
|
||||||
let mut child = Command::new(bin)
|
let mut child = Command::new(bin)
|
||||||
.arg("--host")
|
.args(host_args(audio_app))
|
||||||
.arg("--output")
|
|
||||||
.arg("json")
|
|
||||||
.stdin(Stdio::null())
|
.stdin(Stdio::null())
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::null())
|
.stderr(Stdio::null())
|
||||||
@@ -384,6 +470,68 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn host_args_without_app_shares_whole_desktop() {
|
||||||
|
// No app selected → no --app flag → pixelpass keeps its default
|
||||||
|
// (whole-desktop) audio capture.
|
||||||
|
assert_eq!(host_args(None), vec!["--host", "--output", "json"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn host_args_with_app_appends_single_token_flag() {
|
||||||
|
// The chosen app rides in the `--app=<name>` single-token form so a
|
||||||
|
// name beginning with `-` can never be reparsed as a flag (A23).
|
||||||
|
assert_eq!(
|
||||||
|
host_args(Some("Firefox")),
|
||||||
|
vec!["--host", "--output", "json", "--app=Firefox"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
host_args(Some("-rm -rf")).last().unwrap(),
|
||||||
|
"--app=-rm -rf",
|
||||||
|
"hyphen-leading name stays a single token bound to --app"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn host_args_blank_or_control_app_is_dropped() {
|
||||||
|
// An empty / whitespace / control-laden selection is sanitized away,
|
||||||
|
// falling back to whole-desktop capture rather than a broken flag.
|
||||||
|
assert_eq!(host_args(Some(" ")), vec!["--host", "--output", "json"]);
|
||||||
|
assert_eq!(host_args(Some("bad\nname")), vec!["--host", "--output", "json"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_app_name_trims_and_rejects_garbage() {
|
||||||
|
assert_eq!(sanitize_app_name(" Firefox \n"), Some("Firefox".to_string()));
|
||||||
|
assert_eq!(sanitize_app_name(""), None);
|
||||||
|
assert_eq!(sanitize_app_name(" "), None);
|
||||||
|
assert_eq!(sanitize_app_name("a\tb"), None);
|
||||||
|
assert_eq!(sanitize_app_name(&"x".repeat(MAX_APP_NAME_LEN + 1)), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_audio_apps_dedups_and_sorts_by_application_name() {
|
||||||
|
let stdout = br#"[
|
||||||
|
{"index":1,"properties":{"application.name":"Firefox"}},
|
||||||
|
{"index":2,"properties":{"application.name":"mpv"}},
|
||||||
|
{"index":3,"properties":{"application.name":"Firefox"}},
|
||||||
|
{"index":4,"properties":{"application.name":" Spotify "}},
|
||||||
|
{"index":5,"properties":{"application.name":""}},
|
||||||
|
{"index":6,"properties":{"other":"no name here"}}
|
||||||
|
]"#;
|
||||||
|
assert_eq!(
|
||||||
|
parse_audio_apps(stdout),
|
||||||
|
vec!["Firefox".to_string(), "Spotify".to_string(), "mpv".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_audio_apps_empty_or_garbage_is_empty() {
|
||||||
|
assert_eq!(parse_audio_apps(b""), Vec::<String>::new());
|
||||||
|
assert_eq!(parse_audio_apps(b"not json"), Vec::<String>::new());
|
||||||
|
assert_eq!(parse_audio_apps(b"[]"), Vec::<String>::new());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sanitize_ticket_accepts_pixelpass_endpoint_ticket_shape() {
|
fn sanitize_ticket_accepts_pixelpass_endpoint_ticket_shape() {
|
||||||
let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm";
|
let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm";
|
||||||
|
|||||||
Reference in New Issue
Block a user