feat: screen sharing via pixelpass (Discord-style, presence-borne ticket)

Surface pixelpass screen-sharing from inside a peerspeak room. peerspeak owns
voice, pixelpass owns pixels — they're never Cargo deps of each other; the
contract is pixelpass's CLI flags + its `--output json` stdout stream.

Modelled on Discord: multiple simultaneous sharers, a 🔴 Live badge + 👁 Watch
on each sharing peer's card, and in-progress shares visible to late joiners.

- New `src/screenshare` module: pure `parse_pixelpass_event` seam + `pixelpass_path`
  discovery (13 unit tests), async `spawn_host` (→ ticket) and `spawn_viewer`
  (→ parse connected{url} → open mpv, vlc fallback). No new deps.
- Sharing rides presence: `PeerState.sharing: Option<ticket>` (serde-defaulted),
  so the existing gossip re-announce delivers the offer to late joiners for free
  and a PeerUpdated fires on start/stop — no separate gossip message needed.
- core: Start/Stop/ViewShare commands; host + viewer children tracked in the
  session, killed on stop/leave (kill_on_drop backstop). Viewer limit left to
  pixelpass's bandwidth-measured cap.
- UI: Share/Stop button (graceful "needs pixelpass" disabled state), Live badge
  + Watch on peer cards, Sharing badge on the self card. Verified by screenshot.
- config: optional `pixelpass_path` override (hand-editable).

