From 955a5d51f874b307b03b3e1d57820097daf7bfab Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 25 Oct 2025 08:07:59 +0300 Subject: [PATCH 1/6] multicall: cleanup; match wl-copy/wl-paste interfaces more closely Signed-off-by: NotAShelf Change-Id: I8cc05c0141cccff8378ef4fd83ccf77d6a6a6964 --- Cargo.lock | 1 + Cargo.toml | 1 + src/multicall.rs | 530 +++++++++++++++++++++++++++++++++-------------- 3 files changed, 371 insertions(+), 161 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0cad45c..ef17d85 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1521,6 +1521,7 @@ dependencies = [ "env_logger", "imagesize", "inquire", + "libc", "log", "notify-rust", "ratatui", diff --git a/Cargo.toml b/Cargo.toml index ab80a8d..b66f921 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ inquire = { default-features = false, version = "0.9.1", features = [ log = "0.4.28" env_logger = "0.11.8" thiserror = "2.0.17" +libc = "0.2" wl-clipboard-rs = "0.9.2" rusqlite = { version = "0.37.0", features = ["bundled"] } smol = "2.0.2" diff --git a/src/multicall.rs b/src/multicall.rs index f387df0..86b0853 100644 --- a/src/multicall.rs +++ b/src/multicall.rs @@ -1,93 +1,119 @@ -use std::io::{self, Read, Write}; +use std::{ + io::{self, Read, Write}, + os::fd::IntoRawFd, + process::Command, +}; use clap::{ArgAction, Parser}; -use wl_clipboard_rs::paste::{ - ClipboardType, - Error, - MimeType, - Seat, - get_contents, +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 = std::path::Path::new(&argv0) - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or(""); + let base = get_base(&argv0); match base { "stash-copy" | "wl-copy" => { - multicall_stash_copy(); + wl_copy_main(); true }, "stash-paste" | "wl-paste" => { - multicall_stash_paste(); + wl_paste_main(); true }, _ => false, } } -#[allow(clippy::too_many_lines)] -fn multicall_stash_copy() { - use clap::{ArgAction, Parser}; - use wl_clipboard_rs::{ - copy::{ClipboardType, MimeType, Options, ServeRequests, Source}, - utils::{PrimarySelectionCheckError, is_primary_selection_supported}, - }; - #[derive(Parser, Debug)] - #[command( - name = "stash-copy", - about = "Copy clipboard contents on Wayland.", - version, - disable_help_subcommand = true - )] - #[allow(clippy::struct_excessive_bools)] - struct Args { - /// 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, - } +#[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, - let args = Args::parse(); + /// 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() { @@ -115,120 +141,201 @@ fn multicall_stash_copy() { } let clipboard = if args.primary { - ClipboardType::Primary + CopyClipboardType::Primary } else { - ClipboardType::Regular + CopyClipboardType::Regular }; let mime_type = if let Some(mt) = args.mime_type.as_deref() { if mt == "text" || mt == "text/plain" { - MimeType::Text + CopyMimeType::Text } else if mt == "autodetect" { - MimeType::Autodetect + CopyMimeType::Autodetect } else { - MimeType::Specific(mt.to_string()) + CopyMimeType::Specific(mt.to_string()) } } else { - MimeType::Autodetect + CopyMimeType::Autodetect }; - let mut input: Vec = Vec::new(); - if args.text.is_empty() { - if let Err(e) = std::io::stdin().read_to_end(&mut input) { - eprintln!("failed to read stdin: {e}"); - std::process::exit(1); - } - } else { - input = args.text.join(" ").into_bytes(); - } - - let mut opts = Options::new(); - opts.clipboard(clipboard); - - if args.trim_newline { - opts.trim_newline(true); - } - if args.foreground { - opts.foreground(true); - } - if let Some(seat) = args.seat.as_deref() { - log::debug!( - "'--seat' is not supported by stash (using default seat: {seat})" - ); - } - if args.omit_additional_text_mime_types { - opts.omit_additional_text_mime_types(true); - } - // --paste-once overrides serve-requests - if args.paste_once { - opts.serve_requests(ServeRequests::Only(1)); - } else if let Some(n) = args.serve_requests { - opts.serve_requests(ServeRequests::Only(n)); - } - // --clear + // Handle clear operation if args.clear { - // Clear clipboard by setting empty contents + 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; } - if let Err(e) = opts.copy(Source::Bytes(input.into()), mime_type) { - log::error!("failed to copy to clipboard: {e}"); - std::process::exit(1); + + // 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); + }, + } } } -fn multicall_stash_paste() { - #[derive(Parser, Debug)] - #[command( - name = "stash-paste", - about = "Paste clipboard contents on Wayland.", - version, - disable_help_subcommand = true - )] - struct Args { - /// 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, - } +#[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, - let args = Args::parse(); + /// 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 { - ClipboardType::Primary + PasteClipboardType::Primary } else { - ClipboardType::Regular + PasteClipboardType::Regular }; - if let Some(seat) = args.seat.as_deref() { - log::debug!( - "'--seat' is not supported by stash (using default seat: {seat})" - ); - } + 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_contents(clipboard, Seat::Unspecified, MimeType::Text) { - Ok((_reader, available_types)) => { - log::info!("{available_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); @@ -236,12 +343,106 @@ fn multicall_stash_paste() { } } + // 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") => MimeType::Text, - Some(other) => MimeType::Specific(other), + None | Some("text" | "autodetect") => PasteMimeType::Text, + Some(other) => PasteMimeType::Specific(other), }; - match get_contents(clipboard, Seat::Unspecified, mime_type) { + match get_contents(clipboard, seat, mime_type) { Ok((mut reader, _types)) => { let mut out = io::stdout(); let mut buf = Vec::new(); @@ -250,9 +451,15 @@ fn multicall_stash_paste() { if n == 0 && args.no_newline { std::process::exit(1); } - let _ = out.write_all(&buf); + 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") { - let _ = out.write_all(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) => { @@ -261,16 +468,17 @@ fn multicall_stash_paste() { }, } }, - Err(Error::NoSeats) => { + Err(PasteError::NoSeats) => { log::error!("no seats available (is a Wayland compositor running?)"); std::process::exit(1); }, - Err(Error::ClipboardEmpty) => { + Err(PasteError::ClipboardEmpty) => { if args.no_newline { std::process::exit(1); } + // Otherwise, exit successfully with no output }, - Err(Error::NoMimeType) => { + Err(PasteError::NoMimeType) => { log::error!("clipboard does not contain requested MIME type"); std::process::exit(1); }, From e94d931e67bc1a23bde957695d5e3c18b8466929 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 25 Oct 2025 09:01:35 +0300 Subject: [PATCH 2/6] chore: remove redundant unix check in build wrapper Signed-off-by: NotAShelf Change-Id: I174857e67f2e400d5dfdd8bfbe7c681d6a6a6964 --- build.rs | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/build.rs b/build.rs index 533368c..f777a7c 100644 --- a/build.rs +++ b/build.rs @@ -5,16 +5,6 @@ const MULTICALL_LINKS: &[&str] = &["stash-copy", "stash-paste", "wl-copy", "wl-paste"]; fn main() { - // Only run on Unix-like systems - #[cfg(not(unix))] - { - println!( - "cargo:warning=Multicall symlinks are only supported on Unix-like \ - systems." - ); - return; - } - // OUT_DIR is something like .../target/debug/build//out // We want .../target/debug or .../target/release let out_dir = env::var("OUT_DIR").expect("OUT_DIR not set"); From 78acc38044af542595375b5a6bb0bdd4b802d36b Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 27 Oct 2025 09:31:53 +0300 Subject: [PATCH 3/6] multicall: cleanup; modularize Signed-off-by: NotAShelf Change-Id: I658f22fdf983777354a5beb32df631916a6a6964 --- src/multicall.rs | 490 -------------------------------------- src/multicall/mod.rs | 44 ++++ src/multicall/wl_copy.rs | 276 +++++++++++++++++++++ src/multicall/wl_paste.rs | 454 +++++++++++++++++++++++++++++++++++ 4 files changed, 774 insertions(+), 490 deletions(-) delete mode 100644 src/multicall.rs create mode 100644 src/multicall/mod.rs create mode 100644 src/multicall/wl_copy.rs create mode 100644 src/multicall/wl_paste.rs 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(()) +} From c95d9a4567e925cf74f11e9f59f4bf7aa8f900e9 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 27 Oct 2025 11:10:37 +0300 Subject: [PATCH 4/6] chore: remove unused deps; format with taplo Signed-off-by: NotAShelf Change-Id: If575be0b2c6f1f8b8eac6cacaa2784606a6a6964 --- .rustfmt.toml | 47 +++++++------ Cargo.lock | 183 +++++++++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 14 ++-- 3 files changed, 211 insertions(+), 33 deletions(-) diff --git a/.rustfmt.toml b/.rustfmt.toml index cb120a3..324bf8b 100644 --- a/.rustfmt.toml +++ b/.rustfmt.toml @@ -1,27 +1,26 @@ -condense_wildcard_suffixes = true +condense_wildcard_suffixes = true doc_comment_code_block_width = 80 -edition = "2024" # Keep in sync with Cargo.toml. +edition = "2024" # Keep in sync with Cargo.toml. enum_discrim_align_threshold = 60 -force_explicit_abi = false -force_multiline_blocks = true -format_code_in_doc_comments = true -format_macro_matchers = true -format_strings = true -group_imports = "StdExternalCrate" -hex_literal_case = "Upper" -imports_granularity = "Crate" -imports_layout = "HorizontalVertical" -inline_attribute_width = 60 -match_block_trailing_comma = true -max_width = 80 -newline_style = "Unix" -normalize_comments = true -normalize_doc_attributes = true -overflow_delimited_expr = true +force_explicit_abi = false +force_multiline_blocks = true +format_code_in_doc_comments = true +format_macro_matchers = true +format_strings = true +group_imports = "StdExternalCrate" +hex_literal_case = "Upper" +imports_granularity = "Crate" +imports_layout = "HorizontalVertical" +inline_attribute_width = 60 +match_block_trailing_comma = true +max_width = 80 +newline_style = "Unix" +normalize_comments = true +normalize_doc_attributes = true +overflow_delimited_expr = true struct_field_align_threshold = 60 -tab_spaces = 2 -unstable_features = true -use_field_init_shorthand = true -use_try_shorthand = true -wrap_comments = true - +tab_spaces = 2 +unstable_features = true +use_field_init_shorthand = true +use_try_shorthand = true +wrap_comments = true diff --git a/Cargo.lock b/Cargo.lock index ef17d85..d40b375 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,21 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.3" @@ -232,6 +247,21 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link 0.2.1", +] + [[package]] name = "base64" version = "0.22.1" @@ -353,6 +383,33 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +[[package]] +name = "color-eyre" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d" +dependencies = [ + "backtrace", + "color-spantrace", + "eyre", + "indenter", + "once_cell", + "owo-colors", + "tracing-error", +] + +[[package]] +name = "color-spantrace" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427" +dependencies = [ + "once_cell", + "owo-colors", + "tracing-core", + "tracing-error", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -440,6 +497,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "ctrlc" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "881c5d0a13b2f1498e2306e82cbada78390e152d4b1378fb28a84f4dcd0dc4f3" +dependencies = [ + "dispatch", + "nix", + "windows-sys 0.61.2", +] + [[package]] name = "darling" version = "0.20.11" @@ -526,6 +594,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + [[package]] name = "dispatch2" version = "0.3.0" @@ -650,6 +724,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -740,6 +824,12 @@ dependencies = [ "wasi 0.14.7+wasi-0.2.4", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + [[package]] name = "hashbrown" version = "0.15.5" @@ -796,6 +886,12 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + [[package]] name = "indexmap" version = "2.11.4" @@ -883,6 +979,12 @@ dependencies = [ "syn", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.177" @@ -985,6 +1087,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + [[package]] name = "mio" version = "1.0.4" @@ -1079,6 +1190,15 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -1117,6 +1237,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "owo-colors" +version = "4.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" + [[package]] name = "parking" version = "2.2.1" @@ -1346,6 +1472,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rustc-demangle" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" + [[package]] name = "rustix" version = "0.38.44" @@ -1444,6 +1576,15 @@ dependencies = [ "syn", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1516,12 +1657,13 @@ dependencies = [ "base64", "clap", "clap-verbosity-flag", + "color-eyre", "crossterm 0.29.0", + "ctrlc", "dirs", "env_logger", "imagesize", "inquire", - "libc", "log", "notify-rust", "ratatui", @@ -1534,7 +1676,6 @@ dependencies = [ "unicode-segmentation", "unicode-width 0.2.0", "wayland-client", - "wayland-protocols", "wayland-protocols-wlr", "wl-clipboard-rs", ] @@ -1629,6 +1770,15 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" version = "0.3.44" @@ -1707,6 +1857,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-error" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" +dependencies = [ + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +dependencies = [ + "sharded-slab", + "thread_local", + "tracing-core", ] [[package]] @@ -1773,6 +1945,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" @@ -1823,6 +2001,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" dependencies = [ "bitflags", + "log", "rustix 1.1.2", "wayland-backend", "wayland-scanner", diff --git a/Cargo.toml b/Cargo.toml index b66f921..103ec7d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,26 +10,27 @@ repository = "https://github.com/notashelf/stash" rust-version = "1.85" [[bin]] -name = "stash" # actual binary name for Nix, Cargo, etc. +name = "stash" # actual binary name for Nix, Cargo, etc. path = "src/main.rs" [features] default = ["use-toplevel", "notifications"] -use-toplevel = ["dep:wayland-client", "dep:wayland-protocols", "dep:wayland-protocols-wlr"] +use-toplevel = ["dep:wayland-client", "dep:wayland-protocols-wlr"] notifications = ["dep:notify-rust"] [dependencies] clap = { version = "4.5.48", features = ["derive", "env"] } clap-verbosity-flag = "3.0.4" +ctrlc = "3.5.0" +color-eyre = "0.6.5" dirs = "6.0.0" imagesize = "0.14.0" inquire = { default-features = false, version = "0.9.1", features = [ - "crossterm", + "crossterm", ] } log = "0.4.28" env_logger = "0.11.8" thiserror = "2.0.17" -libc = "0.2" wl-clipboard-rs = "0.9.2" rusqlite = { version = "0.37.0", features = ["bundled"] } smol = "2.0.2" @@ -41,9 +42,8 @@ ratatui = "0.29.0" crossterm = "0.29.0" unicode-segmentation = "1.12.0" unicode-width = "0.2.0" # FIXME: held back by ratatui -wayland-client = { version = "0.31.11", optional = true } -wayland-protocols = { version = "0.32.0", optional = true } -wayland-protocols-wlr = { version = "0.3.9", optional = true } +wayland-client = { version = "0.31.11", features = ["log"], optional = true } +wayland-protocols-wlr = { version = "0.3.9", default-features = false, optional = true } notify-rust = { version = "4.11.7", optional = true } [profile.release] From 43a3aae49656d55b04da180e3e9daf65f0672822 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 27 Oct 2025 11:49:26 +0300 Subject: [PATCH 5/6] docs: add attributions section; detail remaining sections Signed-off-by: NotAShelf Change-Id: Ice462ee8fc34e375a01940b6b013f5496a6a6964 --- README.md | 122 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 106 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index caa0a3d..f5eba93 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,9 @@
- Wayland clipboard "manager" with fast persistent history and multi-media - support. Stores and previews clipboard entries (text, images) on the command + Lightweight Wayland clipboard "manager" with fast persistent history and + robust multi-media support. Stores and previews clipboard entries (text, images) + on the clipboard with a neat TUI and advanced scripting capabilities. line.
@@ -35,8 +36,8 @@ ## Features -Stash is a feature-rich, yet simple clipboard management utility with many -features such as but not limited to: +Stash is a feature-rich, yet simple and lightweight clipboard management utility +with many features such as but not necessarily limited to: - Automatic MIME detection for stored entries - Fast persistent storage using SQLite @@ -64,7 +65,7 @@ you are on NixOS. ```nix { # Add Stash to your inputs like so - inputs.stash.url = "github:notashelf/stash"; + inputs.stash.url = "github:NotAShelf/stash"; outputs = { /* ... */ }; } @@ -86,10 +87,11 @@ in { } ``` -You can also run it one time with `nix run` +If you want to give Stash a try before you switch to it, you may also run it one +time with `nix run`. ```sh -nix run github:notashelf/stash -- watch # start the watch daemon +nix run github:NotAShelf/stash -- watch # start the watch daemon ``` ### Without Nix @@ -98,12 +100,13 @@ nix run github:notashelf/stash -- watch # start the watch daemon You can also install Stash on any of your systems _without_ using Nix. New releases are made when a version gets tagged, and are available under -[GitHub Releases]. To install Stash on your system without Nix, eiter: +[GitHub Releases]. To install Stash on your system without Nix, either: - Download a tagged release from [GitHub Releases] for your platform and place the binary in your `$PATH`. Instructions may differ based on your distribution, but generally you want to download the built binary from - releases and put it somewhere like `/usr/bin`. + releases and put it somewhere like `/usr/bin` or `~/.local/bin` depending on + your distribution. - Build and install from source with Cargo: ```bash @@ -112,16 +115,63 @@ releases are made when a version gets tagged, and are available under ## Usage -Command interface is only slightly different from Cliphist. In most cases, it -will be as simple as replacing `cliphist` with `stash` in your commands, aliases -or scripts. - > [!NOTE] > It is not a priority to provide 1:1 backwards compatibility with Cliphist. > While the interface is _almost_ identical, Stash chooses to build upon > Cliphist's design and extend existing design choices. See > [Migrating from Cliphist](#migrating-from-cliphist) for more details. +The command interface of Stash is _only slightly_ different from Cliphist. In +most cases, you may simply replace `cliphist` with `stash` and your commands, +aliases or scripts will continue to work as intended. + +Some of the commands allow further fine-graining with flags such as `--type` or +`--format` to allow specific input and output specifiers. See `--help` for +individual subcommands if in doubt. + + + +```console +$ stash help +Wayland clipboard manager + +Usage: stash [OPTIONS] [COMMAND] + +Commands: + store Store clipboard contents + list List clipboard history + decode Decode and output clipboard entry by id + delete Delete clipboard entry by id (if numeric), or entries matching a query (if not). Numeric arguments are treated as ids. Use --type to specify explicitly + wipe Wipe all clipboard history + import Import clipboard data from stdin (default: TSV format) + watch Start a process to watch clipboard for changes and store automatically + help Print this message or the help of the given subcommand(s) + +Options: + --max-items + Maximum number of clipboard entries to keep [default: 18446744073709551615] + --max-dedupe-search + Number of recent entries to check for duplicates when storing new clipboard data [default: 20] + --preview-width + Maximum width (in characters) for clipboard entry previews in list output [default: 100] + --db-path + Path to the `SQLite` clipboard database file + --excluded-apps + Application names to exclude from clipboard history [env: STASH_EXCLUDED_APPS=] + --ask + Ask for confirmation before destructive operations + -v, --verbose... + Increase logging verbosity + -q, --quiet... + Decrease logging verbosity + -h, --help + Print help + -V, --version + Print version +``` + + + ### Store an entry ```bash @@ -134,18 +184,34 @@ echo "some clipboard text" | stash store stash list ``` +Stash list will list all entries in an interactive TUI that allows navigation +and copying/deleting entries. This behaviour is EXCLUSIVE TO TTYs and Stash will +display entries in Cliphist-compatible TSV format in Bash scripts. You may also +enforce the output format with `stash list --format `. + ### Decode an entry by ID ```bash -stash decode --input "1234" +stash decode ``` +> [!TIP] +> Decoding from dmenu-compatible tools: +> +> ```bash +> stash list | tofi | stash decode +> ``` + ### Delete entries matching a query ```bash -stash delete --type query --arg "some text" +stash delete --type [id | query] ``` +By default stash will try to guess the type of an entry, but this may not be +desirable for all users. If you wish to be explicit, pass `--type` to +`stash delete`. + ### Delete multiple entries by ID (from a file or stdin) ```bash @@ -205,7 +271,8 @@ This can be configured in one of two ways. You can use the **environment variable** `STASTH_SENSITIVE_REGEX` to a valid regex pattern, and if the clipboard text matches the regex it will not be stored. This can be used for trivial secrets such as but not limited to GitHub tokens or secrets that follow -a rule, e.g. a prefix. +a rule, e.g. a prefix. You would typically set this in your `~/.bashrc` or +similar but in some cases this might be a security flaw. The safer alternative to this is using **Systemd LoadCrediental**. If Stash is running as a Systemd service, you can provide a regex pattern using a crediental @@ -228,6 +295,9 @@ logged. > **Example regex to block common password patterns**: > > `(password|secret|api[_-]?key|token)[=: ]+[^\s]+` +> +> For security reasons, you are recommended to use the regex only for generic +> tokens that follow a specific rule, for example a generic prefix or suffix. #### Clipboard Filtering by Application Class @@ -327,6 +397,26 @@ figured out something new, e.g. a neat shell trick, feel free to add it here! cliphist list --db ~/.cache/cliphist/db | stash import ``` +3. Stash provides its own implementation of `wl-copy` and `wl-paste` commands + backed by `wl-clipboard-rs`. Those implementations are backwards compatible + with `wl-clipboard`, and may be used as **drop-in** replacements. The default + build wrapper in `build.rs` links `stash` to `stash-copy` and `stash-paste`, + which are also available as `wl-copy` and `wl-paste` respectively. The Nix + package automatically links those to `$out/bin` for you, which means they are + installed by default but other package managers may need additional steps by + the packagers. While building from source, you may link + `target/release/stash` manually. + +## Attributions + +My thanks go first to [@YaLTeR](https://github.com/YaLTeR/) for the +[wl-clipboard-rs](https://github.com/YaLTeR/wl-clipboard-rs) crate. Stash is +powered by [several crates](./Cargo.toml), but none of them were as detrimental +in Stash's design process. + +Additional thanks to my testers, who have tested earlier versions of Stash and +provided feedback. Thank you :) + ## License This project is made available under Mozilla Public License (MPL) version 2.0. From d59ac77b9fcac8edefcb639a1cc4b647c9348769 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 27 Oct 2025 12:19:45 +0300 Subject: [PATCH 6/6] stash: utilize clap for multicall functionality; simplify CLI handler Signed-off-by: NotAShelf Change-Id: I84d9f46bb9bba0e893aa4f99d6ff48f76a6a6964 --- src/main.rs | 53 ++++++++++++++++++++++---------------------- src/multicall/mod.rs | 38 ------------------------------- 2 files changed, 26 insertions(+), 65 deletions(-) diff --git a/src/main.rs b/src/main.rs index f5c6b2e..d36af5f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,6 @@ use std::{ env, io::{self, IsTerminal}, path::PathBuf, - process, }; use clap::{CommandFactory, Parser, Subcommand}; @@ -129,14 +128,27 @@ fn report_error( } #[allow(clippy::too_many_lines)] // whatever -fn main() { - // Multicall dispatch: stash-copy, stash-paste, wl-copy, wl-paste - if crate::multicall::multicall_dispatch() { - // If handled, exit immediately - std::process::exit(0); +fn main() -> color_eyre::eyre::Result<()> { + // Check if we're being called as a multicall binary + let program_name = env::args().next().map(|s| { + PathBuf::from(s) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("stash") + .to_string() + }); + + if let Some(ref name) = program_name { + if name == "wl-copy" || name == "stash-copy" { + crate::multicall::wl_copy::wl_copy_main()?; + return Ok(()); + } else if name == "wl-paste" || name == "stash-paste" { + crate::multicall::wl_paste::wl_paste_main()?; + return Ok(()); + } } - // If not multicall, proceed with normal CLI handling + // Normal CLI handling smol::block_on(async { let cli = Cli::parse(); env_logger::Builder::new() @@ -151,24 +163,11 @@ fn main() { }); if let Some(parent) = db_path.parent() { - if let Err(e) = std::fs::create_dir_all(parent) { - log::error!("Failed to create database directory: {e}"); - process::exit(1); - } + std::fs::create_dir_all(parent)?; } - let conn = rusqlite::Connection::open(&db_path).unwrap_or_else(|e| { - log::error!("Failed to open SQLite database: {e}"); - process::exit(1); - }); - - let db = match db::SqliteClipboardDb::new(conn) { - Ok(db) => db, - Err(e) => { - log::error!("Failed to initialize SQLite database: {e}"); - process::exit(1); - }, - }; + let conn = rusqlite::Connection::open(&db_path)?; + let db = db::SqliteClipboardDb::new(conn)?; match cli.command { Some(Command::Store) => { @@ -345,12 +344,12 @@ fn main() { &[], ); }, + None => { - if let Err(e) = Cli::command().print_help() { - log::error!("Failed to print help: {e}"); - } + Cli::command().print_help()?; println!(); }, } - }); + Ok(()) + }) } diff --git a/src/multicall/mod.rs b/src/multicall/mod.rs index 46f19f3..5f1c795 100644 --- a/src/multicall/mod.rs +++ b/src/multicall/mod.rs @@ -4,41 +4,3 @@ // 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, - } -}