Compare commits

...
3 Commits
Author SHA1 Message Date
molluskandClaude Opus 4.8 913b0b6b20 feat(screenshare): pass --strict-audio + surface app_audio drop warning (A23 P2)
windows-build / windows-build (push) Has been cancelled
cargo-deny / cargo-deny (push) Has been cancelled
Consumes the new pixelpass --strict-audio mode + app_audio events (pixelpass
85fdebe) to close Codex's A23 P2: the per-app pick alone was best-effort —
pixelpass would fall back to whole-desktop audio before the app routed and again
if it stopped, both reintroducing the call echo.

- host_args appends --strict-audio alongside --app=<name>, so pixelpass never
  mirrors the desktop for our share: viewers hear only the chosen app or silence.
- Parse the app_audio JSON event (routed/lost) into PixelpassEvent; spawn_host
  takes an optional notices channel and the stdout drain forwards events on it.
- Core spawns a forwarder (only when an app is selected) mapping routed/lost to
  UiEvent::ShareAudioActive(bool); the channel/task self-terminate on host EOF.
- App tracks share_audio_dropped and shows a transient warning under the "Sharing
  your screen" badge when the chosen app's audio stops ("viewers hear silence
  until it plays again"). Reset on start/stop/room-leave.

In-process mpsc only — no wire/GOSSIP_PROTO change. 435 lib tests (+2: app_audio
parse + the dropped-flag state machine), clippy --all-targets + release clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 17:54:06 -04:00
molluskandClaude Opus 4.8 36fb8bfa9a fix(screenshare): address Codex A23 audit P3s — picker state machine, enum parity, pactl timeout
Triage of Codex's read-only A23 audit (a23-audit-2026-06-26.md). The P2 (pixelpass
--app best-effort fallback) is cross-repo and deferred to a design decision; these
are the three actionable peerspeak-side P3s:

- P3-1: guard the share-startup window. New `share_starting` flag blocks reopening
  the picker (and re-firing StartScreenShare) between ConfirmShareScreen and the
  core's ScreenShareStarted; cleared on Started/Stopped/Error so a failed spawn
  (surfaced as Error, not Stopped) can't wedge it. +2 state-machine tests.
- P3-2: parse_audio_apps now runs each name through sanitize_app_name, so the
  picker never offers a name that host_args would later silently drop (which would
  revert the share to whole-desktop audio = the A23 echo, with no signal). +1 test.
- P3-3: list_audio_apps wraps pactl in a 2s timeout so a wedged enumeration can't
  stall the core command loop (mute/deafen/leave/stop) while the picker opens.

