Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82e1740d3c | ||
|
|
4dc1bcd546 | ||
|
|
067997f9ba | ||
|
|
660eb27a84 | ||
|
|
913b0b6b20 | ||
|
|
36fb8bfa9a | ||
|
|
2e9164745f |
+29
@@ -2,10 +2,39 @@
|
|||||||
name = "peerspeak"
|
name = "peerspeak"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
description = "Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
||||||
# Application crate, not a crates.io library — refuse `cargo publish` and let
|
# Application crate, not a crates.io library — refuse `cargo publish` and let
|
||||||
# cargo-deny's [licenses.private] skip the missing-license check.
|
# cargo-deny's [licenses.private] skip the missing-license check.
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
|
# Debian/Ubuntu packaging (cargo-deb). Mirrors packaging/PKGBUILD: only the main
|
||||||
|
# `peerspeak` binary ships (not test_net/specview), plus the desktop entry and the
|
||||||
|
# hicolor icon set. Runtime shared-lib deps (libpipewire, libopus, libc, …) are
|
||||||
|
# resolved by dpkg-shlibdeps via `depends = "$auto"`. Build inside a Debian/Ubuntu
|
||||||
|
# distrobox so the binary links that distro's glibc, then `cargo deb --no-build`.
|
||||||
|
[package.metadata.deb]
|
||||||
|
maintainer = "mollusk <jitty+lc1iz0dc@protonmail.com>"
|
||||||
|
copyright = "2026, mollusk. Private build — not for redistribution."
|
||||||
|
section = "net"
|
||||||
|
priority = "optional"
|
||||||
|
depends = "$auto"
|
||||||
|
# pixelpass = in-room screen sharing; mpv = the screen-share viewer (vlc fallback).
|
||||||
|
recommends = "pixelpass, mpv"
|
||||||
|
extended-description = "Decentralized peer-to-peer voice chat over iroh (QUIC) with PipeWire audio, the Opus codec, and an iced GUI. Full-mesh, no central server."
|
||||||
|
assets = [
|
||||||
|
["target/release/peerspeak", "usr/bin/", "755"],
|
||||||
|
["packaging/peerspeak.desktop", "usr/share/applications/", "644"],
|
||||||
|
["assets/icons/peerspeak.svg", "usr/share/icons/hicolor/scalable/apps/peerspeak.svg", "644"],
|
||||||
|
["assets/icons/peerspeak-16.png", "usr/share/icons/hicolor/16x16/apps/peerspeak.png", "644"],
|
||||||
|
["assets/icons/peerspeak-24.png", "usr/share/icons/hicolor/24x24/apps/peerspeak.png", "644"],
|
||||||
|
["assets/icons/peerspeak-32.png", "usr/share/icons/hicolor/32x32/apps/peerspeak.png", "644"],
|
||||||
|
["assets/icons/peerspeak-48.png", "usr/share/icons/hicolor/48x48/apps/peerspeak.png", "644"],
|
||||||
|
["assets/icons/peerspeak-64.png", "usr/share/icons/hicolor/64x64/apps/peerspeak.png", "644"],
|
||||||
|
["assets/icons/peerspeak-128.png", "usr/share/icons/hicolor/128x128/apps/peerspeak.png", "644"],
|
||||||
|
["assets/icons/peerspeak-256.png", "usr/share/icons/hicolor/256x256/apps/peerspeak.png", "644"],
|
||||||
|
["assets/icons/peerspeak-512.png", "usr/share/icons/hicolor/512x512/apps/peerspeak.png", "644"],
|
||||||
|
]
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
name = "peerspeak"
|
name = "peerspeak"
|
||||||
path = "src/lib.rs"
|
path = "src/lib.rs"
|
||||||
|
|||||||
@@ -225,6 +225,20 @@ state change; rate-limit pings), tickets from friends (validate defensively, no
|
|||||||
auto-join), the discovery publish (only when toggled, ideally auto-expiring).
|
auto-join), the discovery publish (only when toggled, ideally auto-expiring).
|
||||||
`cargo audit` (JSON store → no new deps expected). Field test on dopedart.
|
`cargo audit` (JSON store → no new deps expected). Field test on dopedart.
|
||||||
|
|
||||||
|
**Local hardening DONE 2026-06-27:** inbound friend-presence replies are now
|
||||||
|
rate-limited per authenticated friend id (`PresenceRateLimiter`: burst 4, refill
|
||||||
|
1/15s) and wired into the live friends listener before it builds a `Pong`; denied
|
||||||
|
probes get the same silent no-data close as unauthorized probes. Existing
|
||||||
|
defensive reply handling still validates room tickets against the authenticated
|
||||||
|
friend id and never auto-joins. Verified with `cargo test presence`,
|
||||||
|
`cargo test --lib`, `cargo clippy --all-targets -- -D warnings`, and
|
||||||
|
`cargo audit --no-fetch --stale` (local DB; reports only the two already-allowed
|
||||||
|
unmaintained advisories in `deny.toml`). A fresh advisory fetch was blocked in
|
||||||
|
this sandbox by network restrictions.
|
||||||
|
|
||||||
|
**Remaining:** live 2-machine field test on dopedart, a fresh online
|
||||||
|
`cargo audit`, and any follow-up findings from that test.
|
||||||
|
|
||||||
## The connect flow (the user's scenario, end to end)
|
## The connect flow (the user's scenario, end to end)
|
||||||
1. Friend X, at a coffee shop, opens peerspeak and starts a gathering labeled
|
1. Friend X, at a coffee shop, opens peerspeak and starts a gathering labeled
|
||||||
"HangOut."
|
"HangOut."
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
|
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
|
||||||
pkgname=peerspeak-git
|
pkgname=peerspeak-git
|
||||||
_pkgname=peerspeak
|
_pkgname=peerspeak
|
||||||
pkgver=0.3.0.r229.g7fb1c96
|
pkgver=0.4.0.r254.g913b0b6
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
+527
-10
@@ -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,37 @@ 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>,
|
||||||
|
/// 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 (or hasn't started yet), 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 current share is a specific-app capture (vs whole-desktop).
|
||||||
|
/// Set from the confirmed selection on `ScreenShareStarted`, cleared on
|
||||||
|
/// stop/reset. Gates applying `ShareAudioActive`, so a late event from a
|
||||||
|
/// just-killed host can't flip the warning on a new whole-desktop share or
|
||||||
|
/// after stop (audit P3, unscoped events).
|
||||||
|
share_audio_app_active: bool,
|
||||||
|
/// Whether the resolved pixelpass supports `--strict-audio` (per-app audio).
|
||||||
|
/// `false` ⇒ the picker offers whole-desktop only, because a per-app share
|
||||||
|
/// would pass a flag an older pixelpass rejects (audit P2). Optimistic `true`
|
||||||
|
/// until the core's `AudioAppsListed` reports otherwise.
|
||||||
|
share_app_audio_supported: bool,
|
||||||
/// Whether the 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 +673,13 @@ 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;
|
||||||
|
self.share_starting = false;
|
||||||
|
self.share_audio_dropped = false;
|
||||||
|
self.share_audio_app_active = false;
|
||||||
|
self.share_app_audio_supported = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn custom_sound_path(&self, sound: Sound) -> &str {
|
fn custom_sound_path(&self, sound: Sound) -> &str {
|
||||||
@@ -755,6 +801,13 @@ 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,
|
||||||
|
share_starting: false,
|
||||||
|
share_audio_dropped: false,
|
||||||
|
share_audio_app_active: false,
|
||||||
|
share_app_audio_supported: true,
|
||||||
drawer_chat_open: false,
|
drawer_chat_open: false,
|
||||||
mic_level: 0.0,
|
mic_level: 0.0,
|
||||||
mic_test_active: false,
|
mic_test_active: false,
|
||||||
@@ -1138,8 +1191,37 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
AppMessage::ToggleScreenShare => {
|
AppMessage::ToggleScreenShare => {
|
||||||
if state.self_sharing {
|
if state.self_sharing {
|
||||||
let _ = state.controller.send(CoreCommand::StopScreenShare);
|
let _ = state.controller.send(CoreCommand::StopScreenShare);
|
||||||
} else {
|
} else if !state.share_starting {
|
||||||
let _ = state.controller.send(CoreCommand::StartScreenShare);
|
// 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();
|
state.status_message = "Starting screen share…".to_string();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1298,14 +1380,55 @@ 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, app_audio_supported } => {
|
||||||
|
// Only meaningful while the picker is open; if the user
|
||||||
|
// already cancelled, drop it.
|
||||||
|
if state.share_picker_open {
|
||||||
|
state.share_app_audio_supported = app_audio_supported;
|
||||||
|
if app_audio_supported {
|
||||||
|
// Keep the current selection if it still exists in the
|
||||||
|
// refreshed list, else fall back to "All system audio".
|
||||||
|
if let Some(sel) = &state.share_audio_selection
|
||||||
|
&& !apps.iter().any(|a| a == sel)
|
||||||
|
{
|
||||||
|
state.share_audio_selection = None;
|
||||||
|
}
|
||||||
|
state.share_audio_apps = apps;
|
||||||
|
} else {
|
||||||
|
// Older pixelpass: per-app capture would hard-fail
|
||||||
|
// (--strict-audio unknown). Force whole-desktop only.
|
||||||
|
state.share_audio_apps.clear();
|
||||||
|
state.share_audio_selection = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
UiEvent::ScreenShareStarted => {
|
UiEvent::ScreenShareStarted => {
|
||||||
state.self_sharing = true;
|
state.self_sharing = true;
|
||||||
|
state.share_starting = false;
|
||||||
|
state.share_audio_dropped = false;
|
||||||
|
// Remember whether this share captures a specific app, so we
|
||||||
|
// only apply `app_audio` warnings to app shares (P3).
|
||||||
|
state.share_audio_app_active = state.share_audio_selection.is_some();
|
||||||
|
// Defensive: ensure no picker lingers across a successful start.
|
||||||
|
state.share_picker_open = false;
|
||||||
state.status_message = "Sharing your screen".to_string();
|
state.status_message = "Sharing your screen".to_string();
|
||||||
}
|
}
|
||||||
UiEvent::ScreenShareStopped => {
|
UiEvent::ScreenShareStopped => {
|
||||||
state.self_sharing = false;
|
state.self_sharing = false;
|
||||||
|
state.share_starting = false;
|
||||||
|
state.share_audio_dropped = false;
|
||||||
|
state.share_audio_app_active = false;
|
||||||
state.status_message = "Screen share stopped".to_string();
|
state.status_message = "Screen share stopped".to_string();
|
||||||
}
|
}
|
||||||
|
UiEvent::ShareAudioActive(active) => {
|
||||||
|
// Per-app audio routed/lost. Apply only while we're actually
|
||||||
|
// sharing a specific app: a late event from a just-killed host
|
||||||
|
// must not flip the warning after stop or on a whole-desktop
|
||||||
|
// share (audit P3, unscoped events).
|
||||||
|
if state.self_sharing && state.share_audio_app_active {
|
||||||
|
state.share_audio_dropped = !active;
|
||||||
|
}
|
||||||
|
}
|
||||||
UiEvent::IdentityStatus { node_id, persisted, error } => {
|
UiEvent::IdentityStatus { node_id, persisted, error } => {
|
||||||
state.self_node_id = Some(node_id);
|
state.self_node_id = Some(node_id);
|
||||||
state.identity_persisted = persisted;
|
state.identity_persisted = persisted;
|
||||||
@@ -1348,6 +1471,10 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
UiEvent::Error(err) => {
|
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);
|
state.status_message = format!("Error: {}", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3987,16 +4114,32 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
};
|
};
|
||||||
el
|
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 {
|
let el: Element<'_, AppMessage> = if state.self_sharing {
|
||||||
row![
|
let badge = row![
|
||||||
icon(IconKind::Live, 14.0, color_red),
|
icon(IconKind::Live, 14.0, color_red),
|
||||||
text("Sharing your screen").size(13).color(color_red),
|
text("Sharing your screen").size(13).color(color_red),
|
||||||
]
|
]
|
||||||
.spacing(6)
|
.spacing(6)
|
||||||
.align_y(iced::alignment::Vertical::Center)
|
.align_y(iced::alignment::Vertical::Center);
|
||||||
.into()
|
if state.share_audio_dropped {
|
||||||
|
column![
|
||||||
|
badge,
|
||||||
|
text(
|
||||||
|
"⚠ Shared app isn't sending audio — viewers hear silence until it plays"
|
||||||
|
)
|
||||||
|
.size(11)
|
||||||
|
.color(color_yellow),
|
||||||
|
]
|
||||||
|
.spacing(3)
|
||||||
|
.into()
|
||||||
|
} else {
|
||||||
|
badge.into()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
iced::widget::Space::new().width(0.0).height(0.0).into()
|
iced::widget::Space::new().width(0.0).height(0.0).into()
|
||||||
};
|
};
|
||||||
@@ -4753,7 +4896,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 +5448,185 @@ 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_app_audio_supported {
|
||||||
|
// Older pixelpass without --strict-audio: per-app capture is unavailable
|
||||||
|
// (it would hard-fail), so only whole-desktop is offered. Nudge to upgrade.
|
||||||
|
column![
|
||||||
|
options,
|
||||||
|
text("Update pixelpass to capture a single app's audio (avoids echoing the call to viewers).")
|
||||||
|
.size(11)
|
||||||
|
.color(yellow),
|
||||||
|
]
|
||||||
|
.spacing(8)
|
||||||
|
.into()
|
||||||
|
} else 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.
|
||||||
@@ -5898,8 +6223,9 @@ impl Program<AppMessage> for Icon {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
attachment_default_name, format_duration, initial_window_position, reconnect_attempt_chime,
|
attachment_default_name, format_duration, initial_window_position, reconnect_attempt_chime,
|
||||||
reconnected_chime, set_peer_gate_config, set_peer_volume_config, AppConfig, AppState,
|
reconnected_chime, set_peer_gate_config, set_peer_volume_config, update, AppConfig,
|
||||||
AttachmentCache, AttachmentState, ChatEntry, GateMeter, METER_MAX,
|
AppMessage, AppState, AttachmentCache, AttachmentState, ChatEntry, GateMeter, METER_MAX,
|
||||||
|
UiEvent,
|
||||||
};
|
};
|
||||||
use iroh::SecretKey;
|
use iroh::SecretKey;
|
||||||
|
|
||||||
@@ -6059,6 +6385,13 @@ 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.share_starting = true;
|
||||||
|
state.share_audio_dropped = true;
|
||||||
|
state.share_audio_app_active = true;
|
||||||
|
state.share_app_audio_supported = false;
|
||||||
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 +6412,13 @@ 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());
|
||||||
|
assert!(!state.share_starting);
|
||||||
|
assert!(!state.share_audio_dropped);
|
||||||
|
assert!(!state.share_audio_app_active);
|
||||||
|
assert!(state.share_app_audio_supported, "reset is optimistic by default");
|
||||||
|
|
||||||
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() {
|
||||||
@@ -6089,6 +6429,183 @@ mod tests {
|
|||||||
panic!("clip player did not stop during room reset");
|
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.
|
||||||
|
// A specific app was chosen in the picker, so the share is app-specific.
|
||||||
|
let mut state = AppState {
|
||||||
|
share_audio_selection: Some("mpv".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start sharing — flag is clear, and the share is marked app-specific.
|
||||||
|
let _ = update(
|
||||||
|
&mut state,
|
||||||
|
AppMessage::UiEventReceived(UiEvent::ScreenShareStarted),
|
||||||
|
);
|
||||||
|
assert!(!state.share_audio_dropped);
|
||||||
|
assert!(state.share_audio_app_active);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
assert!(!state.share_audio_app_active);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn share_audio_active_ignored_unless_app_sharing() {
|
||||||
|
// P3 (unscoped events): a late app_audio event from a just-killed host
|
||||||
|
// must not flip the warning when we're not sharing a specific app —
|
||||||
|
// neither after stop nor on a whole-desktop share.
|
||||||
|
|
||||||
|
// (a) Whole-desktop share (no app selected): events are ignored.
|
||||||
|
let mut state = AppState::default();
|
||||||
|
let _ = update(
|
||||||
|
&mut state,
|
||||||
|
AppMessage::UiEventReceived(UiEvent::ScreenShareStarted),
|
||||||
|
);
|
||||||
|
assert!(!state.share_audio_app_active, "no app selected ⇒ not app-specific");
|
||||||
|
let _ = update(
|
||||||
|
&mut state,
|
||||||
|
AppMessage::UiEventReceived(UiEvent::ShareAudioActive(false)),
|
||||||
|
);
|
||||||
|
assert!(!state.share_audio_dropped, "whole-desktop share ignores app_audio");
|
||||||
|
|
||||||
|
// (b) After stop: a straggling event can't resurrect the warning.
|
||||||
|
let mut state = AppState {
|
||||||
|
share_audio_selection: Some("mpv".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let _ = update(
|
||||||
|
&mut state,
|
||||||
|
AppMessage::UiEventReceived(UiEvent::ScreenShareStarted),
|
||||||
|
);
|
||||||
|
let _ = update(
|
||||||
|
&mut state,
|
||||||
|
AppMessage::UiEventReceived(UiEvent::ScreenShareStopped),
|
||||||
|
);
|
||||||
|
let _ = update(
|
||||||
|
&mut state,
|
||||||
|
AppMessage::UiEventReceived(UiEvent::ShareAudioActive(false)),
|
||||||
|
);
|
||||||
|
assert!(!state.share_audio_dropped, "post-stop event is ignored");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn old_pixelpass_picker_offers_whole_desktop_only() {
|
||||||
|
// P2 (version skew): when the resolved pixelpass lacks --strict-audio, the
|
||||||
|
// picker must drop all per-app options and force the whole-desktop choice,
|
||||||
|
// so a per-app share (which would pass the unknown flag) can't be started.
|
||||||
|
let mut state = AppState {
|
||||||
|
share_picker_open: true,
|
||||||
|
share_audio_selection: Some("Firefox".to_string()),
|
||||||
|
share_audio_apps: vec!["Firefox".to_string(), "mpv".to_string()],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let _ = update(
|
||||||
|
&mut state,
|
||||||
|
AppMessage::UiEventReceived(UiEvent::AudioAppsListed {
|
||||||
|
apps: vec!["Firefox".to_string(), "mpv".to_string()],
|
||||||
|
app_audio_supported: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert!(!state.share_app_audio_supported);
|
||||||
|
assert!(state.share_audio_apps.is_empty(), "no per-app rows offered");
|
||||||
|
assert!(state.share_audio_selection.is_none(), "forced to whole-desktop");
|
||||||
|
|
||||||
|
// A supported pixelpass keeps the app list and a valid selection.
|
||||||
|
let _ = update(
|
||||||
|
&mut state,
|
||||||
|
AppMessage::UiEventReceived(UiEvent::AudioAppsListed {
|
||||||
|
apps: vec!["Firefox".to_string(), "mpv".to_string()],
|
||||||
|
app_audio_supported: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert!(state.share_app_audio_supported);
|
||||||
|
assert_eq!(state.share_audio_apps.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn peer_gate_persists_when_on_and_clears_when_off() {
|
fn peer_gate_persists_when_on_and_clears_when_off() {
|
||||||
let mut config = AppConfig::default();
|
let mut config = AppConfig::default();
|
||||||
|
|||||||
+20
-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,10 +147,22 @@ 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. `app_audio_supported` reports
|
||||||
|
/// whether the resolved pixelpass understands `--strict-audio`: when `false`
|
||||||
|
/// (an older pixelpass) the picker must offer whole-desktop audio only, since
|
||||||
|
/// a per-app share would pass a flag that older binary rejects (audit P2).
|
||||||
|
AudioAppsListed { apps: Vec<String>, app_audio_supported: bool },
|
||||||
/// Our own screen share started; the UI flips the Share button to "Stop".
|
/// Our own screen share started; the UI flips the Share button to "Stop".
|
||||||
ScreenShareStarted,
|
ScreenShareStarted,
|
||||||
/// Our own screen share stopped (or failed to start).
|
/// Our own screen share stopped (or failed to start).
|
||||||
ScreenShareStopped,
|
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
|
/// 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 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
|
/// `persisted = false` means the key file couldn't be read/written and we're
|
||||||
|
|||||||
+65
-5
@@ -1074,22 +1074,34 @@ async fn run_core_loop(
|
|||||||
// Join, cleared on Leave.
|
// Join, cleared on Leave.
|
||||||
let current_room: Arc<std::sync::Mutex<Option<crate::presence::RoomPresence>>> =
|
let current_room: Arc<std::sync::Mutex<Option<crate::presence::RoomPresence>>> =
|
||||||
Arc::new(std::sync::Mutex::new(None));
|
Arc::new(std::sync::Mutex::new(None));
|
||||||
|
let presence_rate_limiter =
|
||||||
|
Arc::new(std::sync::Mutex::new(crate::presence::PresenceRateLimiter::default()));
|
||||||
|
|
||||||
// Reply policy for the idle friends listener (B2): answer friends only, never
|
// Reply policy for the idle friends listener (B2): answer friends only, never
|
||||||
// while invisible (`should_answer`), and report our current gathering so a friend
|
// while invisible (`should_answer`), and report our current gathering so a friend
|
||||||
// can one-click join. Reads the shared snapshots, so it stays correct as they
|
// can one-click join. Rate-limits allowed friends before building a reply, so a
|
||||||
// change and survives a network-stack rebuild. Pure-sync (no awaits, no lock held
|
// spammy saved peer gets the same silent close as an unauthorized peer. Reads the
|
||||||
// across one). Built once and handed to every `build_net_stack`.
|
// shared snapshots, so it stays correct as they change and survives a network-stack
|
||||||
|
// rebuild. Pure-sync (no awaits, no lock held across one). Built once and handed
|
||||||
|
// to every `build_net_stack`.
|
||||||
let friends_handler: crate::presence_net::Handler = {
|
let friends_handler: crate::presence_net::Handler = {
|
||||||
let friends = friends.clone();
|
let friends = friends.clone();
|
||||||
let presence_mode = presence_mode.clone();
|
let presence_mode = presence_mode.clone();
|
||||||
let current_room = current_room.clone();
|
let current_room = current_room.clone();
|
||||||
|
let presence_rate_limiter = presence_rate_limiter.clone();
|
||||||
Arc::new(move |from| {
|
Arc::new(move |from| {
|
||||||
let mode = *presence_mode.lock().unwrap();
|
let mode = *presence_mode.lock().unwrap();
|
||||||
let allowed = crate::presence::should_answer(&from, &friends.lock().unwrap(), mode);
|
let allowed = crate::presence::should_answer(&from, &friends.lock().unwrap(), mode);
|
||||||
if !allowed {
|
if !allowed {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
if !presence_rate_limiter
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.allow(from, std::time::Instant::now())
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
let room = current_room.lock().unwrap().clone();
|
let room = current_room.lock().unwrap().clone();
|
||||||
Some(crate::presence::ControlMsg::Pong { room })
|
Some(crate::presence::ControlMsg::Pong { room })
|
||||||
})
|
})
|
||||||
@@ -2634,7 +2646,29 @@ 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 => {
|
||||||
|
// Probe whether this pixelpass supports `--strict-audio` before
|
||||||
|
// offering per-app capture: an older binary would reject the flag
|
||||||
|
// and hard-fail the share (audit P2). When unsupported (or
|
||||||
|
// pixelpass is missing), skip enumeration and let the picker show
|
||||||
|
// whole-desktop audio only — never a best-effort `--app` that
|
||||||
|
// would reopen the A23 echo.
|
||||||
|
let app_audio_supported =
|
||||||
|
match crate::screenshare::pixelpass_path(pixelpass_override.as_deref()) {
|
||||||
|
Some(bin) => crate::screenshare::supports_strict_audio(&bin).await,
|
||||||
|
None => false,
|
||||||
|
};
|
||||||
|
let apps = if app_audio_supported {
|
||||||
|
crate::screenshare::list_audio_apps().await
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
let _ = ui_tx
|
||||||
|
.send(UiEvent::AudioAppsListed { apps, app_audio_supported })
|
||||||
|
.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 +2689,33 @@ async fn run_core_loop(
|
|||||||
continue;
|
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)) => {
|
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);
|
||||||
|
|||||||
@@ -15,6 +15,16 @@
|
|||||||
use crate::friends::FriendStore;
|
use crate::friends::FriendStore;
|
||||||
use iroh::EndpointId;
|
use iroh::EndpointId;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// Maximum immediate presence replies to one friend before throttling. Normal
|
||||||
|
/// presence polling is once per minute, so this only catches repeated/manual or
|
||||||
|
/// abusive probes while still allowing a short burst after app startup.
|
||||||
|
pub const PRESENCE_RATE_LIMIT_BURST: u32 = 4;
|
||||||
|
|
||||||
|
/// Refill one presence-reply token per friend at this cadence.
|
||||||
|
pub const PRESENCE_RATE_LIMIT_REFILL: Duration = Duration::from_secs(15);
|
||||||
|
|
||||||
/// The user's presence posture — how reachable they are to friends while idle.
|
/// The user's presence posture — how reachable they are to friends while idle.
|
||||||
/// Persisted in `AppConfig`; the default keeps you privately reachable to friends
|
/// Persisted in `AppConfig`; the default keeps you privately reachable to friends
|
||||||
@@ -100,6 +110,48 @@ pub fn should_answer(from: &EndpointId, friends: &FriendStore, mode: PresenceMod
|
|||||||
mode.answers_pings() && friends.contains(from)
|
mode.answers_pings() && friends.contains(from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct RateBucket {
|
||||||
|
tokens: u32,
|
||||||
|
last_refill: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-friend limiter for inbound presence pings. It is intentionally keyed by
|
||||||
|
/// the authenticated connection id, not payload data. Callers should only invoke
|
||||||
|
/// it after [`should_answer`] passes, so strangers do not consume memory here.
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct PresenceRateLimiter {
|
||||||
|
buckets: HashMap<EndpointId, RateBucket>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PresenceRateLimiter {
|
||||||
|
/// Return whether `from` may receive a presence reply at `now`.
|
||||||
|
///
|
||||||
|
/// This is a token bucket: each friend starts with a small burst and regains
|
||||||
|
/// one token every [`PRESENCE_RATE_LIMIT_REFILL`]. A denied probe should be
|
||||||
|
/// answered with no data, matching the listener's "reveal nothing" policy.
|
||||||
|
pub fn allow(&mut self, from: EndpointId, now: Instant) -> bool {
|
||||||
|
let bucket = self.buckets.entry(from).or_insert(RateBucket {
|
||||||
|
tokens: PRESENCE_RATE_LIMIT_BURST,
|
||||||
|
last_refill: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
let elapsed = now.saturating_duration_since(bucket.last_refill);
|
||||||
|
let refill = elapsed.as_secs() / PRESENCE_RATE_LIMIT_REFILL.as_secs();
|
||||||
|
if refill > 0 {
|
||||||
|
let refill = refill.min(u32::MAX as u64) as u32;
|
||||||
|
bucket.tokens = PRESENCE_RATE_LIMIT_BURST.min(bucket.tokens.saturating_add(refill));
|
||||||
|
bucket.last_refill = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
if bucket.tokens == 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
bucket.tokens -= 1;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// What we learned about a friend from a successful ping reply.
|
/// What we learned about a friend from a successful ping reply.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum FriendPresence {
|
pub enum FriendPresence {
|
||||||
@@ -177,6 +229,36 @@ mod tests {
|
|||||||
assert!(!should_answer(&stranger, &friends, PresenceMode::Invisible));
|
assert!(!should_answer(&stranger, &friends, PresenceMode::Invisible));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn presence_rate_limiter_allows_a_small_burst_then_refills() {
|
||||||
|
let mut limiter = PresenceRateLimiter::default();
|
||||||
|
let friend = id();
|
||||||
|
let now = Instant::now();
|
||||||
|
|
||||||
|
for _ in 0..PRESENCE_RATE_LIMIT_BURST {
|
||||||
|
assert!(limiter.allow(friend, now));
|
||||||
|
}
|
||||||
|
assert!(!limiter.allow(friend, now));
|
||||||
|
assert!(!limiter.allow(friend, now + PRESENCE_RATE_LIMIT_REFILL - Duration::from_millis(1)));
|
||||||
|
|
||||||
|
assert!(limiter.allow(friend, now + PRESENCE_RATE_LIMIT_REFILL));
|
||||||
|
assert!(!limiter.allow(friend, now + PRESENCE_RATE_LIMIT_REFILL));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn presence_rate_limiter_is_per_peer() {
|
||||||
|
let mut limiter = PresenceRateLimiter::default();
|
||||||
|
let a = id();
|
||||||
|
let b = id();
|
||||||
|
let now = Instant::now();
|
||||||
|
|
||||||
|
for _ in 0..PRESENCE_RATE_LIMIT_BURST {
|
||||||
|
assert!(limiter.allow(a, now));
|
||||||
|
}
|
||||||
|
assert!(!limiter.allow(a, now));
|
||||||
|
assert!(limiter.allow(b, now));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn presence_mode_flags() {
|
fn presence_mode_flags() {
|
||||||
assert!(PresenceMode::Discoverable.publishes_to_discovery());
|
assert!(PresenceMode::Discoverable.publishes_to_discovery());
|
||||||
|
|||||||
+405
-15
@@ -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.
|
||||||
@@ -64,6 +68,12 @@ pub enum PixelpassEvent {
|
|||||||
CaptureStarted,
|
CaptureStarted,
|
||||||
/// Host: capture pipeline torn down (on last viewer).
|
/// Host: capture pipeline torn down (on last viewer).
|
||||||
CaptureStopped,
|
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`).
|
/// A recognized event we don't act on (e.g. `host_info`).
|
||||||
Other,
|
Other,
|
||||||
}
|
}
|
||||||
@@ -98,6 +108,11 @@ pub fn parse_pixelpass_event(line: &str) -> Option<PixelpassEvent> {
|
|||||||
Some("stopped") => PixelpassEvent::CaptureStopped,
|
Some("stopped") => PixelpassEvent::CaptureStopped,
|
||||||
_ => PixelpassEvent::Other,
|
_ => 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,
|
_ => PixelpassEvent::Other,
|
||||||
};
|
};
|
||||||
Some(ev)
|
Some(ev)
|
||||||
@@ -107,6 +122,144 @@ 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> --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())
|
||||||
|
// On [`LIST_APPS_TIMEOUT`] the `output()` future is dropped, which drops
|
||||||
|
// the child — `kill_on_drop(true)` then SIGKILLs and reaps it so a wedged
|
||||||
|
// `pactl` can't linger/accumulate across picker opens (audit P3).
|
||||||
|
.kill_on_drop(true)
|
||||||
|
.output();
|
||||||
|
match tokio::time::timeout(LIST_APPS_TIMEOUT, run).await {
|
||||||
|
Ok(Ok(o)) if o.status.success() => parse_audio_apps(&o.stdout),
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hard cap on the capability probe (`pixelpass --help`). Conservative: a slow or
|
||||||
|
/// hung pixelpass degrades to "strict audio unsupported" → whole-desktop-only
|
||||||
|
/// picker (safe), never a stalled core loop.
|
||||||
|
const HELP_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
/// Whether the resolved pixelpass understands `--strict-audio` (added in pixelpass
|
||||||
|
/// `85fdebe`). peerspeak only offers per-app audio capture when it does: a per-app
|
||||||
|
/// share always appends `--strict-audio`, and an **older** pixelpass would have
|
||||||
|
/// clap reject the unknown flag → the host spawn hard-fails and the share is
|
||||||
|
/// broken (audit P2, version skew). When unsupported the picker degrades to
|
||||||
|
/// whole-desktop audio only — we never silently drop to best-effort `--app`, which
|
||||||
|
/// would reintroduce the call echo (A23).
|
||||||
|
///
|
||||||
|
/// Any probe failure/timeout returns `false` (degrade to the safe path). The
|
||||||
|
/// `--help` child is `kill_on_drop` so a hung pixelpass can't linger.
|
||||||
|
pub async fn supports_strict_audio(bin: &Path) -> bool {
|
||||||
|
let run = Command::new(bin)
|
||||||
|
.arg("--help")
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.kill_on_drop(true)
|
||||||
|
.output();
|
||||||
|
match tokio::time::timeout(HELP_PROBE_TIMEOUT, run).await {
|
||||||
|
Ok(Ok(o)) => help_mentions_strict_audio(&o.stdout),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure check: does `pixelpass --help` advertise `--strict-audio`? Matches the
|
||||||
|
/// flag token rather than a whole line, since clap may wrap/realign help text.
|
||||||
|
pub fn help_mentions_strict_audio(help_stdout: &[u8]) -> bool {
|
||||||
|
String::from_utf8_lossy(help_stdout).contains("--strict-audio")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
/// 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,20 +315,30 @@ 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>,
|
||||||
|
notices: Option<tokio::sync::mpsc::UnboundedSender<PixelpassEvent>>,
|
||||||
|
) -> 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())
|
// Capture stderr (not null): pixelpass prints its startup precondition
|
||||||
|
// failures there — a missing GStreamer plugin / `pactl`, each with an
|
||||||
|
// actionable "Install hint: sudo apt install ..." line. If the host dies
|
||||||
|
// before its ticket we fold that tail into our error so the user sees
|
||||||
|
// *what to install* instead of a dead-end "exited before a ticket". On
|
||||||
|
// the success path we drain it in the background so the pipe can't fill.
|
||||||
|
.stderr(Stdio::piped())
|
||||||
.kill_on_drop(true)
|
.kill_on_drop(true)
|
||||||
.spawn()?;
|
.spawn()?;
|
||||||
|
|
||||||
@@ -183,6 +346,7 @@ pub async fn spawn_host(bin: &Path) -> std::io::Result<(Child, String)> {
|
|||||||
.stdout
|
.stdout
|
||||||
.take()
|
.take()
|
||||||
.ok_or_else(|| std::io::Error::other("pixelpass host stdout missing"))?;
|
.ok_or_else(|| std::io::Error::other("pixelpass host stdout missing"))?;
|
||||||
|
let stderr = child.stderr.take();
|
||||||
let mut lines = BufReader::new(stdout).lines();
|
let mut lines = BufReader::new(stdout).lines();
|
||||||
|
|
||||||
let ticket = match read_until(&mut lines, |e| match e {
|
let ticket = match read_until(&mut lines, |e| match e {
|
||||||
@@ -194,9 +358,10 @@ pub async fn spawn_host(bin: &Path) -> std::io::Result<(Child, String)> {
|
|||||||
Ok(Some(t)) => t,
|
Ok(Some(t)) => t,
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
let _ = child.kill().await;
|
let _ = child.kill().await;
|
||||||
return Err(std::io::Error::other(
|
let detail = read_stderr_tail(stderr).await;
|
||||||
"pixelpass host exited before emitting a ticket",
|
return Err(std::io::Error::other(format!(
|
||||||
));
|
"pixelpass host exited before emitting a ticket{detail}"
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = child.kill().await;
|
let _ = child.kill().await;
|
||||||
@@ -204,10 +369,65 @@ pub async fn spawn_host(bin: &Path) -> std::io::Result<(Child, String)> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
drain_in_background(lines, "host");
|
if let Some(stderr) = stderr {
|
||||||
|
drain_stderr_in_background(stderr);
|
||||||
|
}
|
||||||
|
drain_in_background(lines, "host", notices);
|
||||||
Ok((child, ticket))
|
Ok((child, ticket))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read a killed pixelpass child's stderr to EOF and reduce it to a short,
|
||||||
|
/// user-facing diagnostic tail via [`pixelpass_failure_detail`]. Bounded: the
|
||||||
|
/// caller kills the child first, so the pipe EOFs promptly. Returns an empty
|
||||||
|
/// string when stderr was already taken or carried nothing useful.
|
||||||
|
async fn read_stderr_tail(stderr: Option<tokio::process::ChildStderr>) -> String {
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
let Some(mut stderr) = stderr else {
|
||||||
|
return String::new();
|
||||||
|
};
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let _ = stderr.read_to_end(&mut buf).await;
|
||||||
|
pixelpass_failure_detail(&String::from_utf8_lossy(&buf))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discard a running pixelpass child's stderr in the background so its pipe
|
||||||
|
/// can't fill and stall the host (mirrors [`drain_in_background`] for stdout).
|
||||||
|
fn drain_stderr_in_background(mut stderr: tokio::process::ChildStderr) {
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut buf = [0u8; 4096];
|
||||||
|
while let Ok(n) = stderr.read(&mut buf).await {
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract a human-useful tail from a failed pixelpass child's stderr to append
|
||||||
|
/// to our error. pixelpass writes actionable startup errors there (a missing
|
||||||
|
/// GStreamer element / `pactl` plus an `Install hint: sudo apt install ...`
|
||||||
|
/// line), which is exactly what a freshly-installed host needs to see. The
|
||||||
|
/// decorative host banner (box-drawing) is dropped — it only prints on the
|
||||||
|
/// success path, but we filter it defensively. Pure: no I/O. Returns an empty
|
||||||
|
/// string when there's nothing worth surfacing (so callers can append blindly).
|
||||||
|
pub fn pixelpass_failure_detail(stderr: &str) -> String {
|
||||||
|
let useful: Vec<&str> = stderr
|
||||||
|
.lines()
|
||||||
|
.map(str::trim_end)
|
||||||
|
.filter(|l| !l.trim().is_empty())
|
||||||
|
.filter(|l| !l.trim_start().starts_with(['│', '┌', '└', '├']))
|
||||||
|
.collect();
|
||||||
|
if useful.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
// The anyhow error and its install hint are the *last* lines printed, so
|
||||||
|
// keep the tail rather than the head.
|
||||||
|
const MAX_LINES: usize = 12;
|
||||||
|
let start = useful.len().saturating_sub(MAX_LINES);
|
||||||
|
format!("\n\npixelpass reported:\n{}", useful[start..].join("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Spawn a pixelpass viewer for `ticket`, wait for it to connect, and open the
|
/// Spawn a pixelpass viewer for `ticket`, wait for it to connect, and open the
|
||||||
/// stream in a local player (mpv, falling back to vlc). Returns the live viewer
|
/// stream in a local player (mpv, falling back to vlc). Returns the live viewer
|
||||||
/// child so the caller can kill it on room-leave; it also self-exits when the
|
/// child so the caller can kill it on room-leave; it also self-exits when the
|
||||||
@@ -251,7 +471,7 @@ pub async fn spawn_viewer(bin: &Path, ticket: &str) -> std::io::Result<Child> {
|
|||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
drain_in_background(lines, "viewer");
|
drain_in_background(lines, "viewer", None);
|
||||||
Ok(child)
|
Ok(child)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,15 +506,24 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Keep reading the child's stdout to EOF in the background so a full pipe can't
|
/// Keep reading the child's stdout to EOF in the background so a full pipe can't
|
||||||
/// stall it; log notable events for diagnostics.
|
/// stall it; log notable events for diagnostics. When `notices` is `Some`, each
|
||||||
fn drain_in_background<R>(mut lines: tokio::io::Lines<BufReader<R>>, role: &'static str)
|
/// parsed event is also forwarded to the caller (the core, which translates the
|
||||||
where
|
/// `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,
|
R: tokio::io::AsyncRead + Unpin + Send + 'static,
|
||||||
{
|
{
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Ok(Some(line)) = lines.next_line().await {
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
if let Some(ev) = parse_pixelpass_event(&line) {
|
if let Some(ev) = parse_pixelpass_event(&line) {
|
||||||
crate::log_msg(&format!("pixelpass {role}: {}", event_for_log(&ev)));
|
crate::log_msg(&format!("pixelpass {role}: {}", event_for_log(&ev)));
|
||||||
|
if let Some(tx) = ¬ices {
|
||||||
|
let _ = tx.send(ev);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -313,6 +542,8 @@ fn event_for_log(ev: &PixelpassEvent) -> String {
|
|||||||
PixelpassEvent::Refused(reason) => format!("viewer_refused reason={reason:?}"),
|
PixelpassEvent::Refused(reason) => format!("viewer_refused reason={reason:?}"),
|
||||||
PixelpassEvent::CaptureStarted => "capture_started".to_string(),
|
PixelpassEvent::CaptureStarted => "capture_started".to_string(),
|
||||||
PixelpassEvent::CaptureStopped => "capture_stopped".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(),
|
PixelpassEvent::Other => "other".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -384,6 +615,142 @@ 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 failure_detail_surfaces_install_hint_and_drops_banner() {
|
||||||
|
// The real shape of a fresh-host failure: anyhow error + install hint on
|
||||||
|
// stderr. We must keep those (so the user knows what to apt install) and
|
||||||
|
// drop the decorative banner box-drawing lines.
|
||||||
|
let stderr = "\
|
||||||
|
┌─ PixelPass · host ─────────────────────────────────────────
|
||||||
|
│ display server : Wayland
|
||||||
|
└────────────────────────────────────────────────────────────
|
||||||
|
Error: GStreamer element `vah264enc` not available.
|
||||||
|
Install hint: sudo apt install gstreamer1.0-plugins-bad
|
||||||
|
";
|
||||||
|
let detail = pixelpass_failure_detail(stderr);
|
||||||
|
assert!(detail.starts_with("\n\npixelpass reported:\n"));
|
||||||
|
assert!(detail.contains("vah264enc` not available"));
|
||||||
|
assert!(detail.contains("sudo apt install gstreamer1.0-plugins-bad"));
|
||||||
|
assert!(!detail.contains('│'), "banner box-drawing must be dropped");
|
||||||
|
assert!(!detail.contains('┌'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failure_detail_empty_when_nothing_useful() {
|
||||||
|
// Blank / banner-only stderr yields an empty string so the caller can
|
||||||
|
// append it to the base message unconditionally without trailing noise.
|
||||||
|
assert_eq!(pixelpass_failure_detail(""), "");
|
||||||
|
assert_eq!(pixelpass_failure_detail(" \n \n"), "");
|
||||||
|
assert_eq!(
|
||||||
|
pixelpass_failure_detail("│ display server : Wayland\n│ capture : x\n"),
|
||||||
|
""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failure_detail_keeps_only_the_tail() {
|
||||||
|
// A long stderr is truncated to its last lines (where the real error
|
||||||
|
// and hint live), not its head.
|
||||||
|
let body: String = (0..30).map(|i| format!("line {i}\n")).collect();
|
||||||
|
let detail = pixelpass_failure_detail(&body);
|
||||||
|
assert!(detail.contains("line 29"));
|
||||||
|
assert!(!detail.contains("line 0\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn help_probe_detects_strict_audio_flag() {
|
||||||
|
// A new pixelpass advertises the flag; an old one doesn't. The probe must
|
||||||
|
// match the token even when clap wraps the option onto its own line.
|
||||||
|
let new_help = b"Options:\n --app <APP>\n --strict-audio\n With --app, never fall back...";
|
||||||
|
assert!(help_mentions_strict_audio(new_help));
|
||||||
|
let old_help = b"Options:\n --app <APP>\n --output <OUTPUT>\n -h, --help";
|
||||||
|
assert!(!help_mentions_strict_audio(old_help));
|
||||||
|
// Garbage / empty output degrades to "unsupported" (safe path).
|
||||||
|
assert!(!help_mentions_strict_audio(b""));
|
||||||
|
assert!(!help_mentions_strict_audio(&[0xff, 0xfe, 0x00]));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sanitize_ticket_accepts_pixelpass_endpoint_ticket_shape() {
|
fn sanitize_ticket_accepts_pixelpass_endpoint_ticket_shape() {
|
||||||
let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm";
|
let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm";
|
||||||
@@ -470,6 +837,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]
|
#[test]
|
||||||
fn recognized_but_unused_event_is_other() {
|
fn recognized_but_unused_event_is_other() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
Reference in New Issue
Block a user