interactive: Host/View entry menu, clipboard copy, player picker
Bare `pixelpass` now opens a dialoguer-driven Host/View menu instead of going straight to host mode. Host path copies the ticket to the system clipboard via arboard with silent print-only fallback. View path prompts for the ticket, then after the local listener binds prompts mpv-vs-VLC and spawns it detached (setsid + null stdio) so the player survives pixelpass exiting. Headless invocations (`pixelpass <ticket>`, `pixelpass --repair`) unchanged. Per spec at ~/Documents/pixelpass-interactive-mode-spec.md.
This commit is contained in:
+8
-5
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod alpn;
|
||||
pub mod deps;
|
||||
pub mod display;
|
||||
pub mod process;
|
||||
pub mod signal;
|
||||
pub mod tunnel;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
+25
-3
@@ -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 {
|
||||
|
||||
@@ -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<EndpointTicket> {
|
||||
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<Player> {
|
||||
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 })
|
||||
}
|
||||
+4
-3
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+22
-4
@@ -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!();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user