diff --git a/src/multicall.rs b/src/multicall.rs deleted file mode 100644 index 86b0853..0000000 --- a/src/multicall.rs +++ /dev/null @@ -1,490 +0,0 @@ -use std::{ - io::{self, Read, Write}, - os::fd::IntoRawFd, - process::Command, -}; - -use clap::{ArgAction, Parser}; -use wl_clipboard_rs::{ - copy::{ - ClipboardType as CopyClipboardType, - MimeType as CopyMimeType, - Options, - Seat as CopySeat, - ServeRequests, - Source, - }, - paste::{ - ClipboardType as PasteClipboardType, - Error as PasteError, - MimeType as PasteMimeType, - Seat as PasteSeat, - get_contents, - get_mime_types, - }, - utils::{PrimarySelectionCheckError, is_primary_selection_supported}, -}; - -/// Extract the base name from argv[0]. -fn get_base(argv0: &str) -> &str { - std::path::Path::new(argv0) - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("") -} - -/// Dispatch multicall binary logic based on argv[0]. -/// Returns true if a multicall command was handled and the process should exit. -pub fn multicall_dispatch() -> bool { - let argv0 = std::env::args().next().unwrap_or_default(); - let base = get_base(&argv0); - match base { - "stash-copy" | "wl-copy" => { - wl_copy_main(); - true - }, - "stash-paste" | "wl-paste" => { - wl_paste_main(); - true - }, - _ => false, - } -} - -#[derive(Parser, Debug)] -#[command( - name = "wl-copy", - about = "Copy clipboard contents on Wayland.", - version -)] -#[allow(clippy::struct_excessive_bools)] -struct WlCopyArgs { - /// Serve only a single paste request and then exit - #[arg(short = 'o', long = "paste-once", action = ArgAction::SetTrue)] - paste_once: bool, - - /// Stay in the foreground instead of forking - #[arg(short = 'f', long = "foreground", action = ArgAction::SetTrue)] - foreground: bool, - - /// Clear the clipboard instead of copying - #[arg(short = 'c', long = "clear", action = ArgAction::SetTrue)] - clear: bool, - - /// Use the "primary" clipboard - #[arg(short = 'p', long = "primary", action = ArgAction::SetTrue)] - primary: bool, - - /// Use the regular clipboard - #[arg(short = 'r', long = "regular", action = ArgAction::SetTrue)] - regular: bool, - - /// Trim the trailing newline character before copying - #[arg(short = 'n', long = "trim-newline", action = ArgAction::SetTrue)] - trim_newline: bool, - - /// Pick the seat to work with - #[arg(short = 's', long = "seat")] - seat: Option, - - /// Override the inferred MIME type for the content - #[arg(short = 't', long = "type")] - mime_type: Option, - - /// Enable verbose logging - #[arg(short = 'v', long = "verbose", action = ArgAction::Count)] - verbose: u8, - - /// Check if primary selection is supported and exit - #[arg(long = "check-primary", action = ArgAction::SetTrue)] - check_primary: bool, - - /// Do not offer additional text mime types (stash extension) - #[arg(long = "omit-additional-text-mime-types", action = ArgAction::SetTrue, hide = true)] - omit_additional_text_mime_types: bool, - - /// Number of paste requests to serve before exiting (stash extension) - #[arg(short = 'x', long = "serve-requests", hide = true)] - serve_requests: Option, - - /// Text to copy (if not given, read from stdin) - #[arg(value_name = "TEXT TO COPY", action = ArgAction::Append)] - text: Vec, -} - -fn wl_copy_main() { - let args = WlCopyArgs::parse(); - - if args.check_primary { - match is_primary_selection_supported() { - Ok(true) => { - log::info!("primary selection is supported."); - std::process::exit(0); - }, - Ok(false) => { - log::info!("primary selection is NOT supported."); - std::process::exit(1); - }, - Err(PrimarySelectionCheckError::NoSeats) => { - log::error!("could not determine: no seats available."); - std::process::exit(2); - }, - Err(PrimarySelectionCheckError::MissingProtocol) => { - log::error!("data-control protocol not supported by compositor."); - std::process::exit(3); - }, - Err(e) => { - log::error!("error checking primary selection support: {e}"); - std::process::exit(4); - }, - } - } - - let clipboard = if args.primary { - CopyClipboardType::Primary - } else { - CopyClipboardType::Regular - }; - - let mime_type = if let Some(mt) = args.mime_type.as_deref() { - if mt == "text" || mt == "text/plain" { - CopyMimeType::Text - } else if mt == "autodetect" { - CopyMimeType::Autodetect - } else { - CopyMimeType::Specific(mt.to_string()) - } - } else { - CopyMimeType::Autodetect - }; - - // Handle clear operation - if args.clear { - let mut opts = Options::new(); - opts.clipboard(clipboard); - if let Some(seat_name) = args.seat.as_deref() { - opts.seat(CopySeat::Specific(seat_name.to_string())); - } else { - opts.seat(CopySeat::All); - } - - if let Err(e) = opts.copy(Source::Bytes(Vec::new().into()), mime_type) { - log::error!("failed to clear clipboard: {e}"); - std::process::exit(1); - } - return; - } - - // Read input data - let input: Vec = if args.text.is_empty() { - let mut buffer = Vec::new(); - if let Err(e) = std::io::stdin().read_to_end(&mut buffer) { - eprintln!("failed to read stdin: {e}"); - std::process::exit(1); - } - buffer - } else { - args.text.join(" ").into_bytes() - }; - - // Configure copy options - let mut opts = Options::new(); - opts.clipboard(clipboard); - - if let Some(seat_name) = args.seat.as_deref() { - opts.seat(CopySeat::Specific(seat_name.to_string())); - } else { - opts.seat(CopySeat::All); - } - - if args.trim_newline { - opts.trim_newline(true); - } - - if args.omit_additional_text_mime_types { - opts.omit_additional_text_mime_types(true); - } - - // Configure serving behavior - if args.paste_once { - opts.serve_requests(ServeRequests::Only(1)); - } else if let Some(n) = args.serve_requests { - opts.serve_requests(ServeRequests::Only(n)); - } - - // Handle foreground vs background mode - if args.foreground { - // Foreground mode: copy and serve in current process - if let Err(e) = opts.copy(Source::Bytes(input.into()), mime_type) { - log::error!("failed to copy to clipboard: {e}"); - std::process::exit(1); - } - } else { - // Background mode: fork and let child serve requests - // First prepare the copy to validate before forking - let mut opts_fg = opts.clone(); - opts_fg.foreground(true); - - let prepared_copy = - match opts_fg.prepare_copy(Source::Bytes(input.into()), mime_type) { - Ok(copy) => copy, - Err(e) => { - log::error!("failed to prepare copy: {e}"); - std::process::exit(1); - }, - }; - - // Fork the process - match unsafe { libc::fork() } { - -1 => { - log::error!("failed to fork: {}", std::io::Error::last_os_error()); - std::process::exit(1); - }, - 0 => { - // Child process: serve clipboard requests - // Redirect stdin/stdout to /dev/null to detach from terminal - if let Ok(dev_null) = std::fs::OpenOptions::new() - .read(true) - .write(true) - .open("/dev/null") - { - let fd = dev_null.into_raw_fd(); - unsafe { - libc::dup2(fd, libc::STDIN_FILENO); - libc::dup2(fd, libc::STDOUT_FILENO); - libc::close(fd); - } - } - - // Serve clipboard requests - if let Err(e) = prepared_copy.serve() { - log::error!("failed to serve clipboard: {e}"); - std::process::exit(1); - } - std::process::exit(0); - }, - _ => { - // Parent process: exit immediately - std::process::exit(0); - }, - } - } -} - -#[derive(Parser, Debug)] -#[command( - name = "wl-paste", - about = "Paste clipboard contents on Wayland.", - version, - disable_help_subcommand = true -)] -struct WlPasteArgs { - /// List the offered MIME types instead of pasting - #[arg(short = 'l', long = "list-types", action = ArgAction::SetTrue)] - list_types: bool, - - /// Use the "primary" clipboard - #[arg(short = 'p', long = "primary", action = ArgAction::SetTrue)] - primary: bool, - - /// Do not append a newline character - #[arg(short = 'n', long = "no-newline", action = ArgAction::SetTrue)] - no_newline: bool, - - /// Pick the seat to work with - #[arg(short = 's', long = "seat")] - seat: Option, - - /// Request the given MIME type instead of inferring the MIME type - #[arg(short = 't', long = "type")] - mime_type: Option, - - /// Enable verbose logging - #[arg(short = 'v', long = "verbose", action = ArgAction::Count)] - verbose: u8, - - /// Watch for clipboard changes and run a command - #[arg(short = 'w', long = "watch")] - watch: Option>, -} - -fn wl_paste_main() { - let args = WlPasteArgs::parse(); - - let clipboard = if args.primary { - PasteClipboardType::Primary - } else { - PasteClipboardType::Regular - }; - - let seat = if let Some(seat_name) = args.seat.as_deref() { - PasteSeat::Specific(seat_name) - } else { - PasteSeat::Unspecified - }; - - // Handle list-types option - if args.list_types { - match get_mime_types(clipboard, seat) { - Ok(types) => { - for mime_type in types { - println!("{}", mime_type); - } - std::process::exit(0); - }, - Err(PasteError::NoSeats) => { - log::error!("no seats available (is a Wayland compositor running?)"); - std::process::exit(1); - }, - Err(e) => { - log::error!("failed to list types: {e}"); - std::process::exit(1); - }, - } - } - - // Handle watch mode - if let Some(watch_args) = args.watch { - if watch_args.is_empty() { - eprintln!("--watch requires a command to run"); - std::process::exit(1); - } - - // For now, implement a simple version that just runs once - // Full watch mode would require more complex implementation - log::warn!("watch mode is not fully implemented in this version"); - - let mut cmd = Command::new(&watch_args[0]); - if watch_args.len() > 1 { - cmd.args(&watch_args[1..]); - } - - // Get clipboard content and pipe it to the command - match get_contents(clipboard, seat, PasteMimeType::Text) { - Ok((mut reader, _types)) => { - let mut content = Vec::new(); - if let Err(e) = reader.read_to_end(&mut content) { - log::error!("failed to read clipboard: {e}"); - std::process::exit(1); - } - - // Set environment variable for clipboard state - unsafe { - std::env::set_var( - "CLIPBOARD_STATE", - if content.is_empty() { "nil" } else { "data" }, - ) - }; - - // Spawn the command with the content as stdin - use std::process::Stdio; - cmd.stdin(Stdio::piped()); - - let mut child = match cmd.spawn() { - Ok(child) => child, - Err(e) => { - log::error!("failed to spawn command: {e}"); - std::process::exit(1); - }, - }; - - if let Some(stdin) = child.stdin.take() { - use std::io::Write; - let mut stdin = stdin; - if let Err(e) = stdin.write_all(&content) { - log::error!("failed to write to command stdin: {e}"); - std::process::exit(1); - } - } - - match child.wait() { - Ok(status) => { - std::process::exit(status.code().unwrap_or(1)); - }, - Err(e) => { - log::error!("failed to wait for command: {e}"); - std::process::exit(1); - }, - } - }, - Err(PasteError::NoSeats) => { - log::error!("no seats available (is a Wayland compositor running?)"); - std::process::exit(1); - }, - Err(PasteError::ClipboardEmpty) => { - unsafe { - std::env::set_var("CLIPBOARD_STATE", "nil"); - } - // Run command with /dev/null as stdin - use std::process::Stdio; - cmd.stdin(Stdio::null()); - - match cmd.status() { - Ok(status) => { - std::process::exit(status.code().unwrap_or(1)); - }, - Err(e) => { - log::error!("failed to run command: {e}"); - std::process::exit(1); - }, - } - }, - Err(e) => { - log::error!("clipboard error: {e}"); - std::process::exit(1); - }, - } - } - - // Regular paste mode - let mime_type = match args.mime_type.as_deref() { - None | Some("text" | "autodetect") => PasteMimeType::Text, - Some(other) => PasteMimeType::Specific(other), - }; - - match get_contents(clipboard, seat, mime_type) { - Ok((mut reader, _types)) => { - let mut out = io::stdout(); - let mut buf = Vec::new(); - match reader.read_to_end(&mut buf) { - Ok(n) => { - if n == 0 && args.no_newline { - std::process::exit(1); - } - if let Err(e) = out.write_all(&buf) { - log::error!("failed to write to stdout: {e}"); - std::process::exit(1); - } - if !args.no_newline && !buf.ends_with(b"\n") { - if let Err(e) = out.write_all(b"\n") { - log::error!("failed to write newline to stdout: {e}"); - std::process::exit(1); - } - } - }, - Err(e) => { - log::error!("failed to read clipboard: {e}"); - std::process::exit(1); - }, - } - }, - Err(PasteError::NoSeats) => { - log::error!("no seats available (is a Wayland compositor running?)"); - std::process::exit(1); - }, - Err(PasteError::ClipboardEmpty) => { - if args.no_newline { - std::process::exit(1); - } - // Otherwise, exit successfully with no output - }, - Err(PasteError::NoMimeType) => { - log::error!("clipboard does not contain requested MIME type"); - std::process::exit(1); - }, - Err(e) => { - log::error!("clipboard error: {e}"); - std::process::exit(1); - }, - } -} diff --git a/src/multicall/mod.rs b/src/multicall/mod.rs new file mode 100644 index 0000000..46f19f3 --- /dev/null +++ b/src/multicall/mod.rs @@ -0,0 +1,44 @@ +// Reference documentation: +// https://wayland.freedesktop.org/docs/html/apa.html#protocol-spec-wl_data_device +// https://docs.rs/wl-clipboard-rs/latest/wl_clipboard_rs +// https://github.com/YaLTeR/wl-clipboard-rs/blob/master/wl-clipboard-rs-tools/src/bin/wl_copy.rs +pub mod wl_copy; +pub mod wl_paste; + +use std::env; + +/// Extract the base name from argv[0]. +fn get_base(argv0: &str) -> &str { + std::path::Path::new(argv0) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("") +} + +/// Dispatch multicall binary logic based on `argv[0]`. +/// Returns `true` if a multicall command was handled and the process should +/// exit. +pub fn multicall_dispatch() -> bool { + let argv0 = env::args().next().unwrap_or_else(|| { + log::warn!("unable to determine program name"); + String::new() + }); + let base = get_base(&argv0); + match base { + "stash-copy" | "wl-copy" => { + if let Err(e) = wl_copy::wl_copy_main() { + log::error!("copy failed: {e}"); + std::process::exit(1); + } + true + }, + "stash-paste" | "wl-paste" => { + if let Err(e) = wl_paste::wl_paste_main() { + log::error!("paste failed: {e}"); + std::process::exit(1); + } + true + }, + _ => false, + } +} diff --git a/src/multicall/wl_copy.rs b/src/multicall/wl_copy.rs new file mode 100644 index 0000000..cab79f5 --- /dev/null +++ b/src/multicall/wl_copy.rs @@ -0,0 +1,276 @@ +use std::io::{self, Read}; + +use clap::{ArgAction, Parser}; +use color_eyre::eyre::{Context, Result, bail}; +use wl_clipboard_rs::{ + copy::{ + ClipboardType as CopyClipboardType, + MimeType as CopyMimeType, + Options, + Seat as CopySeat, + ServeRequests, + Source, + }, + utils::{PrimarySelectionCheckError, is_primary_selection_supported}, +}; + +// Maximum clipboard content size to prevent memory exhaustion (100MB) +const MAX_CLIPBOARD_SIZE: usize = 100 * 1024 * 1024; + +#[derive(Parser, Debug)] +#[command( + name = "wl-copy", + about = "Copy clipboard contents on Wayland.", + version +)] +#[allow(clippy::struct_excessive_bools)] +struct WlCopyArgs { + /// Serve only a single paste request and then exit + #[arg(short = 'o', long = "paste-once", action = ArgAction::SetTrue)] + paste_once: bool, + + /// Stay in the foreground instead of forking + #[arg(short = 'f', long = "foreground", action = ArgAction::SetTrue)] + foreground: bool, + + /// Clear the clipboard instead of copying + #[arg(short = 'c', long = "clear", action = ArgAction::SetTrue)] + clear: bool, + + /// Use the "primary" clipboard + #[arg(short = 'p', long = "primary", action = ArgAction::SetTrue)] + primary: bool, + + /// Use the regular clipboard + #[arg(short = 'r', long = "regular", action = ArgAction::SetTrue)] + regular: bool, + + /// Trim the trailing newline character before copying + #[arg(short = 'n', long = "trim-newline", action = ArgAction::SetTrue)] + trim_newline: bool, + + /// Pick the seat to work with + #[arg(short = 's', long = "seat")] + seat: Option, + + /// Override the inferred MIME type for the content + #[arg(short = 't', long = "type")] + mime_type: Option, + + /// Enable verbose logging + #[arg(short = 'v', long = "verbose", action = ArgAction::Count)] + verbose: u8, + + /// Check if primary selection is supported and exit + #[arg(long = "check-primary", action = ArgAction::SetTrue)] + check_primary: bool, + + /// Do not offer additional text mime types (stash extension) + #[arg(long = "omit-additional-text-mime-types", action = ArgAction::SetTrue, hide = true)] + omit_additional_text_mime_types: bool, + + /// Number of paste requests to serve before exiting (stash extension) + #[arg(short = 'x', long = "serve-requests", hide = true)] + serve_requests: Option, + + /// Text to copy (if not given, read from stdin) + #[arg(value_name = "TEXT TO COPY", action = ArgAction::Append)] + text: Vec, +} + +fn handle_check_primary() { + let exit_code = match is_primary_selection_supported() { + Ok(true) => { + log::info!("primary selection is supported."); + 0 + }, + Ok(false) => { + log::info!("primary selection is NOT supported."); + 1 + }, + Err(PrimarySelectionCheckError::NoSeats) => { + log::error!("could not determine: no seats available."); + 2 + }, + Err(PrimarySelectionCheckError::MissingProtocol) => { + log::error!("data-control protocol not supported by compositor."); + 3 + }, + Err(e) => { + log::error!("error checking primary selection support: {e}"); + 4 + }, + }; + + // Exit with the relevant code + std::process::exit(exit_code); +} + +fn get_clipboard_type(primary: bool) -> CopyClipboardType { + if primary { + CopyClipboardType::Primary + } else { + CopyClipboardType::Regular + } +} + +fn get_mime_type(mime_arg: Option<&str>) -> CopyMimeType { + match mime_arg { + Some("text" | "text/plain") => CopyMimeType::Text, + Some("autodetect") | None => CopyMimeType::Autodetect, + Some(specific) => CopyMimeType::Specific(specific.to_string()), + } +} + +fn read_input_data(text_args: &[String]) -> Result> { + if text_args.is_empty() { + let mut buffer = Vec::new(); + let mut stdin = io::stdin(); + + // Read with size limit to prevent memory exhaustion + let mut temp_buffer = [0; 8192]; + loop { + let bytes_read = stdin + .read(&mut temp_buffer) + .context("failed to read from stdin")?; + + if bytes_read == 0 { + break; + } + + if buffer.len() + bytes_read > MAX_CLIPBOARD_SIZE { + bail!( + "input exceeds maximum clipboard size of {} bytes", + MAX_CLIPBOARD_SIZE + ); + } + + buffer.extend_from_slice(&temp_buffer[..bytes_read]); + } + + Ok(buffer) + } else { + let content = text_args.join(" "); + if content.len() > MAX_CLIPBOARD_SIZE { + bail!( + "input exceeds maximum clipboard size of {} bytes", + MAX_CLIPBOARD_SIZE + ); + } + Ok(content.into_bytes()) + } +} + +fn configure_copy_options( + args: &WlCopyArgs, + clipboard: CopyClipboardType, +) -> Options { + let mut opts = Options::new(); + opts.clipboard(clipboard); + opts.seat( + args + .seat + .as_deref() + .map_or(CopySeat::All, |s| CopySeat::Specific(s.to_string())), + ); + + if args.trim_newline { + opts.trim_newline(true); + } + + if args.omit_additional_text_mime_types { + opts.omit_additional_text_mime_types(true); + } + + if args.paste_once { + opts.serve_requests(ServeRequests::Only(1)); + } else if let Some(n) = args.serve_requests { + opts.serve_requests(ServeRequests::Only(n)); + } + + opts +} + +fn handle_clear_clipboard( + args: &WlCopyArgs, + clipboard: CopyClipboardType, + mime_type: CopyMimeType, +) -> Result<()> { + let mut opts = Options::new(); + opts.clipboard(clipboard); + opts.seat( + args + .seat + .as_deref() + .map_or(CopySeat::All, |s| CopySeat::Specific(s.to_string())), + ); + + opts + .copy(Source::Bytes(Vec::new().into()), mime_type) + .context("failed to clear clipboard")?; + + Ok(()) +} + +fn fork_and_serve(prepared_copy: wl_clipboard_rs::copy::PreparedCopy) { + // Use a simpler approach: serve in background thread instead of forking + // This avoids all the complexity and safety issues with fork() + let handle = std::thread::spawn(move || { + if let Err(e) = prepared_copy.serve() { + log::error!("background clipboard service failed: {e}"); + } + }); + + // Give the background thread a moment to start + std::thread::sleep(std::time::Duration::from_millis(50)); + log::debug!("clipboard service started in background thread"); + + // Detach the thread to allow it to run independently + // The thread will be cleaned up when it completes or when the process exits + std::mem::forget(handle); +} + +pub fn wl_copy_main() -> Result<()> { + let args = WlCopyArgs::parse(); + + if args.check_primary { + handle_check_primary(); + } + + let clipboard = get_clipboard_type(args.primary); + let mime_type = get_mime_type(args.mime_type.as_deref()); + + // Handle clear operation + if args.clear { + handle_clear_clipboard(&args, clipboard, mime_type)?; + return Ok(()); + } + + // Read input data + let input = + read_input_data(&args.text).context("failed to read input data")?; + + // Configure copy options + let opts = configure_copy_options(&args, clipboard); + + // Handle foreground vs background mode + if args.foreground { + // Foreground mode: copy and serve in current process + opts + .copy(Source::Bytes(input.into()), mime_type) + .context("failed to copy to clipboard")?; + } else { + // Background mode: spawn child process to serve requests + // First prepare to copy to validate before spawning + let mut opts_fg = opts.clone(); + opts_fg.foreground(true); + + let prepared_copy = opts_fg + .prepare_copy(Source::Bytes(input.into()), mime_type) + .context("failed to prepare copy")?; + + fork_and_serve(prepared_copy); + } + + Ok(()) +} diff --git a/src/multicall/wl_paste.rs b/src/multicall/wl_paste.rs new file mode 100644 index 0000000..909926d --- /dev/null +++ b/src/multicall/wl_paste.rs @@ -0,0 +1,454 @@ +// https://wayland.freedesktop.org/docs/html/apa.html#protocol-spec-wl_data_device +// https://docs.rs/wl-clipboard-rs/latest/wl_clipboard_rs +// https://github.com/YaLTeR/wl-clipboard-rs/blob/master/wl-clipboard-rs-tools/src/bin/wl_paste.rs +use std::{ + collections::hash_map::DefaultHasher, + hash::{Hash, Hasher}, + io::{self, Read, Write}, + process::{Command, Stdio}, + sync::{Arc, Mutex}, + thread, + time::{Duration, Instant}, +}; + +use clap::{ArgAction, Parser}; +use color_eyre::eyre::{Context, Result, bail}; +use wl_clipboard_rs::paste::{ + ClipboardType as PasteClipboardType, + Error as PasteError, + MimeType as PasteMimeType, + Seat as PasteSeat, + get_contents, + get_mime_types, +}; + +// Watch mode timing constants +const WATCH_POLL_INTERVAL_MS: u64 = 500; +const WATCH_DEBOUNCE_INTERVAL_MS: u64 = 1000; + +// Maximum clipboard content size to prevent memory exhaustion (100MB) +const MAX_CLIPBOARD_SIZE: usize = 100 * 1024 * 1024; + +#[derive(Parser, Debug)] +#[command( + name = "wl-paste", + about = "Paste clipboard contents on Wayland.", + version, + disable_help_subcommand = true +)] +struct WlPasteArgs { + /// List the offered MIME types instead of pasting + #[arg(short = 'l', long = "list-types", action = ArgAction::SetTrue)] + list_types: bool, + + /// Use the "primary" clipboard + #[arg(short = 'p', long = "primary", action = ArgAction::SetTrue)] + primary: bool, + + /// Do not append a newline character + #[arg(short = 'n', long = "no-newline", action = ArgAction::SetTrue)] + no_newline: bool, + + /// Pick the seat to work with + #[arg(short = 's', long = "seat")] + seat: Option, + + /// Request the given MIME type instead of inferring the MIME type + #[arg(short = 't', long = "type")] + mime_type: Option, + + /// Enable verbose logging + #[arg(short = 'v', long = "verbose", action = ArgAction::Count)] + verbose: u8, + + /// Watch for clipboard changes and run a command + #[arg(short = 'w', long = "watch")] + watch: Option>, +} + +fn get_paste_mime_type(mime_arg: Option<&str>) -> PasteMimeType { + match mime_arg { + None | Some("text" | "autodetect") => PasteMimeType::Text, + Some(other) => PasteMimeType::Specific(other), + } +} + +fn handle_list_types( + clipboard: PasteClipboardType, + seat: PasteSeat, +) -> Result<()> { + match get_mime_types(clipboard, seat) { + Ok(types) => { + for mime_type in types { + println!("{mime_type}"); + } + + #[allow(clippy::needless_return)] + return Ok(()); + }, + Err(PasteError::NoSeats) => { + bail!("no seats available (is a Wayland compositor running?)"); + }, + Err(e) => { + bail!("failed to list types: {e}"); + }, + } +} + +fn handle_watch_mode( + args: &WlPasteArgs, + clipboard: PasteClipboardType, + seat: PasteSeat, +) -> Result<()> { + let watch_args = args.watch.as_ref().unwrap(); + if watch_args.is_empty() { + bail!("--watch requires a command to run"); + } + + log::info!("starting clipboard watch mode"); + + // Shared state for tracking last content and shutdown signal + let last_content_hash = Arc::new(Mutex::new(None::)); + let shutdown = Arc::new(Mutex::new(false)); + + // Set up signal handler for graceful shutdown + let shutdown_clone = shutdown.clone(); + ctrlc::set_handler(move || { + log::info!("received shutdown signal, stopping watch mode"); + if let Ok(mut shutdown_guard) = shutdown_clone.lock() { + *shutdown_guard = true; + } else { + log::error!("failed to acquire shutdown lock in signal handler"); + } + }) + .context("failed to set signal handler")?; + + let poll_interval = Duration::from_millis(WATCH_POLL_INTERVAL_MS); + let debounce_interval = Duration::from_millis(WATCH_DEBOUNCE_INTERVAL_MS); + let mut last_change_time = Instant::now(); + + loop { + // Check for shutdown signal + match shutdown.lock() { + Ok(shutdown_guard) => { + if *shutdown_guard { + log::info!("shutting down watch mode"); + break Ok(()); + } + }, + Err(e) => { + log::error!("failed to acquire shutdown lock: {e}"); + thread::sleep(poll_interval); + continue; + }, + } + + // Get current clipboard content + let current_hash = match get_clipboard_content_hash(clipboard, seat) { + Ok(hash) => hash, + Err(e) => { + log::error!("failed to get clipboard content hash: {e}"); + thread::sleep(poll_interval); + continue; + }, + }; + + // Check if content has changed + match last_content_hash.lock() { + Ok(mut last_hash_guard) => { + let changed = *last_hash_guard != Some(current_hash); + if changed { + let now = Instant::now(); + + // Debounce rapid changes + if now.duration_since(last_change_time) >= debounce_interval { + *last_hash_guard = Some(current_hash); + last_change_time = now; + drop(last_hash_guard); // Release lock before spawning command + + log::info!("clipboard content changed, executing watch command"); + + // Execute the watch command + if let Err(e) = execute_watch_command(watch_args, clipboard, seat) { + log::error!("failed to execute watch command: {e}"); + // Continue watching even if command fails + } + } + } + changed + }, + Err(e) => { + log::error!("failed to acquire last_content_hash lock: {e}"); + thread::sleep(poll_interval); + continue; + }, + }; + + thread::sleep(poll_interval); + } +} + +fn get_clipboard_content_hash( + clipboard: PasteClipboardType, + seat: PasteSeat, +) -> Result { + match get_contents(clipboard, seat, PasteMimeType::Text) { + Ok((mut reader, _types)) => { + let mut content = Vec::new(); + let mut temp_buffer = [0; 8192]; + + loop { + let bytes_read = reader + .read(&mut temp_buffer) + .context("failed to read clipboard content")?; + + if bytes_read == 0 { + break; + } + + if content.len() + bytes_read > MAX_CLIPBOARD_SIZE { + bail!( + "clipboard content exceeds maximum size of {} bytes", + MAX_CLIPBOARD_SIZE + ); + } + + content.extend_from_slice(&temp_buffer[..bytes_read]); + } + + let mut hasher = DefaultHasher::new(); + content.hash(&mut hasher); + Ok(hasher.finish()) + }, + Err(PasteError::ClipboardEmpty) => { + Ok(0) // Empty clipboard has hash 0 + }, + Err(e) => bail!("clipboard error: {e}"), + } +} + +/// Validate command name to prevent command injection +fn validate_command_name(cmd: &str) -> Result<()> { + if cmd.is_empty() { + bail!("command name cannot be empty"); + } + + // Reject commands with shell metacharacters or path traversal + if cmd.contains(|c| { + ['|', '&', ';', '$', '`', '(', ')', '<', '>', '"', '\'', '\\'].contains(&c) + }) { + bail!("command contains invalid characters: {cmd}"); + } + + // Reject absolute paths and relative path traversal + if cmd.starts_with('/') || cmd.contains("..") { + bail!("command paths are not allowed: {cmd}"); + } + + Ok(()) +} + +/// Set environment variable safely with validation +fn set_clipboard_state_env(has_content: bool) -> Result<()> { + let value = if has_content { "data" } else { "nil" }; + + // Validate the environment variable value + if !matches!(value, "data" | "nil") { + bail!("invalid clipboard state value: {value}"); + } + + // Safe to set environment variable with validated, known-safe value + unsafe { + std::env::set_var("STASH_CLIPBOARD_STATE", value); + } + Ok(()) +} + +fn execute_watch_command( + watch_args: &[String], + clipboard: PasteClipboardType, + seat: PasteSeat, +) -> Result<()> { + if watch_args.is_empty() { + bail!("watch command cannot be empty"); + } + + // Validate command name for security + validate_command_name(&watch_args[0])?; + + let mut cmd = Command::new(&watch_args[0]); + if watch_args.len() > 1 { + cmd.args(&watch_args[1..]); + } + + // Get clipboard content and pipe it to the command + match get_contents(clipboard, seat, PasteMimeType::Text) { + Ok((mut reader, _types)) => { + let mut content = Vec::new(); + let mut temp_buffer = [0; 8192]; + + loop { + let bytes_read = reader + .read(&mut temp_buffer) + .context("failed to read clipboard")?; + + if bytes_read == 0 { + break; + } + + if content.len() + bytes_read > MAX_CLIPBOARD_SIZE { + bail!( + "clipboard content exceeds maximum size of {} bytes", + MAX_CLIPBOARD_SIZE + ); + } + + content.extend_from_slice(&temp_buffer[..bytes_read]); + } + + // Set environment variable safely + set_clipboard_state_env(!content.is_empty())?; + + // Spawn the command with the content as stdin + cmd.stdin(Stdio::piped()); + + let mut child = cmd.spawn()?; + + if let Some(stdin) = child.stdin.take() { + let mut stdin = stdin; + if let Err(e) = stdin.write_all(&content) { + bail!("failed to write to command stdin: {e}"); + } + } + + match child.wait() { + Ok(status) => { + if !status.success() { + log::warn!("watch command exited with status: {status}"); + } + }, + Err(e) => { + bail!("failed to wait for command: {e}"); + }, + } + }, + Err(PasteError::ClipboardEmpty) => { + // Set environment variable safely + set_clipboard_state_env(false)?; + + // Run command with /dev/null as stdin + cmd.stdin(Stdio::null()); + + match cmd.status() { + Ok(status) => { + if !status.success() { + log::warn!("watch command exited with status: {status}"); + } + }, + Err(e) => { + bail!("failed to run command: {e}"); + }, + } + }, + Err(e) => { + bail!("clipboard error: {e}"); + }, + } + + Ok(()) +} + +fn handle_regular_paste( + args: &WlPasteArgs, + clipboard: PasteClipboardType, + seat: PasteSeat, +) -> Result<()> { + let mime_type = get_paste_mime_type(args.mime_type.as_deref()); + + match get_contents(clipboard, seat, mime_type) { + Ok((mut reader, _types)) => { + let mut out = io::stdout(); + let mut buf = Vec::new(); + let mut temp_buffer = [0; 8192]; + + loop { + let bytes_read = reader + .read(&mut temp_buffer) + .context("failed to read clipboard")?; + + if bytes_read == 0 { + break; + } + + if buf.len() + bytes_read > MAX_CLIPBOARD_SIZE { + bail!( + "clipboard content exceeds maximum size of {} bytes", + MAX_CLIPBOARD_SIZE + ); + } + + buf.extend_from_slice(&temp_buffer[..bytes_read]); + } + + if buf.is_empty() && args.no_newline { + bail!("no content available and --no-newline specified"); + } + if let Err(e) = out.write_all(&buf) { + bail!("failed to write to stdout: {e}"); + } + if !args.no_newline && !buf.ends_with(b"\n") { + if let Err(e) = out.write_all(b"\n") { + bail!("failed to write newline to stdout: {e}"); + } + } + }, + Err(PasteError::NoSeats) => { + bail!("no seats available (is a Wayland compositor running?)"); + }, + Err(PasteError::ClipboardEmpty) => { + if args.no_newline { + bail!("clipboard empty and --no-newline specified"); + } + // Otherwise, exit successfully with no output + }, + Err(PasteError::NoMimeType) => { + bail!("clipboard does not contain requested MIME type"); + }, + Err(e) => { + bail!("clipboard error: {e}"); + }, + } + + Ok(()) +} + +pub fn wl_paste_main() -> Result<()> { + let args = WlPasteArgs::parse(); + + let clipboard = if args.primary { + PasteClipboardType::Primary + } else { + PasteClipboardType::Regular + }; + let seat = args + .seat + .as_deref() + .map_or(PasteSeat::Unspecified, PasteSeat::Specific); + + // Handle list-types option + if args.list_types { + handle_list_types(clipboard, seat)?; + return Ok(()); + } + + // Handle watch mode + if args.watch.is_some() { + handle_watch_mode(&args, clipboard, seat)?; + return Ok(()); + } + + // Regular paste mode + handle_regular_paste(&args, clipboard, seat)?; + + Ok(()) +}