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:
@@ -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("")));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user