Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82e1740d3c | ||
|
|
4dc1bcd546 | ||
|
|
067997f9ba | ||
|
|
660eb27a84 |
+29
@@ -2,10 +2,39 @@
|
||||
name = "peerspeak"
|
||||
version = "0.4.0"
|
||||
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
|
||||
# cargo-deny's [licenses.private] skip the missing-license check.
|
||||
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]
|
||||
name = "peerspeak"
|
||||
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).
|
||||
`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)
|
||||
1. Friend X, at a coffee shop, opens peerspeak and starts a gathering labeled
|
||||
"HangOut."
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
|
||||
pkgname=peerspeak-git
|
||||
_pkgname=peerspeak
|
||||
pkgver=0.3.0.r229.g7fb1c96
|
||||
pkgver=0.4.0.r254.g913b0b6
|
||||
pkgrel=1
|
||||
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
||||
arch=('x86_64')
|
||||
|
||||
+138
-12
@@ -589,10 +589,21 @@ pub struct AppState {
|
||||
/// `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.
|
||||
/// 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).
|
||||
drawer_chat_open: bool,
|
||||
/// Raw mic level (normalized RMS, `0.0..=1.0`) for the settings meter.
|
||||
@@ -667,6 +678,8 @@ impl AppState {
|
||||
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 {
|
||||
@@ -793,6 +806,8 @@ impl Default for AppState {
|
||||
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,
|
||||
mic_level: 0.0,
|
||||
mic_test_active: false,
|
||||
@@ -1365,24 +1380,35 @@ 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) => {
|
||||
UiEvent::AudioAppsListed { apps, app_audio_supported } => {
|
||||
// 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".
|
||||
// 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 => {
|
||||
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();
|
||||
@@ -1391,12 +1417,18 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
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();
|
||||
}
|
||||
UiEvent::ShareAudioActive(active) => {
|
||||
// Per-app audio routed/lost (only while sharing a chosen app).
|
||||
// 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 } => {
|
||||
state.self_node_id = Some(node_id);
|
||||
state.identity_persisted = persisted;
|
||||
@@ -4098,7 +4130,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
column![
|
||||
badge,
|
||||
text(
|
||||
"⚠ Shared app's audio stopped — viewers hear silence until it plays again"
|
||||
"⚠ Shared app isn't sending audio — viewers hear silence until it plays"
|
||||
)
|
||||
.size(11)
|
||||
.color(color_yellow),
|
||||
@@ -5501,7 +5533,18 @@ fn with_share_picker<'a>(
|
||||
));
|
||||
}
|
||||
|
||||
let list: Element<'_, AppMessage> = if state.share_audio_apps.is_empty() {
|
||||
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.")
|
||||
@@ -6347,6 +6390,8 @@ mod tests {
|
||||
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.reset_room_state();
|
||||
@@ -6372,6 +6417,8 @@ mod tests {
|
||||
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 {
|
||||
if crate::audio::clip_player::status_snapshot(&state.clip_status).playing_id.is_none() {
|
||||
@@ -6444,14 +6491,19 @@ mod tests {
|
||||
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();
|
||||
// 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.
|
||||
// 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(
|
||||
@@ -6478,6 +6530,80 @@ mod tests {
|
||||
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]
|
||||
|
||||
@@ -149,8 +149,11 @@ pub enum UiEvent {
|
||||
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>),
|
||||
/// 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".
|
||||
ScreenShareStarted,
|
||||
/// Our own screen share stopped (or failed to start).
|
||||
|
||||
+34
-5
@@ -1074,22 +1074,34 @@ async fn run_core_loop(
|
||||
// Join, cleared on Leave.
|
||||
let current_room: Arc<std::sync::Mutex<Option<crate::presence::RoomPresence>>> =
|
||||
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
|
||||
// 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
|
||||
// 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`.
|
||||
// can one-click join. Rate-limits allowed friends before building a reply, so a
|
||||
// spammy saved peer gets the same silent close as an unauthorized peer. Reads the
|
||||
// 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 = friends.clone();
|
||||
let presence_mode = presence_mode.clone();
|
||||
let current_room = current_room.clone();
|
||||
let presence_rate_limiter = presence_rate_limiter.clone();
|
||||
Arc::new(move |from| {
|
||||
let mode = *presence_mode.lock().unwrap();
|
||||
let allowed = crate::presence::should_answer(&from, &friends.lock().unwrap(), mode);
|
||||
if !allowed {
|
||||
return None;
|
||||
}
|
||||
if !presence_rate_limiter
|
||||
.lock()
|
||||
.unwrap()
|
||||
.allow(from, std::time::Instant::now())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let room = current_room.lock().unwrap().clone();
|
||||
Some(crate::presence::ControlMsg::Pong { room })
|
||||
})
|
||||
@@ -2635,8 +2647,25 @@ async fn run_core_loop(
|
||||
}
|
||||
|
||||
CoreCommand::ListAudioApps => {
|
||||
let apps = crate::screenshare::list_audio_apps().await;
|
||||
let _ = ui_tx.send(UiEvent::AudioAppsListed(apps)).await;
|
||||
// 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 } => {
|
||||
|
||||
@@ -15,6 +15,16 @@
|
||||
use crate::friends::FriendStore;
|
||||
use iroh::EndpointId;
|
||||
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.
|
||||
/// 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)
|
||||
}
|
||||
|
||||
#[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.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum FriendPresence {
|
||||
@@ -177,6 +229,36 @@ mod tests {
|
||||
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]
|
||||
fn presence_mode_flags() {
|
||||
assert!(PresenceMode::Discoverable.publishes_to_discovery());
|
||||
|
||||
+161
-4
@@ -183,6 +183,10 @@ pub async fn list_audio_apps() -> Vec<String> {
|
||||
.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),
|
||||
@@ -190,6 +194,41 @@ pub async fn list_audio_apps() -> Vec<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -293,7 +332,13 @@ pub async fn spawn_host(
|
||||
.args(host_args(audio_app))
|
||||
.stdin(Stdio::null())
|
||||
.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)
|
||||
.spawn()?;
|
||||
|
||||
@@ -301,6 +346,7 @@ pub async fn spawn_host(
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| std::io::Error::other("pixelpass host stdout missing"))?;
|
||||
let stderr = child.stderr.take();
|
||||
let mut lines = BufReader::new(stdout).lines();
|
||||
|
||||
let ticket = match read_until(&mut lines, |e| match e {
|
||||
@@ -312,9 +358,10 @@ pub async fn spawn_host(
|
||||
Ok(Some(t)) => t,
|
||||
Ok(None) => {
|
||||
let _ = child.kill().await;
|
||||
return Err(std::io::Error::other(
|
||||
"pixelpass host exited before emitting a ticket",
|
||||
));
|
||||
let detail = read_stderr_tail(stderr).await;
|
||||
return Err(std::io::Error::other(format!(
|
||||
"pixelpass host exited before emitting a ticket{detail}"
|
||||
)));
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = child.kill().await;
|
||||
@@ -322,10 +369,65 @@ pub async fn spawn_host(
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(stderr) = stderr {
|
||||
drain_stderr_in_background(stderr);
|
||||
}
|
||||
drain_in_background(lines, "host", notices);
|
||||
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
|
||||
/// 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
|
||||
@@ -594,6 +696,61 @@ mod tests {
|
||||
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]
|
||||
fn sanitize_ticket_accepts_pixelpass_endpoint_ticket_shape() {
|
||||
let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm";
|
||||
|
||||
Reference in New Issue
Block a user