Tests-green; the 2-machine gossip/remote path is not yet field-verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 15:46:34 -04:00
co-authored by Claude Opus 4.8
parent 5dad86db57
commit d9e544607a
11 changed files with 703 additions and 4 deletions
+426
View File
@@ -0,0 +1,426 @@
//! Screen-share integration: drive `pixelpass` as a child process.
//!
//! peerspeak owns voice; pixelpass owns pixels. The two are **never** Cargo
//! dependencies of each other — the contract is pixelpass's CLI flags plus its
//! `--output json` stdout event stream, treated as a stable public API. This
//! module spawns a pixelpass *host* (to share our screen) or a *viewer* (to
//! watch a peer's share), scrapes the JSON it needs, and otherwise stays out of
//! the way. Absence of the `pixelpass` binary is a normal, handled state — the
//! UI degrades to a disabled "install pixelpass" control rather than erroring.
//!
//! The only piece that travels between peers is the host's relay **ticket**:
//! peerspeak puts it on the sharer's presence (see `PeerState.sharing`) so the
//! room's existing gossip plane distributes it, and each viewer one-clicks it
//! into a local pixelpass viewer. The ticket *is* the capability, so this is
//! "click to grant the room access" with no ACL to fight.
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::{Child, Command};
/// The binary we shell out to. Looked up on `$PATH` unless a config override
/// points elsewhere.
const PIXELPASS_BIN: &str = "pixelpass";
/// 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.
const STARTUP_TIMEOUT: Duration = Duration::from_secs(20);
/// One parsed line from pixelpass's `--output json` stdout stream. Mirrors the
/// `event` tags in pixelpass's `src/common/output.rs`. Recognized-but-unused
/// events collapse to [`PixelpassEvent::Other`]; blank or non-JSON lines parse
/// to `None`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PixelpassEvent {
/// Host: the relay ticket a viewer needs. Emitted once at host startup.
Ticket(String),
/// Viewer: the local player URL is ready to open.
Connected(String),
/// Host: a viewer joined; carries the new active count and the cap.
ViewerJoined { active: u32, max: u32 },
/// Host: a viewer left.
ViewerLeft { active: u32, max: u32 },
/// Host: a viewer was turned away (host full or capture spawn failed).
Refused(String),
/// Host: capture pipeline spawned (on first viewer).
CaptureStarted,
/// Host: capture pipeline torn down (on last viewer).
CaptureStopped,
/// A recognized event we don't act on (e.g. `host_info`).
Other,
}
/// Parse a single stdout line from pixelpass `--output json`. Pure: no I/O.
pub fn parse_pixelpass_event(line: &str) -> Option<PixelpassEvent> {
let line = line.trim();
if line.is_empty() {
return None;
}
let v: serde_json::Value = serde_json::from_str(line).ok()?;
let event = v.get("event")?.as_str()?;
let ev = match event {
"ticket" => PixelpassEvent::Ticket(v.get("value")?.as_str()?.to_string()),
"connected" => PixelpassEvent::Connected(v.get("url")?.as_str()?.to_string()),
"viewer_joined" => PixelpassEvent::ViewerJoined {
active: json_u32(&v, "active"),
max: json_u32(&v, "max"),
},
"viewer_left" => PixelpassEvent::ViewerLeft {
active: json_u32(&v, "active"),
max: json_u32(&v, "max"),
},
"viewer_refused" => PixelpassEvent::Refused(
v.get("reason")
.and_then(|r| r.as_str())
.unwrap_or("")
.to_string(),
),
"capture" => match v.get("state").and_then(|s| s.as_str()) {
Some("started") => PixelpassEvent::CaptureStarted,
Some("stopped") => PixelpassEvent::CaptureStopped,
_ => PixelpassEvent::Other,
},
_ => PixelpassEvent::Other,
};
Some(ev)
}
fn json_u32(v: &serde_json::Value, key: &str) -> u32 {
v.get(key).and_then(|x| x.as_u64()).unwrap_or(0) as u32
}
/// Resolve the pixelpass binary: an explicit config override (used only if it
/// points at an existing file), otherwise the first `pixelpass` found on
/// `$PATH`. `None` means it isn't installed — a normal, handled state. An
/// override that doesn't resolve falls through to the `$PATH` search rather than
/// failing outright.
pub fn pixelpass_path(config_override: Option<&str>) -> Option<PathBuf> {
if let Some(p) = config_override {
let p = p.trim();
if !p.is_empty() {
let pb = PathBuf::from(p);
if pb.is_file() {
return Some(pb);
}
// Override set but missing — fall through to the $PATH search.
}
}
let path_var = std::env::var_os("PATH")?;
std::env::split_paths(&path_var)
.map(|dir| dir.join(PIXELPASS_BIN))
.find(|c| c.is_file())
}
/// Whether pixelpass is available to shell out to.
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
/// 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)> {
let mut child = Command::new(bin)
.arg("--host")
.arg("--output")
.arg("json")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.kill_on_drop(true)
.spawn()?;
let stdout = child
.stdout
.take()
.ok_or_else(|| std::io::Error::other("pixelpass host stdout missing"))?;
let mut lines = BufReader::new(stdout).lines();
let ticket = match read_until(&mut lines, |e| match e {
PixelpassEvent::Ticket(t) => Some(t),
_ => None,
})
.await
{
Ok(Some(t)) => t,
Ok(None) => {
let _ = child.kill().await;
return Err(std::io::Error::other(
"pixelpass host exited before emitting a ticket",
));
}
Err(e) => {
let _ = child.kill().await;
return Err(e);
}
};
drain_in_background(lines, "host");
Ok((child, ticket))
}
/// 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
/// player window closes (its tunnel ends).
pub async fn spawn_viewer(bin: &Path, ticket: &str) -> std::io::Result<Child> {
let mut child = Command::new(bin)
.arg(ticket)
.arg("--output")
.arg("json")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.kill_on_drop(true)
.spawn()?;
let stdout = child
.stdout
.take()
.ok_or_else(|| std::io::Error::other("pixelpass viewer stdout missing"))?;
let mut lines = BufReader::new(stdout).lines();
let url = match read_until(&mut lines, |e| match e {
PixelpassEvent::Connected(u) => Some(u),
_ => None,
})
.await
{
Ok(Some(u)) => u,
Ok(None) => {
let _ = child.kill().await;
return Err(std::io::Error::other(
"pixelpass viewer exited before connecting",
));
}
Err(e) => {
let _ = child.kill().await;
return Err(e);
}
};
if let Err(e) = launch_player(&url) {
let _ = child.kill().await;
return Err(e);
}
drain_in_background(lines, "viewer");
Ok(child)
}
/// Read JSON event lines until `pick` returns `Some(value)`. Returns `Ok(None)`
/// on EOF (child exited first) and `Err` on an I/O error or the startup timeout.
async fn read_until<R, T>(
lines: &mut tokio::io::Lines<BufReader<R>>,
mut pick: impl FnMut(PixelpassEvent) -> Option<T>,
) -> std::io::Result<Option<T>>
where
R: tokio::io::AsyncRead + Unpin,
{
loop {
match tokio::time::timeout(STARTUP_TIMEOUT, lines.next_line()).await {
Ok(Ok(Some(line))) => {
if let Some(ev) = parse_pixelpass_event(&line)
&& let Some(v) = pick(ev)
{
return Ok(Some(v));
}
}
Ok(Ok(None)) => return Ok(None),
Ok(Err(e)) => return Err(e),
Err(_) => {
return Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"timed out waiting for pixelpass startup event",
));
}
}
}
}
/// 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
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}: {ev:?}"));
}
}
});
}
/// Open the viewer stream URL in a media player. Mirrors pixelpass's own
/// low-latency mpv invocation; falls back to vlc. The player is reaped in a
/// background task so it doesn't linger as a zombie when its window closes.
fn launch_player(url: &str) -> std::io::Result<()> {
const MPV_ARGS: &[&str] = &[
"--profile=low-latency",
"--untimed",
"--hwdec=auto",
"--audio-buffer=0.2",
"--demuxer-max-bytes=2M",
"--demuxer-readahead-secs=0.5",
];
const VLC_ARGS: &[&str] = &["--network-caching=200", "--live-caching=200"];
let child = match spawn_player("mpv", MPV_ARGS, url) {
Ok(c) => c,
Err(_) => spawn_player("vlc", VLC_ARGS, url).map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"no media player found — install mpv or vlc to watch screen shares",
)
})?,
};
tokio::spawn(async move {
let mut child = child;
let _ = child.wait().await;
});
Ok(())
}
fn spawn_player(bin: &str, args: &[&str], url: &str) -> std::io::Result<Child> {
Command::new(bin)
.args(args)
.arg(url)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.kill_on_drop(false)
.spawn()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_ticket() {
assert_eq!(
parse_pixelpass_event(r#"{"event":"ticket","value":"abc123"}"#),
Some(PixelpassEvent::Ticket("abc123".to_string()))
);
}
#[test]
fn parses_connected_url() {
assert_eq!(
parse_pixelpass_event(r#"{"event":"connected","url":"http://127.0.0.1:5500"}"#),
Some(PixelpassEvent::Connected("http://127.0.0.1:5500".to_string()))
);
}
#[test]
fn parses_viewer_joined_and_left() {
assert_eq!(
parse_pixelpass_event(r#"{"event":"viewer_joined","id":"x","active":2,"max":4}"#),
Some(PixelpassEvent::ViewerJoined { active: 2, max: 4 })
);
assert_eq!(
parse_pixelpass_event(r#"{"event":"viewer_left","id":"x","active":1,"max":4}"#),
Some(PixelpassEvent::ViewerLeft { active: 1, max: 4 })
);
}
#[test]
fn viewer_counts_default_to_zero_when_absent() {
assert_eq!(
parse_pixelpass_event(r#"{"event":"viewer_joined"}"#),
Some(PixelpassEvent::ViewerJoined { active: 0, max: 0 })
);
}
#[test]
fn parses_refused_with_and_without_reason() {
assert_eq!(
parse_pixelpass_event(r#"{"event":"viewer_refused","reason":"host is full"}"#),
Some(PixelpassEvent::Refused("host is full".to_string()))
);
assert_eq!(
parse_pixelpass_event(r#"{"event":"viewer_refused"}"#),
Some(PixelpassEvent::Refused("".to_string()))
);
}
#[test]
fn parses_capture_states() {
assert_eq!(
parse_pixelpass_event(r#"{"event":"capture","state":"started"}"#),
Some(PixelpassEvent::CaptureStarted)
);
assert_eq!(
parse_pixelpass_event(r#"{"event":"capture","state":"stopped"}"#),
Some(PixelpassEvent::CaptureStopped)
);
// Unknown capture state is recognized-but-unused, not a parse failure.
assert_eq!(
parse_pixelpass_event(r#"{"event":"capture","state":"paused"}"#),
Some(PixelpassEvent::Other)
);
}
#[test]
fn recognized_but_unused_event_is_other() {
assert_eq!(
parse_pixelpass_event(
r#"{"event":"host_info","display_server":"wayland","max_viewers":4}"#
),
Some(PixelpassEvent::Other)
);
assert_eq!(
parse_pixelpass_event(r#"{"event":"some_future_event"}"#),
Some(PixelpassEvent::Other)
);
}
#[test]
fn blank_and_non_json_lines_are_none() {
assert_eq!(parse_pixelpass_event(""), None);
assert_eq!(parse_pixelpass_event(" "), None);
assert_eq!(parse_pixelpass_event("not json at all"), None);
// Valid JSON but not an event object.
assert_eq!(parse_pixelpass_event("[1,2,3]"), None);
assert_eq!(parse_pixelpass_event(r#"{"no_event":"here"}"#), None);
}
#[test]
fn ticket_without_value_is_none() {
// A malformed ticket event (missing `value`) must not panic.
assert_eq!(parse_pixelpass_event(r#"{"event":"ticket"}"#), None);
}
#[test]
fn surrounding_whitespace_is_tolerated() {
assert_eq!(
parse_pixelpass_event(" {\"event\":\"ticket\",\"value\":\"t\"}\n"),
Some(PixelpassEvent::Ticket("t".to_string()))
);
}
#[test]
fn path_override_to_existing_file_is_used() {
// A real file (this source file) stands in for a custom binary location.
let this_file = file!();
let resolved = pixelpass_path(Some(this_file));
assert_eq!(resolved.as_deref(), Some(Path::new(this_file)));
assert!(is_available(Some(this_file)));
}
#[test]
fn empty_override_falls_through() {
// An empty/whitespace override is ignored (falls through to $PATH); we
// only assert it doesn't return the empty path as a match.
assert_ne!(pixelpass_path(Some(" ")).as_deref(), Some(Path::new("")));
}
}