diff --git a/Cargo.lock b/Cargo.lock index 8a5573a..8b7c7c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -127,6 +127,24 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "log", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.59.0", + "wl-clipboard-rs", + "x11rb", +] + [[package]] name = "arc-swap" version = "1.9.1" @@ -583,6 +601,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "cmov" version = "0.5.3" @@ -623,6 +650,18 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys 0.61.2", +] + [[package]] name = "const-oid" version = "0.10.2" @@ -944,6 +983,16 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "dialoguer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" +dependencies = [ + "console", + "shell-words", +] + [[package]] name = "diatomic-waker" version = "0.2.3" @@ -1026,6 +1075,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "ed25519" version = "3.0.0" @@ -1071,6 +1126,12 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "endi" version = "1.1.1" @@ -1125,6 +1186,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "event-listener" version = "5.4.1" @@ -1164,6 +1231,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "fnv" version = "1.0.7" @@ -2677,6 +2750,18 @@ dependencies = [ "objc2-encode", ] +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-graphics", + "objc2-foundation", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -2690,6 +2775,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + [[package]] name = "objc2-core-wlan" version = "0.3.2" @@ -2723,6 +2821,17 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "objc2-security" version = "0.3.2" @@ -2802,6 +2911,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "papaya" version = "0.2.4" @@ -2862,6 +2981,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "pharos" version = "0.5.3" @@ -2942,8 +3072,10 @@ name = "pixelpass" version = "0.1.0" dependencies = [ "anyhow", + "arboard", "ashpd", "clap", + "dialoguer", "directories", "iroh", "iroh-tickets", @@ -3637,6 +3769,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -4236,6 +4374,17 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom 8.0.0", + "petgraph", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -4535,6 +4684,76 @@ dependencies = [ "semver", ] +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +dependencies = [ + "bitflags", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.98" @@ -4991,6 +5210,24 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wl-clipboard-rs" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix", + "thiserror 2.0.18", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "wmi" version = "0.18.4" diff --git a/Cargo.toml b/Cargo.toml index 9003eb9..653df24 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,8 @@ pipewire = "0.9" x11rb = { version = "0.13", default-features = false, features = ["allow-unsafe-code"] } uuid = { version = "1", features = ["v4"] } iroh-tickets = "1.0.0-rc.0" +dialoguer = { version = "0.12", default-features = false } +arboard = { version = "3", default-features = false, features = ["wayland-data-control"] } [profile.release] lto = "thin" diff --git a/src/cli.rs b/src/cli.rs index eda24bf..bec26a6 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -5,8 +5,8 @@ use clap::{Parser, ValueEnum}; name = "pixelpass", version, about = "P2P screen sharing over iroh + ffmpeg", - long_about = "Run with no arguments to host (prints a ticket to share). \ - Pass a ticket to view." + long_about = "Run with no arguments for an interactive Host/View menu. \ + Pass a ticket positionally to skip the menu and view headlessly." )] pub struct Cli { /// iroh ticket. If present, runs as viewer. If absent, runs as host. @@ -76,15 +76,17 @@ pub struct HostOpts { pub framerate: u32, pub no_hwencode: bool, pub low_latency: bool, + pub interactive: bool, } #[derive(Debug, Clone)] pub struct ViewerOpts { pub port: u16, + pub interactive: bool, } impl Cli { - pub fn into_host_opts(self) -> HostOpts { + pub fn into_host_opts(self, interactive: bool) -> HostOpts { HostOpts { window: self.window, app: self.app, @@ -94,10 +96,11 @@ impl Cli { framerate: self.framerate, no_hwencode: self.no_hwencode, low_latency: self.low_latency, + interactive, } } - pub fn into_viewer_opts(self) -> ViewerOpts { - ViewerOpts { port: self.port } + pub fn into_viewer_opts(self, interactive: bool) -> ViewerOpts { + ViewerOpts { port: self.port, interactive } } } diff --git a/src/common/mod.rs b/src/common/mod.rs index 60fa1b1..fe24f8e 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -1,5 +1,6 @@ pub mod alpn; pub mod deps; pub mod display; +pub mod process; pub mod signal; pub mod tunnel; diff --git a/src/common/process.rs b/src/common/process.rs new file mode 100644 index 0000000..d9ed536 --- /dev/null +++ b/src/common/process.rs @@ -0,0 +1,25 @@ +use std::io; +use std::os::unix::process::CommandExt; +use std::process::{Command, Stdio}; + +/// Spawn a child process fully detached from this process group. +/// +/// The child gets its own session via `setsid(2)` and null stdio, so it +/// survives the parent exiting and doesn't take a SIGKILL cascade when +/// pixelpass dies. The `Child` is dropped immediately — `std::process::Child::drop` +/// does not kill the process on Unix. +pub fn spawn_detached(prog: &str, args: &[&str]) -> io::Result<()> { + unsafe { + Command::new(prog) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .pre_exec(|| { + nix::unistd::setsid().ok(); + Ok(()) + }) + .spawn()?; + } + Ok(()) +} diff --git a/src/host/mod.rs b/src/host/mod.rs index 25d776b..bd31079 100644 --- a/src/host/mod.rs +++ b/src/host/mod.rs @@ -30,7 +30,8 @@ pub async fn run(opts: HostOpts) -> Result<()> { let addr = endpoint.addr(); let ticket = EndpointTicket::new(addr); - print_host_banner(&ticket, display, &opts); + let clipboard_ok = opts.interactive && copy_to_clipboard(&ticket.to_string()); + print_host_banner(&ticket, display, &opts, clipboard_ok); let result = accept_loop(&endpoint, display, &opts, cancel.clone()).await; @@ -93,7 +94,12 @@ async fn handle_peer( Ok(()) } -fn print_host_banner(ticket: &EndpointTicket, display: DisplayServer, opts: &HostOpts) { +fn print_host_banner( + ticket: &EndpointTicket, + display: DisplayServer, + opts: &HostOpts, + clipboard_ok: bool, +) { eprintln!(); eprintln!("┌─ PixelPass · host ─────────────────────────────────────────"); eprintln!("│ display server : {display:?}"); @@ -101,7 +107,13 @@ fn print_host_banner(ticket: &EndpointTicket, display: DisplayServer, opts: &Hos 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:"); + if clipboard_ok { + eprintln!("│ Your share code has been copied to your clipboard."); + eprintln!("│ Send it to your viewer. (If clipboard didn't work, the"); + eprintln!("│ code is also shown below for manual copy.)"); + } else { + eprintln!("│ Share this ticket with your viewer:"); + } eprintln!("│"); eprintln!("│ pixelpass {ticket}"); eprintln!("│"); @@ -111,6 +123,16 @@ fn print_host_banner(ticket: &EndpointTicket, display: DisplayServer, opts: &Hos eprintln!(); } +fn copy_to_clipboard(text: &str) -> bool { + match arboard::Clipboard::new().and_then(|mut cb| cb.set_text(text.to_owned())) { + Ok(()) => true, + Err(e) => { + tracing::warn!("clipboard copy failed: {e}"); + false + } + } +} + fn capture_summary(opts: &HostOpts) -> String { let mut bits = vec![if opts.window { "window" } else { "fullscreen" }.to_string()]; if let Some(app) = &opts.app { diff --git a/src/interactive.rs b/src/interactive.rs new file mode 100644 index 0000000..e8c31b5 --- /dev/null +++ b/src/interactive.rs @@ -0,0 +1,86 @@ +use anyhow::Result; +use dialoguer::{Input, Select, theme::ColorfulTheme}; +use iroh_tickets::endpoint::EndpointTicket; +use std::str::FromStr; + +use crate::cli::Cli; +use crate::{host, viewer}; + +pub async fn run(cli: Cli) -> Result<()> { + print_welcome(); + + let theme = ColorfulTheme::default(); + let choice = Select::with_theme(&theme) + .with_prompt("What do you want to do?") + .items(&[ + "Host (share my screen)", + "View (watch someone else's screen)", + ]) + .default(0) + .interact()?; + + match choice { + 0 => host::run(cli.into_host_opts(true)).await, + _ => { + let ticket = prompt_ticket(&theme)?; + viewer::run(ticket, cli.into_viewer_opts(true)).await + } + } +} + +fn print_welcome() { + eprintln!(); + eprintln!("Welcome to PixelPass."); + eprintln!(); +} + +fn prompt_ticket(theme: &ColorfulTheme) -> Result { + loop { + let raw: String = Input::with_theme(theme) + .with_prompt("Paste the share code you received") + .interact_text()?; + match EndpointTicket::from_str(raw.trim()) { + Ok(t) => return Ok(t), + Err(_) => eprintln!("That doesn't look like a share code. Try again."), + } + } +} + +/// Player options the interactive viewer can launch. +#[derive(Clone, Copy)] +pub enum Player { + Mpv, + Vlc, +} + +impl Player { + pub fn spawn(self, url: &str) -> std::io::Result<()> { + match self { + Player::Mpv => crate::common::process::spawn_detached( + "mpv", + &[ + "--profile=low-latency", + "--untimed", + "--audio-buffer=0.2", + "--demuxer-max-bytes=2M", + "--demuxer-readahead-secs=0.5", + url, + ], + ), + Player::Vlc => crate::common::process::spawn_detached( + "vlc", + &["--network-caching=200", "--live-caching=200", url], + ), + } + } +} + +pub fn prompt_player() -> Result { + let theme = ColorfulTheme::default(); + let choice = Select::with_theme(&theme) + .with_prompt("Connected. Pick a player to launch") + .items(&["mpv", "VLC"]) + .default(0) + .interact()?; + Ok(if choice == 0 { Player::Mpv } else { Player::Vlc }) +} diff --git a/src/main.rs b/src/main.rs index aac41ea..c27b028 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod cli; mod common; mod host; +mod interactive; mod repair; mod viewer; @@ -24,12 +25,12 @@ async fn main() -> Result<()> { let ticket: EndpointTicket = s.parse().map_err(|e| { anyhow::anyhow!( "argument doesn't look like a pixelpass ticket ({e}).\n\ - Run with no arguments to host, or pass a ticket to view." + Run with no arguments for the interactive menu, or pass a ticket to view." ) })?; - viewer::run(ticket, cli.into_viewer_opts()).await + viewer::run(ticket, cli.into_viewer_opts(false)).await } - None => host::run(cli.into_host_opts()).await, + None => interactive::run(cli).await, } } diff --git a/src/viewer/mod.rs b/src/viewer/mod.rs index 2452c88..590a6b9 100644 --- a/src/viewer/mod.rs +++ b/src/viewer/mod.rs @@ -1,4 +1,4 @@ -use anyhow::Result; +use anyhow::{Context, Result}; use iroh::Endpoint; use iroh::endpoint::presets; use iroh_tickets::endpoint::EndpointTicket; @@ -22,7 +22,17 @@ pub async fn run(ticket: EndpointTicket, opts: ViewerOpts) -> Result<()> { let listener = TcpListener::bind(("127.0.0.1", opts.port)).await?; let port = listener.local_addr()?.port(); - print_viewer_banner(port); + let url = format!("http://127.0.0.1:{port}"); + + if opts.interactive { + let player = crate::interactive::prompt_player()?; + player + .spawn(&url) + .with_context(|| "failed to launch player")?; + print_viewer_banner_interactive(); + } else { + print_viewer_banner(&url); + } let result = tokio::select! { accepted = listener.accept() => { @@ -40,8 +50,7 @@ pub async fn run(ticket: EndpointTicket, opts: ViewerOpts) -> Result<()> { result } -fn print_viewer_banner(port: u16) { - let url = format!("http://127.0.0.1:{port}"); +fn print_viewer_banner(url: &str) { eprintln!(); eprintln!("┌─ PixelPass · viewer ───────────────────────────────────────"); eprintln!("│ Connected to host. Open the stream in your player:"); @@ -55,3 +64,12 @@ fn print_viewer_banner(port: u16) { eprintln!("└────────────────────────────────────────────────────────────"); eprintln!(); } + +fn print_viewer_banner_interactive() { + eprintln!(); + eprintln!("┌─ PixelPass · viewer ───────────────────────────────────────"); + eprintln!("│ Player launched. Close it (or press Ctrl+C here) to"); + eprintln!("│ disconnect."); + eprintln!("└────────────────────────────────────────────────────────────"); + eprintln!(); +}