multicall: cleanup; match wl-copy/wl-paste interfaces more closely

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I8cc05c0141cccff8378ef4fd83ccf77d6a6a6964
This commit is contained in:
raf 2025-10-25 08:07:59 +03:00
commit 955a5d51f8
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
3 changed files with 357 additions and 147 deletions

1
Cargo.lock generated
View file

@ -1521,6 +1521,7 @@ dependencies = [
"env_logger", "env_logger",
"imagesize", "imagesize",
"inquire", "inquire",
"libc",
"log", "log",
"notify-rust", "notify-rust",
"ratatui", "ratatui",

View file

@ -29,6 +29,7 @@ inquire = { default-features = false, version = "0.9.1", features = [
log = "0.4.28" log = "0.4.28"
env_logger = "0.11.8" env_logger = "0.11.8"
thiserror = "2.0.17" thiserror = "2.0.17"
libc = "0.2"
wl-clipboard-rs = "0.9.2" wl-clipboard-rs = "0.9.2"
rusqlite = { version = "0.37.0", features = ["bundled"] } rusqlite = { version = "0.37.0", features = ["bundled"] }
smol = "2.0.2" smol = "2.0.2"

View file

@ -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 clap::{ArgAction, Parser};
use wl_clipboard_rs::paste::{ use wl_clipboard_rs::{
ClipboardType, copy::{
Error, ClipboardType as CopyClipboardType,
MimeType, MimeType as CopyMimeType,
Seat, Options,
Seat as CopySeat,
ServeRequests,
Source,
},
paste::{
ClipboardType as PasteClipboardType,
Error as PasteError,
MimeType as PasteMimeType,
Seat as PasteSeat,
get_contents, 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]. /// Dispatch multicall binary logic based on argv[0].
/// Returns true if a multicall command was handled and the process should exit. /// Returns true if a multicall command was handled and the process should exit.
pub fn multicall_dispatch() -> bool { pub fn multicall_dispatch() -> bool {
let argv0 = std::env::args().next().unwrap_or_default(); let argv0 = std::env::args().next().unwrap_or_default();
let base = std::path::Path::new(&argv0) let base = get_base(&argv0);
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("");
match base { match base {
"stash-copy" | "wl-copy" => { "stash-copy" | "wl-copy" => {
multicall_stash_copy(); wl_copy_main();
true true
}, },
"stash-paste" | "wl-paste" => { "stash-paste" | "wl-paste" => {
multicall_stash_paste(); wl_paste_main();
true true
}, },
_ => false, _ => 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)] #[derive(Parser, Debug)]
#[command( #[command(
name = "stash-copy", name = "wl-copy",
about = "Copy clipboard contents on Wayland.", about = "Copy clipboard contents on Wayland.",
version, version
disable_help_subcommand = true
)] )]
#[allow(clippy::struct_excessive_bools)] #[allow(clippy::struct_excessive_bools)]
struct Args { struct WlCopyArgs {
/// Serve only a single paste request and then exit /// Serve only a single paste request and then exit
#[arg(short = 'o', long = "paste-once", action = ArgAction::SetTrue)] #[arg(short = 'o', long = "paste-once", action = ArgAction::SetTrue)]
paste_once: bool, paste_once: bool,
/// Stay in the foreground instead of forking /// Stay in the foreground instead of forking
#[arg(short = 'f', long = "foreground", action = ArgAction::SetTrue)] #[arg(short = 'f', long = "foreground", action = ArgAction::SetTrue)]
foreground: bool, foreground: bool,
/// Clear the clipboard instead of copying /// Clear the clipboard instead of copying
#[arg(short = 'c', long = "clear", action = ArgAction::SetTrue)] #[arg(short = 'c', long = "clear", action = ArgAction::SetTrue)]
clear: bool, clear: bool,
/// Use the \"primary\" clipboard
/// Use the "primary" clipboard
#[arg(short = 'p', long = "primary", action = ArgAction::SetTrue)] #[arg(short = 'p', long = "primary", action = ArgAction::SetTrue)]
primary: bool, primary: bool,
/// Use the regular clipboard /// Use the regular clipboard
#[arg(short = 'r', long = "regular", action = ArgAction::SetTrue)] #[arg(short = 'r', long = "regular", action = ArgAction::SetTrue)]
regular: bool, regular: bool,
/// Trim the trailing newline character before copying /// Trim the trailing newline character before copying
#[arg(short = 'n', long = "trim-newline", action = ArgAction::SetTrue)] #[arg(short = 'n', long = "trim-newline", action = ArgAction::SetTrue)]
trim_newline: bool, trim_newline: bool,
/// Pick the seat to work with /// Pick the seat to work with
#[arg(short = 's', long = "seat")] #[arg(short = 's', long = "seat")]
seat: Option<String>, seat: Option<String>,
/// Override the inferred MIME type for the content /// Override the inferred MIME type for the content
#[arg(short = 't', long = "type")] #[arg(short = 't', long = "type")]
mime_type: Option<String>, mime_type: Option<String>,
/// Enable verbose logging /// Enable verbose logging
#[arg(short = 'v', long = "verbose", action = ArgAction::Count)] #[arg(short = 'v', long = "verbose", action = ArgAction::Count)]
verbose: u8, verbose: u8,
/// Check if primary selection is supported and exit /// Check if primary selection is supported and exit
#[arg(long = "check-primary", action = ArgAction::SetTrue)] #[arg(long = "check-primary", action = ArgAction::SetTrue)]
check_primary: bool, check_primary: bool,
/// Do not offer additional text mime types (stash extension) /// Do not offer additional text mime types (stash extension)
#[arg(long = "omit-additional-text-mime-types", action = ArgAction::SetTrue, hide = true)] #[arg(long = "omit-additional-text-mime-types", action = ArgAction::SetTrue, hide = true)]
omit_additional_text_mime_types: bool, omit_additional_text_mime_types: bool,
/// Number of paste requests to serve before exiting (stash extension) /// Number of paste requests to serve before exiting (stash extension)
#[arg(short = 'x', long = "serve-requests", hide = true)] #[arg(short = 'x', long = "serve-requests", hide = true)]
serve_requests: Option<usize>, serve_requests: Option<usize>,
/// Text to copy (if not given, read from stdin) /// Text to copy (if not given, read from stdin)
#[arg(value_name = "TEXT TO COPY", action = ArgAction::Append)] #[arg(value_name = "TEXT TO COPY", action = ArgAction::Append)]
text: Vec<String>, text: Vec<String>,
} }
let args = Args::parse(); fn wl_copy_main() {
let args = WlCopyArgs::parse();
if args.check_primary { if args.check_primary {
match is_primary_selection_supported() { match is_primary_selection_supported() {
@ -115,120 +141,201 @@ fn multicall_stash_copy() {
} }
let clipboard = if args.primary { let clipboard = if args.primary {
ClipboardType::Primary CopyClipboardType::Primary
} else { } else {
ClipboardType::Regular CopyClipboardType::Regular
}; };
let mime_type = if let Some(mt) = args.mime_type.as_deref() { let mime_type = if let Some(mt) = args.mime_type.as_deref() {
if mt == "text" || mt == "text/plain" { if mt == "text" || mt == "text/plain" {
MimeType::Text CopyMimeType::Text
} else if mt == "autodetect" { } else if mt == "autodetect" {
MimeType::Autodetect CopyMimeType::Autodetect
} else { } else {
MimeType::Specific(mt.to_string()) CopyMimeType::Specific(mt.to_string())
} }
} else { } else {
MimeType::Autodetect CopyMimeType::Autodetect
}; };
let mut input: Vec<u8> = Vec::new(); // Handle clear operation
if args.text.is_empty() { if args.clear {
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(); let mut opts = Options::new();
opts.clipboard(clipboard); 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.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
if args.clear {
// Clear clipboard by setting empty contents
if let Err(e) = opts.copy(Source::Bytes(Vec::new().into()), mime_type) { if let Err(e) = opts.copy(Source::Bytes(Vec::new().into()), mime_type) {
log::error!("failed to clear clipboard: {e}"); log::error!("failed to clear clipboard: {e}");
std::process::exit(1); std::process::exit(1);
} }
return; return;
} }
// Read input data
let input: Vec<u8> = 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) { if let Err(e) = opts.copy(Source::Bytes(input.into()), mime_type) {
log::error!("failed to copy to clipboard: {e}"); log::error!("failed to copy to clipboard: {e}");
std::process::exit(1); 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)] #[derive(Parser, Debug)]
#[command( #[command(
name = "stash-paste", name = "wl-paste",
about = "Paste clipboard contents on Wayland.", about = "Paste clipboard contents on Wayland.",
version, version,
disable_help_subcommand = true disable_help_subcommand = true
)] )]
struct Args { struct WlPasteArgs {
/// List the offered MIME types instead of pasting /// List the offered MIME types instead of pasting
#[arg(short = 'l', long = "list-types", action = ArgAction::SetTrue)] #[arg(short = 'l', long = "list-types", action = ArgAction::SetTrue)]
list_types: bool, list_types: bool,
/// Use the "primary" clipboard /// Use the "primary" clipboard
#[arg(short = 'p', long = "primary", action = ArgAction::SetTrue)] #[arg(short = 'p', long = "primary", action = ArgAction::SetTrue)]
primary: bool, primary: bool,
/// Do not append a newline character /// Do not append a newline character
#[arg(short = 'n', long = "no-newline", action = ArgAction::SetTrue)] #[arg(short = 'n', long = "no-newline", action = ArgAction::SetTrue)]
no_newline: bool, no_newline: bool,
/// Pick the seat to work with /// Pick the seat to work with
#[arg(short = 's', long = "seat")] #[arg(short = 's', long = "seat")]
seat: Option<String>, seat: Option<String>,
/// Request the given MIME type instead of inferring the MIME type /// Request the given MIME type instead of inferring the MIME type
#[arg(short = 't', long = "type")] #[arg(short = 't', long = "type")]
mime_type: Option<String>, mime_type: Option<String>,
/// Enable verbose logging /// Enable verbose logging
#[arg(short = 'v', long = "verbose", action = ArgAction::Count)] #[arg(short = 'v', long = "verbose", action = ArgAction::Count)]
verbose: u8, verbose: u8,
/// Watch for clipboard changes and run a command
#[arg(short = 'w', long = "watch")]
watch: Option<Vec<String>>,
} }
let args = Args::parse(); fn wl_paste_main() {
let args = WlPasteArgs::parse();
let clipboard = if args.primary { let clipboard = if args.primary {
ClipboardType::Primary PasteClipboardType::Primary
} else { } else {
ClipboardType::Regular PasteClipboardType::Regular
}; };
if let Some(seat) = args.seat.as_deref() { let seat = if let Some(seat_name) = args.seat.as_deref() {
log::debug!( PasteSeat::Specific(seat_name)
"'--seat' is not supported by stash (using default seat: {seat})" } else {
); PasteSeat::Unspecified
} };
// Handle list-types option
if args.list_types { if args.list_types {
match get_contents(clipboard, Seat::Unspecified, MimeType::Text) { match get_mime_types(clipboard, seat) {
Ok((_reader, available_types)) => { Ok(types) => {
log::info!("{available_types}"); for mime_type in types {
println!("{}", mime_type);
}
std::process::exit(0); std::process::exit(0);
}, },
Err(PasteError::NoSeats) => {
log::error!("no seats available (is a Wayland compositor running?)");
std::process::exit(1);
},
Err(e) => { Err(e) => {
log::error!("failed to list types: {e}"); log::error!("failed to list types: {e}");
std::process::exit(1); std::process::exit(1);
@ -236,12 +343,106 @@ fn multicall_stash_paste() {
} }
} }
let mime_type = match args.mime_type.as_deref() { // Handle watch mode
None | Some("text" | "autodetect") => MimeType::Text, if let Some(watch_args) = args.watch {
Some(other) => MimeType::Specific(other), 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" },
)
}; };
match get_contents(clipboard, Seat::Unspecified, mime_type) { // 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)) => { Ok((mut reader, _types)) => {
let mut out = io::stdout(); let mut out = io::stdout();
let mut buf = Vec::new(); let mut buf = Vec::new();
@ -250,9 +451,15 @@ fn multicall_stash_paste() {
if n == 0 && args.no_newline { if n == 0 && args.no_newline {
std::process::exit(1); 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") { 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) => { 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?)"); log::error!("no seats available (is a Wayland compositor running?)");
std::process::exit(1); std::process::exit(1);
}, },
Err(Error::ClipboardEmpty) => { Err(PasteError::ClipboardEmpty) => {
if args.no_newline { if args.no_newline {
std::process::exit(1); 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"); log::error!("clipboard does not contain requested MIME type");
std::process::exit(1); std::process::exit(1);
}, },