433 lib tests (+3), clippy --all-targets clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 17:37:49 -04:00
molluskandClaude Opus 4.8 2e9164745f 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>
2026-06-26 17:18:49 -04:00
4 changed files with 695 additions and 24 deletions
+399 -8
View File
@@ -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<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.
WatchShare(String),
/// Result of asynchronously enqueueing the core shutdown command.
@@ -565,6 +573,26 @@ 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<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>,
/// A share start is in flight: `ConfirmShareScreen` was sent but the core
/// hasn't yet replied with `ScreenShareStarted`/an error. Blocks reopening
/// the picker (and re-confirming) during that startup window. Cleared on
/// `ScreenShareStarted`, `ScreenShareStopped`, or any `Error`.
share_starting: bool,
/// While sharing a specific app's audio (A23 strict mode): `true` when that
/// app's audio has stopped, so viewers currently hear silence. Drives a
/// transient warning. Always `false` for whole-desktop shares (pixelpass
/// emits no `app_audio` events then) and when not sharing.
share_audio_dropped: bool,
/// 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 +662,11 @@ 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;
self.share_starting = false;
self.share_audio_dropped = false;
}
fn custom_sound_path(&self, sound: Sound) -> &str {
@@ -755,6 +788,11 @@ 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,
share_starting: false,
share_audio_dropped: false,
drawer_chat_open: false,
mic_level: 0.0,
mic_test_active: false,
@@ -1138,8 +1176,37 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
AppMessage::ToggleScreenShare => {
if state.self_sharing {
let _ = state.controller.send(CoreCommand::StopScreenShare);
} else {
let _ = state.controller.send(CoreCommand::StartScreenShare);
} else if !state.share_starting {
// 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.
// Suppressed while a start is already in flight (`share_starting`)
// so the picker can't be reopened during the startup window.
state.share_picker_open = true;
state.share_audio_selection = None;
state.share_audio_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 => {
// Only a confirm from an open picker starts a share; a stray confirm
// (or one arriving while a start is already in flight) is ignored, so
// we can't double-send StartScreenShare.
if state.share_picker_open && !state.share_starting {
state.share_picker_open = false;
state.share_starting = true;
let audio_app = state.share_audio_selection.clone();
let _ = state
.controller
.send(CoreCommand::StartScreenShare { audio_app });
state.status_message = "Starting screen share…".to_string();
}
}
@@ -1298,14 +1365,38 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
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.share_starting = false;
state.share_audio_dropped = false;
// Defensive: ensure no picker lingers across a successful start.
state.share_picker_open = false;
state.status_message = "Sharing your screen".to_string();
}
UiEvent::ScreenShareStopped => {
state.self_sharing = false;
state.share_starting = false;
state.share_audio_dropped = false;
state.status_message = "Screen share stopped".to_string();
}
UiEvent::ShareAudioActive(active) => {
// Per-app audio routed/lost (only while sharing a chosen app).
state.share_audio_dropped = !active;
}
UiEvent::IdentityStatus { node_id, persisted, error } => {
state.self_node_id = Some(node_id);
state.identity_persisted = persisted;
@@ -1348,6 +1439,10 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
}
}
UiEvent::Error(err) => {
// A failed share start (spawn error) surfaces here, not via
// ScreenShareStopped, so clear the in-flight flag to let the
// user retry instead of being wedged.
state.share_starting = false;
state.status_message = format!("Error: {}", err);
}
}
@@ -3987,16 +4082,32 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
};
el
},
// Live "you're sharing" badge — only present while sharing.
// Live "you're sharing" badge — only present while sharing. When
// sharing a specific app whose audio has dropped (A23 strict
// mode), a warning line is added: viewers hear silence, not the
// call, until that app plays again.
{
let el: Element<'_, AppMessage> = if state.self_sharing {
row![
let badge = row![
icon(IconKind::Live, 14.0, color_red),
text("Sharing your screen").size(13).color(color_red),
]
.spacing(6)
.align_y(iced::alignment::Vertical::Center)
.align_y(iced::alignment::Vertical::Center);
if state.share_audio_dropped {
column![
badge,
text(
"⚠ Shared app's audio stopped — viewers hear silence until it plays again"
)
.size(11)
.color(color_yellow),
]
.spacing(3)
.into()
} else {
badge.into()
}
} else {
iced::widget::Space::new().width(0.0).height(0.0).into()
};
@@ -4753,8 +4864,11 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.style(c_style(root_bg, Color::TRANSPARENT, 0.0));
with_hotkey_info(
with_share_picker(
with_pixelpass_help(with_layout_picker(room.into(), state), state),
state,
),
state,
)
}
}
@@ -5302,6 +5416,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=<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
/// destructive action — minting a new id discards the old one — so it's gated
/// behind an explicit confirm with a clear warning.
@@ -5898,8 +6180,9 @@ impl Program<AppMessage> for Icon {
mod tests {
use super::{
attachment_default_name, format_duration, initial_window_position, reconnect_attempt_chime,
reconnected_chime, set_peer_gate_config, set_peer_volume_config, AppConfig, AppState,
AttachmentCache, AttachmentState, ChatEntry, GateMeter, METER_MAX,
reconnected_chime, set_peer_gate_config, set_peer_volume_config, update, AppConfig,
AppMessage, AppState, AttachmentCache, AttachmentState, ChatEntry, GateMeter, METER_MAX,
UiEvent,
};
use iroh::SecretKey;
@@ -6059,6 +6342,11 @@ 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.share_starting = true;
state.share_audio_dropped = true;
state.clip_status.lock().unwrap().playing_id = Some(attachment_id);
state.reset_room_state();
@@ -6079,6 +6367,11 @@ 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());
assert!(!state.share_starting);
assert!(!state.share_audio_dropped);
for _ in 0..50 {
if crate::audio::clip_player::status_snapshot(&state.clip_status).playing_id.is_none() {
@@ -6089,6 +6382,104 @@ mod tests {
panic!("clip player did not stop during room reset");
}
#[test]
fn share_picker_startup_window_is_guarded() {
// P3-1: between confirming the picker and the core's ScreenShareStarted,
// self_sharing is still false. The picker must not be reopenable in that
// window, and a stray confirm must not re-fire StartScreenShare.
// Picker open, user confirms a selection.
let mut state = AppState {
share_picker_open: true,
share_audio_selection: Some("mpv".to_string()),
..Default::default()
};
let _ = update(&mut state, AppMessage::ConfirmShareScreen);
assert!(state.share_starting, "confirm should mark a start in flight");
assert!(!state.share_picker_open, "confirm should close the picker");
assert!(!state.self_sharing, "core hasn't acked the start yet");
// Clicking Share again during startup must NOT reopen the picker.
let _ = update(&mut state, AppMessage::ToggleScreenShare);
assert!(!state.share_picker_open, "picker must stay closed while starting");
assert!(state.share_starting);
// A stray confirm during startup is ignored (no double-start).
let _ = update(&mut state, AppMessage::ConfirmShareScreen);
assert!(state.share_starting);
// Core acks: flag clears, sharing begins, no picker lingers.
let _ = update(
&mut state,
AppMessage::UiEventReceived(UiEvent::ScreenShareStarted),
);
assert!(!state.share_starting);
assert!(state.self_sharing);
assert!(!state.share_picker_open);
}
#[test]
fn share_start_failure_clears_in_flight_flag() {
// A failed spawn surfaces as UiEvent::Error (not ScreenShareStopped); the
// in-flight flag must still clear so the user can retry.
let mut state = AppState {
share_picker_open: true,
..Default::default()
};
let _ = update(&mut state, AppMessage::ConfirmShareScreen);
assert!(state.share_starting);
let _ = update(
&mut state,
AppMessage::UiEventReceived(UiEvent::Error("boom".into())),
);
assert!(!state.share_starting, "error must un-wedge the start flag");
assert!(!state.self_sharing);
// And now the picker can be opened again.
let _ = update(&mut state, AppMessage::ToggleScreenShare);
assert!(state.share_picker_open);
}
#[test]
fn share_audio_dropped_tracks_app_audio_events() {
// While sharing a specific app, app_audio lost/routed toggles the warning
// flag; start and stop both reset it so it can't linger across sessions.
let mut state = AppState::default();
// Start sharing — flag is clear.
let _ = update(
&mut state,
AppMessage::UiEventReceived(UiEvent::ScreenShareStarted),
);
assert!(!state.share_audio_dropped);
// The chosen app's audio stops → warning on.
let _ = update(
&mut state,
AppMessage::UiEventReceived(UiEvent::ShareAudioActive(false)),
);
assert!(state.share_audio_dropped);
// It plays again → warning off.
let _ = update(
&mut state,
AppMessage::UiEventReceived(UiEvent::ShareAudioActive(true)),
);
assert!(!state.share_audio_dropped);
// Drop again, then stop sharing → flag reset regardless.
let _ = update(
&mut state,
AppMessage::UiEventReceived(UiEvent::ShareAudioActive(false)),
);
assert!(state.share_audio_dropped);
let _ = update(
&mut state,
AppMessage::UiEventReceived(UiEvent::ScreenShareStopped),
);
assert!(!state.share_audio_dropped);
}
#[test]
fn peer_gate_persists_when_on_and_clears_when_off() {
let mut config = AppConfig::default();
+17 -1
View File
@@ -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<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
/// 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
/// ticket. No-op when not sharing.
StopScreenShare,
@@ -140,10 +147,19 @@ pub enum UiEvent {
AttachmentReady { from: EndpointId, id: crate::files::AttachmentId, data: Vec<u8> },
/// 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<String>),
/// Our own screen share started; the UI flips the Share button to "Stop".
ScreenShareStarted,
/// Our own screen share stopped (or failed to start).
ScreenShareStopped,
/// Per-app screen-share audio routing state (A23). `true` = the app we chose
/// is now reaching viewers; `false` = its audio stopped, so under our strict
/// run viewers currently hear silence. The UI shows a transient warning while
/// `false`. Only meaningful while sharing a specific app (not whole-desktop).
ShareAudioActive(bool),
/// Our node identity (W7): the current node id string, and whether it is
/// PERSISTED to disk. Sent once at startup and again after a regenerate.
/// `persisted = false` means the key file couldn't be read/written and we're
+33 -2
View File
@@ -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,33 @@ async fn run_core_loop(
continue;
}
};
match crate::screenshare::spawn_host(&bin).await {
// Forward pixelpass `app_audio` events (only emitted when an app
// is selected) to the UI so it can warn when the chosen app's
// audio drops. The channel closes when the host dies (drain hits
// EOF), ending the forwarder task on its own.
let notices = audio_app.as_deref().map(|_| {
let (tx, mut rx) =
tokio::sync::mpsc::unbounded_channel::<crate::screenshare::PixelpassEvent>();
let ui_tx_notices = ui_tx.clone();
tokio::spawn(async move {
while let Some(ev) = rx.recv().await {
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;
}
}
});
tx
});
match crate::screenshare::spawn_host(&bin, audio_app.as_deref(), notices).await {
Ok((child, ticket)) => {
crate::log_msg("Screen share host started");
session.screenshare_host = Some(child);
+244 -11
View File
@@ -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.
@@ -64,6 +68,12 @@ pub enum PixelpassEvent {
CaptureStarted,
/// Host: capture pipeline torn down (on last viewer).
CaptureStopped,
/// Host (per-app audio): the chosen app's audio is now reaching viewers.
AppAudioRouted,
/// Host (per-app audio): the chosen app's last audio stream went away. Under
/// our `--strict-audio` run this means viewers now hear silence (not the call
/// echo) until the app produces audio again — we surface it as a warning.
AppAudioLost,
/// A recognized event we don't act on (e.g. `host_info`).
Other,
}
@@ -98,6 +108,11 @@ pub fn parse_pixelpass_event(line: &str) -> Option<PixelpassEvent> {
Some("stopped") => PixelpassEvent::CaptureStopped,
_ => PixelpassEvent::Other,
},
"app_audio" => match v.get("state").and_then(|s| s.as_str()) {
Some("routed") => PixelpassEvent::AppAudioRouted,
Some("lost") => PixelpassEvent::AppAudioLost,
_ => PixelpassEvent::Other,
},
_ => PixelpassEvent::Other,
};
Some(ev)
@@ -107,6 +122,105 @@ 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=<name> --strict-audio` 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).
///
/// `--strict-audio` is what makes the fix a guarantee rather than best-effort:
/// without it, pixelpass falls back to the whole-desktop loopback before the
/// app's first stream routes and again if the app's audio later stops — both of
/// which reintroduce the echo. With it, the viewer hears only the chosen app (or
/// silence), and pixelpass emits `app_audio` events we surface as a warning.
///
/// 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.push("--strict-audio".to_string());
}
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())
}
/// Hard cap on how long enumeration waits for `pactl`. It runs inline on the core
/// command loop (the picker awaits it before opening), so a wedged/slow `pactl`
/// must not stall mute/deafen/leave/stop. On timeout we treat it like any other
/// failure: empty list → "All system audio" only.
const LIST_APPS_TIMEOUT: Duration = Duration::from_secs(2);
/// 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, or [`LIST_APPS_TIMEOUT`] elapsed)
/// — a normal, handled state that leaves the picker showing only "All system
/// audio".
pub async fn list_audio_apps() -> Vec<String> {
let run = Command::new("pactl")
.args(["-f", "json", "list", "sink-inputs"])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.output();
match tokio::time::timeout(LIST_APPS_TIMEOUT, run).await {
Ok(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.
/// Each name is passed through [`sanitize_app_name`] so the picker only ever
/// offers names that will actually survive [`host_args`]; otherwise a name that
/// parses here but fails sanitization later would be selectable yet silently
/// drop the `--app` flag and revert the share to whole-desktop audio (A23 echo).
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)
.filter_map(|n| sanitize_app_name(&n))
.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
/// 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 +276,21 @@ 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=<name>]`), 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>,
notices: Option<tokio::sync::mpsc::UnboundedSender<PixelpassEvent>>,
) -> 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())
@@ -204,7 +322,7 @@ pub async fn spawn_host(bin: &Path) -> std::io::Result<(Child, String)> {
}
};
drain_in_background(lines, "host");
drain_in_background(lines, "host", notices);
Ok((child, ticket))
}
@@ -251,7 +369,7 @@ pub async fn spawn_viewer(bin: &Path, ticket: &str) -> std::io::Result<Child> {
return Err(e);
}
drain_in_background(lines, "viewer");
drain_in_background(lines, "viewer", None);
Ok(child)
}
@@ -286,15 +404,24 @@ where
}
/// Keep reading the child's stdout to EOF in the background so a full pipe can't
/// stall it; log notable events for diagnostics.
fn drain_in_background<R>(mut lines: tokio::io::Lines<BufReader<R>>, role: &'static str)
where
/// stall it; log notable events for diagnostics. When `notices` is `Some`, each
/// parsed event is also forwarded to the caller (the core, which translates the
/// `app_audio` ones into a UI warning); a send failure (receiver dropped) just
/// stops forwarding, draining continues. The task ends on EOF (child exited).
fn drain_in_background<R>(
mut lines: tokio::io::Lines<BufReader<R>>,
role: &'static str,
notices: Option<tokio::sync::mpsc::UnboundedSender<PixelpassEvent>>,
) where
R: tokio::io::AsyncRead + Unpin + Send + 'static,
{
tokio::spawn(async move {
while let Ok(Some(line)) = lines.next_line().await {
if let Some(ev) = parse_pixelpass_event(&line) {
crate::log_msg(&format!("pixelpass {role}: {}", event_for_log(&ev)));
if let Some(tx) = &notices {
let _ = tx.send(ev);
}
}
}
});
@@ -313,6 +440,8 @@ fn event_for_log(ev: &PixelpassEvent) -> String {
PixelpassEvent::Refused(reason) => format!("viewer_refused reason={reason:?}"),
PixelpassEvent::CaptureStarted => "capture_started".to_string(),
PixelpassEvent::CaptureStopped => "capture_stopped".to_string(),
PixelpassEvent::AppAudioRouted => "app_audio_routed".to_string(),
PixelpassEvent::AppAudioLost => "app_audio_lost".to_string(),
PixelpassEvent::Other => "other".to_string(),
}
}
@@ -384,6 +513,87 @@ 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), plus
// `--strict-audio` so pixelpass never falls back to whole-desktop audio.
assert_eq!(
host_args(Some("Firefox")),
vec!["--host", "--output", "json", "--app=Firefox", "--strict-audio"]
);
// The hyphen-leading name is still bound to --app as a single token;
// --strict-audio is the trailing flag.
let args = host_args(Some("-rm -rf"));
assert_eq!(args[3], "--app=-rm -rf");
assert_eq!(args[4], "--strict-audio");
}
#[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]
fn parse_audio_apps_drops_names_host_args_would_reject() {
// Names that parse from pactl but fail `sanitize_app_name` (control chars,
// overlong) must NOT be offered in the picker — otherwise the user could
// pick one, `host_args` would silently drop `--app`, and the share would
// revert to whole-desktop audio (A23 echo) with no signal. The valid name
// survives; the control-char and overlong ones are filtered out.
let overlong = "x".repeat(MAX_APP_NAME_LEN + 1);
let stdout = format!(
r#"[
{{"index":1,"properties":{{"application.name":"mpv"}}}},
{{"index":2,"properties":{{"application.name":"bad\nname"}}}},
{{"index":3,"properties":{{"application.name":"{overlong}"}}}}
]"#
);
assert_eq!(parse_audio_apps(stdout.as_bytes()), vec!["mpv".to_string()]);
}
#[test]
fn sanitize_ticket_accepts_pixelpass_endpoint_ticket_shape() {
let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm";
@@ -470,6 +680,29 @@ mod tests {
);
}
#[test]
fn parses_app_audio_states() {
// The wire contract from pixelpass's --strict-audio run (A23): routed =
// the chosen app's audio is live; lost = it stopped (viewers now silent).
assert_eq!(
parse_pixelpass_event(r#"{"event":"app_audio","state":"routed"}"#),
Some(PixelpassEvent::AppAudioRouted)
);
assert_eq!(
parse_pixelpass_event(r#"{"event":"app_audio","state":"lost"}"#),
Some(PixelpassEvent::AppAudioLost)
);
// Unknown / missing state is recognized-but-unused, not a parse failure.
assert_eq!(
parse_pixelpass_event(r#"{"event":"app_audio","state":"weird"}"#),
Some(PixelpassEvent::Other)
);
assert_eq!(
parse_pixelpass_event(r#"{"event":"app_audio"}"#),
Some(PixelpassEvent::Other)
);
}
#[test]
fn recognized_but_unused_event_is_other() {
assert_eq!(