diff --git a/src/app/mod.rs b/src/app/mod.rs index 3bc8727..4de68e1 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -426,8 +426,16 @@ pub enum AppMessage { SetBackgroundDim(f32), /// Toggle the Chat drawer open/closed (drawer layout). 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, + /// 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), + /// Confirm the picker: start the share with the currently selected audio app. + ConfirmShareScreen, /// Watch a peer's screen share, identified by their pixelpass ticket. WatchShare(String), /// Result of asynchronously enqueueing the core shutdown command. @@ -565,6 +573,16 @@ pub struct AppState { hotkey_info_open: bool, /// Whether the pixelpass screen-share explainer popup is open (A11). 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, + /// 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, /// Whether the Chat drawer is open (drawer layout only). drawer_chat_open: bool, /// Raw mic level (normalized RMS, `0.0..=1.0`) for the settings meter. @@ -634,6 +652,9 @@ impl AppState { self.call_started = None; self.mic_level = 0.0; 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 { @@ -755,6 +776,9 @@ impl Default for AppState { layout_picker_open: false, hotkey_info_open: false, pixelpass_help_open: false, + share_picker_open: false, + share_audio_apps: Vec::new(), + share_audio_selection: None, drawer_chat_open: false, mic_level: 0.0, mic_test_active: false, @@ -1139,10 +1163,31 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { if state.self_sharing { let _ = state.controller.send(CoreCommand::StopScreenShare); } else { - let _ = state.controller.send(CoreCommand::StartScreenShare); - state.status_message = "Starting screen share…".to_string(); + // Open the audio picker instead of sharing immediately, so the + // user chooses which app's audio to capture rather than the whole + // desktop (which echoes the call back to viewers, A23). Default + // selection is "All system audio" (None). Kick off a fresh + // 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) => { let _ = state.controller.send(CoreCommand::ViewShare(ticket)); state.status_message = "Opening screen share…".to_string(); @@ -1298,6 +1343,20 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.attachments.insert(key, AttachmentState::Failed(error.clone()), None); 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 => { state.self_sharing = true; 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)); 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, ) } @@ -5302,6 +5364,174 @@ fn with_pixelpass_help<'a>( .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=` 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 /// destructive action — minting a new id discards the old one — so it's gated /// behind an explicit confirm with a clear warning. @@ -6059,6 +6289,9 @@ mod tests { state.call_started = Some(now); state.mic_level = 0.75; 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.reset_room_state(); @@ -6079,6 +6312,9 @@ mod tests { assert!(state.call_started.is_none()); assert_eq!(state.mic_level, 0.0); 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 { if crate::audio::clip_player::status_snapshot(&state.clip_status).playing_id.is_none() { diff --git a/src/core/messages.rs b/src/core/messages.rs index 293c3d4..c6f888d 100644 --- a/src/core/messages.rs +++ b/src/core/messages.rs @@ -64,9 +64,16 @@ pub enum CoreCommand { /// Set the pixelpass binary location (config override, empty = use `$PATH`). /// Sent at startup so screen-share can resolve the binary. SetPixelpassPath(Option), + /// 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 /// 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 }, /// Stop sharing our screen: kill the pixelpass host and clear the presence /// ticket. No-op when not sharing. StopScreenShare, @@ -140,6 +147,10 @@ pub enum UiEvent { AttachmentReady { from: EndpointId, id: crate::files::AttachmentId, data: Vec }, /// An attachment fetch failed (sender gone, too large, decode error, etc.). 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), /// Our own screen share started; the UI flips the Share button to "Stop". ScreenShareStarted, /// Our own screen share stopped (or failed to start). diff --git a/src/core/mod.rs b/src/core/mod.rs index 42b647d..ee6caaa 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -2634,7 +2634,12 @@ async fn run_core_loop( 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 _ = ui_tx .send(UiEvent::Error("Join a call before sharing your screen".into())) @@ -2655,7 +2660,7 @@ async fn run_core_loop( continue; } }; - match crate::screenshare::spawn_host(&bin).await { + match crate::screenshare::spawn_host(&bin, audio_app.as_deref()).await { Ok((child, ticket)) => { crate::log_msg("Screen share host started"); session.screenshare_host = Some(child); diff --git a/src/screenshare/mod.rs b/src/screenshare/mod.rs index 6187673..fb40ac2 100644 --- a/src/screenshare/mod.rs +++ b/src/screenshare/mod.rs @@ -39,6 +39,10 @@ fn pixelpass_path_candidates(dir: &Path) -> [PathBuf; 1] { /// growth, but reject unbounded gossip payloads before the UI offers "Watch". 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 /// 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. @@ -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 } +/// Build the argv for a pixelpass *host*. Always `--host --output json`; when +/// `audio_app` is `Some`, append `--app=` 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=` 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 { + 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 { + 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 { + 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 { + let Ok(entries) = serde_json::from_slice::>(stdout) else { + return Vec::new(); + }; + let mut names: Vec = 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, +} + /// 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 /// 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() } -/// Spawn a pixelpass host (`pixelpass --host --output json`), wait for its -/// startup ticket, and return the live child plus the ticket. The child keeps +/// Spawn a pixelpass host (`pixelpass --host --output json [--app=]`), wait +/// 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 /// 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, /// 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) - .arg("--host") - .arg("--output") - .arg("json") + .args(host_args(audio_app)) .stdin(Stdio::null()) .stdout(Stdio::piped()) .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=` 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::::new()); + assert_eq!(parse_audio_apps(b"not json"), Vec::::new()); + assert_eq!(parse_audio_apps(b"[]"), Vec::::new()); + } + #[test] fn sanitize_ticket_accepts_pixelpass_endpoint_ticket_shape() { let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm";