Compare commits

...
Author SHA1 Message Date
molluskandClaude Opus 4.8 82e1740d3c QUARANTINE: Codex off-task presence rate-limiter (NOT A24, unreviewed)
Codex was assigned A24 (per-peer volume) but instead built a friends-listener
presence rate-limiter touching the fenced security surface (presence.rs, the
friends listener in core, contacts-plan.md). Preserved here for later review as
a possible W7 hardening item; NOT merged to main, NOT the assigned task.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 02:39:34 -04:00
molluskandClaude Opus 4.8 4dc1bcd546 screenshare: surface pixelpass stderr on host startup failure
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
When a pixelpass host dies before emitting its ticket (e.g. a fresh Ubuntu
box missing the GStreamer capture stack), we discarded its stderr — which is
exactly where pixelpass prints the failing element plus an actionable
"Install hint: sudo apt install ..." line. The user only saw a dead-end
"pixelpass host exited before emitting a ticket".

Capture stderr instead of nulling it, and on the early-exit path fold a short
tail into our error via the pure `pixelpass_failure_detail` (drops the
decorative banner, keeps the error + hint, caps at the last 12 lines). On the
success path stderr is drained in the background so its pipe can't stall the
host. +3 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 23:10:16 -04:00
molluskandClaude Opus 4.8 067997f9ba screenshare: action A23 P2/P3 follow-up audit (capability probe, event guard, pactl kill)
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Triage of Codex's a23-p2-audit-2026-06-26 (the strict-audio fix itself verified
sound — no path reloads the desktop loopback). Four findings actioned:

- P2 (F1, silent-from-start): pair pixelpass's new initial `app_audio:lost`
  with a reworded warning ("Shared app isn't sending audio — viewers hear
  silence until it plays") so an app share that never produced audio is flagged,
  not just one that stopped.
- P2 (version skew): probe `pixelpass --help` for `--strict-audio` before
  offering per-app capture (pure `help_mentions_strict_audio` + timeout-bounded,
  kill-on-drop `supports_strict_audio`). An older pixelpass would have clap
  reject the flag and hard-fail the share; now the picker degrades to
  whole-desktop only with an upgrade nudge. Never a silent best-effort `--app`
  fallback (that reopens A23). AudioAppsListed now carries `app_audio_supported`.
- P3 (pactl child leak): list_audio_apps' enumeration child is now
  kill_on_drop, so a wedged pactl is reaped on the 2s timeout.
- P3 (unscoped events): apply ShareAudioActive only while actually sharing a
  specific app (`share_audio_app_active`), so a late event from a just-killed
  host can't flip the warning after stop or on a whole-desktop share.

+4 tests (probe parse, post-stop/whole-desktop guard, old-pixelpass picker
degrade), updated reset + app_audio tracking tests. 440 lib tests, clippy
--all-targets clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 22:09:58 -04:00
molluskandClaude Opus 4.8 660eb27a84 packaging: add cargo-deb metadata for Debian/Ubuntu .deb builds
Mirror packaging/PKGBUILD as a [package.metadata.deb] block so a Debian/
Ubuntu .deb can be produced with `cargo deb` from inside a matching
distrobox (glibc is forward-compatible only, so the build base sets the
minimum target). Ships only the `peerspeak` binary (not test_net/specview),
the desktop entry, and the full hicolor icon set; runtime shared-lib deps
are resolved by dpkg-shlibdeps. Recommends pixelpass + mpv. Also add the
missing [package].description (cargo-deb requires a synopsis) and roll the
stale PKGBUILD pkgver fallback to the current 0.4.0 tag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:30:17 -04:00
molluskandClaude Opus 4.8 913b0b6b20 feat(screenshare): pass --strict-audio + surface app_audio drop warning (A23 P2)
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Consumes the new pixelpass --strict-audio mode + app_audio events (pixelpass
85fdebe) to close Codex's A23 P2: the per-app pick alone was best-effort —
pixelpass would fall back to whole-desktop audio before the app routed and again
if it stopped, both reintroducing the call echo.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 17:37:49 -04:00
molluskandClaude Opus 4.8 2e9164745f fix(screenshare): per-app audio capture to kill call-loopback echo (A23)
Screen sharing spawned pixelpass with a bare `--host`, so pixelpass
captured the whole default-sink monitor — which contains peerspeak's own
call playout. A viewer therefore heard their OWN voice echoed back out of
the sharer's machine (confirmed live, backlog A23).

pixelpass already supports `--app <NAME>` (capture only one app's audio,
per-app PipeWire routing); peerspeak just never passed it. This wires that
flag through, peerspeak-side only — no pixelpass change.

- screenshare: pure `host_args(audio_app)` builds the host argv, appending
  `--app=<name>` (single-token form so a hyphen-leading name can't be
  reparsed as a flag) when an app is chosen; `sanitize_app_name` guards the
  locally-chosen value; `list_audio_apps`/`parse_audio_apps` enumerate
  currently-playing apps via `pactl -f json list sink-inputs` (mirroring how
  pixelpass builds its own picker, so the names match what `--app` matches).
- core: `StartScreenShare { audio_app }` + `ListAudioApps`/`AudioAppsListed`.
- GUI: Share Screen now opens a small audio picker (radio-style modal) listing
  the playing apps + "All system audio" (warned, = legacy whole-desktop);
  picking one starts the share with `--app=<name>`. Reset on room leave.

+6 unit tests (host_args with/without/blank app, sanitize_app_name,
parse_audio_apps dedup + garbage). 430 lib tests, clippy --all-targets clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 17:18:49 -04:00
molluskandClaude Opus 4.8 3b640726d7 fix(security): address Codex F-02/F-12 audit — save-filename alias + doc nits
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Codex audit of 381e00b (f02-f12-audit-2026-06-26) found no P1/P2 regression
and verified the F-12 key round-trip invariant sound (iroh EndpointId
Display/FromStr are exact inverses). Acting on the one real P3 + nits:

- P3: save_attachment_task picked the dialog's DEFAULT FILENAME by bare
  attachment id, so a peer reusing a victim's id could mislabel the save
  with another sender's name/extension (bytes were already author-keyed and
  correct — this was a metadata residual, not content aliasing). Extracted a
  pure `attachment_default_name` that matches the full (author, id) key, like
  find_attachment_source. +1 unit test (closes the audit's coverage gap).
- Doc nits: refreshed the stale `attachment_data` reference on ChatEntry,
  the "keyed by attachment id" note on spawn_attachment_fetch, and a
  duplicated doc block above find_attachment_source.

DEFERRED (user decision pending): the P3 judgement call — pending_plays /
invalid_audio / clip playing_id stay bare-id keyed, so duplicate-id audio
rows share play/seek/invalid state (cosmetic; bytes played are still
author-keyed and correct). Fully closing it means threading AttachmentKey
through the clip player.

424 lib tests, clippy --all-targets clean, release build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 17:00:09 -04:00
molluskandClaude Opus 4.8 381e00bc0e fix(security): bound + author-key chat attachment cache (Tier C F-02/F-12)
The chat-attachment result cache (`attachment_data` + `image_handle_cache`)
was keyed by attachment id alone and only cleared on room-leave, so an
authenticated insider could (F-02) stream distinct attachments to grow it
without bound, and (F-12) reuse a victim's attachment id to alias displayed/
saved bytes — the id is attacker-chosen, so a signature only proves keypair
ownership, not a distinct human.

F-12: thread the author (`from: EndpointId`) back through the
`AttachmentReady`/`AttachmentFailed` core→UI events (the fetch task already
holds it) and key all attachment result state on `(author, id)`:
- new `AttachmentKey = (EndpointId, AttachmentId)`;
- `attachment_data` + `image_handle_cache` fold into one `AttachmentCache`;
- `pending_saves` and the `SaveAttachment`/`PlayAudio` messages re-keyed, so
  the save/fetch dispatch can't be redirected to the wrong sender's line;
- `find_attachment_source` now matches author AND id;
- the render path resolves each line's key from `ChatEntry.from`.

F-02: `AttachmentCache` is bounded (`ATTACHMENT_CACHE_CAP = 64`) with
insertion-order eviction. True LRU is impossible because iced's `view`
borrows `&self` and so can't reorder on a render read; the generous cap means
a normal session never evicts and the newest (on-screen) entries are always
retained — only an abusive stream hits the bound.

Deliberately id-keyed (cosmetic only, documented): the clip player's
`playing_id`, `pending_plays`, `invalid_audio` — they're coupled to the
id-keyed clip player, and the bytes actually played come from the
author-keyed cache, so content is always correct.

No gossip/wire/protocol change (UiEvent is in-process), no new deps. +6
unit tests (cache eviction, replace-keeps-position, same-id/distinct-author
non-aliasing, is_ready/handle/clear, cap-zero clamp). 423 lib tests,
clippy --all-targets clean, release build green. TESTS-GREEN-ONLY.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 15:58:26 -04:00
8 changed files with 1479 additions and 151 deletions
+29
View File
@@ -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"
+14
View File
@@ -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
View File
@@ -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')
+852 -122
View File
File diff suppressed because it is too large Load Diff
+26 -5
View File
@@ -64,9 +64,16 @@ pub enum CoreCommand {
/// Set the pixelpass binary location (config override, empty = use `$PATH`).
/// Sent at startup so screen-share can resolve the binary.
SetPixelpassPath(Option<String>),
/// Enumerate apps currently producing audio (for the screen-share audio
/// picker, A23). Replies with [`UiEvent::AudioAppsListed`]. Cheap shell-out;
/// safe to call each time the picker opens.
ListAudioApps,
/// Start sharing our screen: spawn a pixelpass host and announce its ticket
/// on our presence so the room can watch. No-op when not in a call.
StartScreenShare,
/// `audio_app` selects which app's audio to capture: `Some(name)` captures
/// only that app (avoiding the call-loopback echo, A23); `None` shares the
/// whole desktop audio (the legacy behavior).
StartScreenShare { audio_app: Option<String> },
/// Stop sharing our screen: kill the pixelpass host and clear the presence
/// ticket. No-op when not sharing.
StopScreenShare,
@@ -133,15 +140,29 @@ pub enum UiEvent {
/// string, used to key their avatar (W4).
ChatMessage { from: String, name: String, text: String, attachment: Option<crate::files::ChatAttachment> },
/// An attachment's bytes are now available (auto-fetched for images, or
/// fetched on demand for files). Keyed by attachment id so the UI can match
/// it to the chat entry.
AttachmentReady { id: crate::files::AttachmentId, data: Vec<u8> },
/// fetched on demand for files). Keyed by `(from, id)`: the id is
/// attacker-chosen, so a malicious peer can reuse a victim's id — the author
/// disambiguates whose bytes these are and stops content aliasing (Tier C
/// F-12).
AttachmentReady { from: EndpointId, id: crate::files::AttachmentId, data: Vec<u8> },
/// An attachment fetch failed (sender gone, too large, decode error, etc.).
AttachmentFailed { 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".
ScreenShareStarted,
/// Our own screen share stopped (or failed to start).
ScreenShareStopped,
/// Per-app screen-share audio routing state (A23). `true` = the app we chose
/// is now reaching viewers; `false` = its audio stopped, so under our strict
/// run viewers currently hear silence. The UI shows a transient warning while
/// `false`. Only meaningful while sharing a specific app (not whole-desktop).
ShareAudioActive(bool),
/// Our node identity (W7): the current node id string, and whether it is
/// PERSISTED to disk. Sent once at startup and again after a regenerate.
/// `persisted = false` means the key file couldn't be read/written and we're
+70 -8
View File
@@ -805,7 +805,8 @@ fn should_auto_fetch(is_image: bool, author_in_roster: bool, already_inflight: b
/// Fetch a chat attachment's bytes from `from` over the file plane in a detached
/// task, then report the result to the UI via [`UiEvent::AttachmentReady`] /
/// [`UiEvent::AttachmentFailed`] keyed by the attachment id. For images
/// [`UiEvent::AttachmentFailed`], tagged with `from` so the UI keys the bytes by
/// `(author, id)` and can't alias a same-id attachment from another sender. For images
/// (`is_image`) the bytes are defensively re-validated (decodable + within pixel
/// limits) before being handed to the renderer; an "Image" that doesn't decode is
/// reported as a failure rather than rendered. `guard` is `Some` for bounded
@@ -827,6 +828,7 @@ fn spawn_attachment_fetch(
if is_image && crate::files::validate_image_bytes(&data).is_none() {
let _ = ui_tx
.send(UiEvent::AttachmentFailed {
from,
id: att.id,
error: "received image failed to decode".to_string(),
})
@@ -834,12 +836,12 @@ fn spawn_attachment_fetch(
return;
}
let _ = ui_tx
.send(UiEvent::AttachmentReady { id: att.id, data })
.send(UiEvent::AttachmentReady { from, id: att.id, data })
.await;
}
Err(e) => {
let _ = ui_tx
.send(UiEvent::AttachmentFailed { id: att.id, error: e.to_string() })
.send(UiEvent::AttachmentFailed { from, id: att.id, error: e.to_string() })
.await;
}
}
@@ -1072,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 })
})
@@ -2632,7 +2646,29 @@ async fn run_core_loop(
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 _ = ui_tx
.send(UiEvent::Error("Join a call before sharing your screen".into()))
@@ -2653,7 +2689,33 @@ async fn run_core_loop(
continue;
}
};
match crate::screenshare::spawn_host(&bin).await {
// Forward pixelpass `app_audio` events (only emitted when an app
// is selected) to the UI so it can warn when the chosen app's
// audio drops. The channel closes when the host dies (drain hits
// EOF), ending the forwarder task on its own.
let notices = audio_app.as_deref().map(|_| {
let (tx, mut rx) =
tokio::sync::mpsc::unbounded_channel::<crate::screenshare::PixelpassEvent>();
let ui_tx_notices = ui_tx.clone();
tokio::spawn(async move {
while let Some(ev) = rx.recv().await {
let active = match ev {
crate::screenshare::PixelpassEvent::AppAudioRouted => true,
crate::screenshare::PixelpassEvent::AppAudioLost => false,
_ => continue,
};
if ui_tx_notices
.send(UiEvent::ShareAudioActive(active))
.await
.is_err()
{
break;
}
}
});
tx
});
match crate::screenshare::spawn_host(&bin, audio_app.as_deref(), notices).await {
Ok((child, ticket)) => {
crate::log_msg("Screen share host started");
session.screenshare_host = Some(child);
+82
View File
@@ -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());
+405 -15
View File
@@ -39,6 +39,10 @@ fn pixelpass_path_candidates(dir: &Path) -> [PathBuf; 1] {
/// growth, but reject unbounded gossip payloads before the UI offers "Watch".
const MAX_TICKET_LEN: usize = 512;
/// Upper bound on a PipeWire `application.name` we'll pass to `--app`. Real names
/// are short ("Firefox", "mpv"); this only guards against a pathological value.
const MAX_APP_NAME_LEN: usize = 256;
/// How long to wait for the host to emit its ticket / the viewer to connect
/// before giving up and killing the child. Startup is normally sub-second; this
/// is only a safety net so a hung pixelpass can't wedge the caller forever.
@@ -64,6 +68,12 @@ pub enum PixelpassEvent {
CaptureStarted,
/// Host: capture pipeline torn down (on last viewer).
CaptureStopped,
/// Host (per-app audio): the chosen app's audio is now reaching viewers.
AppAudioRouted,
/// Host (per-app audio): the chosen app's last audio stream went away. Under
/// our `--strict-audio` run this means viewers now hear silence (not the call
/// echo) until the app produces audio again — we surface it as a warning.
AppAudioLost,
/// A recognized event we don't act on (e.g. `host_info`).
Other,
}
@@ -98,6 +108,11 @@ pub fn parse_pixelpass_event(line: &str) -> Option<PixelpassEvent> {
Some("stopped") => PixelpassEvent::CaptureStopped,
_ => PixelpassEvent::Other,
},
"app_audio" => match v.get("state").and_then(|s| s.as_str()) {
Some("routed") => PixelpassEvent::AppAudioRouted,
Some("lost") => PixelpassEvent::AppAudioLost,
_ => PixelpassEvent::Other,
},
_ => PixelpassEvent::Other,
};
Some(ev)
@@ -107,6 +122,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
}
/// 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
/// rides gossip presence, which is untrusted and spoofable), so flags come first
/// and the ticket is passed as a positional **after a `--` end-of-options
@@ -162,20 +315,30 @@ pub fn is_available(config_override: Option<&str>) -> bool {
pixelpass_path(config_override).is_some()
}
/// Spawn a pixelpass host (`pixelpass --host --output json`), wait for its
/// startup ticket, and return the live child plus the ticket. The child keeps
/// Spawn a pixelpass host (`pixelpass --host --output json [--app=<name>]`), wait
/// for its startup ticket, and return the live child plus the ticket. When
/// `audio_app` is `Some`, pixelpass captures only that app's audio instead of the
/// whole desktop sink, which avoids the call-loopback echo (A23). The child keeps
/// running (streaming to viewers) until killed or dropped; remaining stdout is
/// drained in a background task so a full pipe can't stall the host. We do
/// **not** pass `--max-viewers`: pixelpass bandwidth-measures its own safe cap,
/// protecting the sharer's uplink, and refuses extras with `viewer_refused`.
pub async fn spawn_host(bin: &Path) -> std::io::Result<(Child, String)> {
pub async fn spawn_host(
bin: &Path,
audio_app: Option<&str>,
notices: Option<tokio::sync::mpsc::UnboundedSender<PixelpassEvent>>,
) -> std::io::Result<(Child, String)> {
let mut child = Command::new(bin)
.arg("--host")
.arg("--output")
.arg("json")
.args(host_args(audio_app))
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
// 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()?;
@@ -183,6 +346,7 @@ pub async fn spawn_host(bin: &Path) -> std::io::Result<(Child, String)> {
.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 {
@@ -194,9 +358,10 @@ pub async fn spawn_host(bin: &Path) -> std::io::Result<(Child, String)> {
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;
@@ -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))
}
/// 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
@@ -251,7 +471,7 @@ pub async fn spawn_viewer(bin: &Path, ticket: &str) -> std::io::Result<Child> {
return Err(e);
}
drain_in_background(lines, "viewer");
drain_in_background(lines, "viewer", None);
Ok(child)
}
@@ -286,15 +506,24 @@ where
}
/// Keep reading the child's stdout to EOF in the background so a full pipe can't
/// stall it; log notable events for diagnostics.
fn drain_in_background<R>(mut lines: tokio::io::Lines<BufReader<R>>, role: &'static str)
where
/// stall it; log notable events for diagnostics. When `notices` is `Some`, each
/// parsed event is also forwarded to the caller (the core, which translates the
/// `app_audio` ones into a UI warning); a send failure (receiver dropped) just
/// stops forwarding, draining continues. The task ends on EOF (child exited).
fn drain_in_background<R>(
mut lines: tokio::io::Lines<BufReader<R>>,
role: &'static str,
notices: Option<tokio::sync::mpsc::UnboundedSender<PixelpassEvent>>,
) where
R: tokio::io::AsyncRead + Unpin + Send + 'static,
{
tokio::spawn(async move {
while let Ok(Some(line)) = lines.next_line().await {
if let Some(ev) = parse_pixelpass_event(&line) {
crate::log_msg(&format!("pixelpass {role}: {}", event_for_log(&ev)));
if let Some(tx) = &notices {
let _ = tx.send(ev);
}
}
}
});
@@ -313,6 +542,8 @@ fn event_for_log(ev: &PixelpassEvent) -> String {
PixelpassEvent::Refused(reason) => format!("viewer_refused reason={reason:?}"),
PixelpassEvent::CaptureStarted => "capture_started".to_string(),
PixelpassEvent::CaptureStopped => "capture_stopped".to_string(),
PixelpassEvent::AppAudioRouted => "app_audio_routed".to_string(),
PixelpassEvent::AppAudioLost => "app_audio_lost".to_string(),
PixelpassEvent::Other => "other".to_string(),
}
}
@@ -384,6 +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]
fn sanitize_ticket_accepts_pixelpass_endpoint_ticket_shape() {
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]
fn recognized_but_unused_event_is_other() {
assert_eq!(