mirror of
https://github.com/NotAShelf/stash.git
synced 2026-04-13 14:33:47 +00:00
multicall: cleanup; modularize
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I658f22fdf983777354a5beb32df631916a6a6964
This commit is contained in:
parent
e94d931e67
commit
78acc38044
4 changed files with 774 additions and 490 deletions
490
src/multicall.rs
490
src/multicall.rs
|
|
@ -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<String>,
|
|
||||||
|
|
||||||
/// Override the inferred MIME type for the content
|
|
||||||
#[arg(short = 't', long = "type")]
|
|
||||||
mime_type: Option<String>,
|
|
||||||
|
|
||||||
/// 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<usize>,
|
|
||||||
|
|
||||||
/// Text to copy (if not given, read from stdin)
|
|
||||||
#[arg(value_name = "TEXT TO COPY", action = ArgAction::Append)]
|
|
||||||
text: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
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<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) {
|
|
||||||
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<String>,
|
|
||||||
|
|
||||||
/// Request the given MIME type instead of inferring the MIME type
|
|
||||||
#[arg(short = 't', long = "type")]
|
|
||||||
mime_type: Option<String>,
|
|
||||||
|
|
||||||
/// 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<Vec<String>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
44
src/multicall/mod.rs
Normal file
44
src/multicall/mod.rs
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
276
src/multicall/wl_copy.rs
Normal file
276
src/multicall/wl_copy.rs
Normal file
|
|
@ -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<String>,
|
||||||
|
|
||||||
|
/// Override the inferred MIME type for the content
|
||||||
|
#[arg(short = 't', long = "type")]
|
||||||
|
mime_type: Option<String>,
|
||||||
|
|
||||||
|
/// 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<usize>,
|
||||||
|
|
||||||
|
/// Text to copy (if not given, read from stdin)
|
||||||
|
#[arg(value_name = "TEXT TO COPY", action = ArgAction::Append)]
|
||||||
|
text: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
454
src/multicall/wl_paste.rs
Normal file
454
src/multicall/wl_paste.rs
Normal file
|
|
@ -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<String>,
|
||||||
|
|
||||||
|
/// Request the given MIME type instead of inferring the MIME type
|
||||||
|
#[arg(short = 't', long = "type")]
|
||||||
|
mime_type: Option<String>,
|
||||||
|
|
||||||
|
/// 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<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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::<u64>));
|
||||||
|
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<u64> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue