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
+132
View File
@@ -0,0 +1,132 @@
mod capture;
mod tunnel;
use anyhow::{Result, bail};
use iroh::Endpoint;
use iroh::endpoint::{Connection, presets};
use iroh_tickets::endpoint::EndpointTicket;
use tokio::net::TcpStream;
use tokio_util::sync::CancellationToken;
use crate::cli::HostOpts;
use crate::common::{alpn::ALPN, deps, display::DisplayServer, signal};
pub async fn run(opts: HostOpts) -> Result<()> {
let display = DisplayServer::resolve(opts.display_server);
deps::check_host_binaries(display)?;
if display == DisplayServer::Unknown {
bail!(
"could not detect display server (WAYLAND_DISPLAY / DISPLAY / XDG_SESSION_TYPE all unset).\n\
Use --display-server wayland|x11 to override."
);
}
let cancel = signal::install_ctrl_c();
let endpoint = Endpoint::builder(presets::N0)
.alpns(vec![ALPN.to_vec()])
.bind()
.await?;
let addr = endpoint.addr();
let ticket = EndpointTicket::new(addr);
print_host_banner(&ticket, display, &opts);
let result = accept_loop(&endpoint, display, &opts, cancel.clone()).await;
endpoint.close().await;
result
}
async fn accept_loop(
endpoint: &Endpoint,
display: DisplayServer,
opts: &HostOpts,
cancel: CancellationToken,
) -> Result<()> {
tokio::select! {
_ = cancel.cancelled() => {
tracing::info!("cancellation requested before any peer connected");
Ok(())
}
accepted = endpoint.accept() => {
let Some(incoming) = accepted else {
bail!("endpoint stopped accepting connections");
};
let conn = incoming.await?;
let remote = conn.remote_id();
tracing::info!(%remote, "peer connected");
eprintln!("\n[pixelpass] peer connected: {remote}\n");
handle_peer(conn, display, opts, cancel).await
}
}
}
async fn handle_peer(
conn: Connection,
display: DisplayServer,
opts: &HostOpts,
cancel: CancellationToken,
) -> Result<()> {
let (quic_send, quic_recv) = conn.accept_bi().await?;
// Spawn ffmpeg (and on Wayland, the gst-launch bridge) listening on a
// random localhost port. Returns the port and a guard that kills the
// child(ren) on drop.
let capture_handle = capture::spawn(display, opts).await?;
let port = capture_handle.local_port();
// Give ffmpeg's `-listen 1` HTTP server a moment to bind before we dial.
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
let tcp = TcpStream::connect(("127.0.0.1", port)).await?;
let bridge = crate::common::tunnel::bridge(quic_send, quic_recv, tcp);
tokio::select! {
res = bridge => {
if let Err(e) = res {
tracing::warn!("bridge ended with error: {e:#}");
} else {
tracing::info!("bridge closed cleanly");
}
}
_ = cancel.cancelled() => {
tracing::info!("cancellation requested during stream");
}
}
drop(capture_handle); // explicit teardown of ffmpeg/gst
Ok(())
}
fn print_host_banner(ticket: &EndpointTicket, display: DisplayServer, opts: &HostOpts) {
eprintln!();
eprintln!("┌─ PixelPass · host ─────────────────────────────────────────");
eprintln!("│ display server : {display:?}");
eprintln!("│ capture : {}", capture_summary(opts));
eprintln!("│ bitrate / fps : {} kbps @ {} fps", opts.bitrate, opts.framerate);
eprintln!("│ hw encode : {}", if opts.no_hwencode { "off" } else { "auto (VAAPI if available)" });
eprintln!("");
eprintln!("│ Share this ticket with your viewer:");
eprintln!("");
eprintln!("│ pixelpass {ticket}");
eprintln!("");
eprintln!("│ Capture will not start until the viewer connects.");
eprintln!("│ Press Ctrl+C to stop.");
eprintln!("└────────────────────────────────────────────────────────────");
eprintln!();
}
fn capture_summary(opts: &HostOpts) -> String {
let mut bits = vec![if opts.window { "window" } else { "fullscreen" }.to_string()];
if let Some(app) = &opts.app {
bits.push(format!("app-audio={app}"));
} else {
bits.push("system-audio".to_string());
}
if opts.mic {
bits.push("mic".to_string());
}
bits.join(" + ")
}