From 4038dd0cf3391d67b143fa38373ca1ac82863a36 Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Tue, 16 Mar 2021 19:56:00 +0100 Subject: [PATCH] feat(clipboard): implement wayland clipboard manager --- Cargo.lock | 13 +- espanso-clipboard/Cargo.toml | 8 +- espanso-clipboard/src/lib.rs | 31 ++- espanso-clipboard/src/wayland/README.md | 33 +++ espanso-clipboard/src/wayland/fallback/mod.rs | 191 ++++++++++++++++++ espanso-clipboard/src/wayland/mod.rs | 20 ++ espanso-clipboard/src/x11/mod.rs | 2 +- espanso-detect/src/hotkey/mod.rs | 4 + espanso/Cargo.toml | 2 +- espanso/src/main.rs | 11 +- 10 files changed, 294 insertions(+), 21 deletions(-) create mode 100644 espanso-clipboard/src/wayland/README.md create mode 100644 espanso-clipboard/src/wayland/fallback/mod.rs create mode 100644 espanso-clipboard/src/wayland/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 84bc161..96bdada 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -240,13 +240,11 @@ version = "0.1.0" dependencies = [ "anyhow", "cc", - "itertools", "lazy_static", "lazycell", - "libc", "log", - "scopeguard", "thiserror", + "wait-timeout", "widestring", ] @@ -857,6 +855,15 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5a972e5669d67ba988ce3dc826706fb0a8b01471c088cb0b6110b805cc36aed" +[[package]] +name = "wait-timeout" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f200f5b12eb75f8c1ed65abd4b2db8a6e1b138a20de009dacee265a2498f3f6" +dependencies = [ + "libc", +] + [[package]] name = "wasi" version = "0.9.0+wasi-snapshot-preview1" diff --git a/espanso-clipboard/Cargo.toml b/espanso-clipboard/Cargo.toml index 80e2e0c..507b1a0 100644 --- a/espanso-clipboard/Cargo.toml +++ b/espanso-clipboard/Cargo.toml @@ -8,7 +8,7 @@ build="build.rs" [features] # If the wayland feature is enabled, all X11 dependencies will be dropped # and wayland support will be enabled -wayland = [] +wayland = ["wait-timeout"] [dependencies] log = "0.4.14" @@ -20,10 +20,8 @@ lazy_static = "1.4.0" [target.'cfg(windows)'.dependencies] widestring = "0.4.3" -[target.'cfg(target_os="linux")'.dependencies] -libc = "0.2.85" -scopeguard = "1.1.0" -itertools = "0.10.0" +[target.'cfg(target_os = "linux")'.dependencies] +wait-timeout = { version = "0.2.0", optional = true } [build-dependencies] cc = "1.0.66" \ No newline at end of file diff --git a/espanso-clipboard/src/lib.rs b/espanso-clipboard/src/lib.rs index 9e361df..7ad301c 100644 --- a/espanso-clipboard/src/lib.rs +++ b/espanso-clipboard/src/lib.rs @@ -29,9 +29,9 @@ mod win32; #[cfg(not(feature = "wayland"))] mod x11; -//#[cfg(target_os = "linux")] -//#[cfg(feature = "wayland")] -//mod wayland; +#[cfg(target_os = "linux")] +#[cfg(feature = "wayland")] +mod wayland; #[cfg(target_os = "macos")] mod mac; @@ -45,11 +45,17 @@ pub trait Clipboard { #[allow(dead_code)] pub struct ClipboardOptions { + // Wayland-only + // The number of milliseconds the wl-clipboard commands are allowed + // to run before triggering a time-out event. + wayland_command_timeout_ms: u64, } impl Default for ClipboardOptions { fn default() -> Self { - Self {} + Self { + wayland_command_timeout_ms: 2000, + } } } @@ -74,7 +80,16 @@ pub fn get_clipboard(_: ClipboardOptions) -> Result> { #[cfg(target_os = "linux")] #[cfg(feature = "wayland")] -pub fn get_injector(options: InjectorCreationOptions) -> Result> { - info!("using EVDEVInjector"); - Ok(Box::new(evdev::EVDEVInjector::new(options)?)) -} +pub fn get_clipboard(options: ClipboardOptions) -> Result> { + // TODO: On some Wayland compositors (currently sway), the "wlr-data-control" protocol + // could enable the use of a much more efficient implementation relying on the "wl-clipboard-rs" crate. + // Useful links: https://github.com/YaLTeR/wl-clipboard-rs/issues/8 + // + // We could even decide the correct implementation at runtime by checking if the + // required protocol is available, if so use the efficient implementation + // instead of the fallback one, which calls the wl-copy and wl-paste binaries, and is thus + // less efficient + + info!("using WaylandFallbackClipboard"); + Ok(Box::new(wayland::fallback::WaylandFallbackClipboard::new(options)?)) +} \ No newline at end of file diff --git a/espanso-clipboard/src/wayland/README.md b/espanso-clipboard/src/wayland/README.md new file mode 100644 index 0000000..84dd247 --- /dev/null +++ b/espanso-clipboard/src/wayland/README.md @@ -0,0 +1,33 @@ +# Notes on Wayland and clipboard support + +### Running espanso as another user + +When running espanso as another user, we need to set up a couple of permissions +in order to enable the clipboard tools to correctly connect to the Wayland desktop. + +In particular, we need to add the `espanso` user to the same group as the current user +so that it can access the `/run/user/X` directory (with X depending on the user). + +``` +# Find the current user wayland dir with +echo $XDG_RUNTIME_DIR # in my case output: /run/user/1000 + +ls -la /run/user/1000 + +# Now add the `espanso` user to the current user group +sudo usermod -a -G freddy espanso + +# Give permissions to the group +chmod g+rwx /run/user/1000 + +# Give write permission to the wayland socket +chmod g+w /run/user/1000/wayland-0 +``` + +Now the clipboard should work as expected + +## Better implementation + +On some Wayland compositors (currently sway), the "wlr-data-control" protocol could enable the use of a much more efficient implementation relying on the "wl-clipboard-rs" crate. + +Useful links: https://github.com/YaLTeR/wl-clipboard-rs/issues/8 \ No newline at end of file diff --git a/espanso-clipboard/src/wayland/fallback/mod.rs b/espanso-clipboard/src/wayland/fallback/mod.rs new file mode 100644 index 0000000..f4793a3 --- /dev/null +++ b/espanso-clipboard/src/wayland/fallback/mod.rs @@ -0,0 +1,191 @@ +/* + * This file is part of espanso. + * + * Copyright (C) 2019-2021 Federico Terzi + * + * espanso is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * espanso is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with espanso. If not, see . + */ + +use std::{ + io::{Read, Write}, + os::unix::net::UnixStream, + path::PathBuf, + process::Stdio, +}; + +use crate::{Clipboard, ClipboardOptions}; +use anyhow::Result; +use log::error; +use std::process::Command; +use thiserror::Error; +use wait_timeout::ChildExt; + +pub(crate) struct WaylandFallbackClipboard { + command_timeout: u64, +} + +impl WaylandFallbackClipboard { + pub fn new(options: ClipboardOptions) -> Result { + // Make sure wl-paste and wl-copy are available + if Command::new("wl-paste").arg("--version").output().is_err() { + error!("unable to call 'wl-paste' binary, please install the wl-clipboard package."); + return Err(WaylandFallbackClipboardError::MissingWLClipboard().into()); + } + if Command::new("wl-copy").arg("--version").output().is_err() { + error!("unable to call 'wl-copy' binary, please install the wl-clipboard package."); + return Err(WaylandFallbackClipboardError::MissingWLClipboard().into()); + } + + // Try to connect to the wayland display + let wayland_socket = if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") { + PathBuf::from(runtime_dir).join("wayland-0") + } else { + error!("environment variable XDG_RUNTIME_DIR is missing, can't initialize the clipboard"); + return Err(WaylandFallbackClipboardError::MissingEnvVariable().into()); + }; + if UnixStream::connect(wayland_socket).is_err() { + error!("failed to connect to Wayland display"); + return Err(WaylandFallbackClipboardError::ConnectionFailed().into()); + } + + Ok(Self { + command_timeout: options.wayland_command_timeout_ms, + }) + } +} + +impl Clipboard for WaylandFallbackClipboard { + fn get_text(&self) -> Option { + let timeout = std::time::Duration::from_millis(self.command_timeout); + match Command::new("wl-paste") + .arg("--no-newline") + .stdout(Stdio::piped()) + .spawn() + { + Ok(mut child) => match child.wait_timeout(timeout) { + Ok(status_code) => { + if let Some(status) = status_code { + if status.success() { + if let Some(mut io) = child.stdout { + let mut output = Vec::new(); + io.read_to_end(&mut output).ok()?; + Some(String::from_utf8_lossy(&output).to_string()) + } else { + None + } + } else { + error!("error, wl-paste exited with non-zero exit code"); + None + } + } else { + error!("error, wl-paste has timed-out, killing the process"); + if child.kill().is_err() { + error!("unable to kill wl-paste"); + } + None + } + } + Err(err) => { + error!("error while executing 'wl-paste': {}", err); + None + } + }, + Err(err) => { + error!("could not invoke 'wl-paste': {}", err); + None + } + } + } + + fn set_text(&self, text: &str) -> anyhow::Result<()> { + self.invoke_command_with_timeout(&mut Command::new("wl-copy"), text.as_bytes(), "wl-copy") + } + + fn set_image(&self, image_path: &std::path::Path) -> anyhow::Result<()> { + if !image_path.exists() || !image_path.is_file() { + return Err(WaylandFallbackClipboardError::ImageNotFound(image_path.to_path_buf()).into()); + } + + // Load the image data + let mut file = std::fs::File::open(image_path)?; + let mut data = Vec::new(); + file.read_to_end(&mut data)?; + + self.invoke_command_with_timeout(&mut Command::new("wl-copy").arg("--type").arg("image/png"), &data, "wl-copy") + } + + fn set_html(&self, html: &str, _fallback_text: Option<&str>) -> anyhow::Result<()> { + self.invoke_command_with_timeout(&mut Command::new("wl-copy").arg("--type").arg("text/html"), html.as_bytes(), "wl-copy") + } +} + +impl WaylandFallbackClipboard { + fn invoke_command_with_timeout(&self, command: &mut Command, data: &[u8], name: &str) -> Result<()> { + let timeout = std::time::Duration::from_millis(self.command_timeout); + match command + .stdin(Stdio::piped()) + .spawn() + { + Ok(mut child) => { + if let Some(stdin) = child.stdin.as_mut() { + stdin.write_all(data)?; + } + match child.wait_timeout(timeout) { + Ok(status_code) => { + if let Some(status) = status_code { + if status.success() { + Ok(()) + } else { + error!("error, {} exited with non-zero exit code", name); + Err(WaylandFallbackClipboardError::SetOperationFailed().into()) + } + } else { + error!("error, {} has timed-out, killing the process", name); + if child.kill().is_err() { + error!("unable to kill {}", name); + } + Err(WaylandFallbackClipboardError::SetOperationFailed().into()) + } + } + Err(err) => { + error!("error while executing '{}': {}", name, err); + Err(WaylandFallbackClipboardError::SetOperationFailed().into()) + } + } + } + Err(err) => { + error!("could not invoke '{}': {}", name, err); + Err(WaylandFallbackClipboardError::SetOperationFailed().into()) + } + } + } +} + +#[derive(Error, Debug)] +pub(crate) enum WaylandFallbackClipboardError { + #[error("wl-clipboard binaries are missing")] + MissingWLClipboard(), + + #[error("missing XDG_RUNTIME_DIR env variable")] + MissingEnvVariable(), + + #[error("can't connect to Wayland display")] + ConnectionFailed(), + + #[error("clipboard set operation failed")] + SetOperationFailed(), + + #[error("image not found: `{0}`")] + ImageNotFound(PathBuf), +} diff --git a/espanso-clipboard/src/wayland/mod.rs b/espanso-clipboard/src/wayland/mod.rs new file mode 100644 index 0000000..9a99bf1 --- /dev/null +++ b/espanso-clipboard/src/wayland/mod.rs @@ -0,0 +1,20 @@ +/* + * This file is part of espanso. + * + * Copyright (C) 2019-2021 Federico Terzi + * + * espanso is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * espanso is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with espanso. If not, see . + */ + +pub(crate) mod fallback; \ No newline at end of file diff --git a/espanso-clipboard/src/x11/mod.rs b/espanso-clipboard/src/x11/mod.rs index 6ce93e8..6b27c57 100644 --- a/espanso-clipboard/src/x11/mod.rs +++ b/espanso-clipboard/src/x11/mod.rs @@ -17,4 +17,4 @@ * along with espanso. If not, see . */ -pub mod native; \ No newline at end of file +pub(crate) mod native; \ No newline at end of file diff --git a/espanso-detect/src/hotkey/mod.rs b/espanso-detect/src/hotkey/mod.rs index 8495e3a..d08440b 100644 --- a/espanso-detect/src/hotkey/mod.rs +++ b/espanso-detect/src/hotkey/mod.rs @@ -73,18 +73,22 @@ impl HotKey { }) } + #[allow(dead_code)] pub(crate) fn has_ctrl(&self) -> bool { self.modifiers.contains(&ShortcutKey::Control) } + #[allow(dead_code)] pub(crate) fn has_meta(&self) -> bool { self.modifiers.contains(&ShortcutKey::Meta) } + #[allow(dead_code)] pub(crate) fn has_alt(&self) -> bool { self.modifiers.contains(&ShortcutKey::Alt) } + #[allow(dead_code)] pub(crate) fn has_shift(&self) -> bool { self.modifiers.contains(&ShortcutKey::Shift) } diff --git a/espanso/Cargo.toml b/espanso/Cargo.toml index 2df4be9..cf83027 100644 --- a/espanso/Cargo.toml +++ b/espanso/Cargo.toml @@ -10,7 +10,7 @@ edition = "2018" [features] # If the wayland feature is enabled, all X11 dependencies will be dropped -# and only EVDEV-based methods will be supported. +# and only methods suitable for Wayland will be used wayland = ["espanso-detect/wayland", "espanso-inject/wayland", "espanso-clipboard/wayland"] [dependencies] diff --git a/espanso/src/main.rs b/espanso/src/main.rs index c64c658..cef5d47 100644 --- a/espanso/src/main.rs +++ b/espanso/src/main.rs @@ -12,6 +12,7 @@ use simplelog::{CombinedLogger, Config, LevelFilter, TermLogger, TerminalMode}; fn main() { println!("Hello, world!z"); + CombinedLogger::init(vec![ TermLogger::new(LevelFilter::Debug, Config::default(), TerminalMode::Mixed), // WriteLogger::new( @@ -22,6 +23,9 @@ fn main() { ]) .unwrap(); + let clipboard = espanso_clipboard::get_clipboard(Default::default()).unwrap(); + println!("clipboard: {:?}", clipboard.get_text()); + // let icon_paths = vec![ // ( // espanso_ui::icons::TrayIcon::Normal, @@ -68,7 +72,8 @@ fn main() { let mut source = get_source(SourceCreationOptions { //use_evdev: true, hotkeys: vec![ - HotKey::new(1, "OPTION+SPACE").unwrap(), + HotKey::new(1, "CTRL+SPACE").unwrap(), + //HotKey::new(1, "OPTION+SPACE").unwrap(), HotKey::new(2, "CTRL+OPTION+3").unwrap(), ], ..Default::default() @@ -96,9 +101,9 @@ fn main() { if hotkey.hotkey_id == 2 { println!("clip {:?}", clipboard.get_text()); } else if hotkey.hotkey_id == 1 { - //clipboard.set_text("test text").unwrap(); + clipboard.set_text("test text").unwrap(); //clipboard.set_html("test text", Some("test text fallback")).unwrap(); - clipboard.set_image(&PathBuf::from("/home/freddy/insync/Development/Espanso/Images/icongreen.png")).unwrap(); + //clipboard.set_image(&PathBuf::from("/home/freddy/insync/Development/Espanso/Images/icongreen.png")).unwrap(); } } }