diff --git a/.gitignore b/.gitignore
index 92ecf03..ad467a2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,7 @@
# Rust/Cargo
!/Cargo.lock
!/Cargo.toml
+!/build.rs
# Configuration files
!/.config/
diff --git a/README.md b/README.md
index 66354c8..a974185 100644
--- a/README.md
+++ b/README.md
@@ -5,13 +5,13 @@
-
+
-
+
[!TIP]
+> Stash provides `wl-copy` and `wl-paste` binaries for backwards compatibility
+> with the `wl-clipboard` tools. If _must_ depend on those binaries by name, you
+> may simply use the `wl-copy` and `wl-paste` provided as `wl-clipboard-rs`
+> wrappers on your system. In other words, you can use
+> `wl-paste --watch stash store` as an alternative to `stash watch` if
+> preferred.
+
### Options
Some commands take additional flags to modify Stash's behavior. See each
@@ -263,7 +272,7 @@ should know.
- Stash adds a `watch` command to automatically store clipboard changes. This is
an alternative to `wl-paste --watch cliphist list`. You can avoid shelling out
and depending on `wl-paste` as Stash implements it through `wl-clipboard-rs`
- crate.
+ crate and provides its own `wl-copy` and `wl-paste` binaries.
### TSV Export and Import
@@ -316,3 +325,9 @@ figured out something new, e.g. a neat shell trick, feel free to add it here!
```bash
cliphist list --db ~/.cache/cliphist/db | stash import
```
+
+## License
+
+This project is made available under Mozilla Public License (MPL) version 2.0.
+See [LICENSE](LICENSE) for more details on the exact conditions. An online copy
+is provided [here](https://www.mozilla.org/en-US/MPL/2.0/).
diff --git a/build.rs b/build.rs
new file mode 100644
index 0000000..533368c
--- /dev/null
+++ b/build.rs
@@ -0,0 +1,56 @@
+use std::{env, fs, path::Path};
+
+/// List of multicall symlinks to create (name, target)
+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");
+ let bin_dir = Path::new(&out_dir)
+ .ancestors()
+ .nth(3)
+ .expect("Failed to find binary dir");
+
+ // Path to the main stash binary
+ let stash_bin = bin_dir.join("stash");
+
+ // Create symlinks for each multicall binary
+ for link in MULTICALL_LINKS {
+ let link_path = bin_dir.join(link);
+ // Remove existing symlink or file if present
+ let _ = fs::remove_file(&link_path);
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::symlink;
+ match symlink(&stash_bin, &link_path) {
+ Ok(()) => {
+ println!(
+ "cargo:warning=Created symlink: {} -> {}",
+ link_path.display(),
+ stash_bin.display()
+ );
+ },
+ Err(e) => {
+ println!(
+ "cargo:warning=Failed to create symlink {} -> {}: {}",
+ link_path.display(),
+ stash_bin.display(),
+ e
+ );
+ },
+ }
+ }
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index 7a99a40..f5c6b2e 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -10,6 +10,7 @@ use inquire::Confirm;
mod commands;
mod db;
+mod multicall;
#[cfg(feature = "use-toplevel")] mod wayland;
use crate::commands::{
@@ -129,6 +130,13 @@ 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);
+ }
+
+ // If not multicall, proceed with normal CLI handling
smol::block_on(async {
let cli = Cli::parse();
env_logger::Builder::new()
diff --git a/src/multicall.rs b/src/multicall.rs
new file mode 100644
index 0000000..f387df0
--- /dev/null
+++ b/src/multicall.rs
@@ -0,0 +1,282 @@
+use std::io::{self, Read, Write};
+
+use clap::{ArgAction, Parser};
+use wl_clipboard_rs::paste::{
+ ClipboardType,
+ Error,
+ MimeType,
+ Seat,
+ get_contents,
+};
+
+/// 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("");
+ match base {
+ "stash-copy" | "wl-copy" => {
+ multicall_stash_copy();
+ true
+ },
+ "stash-paste" | "wl-paste" => {
+ multicall_stash_paste();
+ 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,
+ }
+
+ let args = Args::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 {
+ ClipboardType::Primary
+ } else {
+ ClipboardType::Regular
+ };
+
+ let mime_type = if let Some(mt) = args.mime_type.as_deref() {
+ if mt == "text" || mt == "text/plain" {
+ MimeType::Text
+ } else if mt == "autodetect" {
+ MimeType::Autodetect
+ } else {
+ MimeType::Specific(mt.to_string())
+ }
+ } else {
+ MimeType::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
+ if args.clear {
+ // Clear clipboard by setting empty contents
+ 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);
+ }
+}
+
+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,
+ }
+
+ let args = Args::parse();
+
+ let clipboard = if args.primary {
+ ClipboardType::Primary
+ } else {
+ ClipboardType::Regular
+ };
+
+ if let Some(seat) = args.seat.as_deref() {
+ log::debug!(
+ "'--seat' is not supported by stash (using default seat: {seat})"
+ );
+ }
+
+ if args.list_types {
+ match get_contents(clipboard, Seat::Unspecified, MimeType::Text) {
+ Ok((_reader, available_types)) => {
+ log::info!("{available_types}");
+ std::process::exit(0);
+ },
+ Err(e) => {
+ log::error!("failed to list types: {e}");
+ std::process::exit(1);
+ },
+ }
+ }
+
+ let mime_type = match args.mime_type.as_deref() {
+ None | Some("text" | "autodetect") => MimeType::Text,
+ Some(other) => MimeType::Specific(other),
+ };
+
+ match get_contents(clipboard, Seat::Unspecified, 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);
+ }
+ let _ = out.write_all(&buf);
+ if !args.no_newline && !buf.ends_with(b"\n") {
+ let _ = out.write_all(b"\n");
+ }
+ },
+ Err(e) => {
+ log::error!("failed to read clipboard: {e}");
+ std::process::exit(1);
+ },
+ }
+ },
+ Err(Error::NoSeats) => {
+ log::error!("no seats available (is a Wayland compositor running?)");
+ std::process::exit(1);
+ },
+ Err(Error::ClipboardEmpty) => {
+ if args.no_newline {
+ std::process::exit(1);
+ }
+ },
+ Err(Error::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);
+ },
+ }
+}