Phase 1 foundation: CLI, iroh tunnel, lazy capture wiring

Scaffolding for PixelPass per ~/Documents/p2p-screenshare-plan.md §7
"Phase 1 — MVP". The full QUIC tunnel handshake works end-to-end
(verified locally: host generates ticket, viewer dials through iroh's
relay, open_bi succeeds, lazy capture is wired correctly).

What's implemented:

- Cargo project with deps locked: iroh 1.0.0-rc.0, iroh-tickets,
  tokio, clap, ashpd, pipewire-rs, x11rb, ashpd, anyhow, thiserror,
  tracing, nix, directories, uuid.
- src/cli.rs: complete clap surface per plan §6 (--window, --app,
  --mic, --display-server, --bitrate, --framerate, --no-hwencode,
  --low-latency, --port, --verbose, --repair).
- Mode dispatch in main.rs: EndpointTicket::from_str is the
  authoritative check; no regex / heuristics.
- common/display.rs: WAYLAND_DISPLAY → DISPLAY → XDG_SESSION_TYPE
  precedence with --display-server override.
- common/deps.rs: per-distro install hints (pacman/apt/dnf/zypper)
  parsing /etc/os-release.
- common/alpn.rs: ALPN = b"pixelpass/0".
- common/tunnel.rs: generic bidirectional bridge between an iroh
  bi-stream and any AsyncRead+AsyncWrite (typically a TCP socket).
- common/signal.rs: ctrl-c -> CancellationToken; second ctrl-c hard
  exit.
- host/mod.rs: build Endpoint, generate ticket, print banner, await
  first peer (lazy — no ffmpeg until peer connects), accept_bi,
  spawn capture, bridge to localhost ffmpeg HTTP listener.
- host/capture.rs: stub returning Phase-2 error; the place X11
  x11grab and Wayland ashpd+gst pipelines will land.
- viewer/mod.rs: Endpoint, connect with ALPN, open_bi, TcpListener
  on 127.0.0.1, print copy-ready mpv/vlc commands, bridge.
- repair.rs: stub for --repair PipeWire scan.

iroh 1.0-rc renamed Node* -> Endpoint* and moved EndpointTicket into
a sibling crate (iroh-tickets); no design impact. Plan still locked.
This commit is contained in:
2026-05-15 15:13:28 -04:00
commit 6ad92081aa
17 changed files with 5886 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
/// ALPN identifying the pixelpass wire protocol on the iroh tunnel.
///
/// Bump the version suffix whenever the wire format changes. Today the wire is
/// "raw MPEG-TS bytes copied bidirectionally," so bumps will be rare.
pub const ALPN: &[u8] = b"pixelpass/0";
+67
View File
@@ -0,0 +1,67 @@
use anyhow::{Result, bail};
use std::path::PathBuf;
use crate::common::display::DisplayServer;
pub fn check_host_binaries(display: DisplayServer) -> Result<()> {
require("ffmpeg")?;
if display == DisplayServer::Wayland {
require("gst-launch-1.0")?;
}
Ok(())
}
fn require(bin: &str) -> Result<PathBuf> {
match which(bin) {
Some(p) => Ok(p),
None => {
let hint = install_hint(bin);
bail!("`{bin}` not found on PATH.\n{hint}");
}
}
}
fn which(bin: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
let candidate = dir.join(bin);
if candidate.is_file() {
return Some(candidate);
}
}
None
}
fn install_hint(bin: &str) -> String {
let distro = detect_distro();
let pkg = match bin {
"ffmpeg" => "ffmpeg",
"gst-launch-1.0" => match distro.as_deref() {
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-plugins-base gst-plugins-good",
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-tools gstreamer1.0-plugins-good",
Some("fedora" | "nobara") => "gstreamer1-plugins-good",
_ => "gstreamer (and tools / good-plugins)",
},
_ => bin,
};
let cmd = match distro.as_deref() {
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => format!("sudo pacman -S {pkg}"),
Some("debian" | "ubuntu" | "pop" | "linuxmint") => format!("sudo apt install {pkg}"),
Some("fedora" | "nobara") => format!("sudo dnf install {pkg}"),
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => format!("sudo zypper install {pkg}"),
_ => format!("install the `{pkg}` package via your distro's package manager"),
};
format!("Install hint: {cmd}")
}
fn detect_distro() -> Option<String> {
let contents = std::fs::read_to_string("/etc/os-release").ok()?;
for line in contents.lines() {
if let Some(rest) = line.strip_prefix("ID=") {
return Some(rest.trim_matches('"').to_lowercase());
}
}
None
}
+32
View File
@@ -0,0 +1,32 @@
use crate::cli::DisplayServerArg;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DisplayServer {
Wayland,
X11,
Unknown,
}
impl DisplayServer {
pub fn detect() -> Self {
if std::env::var_os("WAYLAND_DISPLAY").is_some() {
return DisplayServer::Wayland;
}
if std::env::var_os("DISPLAY").is_some() {
return DisplayServer::X11;
}
match std::env::var("XDG_SESSION_TYPE").as_deref() {
Ok("wayland") => DisplayServer::Wayland,
Ok("x11") => DisplayServer::X11,
_ => DisplayServer::Unknown,
}
}
pub fn resolve(override_: Option<DisplayServerArg>) -> Self {
match override_ {
Some(DisplayServerArg::Wayland) => DisplayServer::Wayland,
Some(DisplayServerArg::X11) => DisplayServer::X11,
None => DisplayServer::detect(),
}
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod alpn;
pub mod deps;
pub mod display;
pub mod signal;
pub mod tunnel;
+20
View File
@@ -0,0 +1,20 @@
use tokio_util::sync::CancellationToken;
/// Install a ctrl-c handler that triggers the returned token.
///
/// The first ctrl-c cancels gracefully; a second ctrl-c terminates the process.
pub fn install_ctrl_c() -> CancellationToken {
let token = CancellationToken::new();
let trigger = token.clone();
tokio::spawn(async move {
if tokio::signal::ctrl_c().await.is_ok() {
tracing::info!("ctrl-c received, shutting down");
trigger.cancel();
}
if tokio::signal::ctrl_c().await.is_ok() {
tracing::warn!("second ctrl-c — exiting now");
std::process::exit(130);
}
});
token
}
+34
View File
@@ -0,0 +1,34 @@
use anyhow::Result;
use iroh::endpoint::{RecvStream, SendStream};
use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt};
/// Bridge an iroh bi-stream and a generic AsyncRead+AsyncWrite (typically a TCP
/// socket) by copying bytes in both directions concurrently.
///
/// Returns once either direction finishes or errors. The peer-side socket
/// halves are owned and dropped here on exit, ensuring FIN propagates.
pub async fn bridge<T>(quic_send: SendStream, quic_recv: RecvStream, peer: T) -> Result<()>
where
T: AsyncRead + AsyncWrite + Unpin,
{
let (mut peer_r, mut peer_w) = tokio::io::split(peer);
let mut quic_send = quic_send;
let mut quic_recv = quic_recv;
let to_quic = async {
let n = tokio::io::copy(&mut peer_r, &mut quic_send).await;
let _ = quic_send.finish();
n
};
let to_peer = async {
let n = tokio::io::copy(&mut quic_recv, &mut peer_w).await;
let _ = peer_w.shutdown().await;
n
};
tokio::select! {
res = to_quic => { res?; }
res = to_peer => { res?; }
}
Ok(())
}