├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── install_scripts.sh ├── scripts ├── _pick.sh ├── copy.sh ├── feh_pick_image.sh ├── fzf_pick.sh └── rm.sh └── src ├── clipboard ├── ctx.rs ├── getter.rs ├── mod.rs ├── targets.rs └── utils.rs ├── config.rs ├── daemon.rs ├── main.rs ├── paths.rs └── utils.rs /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /rclips 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [0.2.0] - 2022-01-17 2 | 3 | ### BREAKING CHANGES 4 | - Follow XDG paths specification. Directory with clipboard history moved from 5 | `~/.rclip` to `~/.local/share/rclip` (means $XDG_DATA_HOME). 6 | 7 | ### Fixes 8 | - Prevent infinity loop in scripts/fzf_pick.sh. 9 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "ansi_term" 7 | version = "0.11.0" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "ee49baf6cb617b853aa8d93bf420db2383fab46d314482ca2803b40d5fde979b" 10 | dependencies = [ 11 | "winapi", 12 | ] 13 | 14 | [[package]] 15 | name = "atty" 16 | version = "0.2.14" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" 19 | dependencies = [ 20 | "hermit-abi", 21 | "libc", 22 | "winapi", 23 | ] 24 | 25 | [[package]] 26 | name = "bitflags" 27 | version = "1.2.1" 28 | source = "registry+https://github.com/rust-lang/crates.io-index" 29 | checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" 30 | 31 | [[package]] 32 | name = "cfg-if" 33 | version = "1.0.0" 34 | source = "registry+https://github.com/rust-lang/crates.io-index" 35 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 36 | 37 | [[package]] 38 | name = "clap" 39 | version = "2.33.3" 40 | source = "registry+https://github.com/rust-lang/crates.io-index" 41 | checksum = "37e58ac78573c40708d45522f0d80fa2f01cc4f9b4e2bf749807255454312002" 42 | dependencies = [ 43 | "ansi_term", 44 | "atty", 45 | "bitflags", 46 | "strsim", 47 | "textwrap", 48 | "unicode-width", 49 | "vec_map", 50 | ] 51 | 52 | [[package]] 53 | name = "dirs" 54 | version = "4.0.0" 55 | source = "registry+https://github.com/rust-lang/crates.io-index" 56 | checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" 57 | dependencies = [ 58 | "dirs-sys", 59 | ] 60 | 61 | [[package]] 62 | name = "dirs-sys" 63 | version = "0.3.6" 64 | source = "registry+https://github.com/rust-lang/crates.io-index" 65 | checksum = "03d86534ed367a67548dc68113a0f5db55432fdfbb6e6f9d77704397d95d5780" 66 | dependencies = [ 67 | "libc", 68 | "redox_users", 69 | "winapi", 70 | ] 71 | 72 | [[package]] 73 | name = "getrandom" 74 | version = "0.2.3" 75 | source = "registry+https://github.com/rust-lang/crates.io-index" 76 | checksum = "7fcd999463524c52659517fe2cea98493cfe485d10565e7b0fb07dbba7ad2753" 77 | dependencies = [ 78 | "cfg-if", 79 | "libc", 80 | "wasi", 81 | ] 82 | 83 | [[package]] 84 | name = "hermit-abi" 85 | version = "0.1.19" 86 | source = "registry+https://github.com/rust-lang/crates.io-index" 87 | checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" 88 | dependencies = [ 89 | "libc", 90 | ] 91 | 92 | [[package]] 93 | name = "libc" 94 | version = "0.2.98" 95 | source = "registry+https://github.com/rust-lang/crates.io-index" 96 | checksum = "320cfe77175da3a483efed4bc0adc1968ca050b098ce4f2f1c13a56626128790" 97 | 98 | [[package]] 99 | name = "log" 100 | version = "0.4.14" 101 | source = "registry+https://github.com/rust-lang/crates.io-index" 102 | checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710" 103 | dependencies = [ 104 | "cfg-if", 105 | ] 106 | 107 | [[package]] 108 | name = "memchr" 109 | version = "2.4.1" 110 | source = "registry+https://github.com/rust-lang/crates.io-index" 111 | checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a" 112 | 113 | [[package]] 114 | name = "proc-macro2" 115 | version = "1.0.28" 116 | source = "registry+https://github.com/rust-lang/crates.io-index" 117 | checksum = "5c7ed8b8c7b886ea3ed7dde405212185f423ab44682667c8c6dd14aa1d9f6612" 118 | dependencies = [ 119 | "unicode-xid", 120 | ] 121 | 122 | [[package]] 123 | name = "quick-xml" 124 | version = "0.22.0" 125 | source = "registry+https://github.com/rust-lang/crates.io-index" 126 | checksum = "8533f14c8382aaad0d592c812ac3b826162128b65662331e1127b45c3d18536b" 127 | dependencies = [ 128 | "memchr", 129 | ] 130 | 131 | [[package]] 132 | name = "quote" 133 | version = "1.0.9" 134 | source = "registry+https://github.com/rust-lang/crates.io-index" 135 | checksum = "c3d0b9745dc2debf507c8422de05d7226cc1f0644216dfdfead988f9b1ab32a7" 136 | dependencies = [ 137 | "proc-macro2", 138 | ] 139 | 140 | [[package]] 141 | name = "rclipd" 142 | version = "0.2.0" 143 | dependencies = [ 144 | "clap", 145 | "dirs", 146 | "serde", 147 | "signal-hook", 148 | "toml", 149 | "xcb", 150 | ] 151 | 152 | [[package]] 153 | name = "redox_syscall" 154 | version = "0.2.9" 155 | source = "registry+https://github.com/rust-lang/crates.io-index" 156 | checksum = "5ab49abadf3f9e1c4bc499e8845e152ad87d2ad2d30371841171169e9d75feee" 157 | dependencies = [ 158 | "bitflags", 159 | ] 160 | 161 | [[package]] 162 | name = "redox_users" 163 | version = "0.4.0" 164 | source = "registry+https://github.com/rust-lang/crates.io-index" 165 | checksum = "528532f3d801c87aec9def2add9ca802fe569e44a544afe633765267840abe64" 166 | dependencies = [ 167 | "getrandom", 168 | "redox_syscall", 169 | ] 170 | 171 | [[package]] 172 | name = "serde" 173 | version = "1.0.133" 174 | source = "registry+https://github.com/rust-lang/crates.io-index" 175 | checksum = "97565067517b60e2d1ea8b268e59ce036de907ac523ad83a0475da04e818989a" 176 | dependencies = [ 177 | "serde_derive", 178 | ] 179 | 180 | [[package]] 181 | name = "serde_derive" 182 | version = "1.0.133" 183 | source = "registry+https://github.com/rust-lang/crates.io-index" 184 | checksum = "ed201699328568d8d08208fdd080e3ff594e6c422e438b6705905da01005d537" 185 | dependencies = [ 186 | "proc-macro2", 187 | "quote", 188 | "syn", 189 | ] 190 | 191 | [[package]] 192 | name = "signal-hook" 193 | version = "0.3.13" 194 | source = "registry+https://github.com/rust-lang/crates.io-index" 195 | checksum = "647c97df271007dcea485bb74ffdb57f2e683f1306c854f468a0c244badabf2d" 196 | dependencies = [ 197 | "libc", 198 | "signal-hook-registry", 199 | ] 200 | 201 | [[package]] 202 | name = "signal-hook-registry" 203 | version = "1.4.0" 204 | source = "registry+https://github.com/rust-lang/crates.io-index" 205 | checksum = "e51e73328dc4ac0c7ccbda3a494dfa03df1de2f46018127f60c693f2648455b0" 206 | dependencies = [ 207 | "libc", 208 | ] 209 | 210 | [[package]] 211 | name = "strsim" 212 | version = "0.8.0" 213 | source = "registry+https://github.com/rust-lang/crates.io-index" 214 | checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" 215 | 216 | [[package]] 217 | name = "syn" 218 | version = "1.0.74" 219 | source = "registry+https://github.com/rust-lang/crates.io-index" 220 | checksum = "1873d832550d4588c3dbc20f01361ab00bfe741048f71e3fecf145a7cc18b29c" 221 | dependencies = [ 222 | "proc-macro2", 223 | "quote", 224 | "unicode-xid", 225 | ] 226 | 227 | [[package]] 228 | name = "textwrap" 229 | version = "0.11.0" 230 | source = "registry+https://github.com/rust-lang/crates.io-index" 231 | checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" 232 | dependencies = [ 233 | "unicode-width", 234 | ] 235 | 236 | [[package]] 237 | name = "toml" 238 | version = "0.5.8" 239 | source = "registry+https://github.com/rust-lang/crates.io-index" 240 | checksum = "a31142970826733df8241ef35dc040ef98c679ab14d7c3e54d827099b3acecaa" 241 | dependencies = [ 242 | "serde", 243 | ] 244 | 245 | [[package]] 246 | name = "unicode-width" 247 | version = "0.1.8" 248 | source = "registry+https://github.com/rust-lang/crates.io-index" 249 | checksum = "9337591893a19b88d8d87f2cec1e73fad5cdfd10e5a6f349f498ad6ea2ffb1e3" 250 | 251 | [[package]] 252 | name = "unicode-xid" 253 | version = "0.2.2" 254 | source = "registry+https://github.com/rust-lang/crates.io-index" 255 | checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" 256 | 257 | [[package]] 258 | name = "vec_map" 259 | version = "0.8.2" 260 | source = "registry+https://github.com/rust-lang/crates.io-index" 261 | checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" 262 | 263 | [[package]] 264 | name = "wasi" 265 | version = "0.10.2+wasi-snapshot-preview1" 266 | source = "registry+https://github.com/rust-lang/crates.io-index" 267 | checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" 268 | 269 | [[package]] 270 | name = "winapi" 271 | version = "0.3.9" 272 | source = "registry+https://github.com/rust-lang/crates.io-index" 273 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 274 | dependencies = [ 275 | "winapi-i686-pc-windows-gnu", 276 | "winapi-x86_64-pc-windows-gnu", 277 | ] 278 | 279 | [[package]] 280 | name = "winapi-i686-pc-windows-gnu" 281 | version = "0.4.0" 282 | source = "registry+https://github.com/rust-lang/crates.io-index" 283 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 284 | 285 | [[package]] 286 | name = "winapi-x86_64-pc-windows-gnu" 287 | version = "0.4.0" 288 | source = "registry+https://github.com/rust-lang/crates.io-index" 289 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 290 | 291 | [[package]] 292 | name = "xcb" 293 | version = "0.10.1" 294 | source = "registry+https://github.com/rust-lang/crates.io-index" 295 | checksum = "771e2b996df720cd1c6dd9ff90f62d91698fd3610cc078388d0564bdd6622a9c" 296 | dependencies = [ 297 | "libc", 298 | "log", 299 | "quick-xml", 300 | ] 301 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rclipd" 3 | version = "0.2.0" 4 | authors = ["UnkwUsr "] 5 | description = """ 6 | Rclip is a clipboard manager with ability to save different entry types (text, 7 | images, etc.). It's just a daemon which look for clipboard updates and save 8 | them each per unique file. 9 | """ 10 | repository = "https://github.com/UnkwUsr/rclip" 11 | keywords = ["clipboard", "daemon", "history", "manager", "fzf"] 12 | categories = ["command-line-utilities"] 13 | license = "MIT" 14 | edition = "2018" 15 | 16 | 17 | [dependencies] 18 | clap = "2.33.3" 19 | signal-hook = "0.3.13" 20 | dirs = "4.0.0" 21 | serde = { version = "1.0.133", features = ["derive"] } 22 | toml = "0.5.8" 23 | 24 | [dependencies.xcb] 25 | version = "0.10.1" 26 | features = ["xfixes"] 27 | 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 UnkwUsr 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rclip 2 | 3 | Rclip - clipboard manager written in rust. It's just a daemon which look for 4 | clipboard updates and save them each per unique file. 5 | 6 | ## Features 7 | 8 | * Each history entry saves in unique file. 9 | * Daemon does not handle clipboard history in RAM, so there is little memory consumption. 10 | * Checking for duplicates (and skipping them). P.S. it compares only last clipboard entry with current new. 11 | * Ability to set list of targets (in Xorg terms it means type of clipboard 12 | entry) that will be saved. (for example libreoffice formatted text, images, standard text). 13 | * Ability to set minimal length of entry you want to save. 14 | * Ability to pause rclip so it will not save next clipboard update (useful, for 15 | example, when setting password from password manager). 16 | 17 | ### Bonus 18 | 19 | * Easy to access each entry and write your own scripts to manipulate them. 20 | * Easy to delete entries. 21 | * Ability to use with fuzzy finders, like [fzf](https://github.com/junegunn/fzf) (scripts examples presented). 22 | 23 | ## Installation 24 | 25 | ### On Arch Linux 26 | 27 | AUR package: [rclip-git](https://aur.archlinux.org/packages/rclip-git/) 28 | 29 | ### With cargo 30 | 31 | cargo install rclipd 32 | 33 | Also see [./install_scripts.sh](./install_scripts.sh) for installing provided 34 | scripts. 35 | 36 | ## Usage 37 | 38 | First thing you need to do - is run daemon: 39 | 40 | rclip daemon 41 | 42 | *(Recommended to add it to startup).* 43 | 44 | All saved history entries stored in `~/.local/share/rclip/{target_name}/` 45 | (where `~/.local/share` follows to $XDG_DATA_HOME by XDG specification), one file per entry. 46 | 47 | ### Copying and removing entries 48 | 49 | For convenience you can use provided scripts `scripts/copy.sh` (or `rclip_copy` if installed from package) and 50 | `scripts/rm.sh` (or `rclip_rm`) or write your own. Mentioned scripts by default operate with 51 | text entries (using `fzf`), but you can pass argument `image` and it will 52 | operate with images (using `feh`). To select image in feh just press "enter" key. 53 | 54 | Note: `feh` have default bind `ctrl+delete` which delete current file. ...And this work in `rclip_copy image`. 55 | 56 | ### Pause saving entries 57 | 58 | If you use password manager, it will be useful to pause rclip, so just send 59 | signal SIGUSR1 and rclip will skip next clipboard update: 60 | 61 | pkill -SIGUSR1 ^rclip$ 62 | 63 | ## Configuration 64 | 65 | Config file `~/.config/rclip/config.toml` will be automatically created on first run. 66 | 67 | There is only two settings: 68 | 69 | 1. `targets_list` - is a list of targets you want to save. Example (default): 70 | 71 | targets_list = [ 72 | 'image/png', 73 | 'UTF8_STRING', 74 | ] 75 | 76 | 2. `min_length` - is a minimal length of entry you want to save. By default is `3`. 77 | 78 | ## Inspiration 79 | 80 | Inspired by [greenclip](https://github.com/erebe/greenclip), a clipboard 81 | manager written in haskell. 82 | -------------------------------------------------------------------------------- /install_scripts.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | install -Dm755 scripts/copy.sh /usr/bin/rclip_copy 4 | install -Dm755 scripts/rm.sh /usr/bin/rclip_rm 5 | 6 | install -Dm755 scripts/feh_pick_image.sh /usr/share/rclip/feh_pick_image.sh 7 | install -Dm755 scripts/fzf_pick.sh /usr/share/rclip/fzf_pick.sh 8 | install -Dm755 scripts/_pick.sh /usr/share/rclip/_pick.sh 9 | -------------------------------------------------------------------------------- /scripts/_pick.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | script_root=$(dirname "$0")/../share/rclip 4 | if [ "$1" = "image" ]; then 5 | source $script_root/feh_pick_image.sh 6 | else 7 | source $script_root/fzf_pick.sh 8 | fi 9 | -------------------------------------------------------------------------------- /scripts/copy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | RCLIP_HOME="${XDG_DATA_HOME:-$HOME/.local/share}/rclip" 4 | 5 | export PICK_PURPOSE="copy" 6 | 7 | script_root=$(dirname "$0")/../share/rclip 8 | source $script_root/_pick.sh 9 | 10 | # send a signal to rclip that now we will set entry from history 11 | pkill -SIGUSR1 ^rclip$ 12 | 13 | # get current timestamp in milliseconds 14 | NEW_FILE="$RCLIP_HOME/$TARGET_NAME/$(date +%s%3N)" 15 | mv $PICKED_FILE $NEW_FILE 16 | 17 | # nohup need to leave process running in the background (useful when call 18 | # script by hotkey) 19 | nohup xclip -t $TARGET_NAME -i $NEW_FILE -sel c > /dev/null 2> /dev/null 20 | -------------------------------------------------------------------------------- /scripts/feh_pick_image.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [[ -z "$PICK_PURPOSE" ]]; then 4 | FEH_TITLE="_undefined purpose_" 5 | else 6 | FEH_TITLE="$PICK_PURPOSE" 7 | fi 8 | 9 | TARGET_NAME="image/png" 10 | PICKED_FILE=$(feh --reverse --title "$FEH_TITLE [%u of %l]" --action 'echo %F; kill %V' $RCLIP_HOME/$TARGET_NAME) 11 | 12 | if [[ -z "$PICKED_FILE" ]]; then 13 | exit 1 14 | fi 15 | -------------------------------------------------------------------------------- /scripts/fzf_pick.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [[ -z "$PICK_PURPOSE" ]]; then 4 | FZF_PROMPT="_undefined purpose_> " 5 | else 6 | FZF_PROMPT="$PICK_PURPOSE> " 7 | fi 8 | 9 | PICKED_FILE=$(gawk ' 10 | BEGIN { 11 | RS = "" 12 | FS ="\n" 13 | } 14 | BEGINFILE { 15 | printf("%s:", FILENAME); 16 | }; 17 | { 18 | for (f=1; f<=NF; ++f) {printf("%s ", $f)}; 19 | }; 20 | ENDFILE { 21 | printf("\n") 22 | }; 23 | ' $(rg --sort path --files-with-matches . $RCLIP_HOME) | \ 24 | fzf --tac -d : --with-nth 2.. --preview "cat {1}" --preview-window=wrap \ 25 | --prompt "$FZF_PROMPT" $FZF_FLAGS | \ 26 | awk -F : '{print $1}') 27 | 28 | if [[ -z "$PICKED_FILE" ]]; then 29 | exit 1 30 | fi 31 | 32 | if [ "$PICK_PURPOSE" != "rm" ]; then 33 | TARGET_NAME="" 34 | temp_val=$(dirname $PICKED_FILE) 35 | until [ "$temp_val" = "$RCLIP_HOME" ] || [ "$temp_val" = "." ] 36 | do 37 | TARGET_NAME="$(basename $temp_val)/$TARGET_NAME" 38 | temp_val=$(dirname $temp_val) 39 | done 40 | TARGET_NAME=${TARGET_NAME::-1} 41 | fi 42 | -------------------------------------------------------------------------------- /scripts/rm.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | RCLIP_HOME="${XDG_DATA_HOME:-$HOME/.local/share}/rclip" 4 | 5 | export PICK_PURPOSE="rm" 6 | export FZF_FLAGS="-m" 7 | 8 | script_root=$(dirname "$0")/../share/rclip 9 | source $script_root/_pick.sh 10 | 11 | rm $PICKED_FILE 12 | -------------------------------------------------------------------------------- /src/clipboard/ctx.rs: -------------------------------------------------------------------------------- 1 | use super::intern_atom; 2 | 3 | use xcb::Atom; 4 | use xcb::Connection; 5 | use xcb::Window; 6 | 7 | pub struct ClipboardCtx { 8 | pub connection: Connection, 9 | pub window: Window, 10 | pub screen: i32, 11 | pub selection_type: Atom, 12 | pub property: Atom, 13 | } 14 | 15 | impl ClipboardCtx { 16 | pub fn new() -> Self { 17 | let (connection, screen) = xcb::Connection::connect(None).unwrap(); 18 | let window = connection.generate_id(); 19 | 20 | { 21 | let screen_ptr = connection 22 | .get_setup() 23 | .roots() 24 | .nth(screen as usize) 25 | .ok_or(xcb::base::ConnError::ClosedInvalidScreen) 26 | .unwrap(); 27 | 28 | xcb::create_window( 29 | &connection, 30 | xcb::COPY_FROM_PARENT as u8, 31 | window, 32 | screen_ptr.root(), 33 | 0, 34 | 0, 35 | 1, 36 | 1, 37 | 0, 38 | xcb::WINDOW_CLASS_INPUT_OUTPUT as u16, 39 | screen_ptr.root_visual(), 40 | &[( 41 | xcb::CW_EVENT_MASK, 42 | xcb::EVENT_MASK_STRUCTURE_NOTIFY | xcb::EVENT_MASK_PROPERTY_CHANGE, 43 | )], 44 | ); 45 | connection.flush(); 46 | } 47 | 48 | let selection_type = intern_atom(&connection, "CLIPBOARD"); 49 | let property = intern_atom(&connection, "RCLIP"); 50 | 51 | ClipboardCtx { 52 | connection, 53 | window, 54 | screen, 55 | selection_type, 56 | property, 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/clipboard/getter.rs: -------------------------------------------------------------------------------- 1 | use super::ClipboardCtx; 2 | use super::Targets; 3 | use crate::config::Config; 4 | use xcb::base::Event; 5 | use xcb::ffi::base::xcb_generic_event_t; 6 | 7 | const LEN_PROPERTY_GET: u32 = std::u32::MAX; 8 | 9 | pub struct Getter<'a> { 10 | ctx: &'a ClipboardCtx, 11 | targets: Targets, 12 | xfixes_event_base: u8, 13 | } 14 | 15 | pub enum GetterError { 16 | UnknownTarget, 17 | } 18 | 19 | pub enum ProcessState { 20 | Done, 21 | WrongTarget, 22 | SkipEvent, 23 | GettingLongValue, 24 | ClipboardChanged, 25 | } 26 | 27 | impl<'a> Getter<'a> { 28 | pub fn new(config: &Config, ctx: &'a ClipboardCtx) -> Self { 29 | let targets = Targets::new(&ctx.connection, config); 30 | 31 | let xfixes = xcb::query_extension(&ctx.connection, "XFIXES") 32 | .get_reply() 33 | .unwrap(); 34 | assert!(xfixes.present()); 35 | xcb::xfixes::query_version(&ctx.connection, 5, 0); 36 | let xfixes_event_base = xfixes.first_event(); 37 | 38 | Getter { 39 | ctx, 40 | targets, 41 | xfixes_event_base, 42 | } 43 | } 44 | 45 | fn send_get_req(&self) { 46 | xcb::convert_selection( 47 | &self.ctx.connection, 48 | self.ctx.window, 49 | self.ctx.selection_type, 50 | self.targets.get_current().atom, 51 | self.ctx.property, 52 | xcb::CURRENT_TIME, 53 | ); 54 | self.ctx.connection.flush(); 55 | } 56 | 57 | fn process_event(&self, event: Event, buf: &mut Vec) -> ProcessState { 58 | let etype = event.response_type(); 59 | 60 | if etype == (self.xfixes_event_base + xcb::xfixes::SELECTION_NOTIFY) { 61 | // in other sources we use event.timestamp() as last arg for convert_selection, 62 | // but else it also work. Ok 63 | ProcessState::ClipboardChanged 64 | } else { 65 | match etype & !0x80 { 66 | xcb::SELECTION_NOTIFY => { 67 | let eve = unsafe { xcb::cast_event::(&event) }; 68 | let ev_prop = eve.property(); 69 | 70 | if ev_prop == xcb::ATOM_NONE { 71 | ProcessState::WrongTarget 72 | } else { 73 | self.process_get_value(buf) 74 | } 75 | } 76 | xcb::PROPERTY_NOTIFY => { 77 | // should we implement it? It's just work 78 | // eprintln!("[rclip] Not yet implemented"); 79 | ProcessState::SkipEvent 80 | } 81 | _ => { 82 | // eprintln!("[rclip] Unknown etype: {}", etype); 83 | ProcessState::SkipEvent 84 | } 85 | } 86 | } 87 | } 88 | fn process_get_value(&self, buf: &mut Vec) -> ProcessState { 89 | let reply = xcb::get_property( 90 | &self.ctx.connection, 91 | false, 92 | self.ctx.window, 93 | // in other sources we use event.property(), 94 | // but else it also work. Ok 95 | // ev_prop, 96 | self.ctx.property, 97 | xcb::ATOM_ANY, 98 | (buf.len() / 4) as u32, 99 | LEN_PROPERTY_GET, 100 | ) 101 | .get_reply() 102 | .unwrap(); 103 | 104 | if reply.type_() != self.targets.get_current().atom { 105 | ProcessState::WrongTarget 106 | } else { 107 | let val = reply.value(); 108 | 109 | buf.extend_from_slice(val); 110 | if ((val.len() / 4) as u32) < LEN_PROPERTY_GET { 111 | ProcessState::Done 112 | } else { 113 | ProcessState::GettingLongValue 114 | } 115 | } 116 | } 117 | 118 | fn prepare_for_get(&mut self) { 119 | self.targets.restore(); 120 | 121 | let screen_ptr = &self 122 | .ctx 123 | .connection 124 | .get_setup() 125 | .roots() 126 | .nth(self.ctx.screen as usize) 127 | .ok_or(xcb::base::ConnError::ClosedInvalidScreen) 128 | .unwrap(); 129 | xcb::xfixes::select_selection_input( 130 | &self.ctx.connection, 131 | screen_ptr.root(), 132 | self.ctx.selection_type, 133 | xcb::xfixes::SELECTION_EVENT_MASK_SET_SELECTION_OWNER 134 | | xcb::xfixes::SELECTION_EVENT_MASK_SELECTION_CLIENT_CLOSE 135 | | xcb::xfixes::SELECTION_EVENT_MASK_SELECTION_WINDOW_DESTROY, 136 | ); 137 | self.ctx.connection.flush(); 138 | } 139 | 140 | /// Will wait until clibpoard changed. 'buf' parameter is where result buffer will be written. 141 | pub fn get_wait(&mut self, buf: &mut Vec) -> Result { 142 | self.prepare_for_get(); 143 | 144 | loop { 145 | match self.ctx.connection.wait_for_event() { 146 | Some(event) => match self.process_event(event, buf) { 147 | ProcessState::Done => { 148 | // don't know why, but with some applications (flameshot, for example) 149 | // clipboard does not changing without deleting property 150 | xcb::delete_property( 151 | &self.ctx.connection, 152 | self.ctx.window, 153 | self.ctx.property, 154 | ); 155 | self.ctx.connection.flush(); 156 | 157 | break; 158 | } 159 | ProcessState::WrongTarget => match self.targets.roll_next() { 160 | Ok(()) => self.send_get_req(), 161 | Err(super::targets::RollError::BoundReached) => { 162 | // empty clipboard. Probably just application that handled last 163 | // clipboard was closed 164 | 165 | // do not return empty result, so continue waiting for clipboard 166 | return self.get_wait(buf); 167 | } 168 | }, 169 | ProcessState::GettingLongValue | ProcessState::ClipboardChanged => { 170 | self.send_get_req(); 171 | 172 | continue; 173 | } 174 | ProcessState::SkipEvent => continue, 175 | }, 176 | None => { 177 | eprintln!("[rclip] X connection broken"); 178 | std::process::exit(0); 179 | } 180 | }; 181 | } 182 | 183 | let tg_name = self.targets.get_current().get_name(); 184 | // reached last target_name, so don't catch any of declared targets (string or img) 185 | if tg_name.eq("TARGETS") { 186 | Err(GetterError::UnknownTarget) 187 | } else { 188 | Ok(tg_name) 189 | } 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/clipboard/mod.rs: -------------------------------------------------------------------------------- 1 | mod ctx; 2 | mod getter; 3 | mod targets; 4 | mod utils; 5 | 6 | pub use ctx::ClipboardCtx; 7 | pub use getter::Getter; 8 | pub use getter::GetterError; 9 | 10 | use targets::Targets; 11 | use utils::intern_atom; 12 | -------------------------------------------------------------------------------- /src/clipboard/targets.rs: -------------------------------------------------------------------------------- 1 | use super::intern_atom; 2 | use crate::config::Config; 3 | use xcb::Atom; 4 | use xcb::Connection; 5 | 6 | /// Target is a type of returned result. May be text, image (png, jpeg, etc), or any other for 7 | /// example like libreoffice formatted text. 8 | /// 9 | /// Xorg also provide common target named TARGETS. This target return list of all supported types 10 | /// for current clipboard. If clipboard wiped (like when program that handled previous clipboard is 11 | /// killed), then even TARGETS target not exist for current clipboard. 12 | pub struct Target { 13 | pub atom: Atom, 14 | pub name: String, 15 | } 16 | 17 | impl Target { 18 | pub fn new(connection: &Connection, name: &str) -> Self { 19 | let atom = intern_atom(&connection, name); 20 | 21 | Target { 22 | atom, 23 | name: name.to_string(), 24 | } 25 | } 26 | 27 | pub fn get_name(&self) -> String { 28 | self.name.clone() 29 | } 30 | } 31 | 32 | #[derive(Debug)] 33 | pub enum RollError { 34 | BoundReached, 35 | } 36 | 37 | pub struct Targets { 38 | targets: Vec, 39 | cur_index: usize, 40 | } 41 | 42 | impl Targets { 43 | pub fn new(connection: &Connection, config: &Config) -> Self { 44 | let mut targets = Vec::new(); 45 | 46 | for target_name in &config.targets_list { 47 | let target = Target::new(connection, &target_name); 48 | targets.push(target); 49 | } 50 | 51 | let request_targets_list = Target::new(connection, "TARGETS"); 52 | targets.push(request_targets_list); 53 | 54 | Targets { 55 | cur_index: 0, 56 | targets, 57 | } 58 | } 59 | 60 | pub fn get_current(&self) -> &Target { 61 | &self.targets[self.cur_index] 62 | } 63 | 64 | pub fn roll_next(&mut self) -> Result<(), RollError> { 65 | if self.cur_index + 1 >= self.targets.len() { 66 | Err(RollError::BoundReached) 67 | } else { 68 | self.cur_index += 1; 69 | Ok(()) 70 | } 71 | } 72 | 73 | pub fn restore(&mut self) { 74 | self.cur_index = 0; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/clipboard/utils.rs: -------------------------------------------------------------------------------- 1 | use xcb::Atom; 2 | use xcb::Connection; 3 | 4 | pub fn intern_atom(connection: &Connection, name: &str) -> Atom { 5 | xcb::intern_atom(connection, false, name) 6 | .get_reply() 7 | .map(|reply| reply.atom()) 8 | .unwrap() 9 | } 10 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use crate::Paths; 2 | use serde::{Deserialize, Serialize}; 3 | use std::io::prelude::*; 4 | 5 | #[derive(Debug, Serialize, Deserialize)] 6 | pub struct Config { 7 | pub targets_list: Vec, 8 | pub min_length: usize, 9 | } 10 | 11 | impl Config { 12 | pub fn new(paths: &Paths) -> Self { 13 | let mut raw = String::new(); 14 | 15 | match std::fs::File::open(&paths.config_path) { 16 | Ok(mut f) => { 17 | f.read_to_string(&mut raw).unwrap(); 18 | toml::from_str(&raw).unwrap() 19 | } 20 | Err(e) => match e.kind() { 21 | std::io::ErrorKind::NotFound => { 22 | let r = Config::default(); 23 | let mut f = std::fs::File::create(&paths.config_path).unwrap(); 24 | f.write_all(toml::to_string_pretty(&r).unwrap().as_bytes()) 25 | .unwrap(); 26 | 27 | r 28 | } 29 | _ => std::panic::panic_any(e), 30 | }, 31 | } 32 | } 33 | 34 | fn default() -> Self { 35 | Config { 36 | targets_list: ["image/png".to_owned(), "UTF8_STRING".to_owned()].to_vec(), 37 | min_length: 3, 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/daemon.rs: -------------------------------------------------------------------------------- 1 | use crate::clipboard::ClipboardCtx; 2 | use crate::clipboard::Getter; 3 | use crate::clipboard::GetterError; 4 | use crate::config::Config; 5 | use crate::utils::get_hash; 6 | use crate::Paths; 7 | use signal_hook::{consts::signal::SIGUSR1, iterator::Signals}; 8 | use std::collections::hash_map::DefaultHasher; 9 | use std::io::Write; 10 | use std::sync::atomic::AtomicBool; 11 | use std::sync::atomic::Ordering; 12 | use std::sync::Arc; 13 | use std::time::{SystemTime, UNIX_EPOCH}; 14 | 15 | pub struct Daemon<'a> { 16 | getter: Getter<'a>, 17 | paths: &'a Paths, 18 | config: &'a Config, 19 | } 20 | 21 | impl<'a> Daemon<'a> { 22 | pub fn new(config: &'a Config, paths: &'a Paths, clipboard_ctx: &'a ClipboardCtx) -> Self { 23 | let getter = Getter::new(config, &clipboard_ctx); 24 | 25 | Daemon { 26 | getter, 27 | paths, 28 | config, 29 | } 30 | } 31 | 32 | pub fn start_loop(&mut self) { 33 | let dooneskip = Arc::new(AtomicBool::new(false)); 34 | let dooneskip_shared = dooneskip.clone(); 35 | 36 | let mut signals = Signals::new(&[SIGUSR1]).unwrap(); 37 | std::thread::spawn(move || { 38 | for _ in signals.forever() { 39 | dooneskip_shared.store(true, Ordering::Release); 40 | } 41 | }); 42 | 43 | let mut prev_hash: u64 = 0; 44 | loop { 45 | std::thread::sleep(::std::time::Duration::from_millis(100)); 46 | 47 | let mut clipboard_data = Vec::new(); 48 | match self.getter.get_wait(&mut clipboard_data) { 49 | Ok(target_name) => { 50 | if dooneskip.load(Ordering::Relaxed) { 51 | println!("[rclip] Skip because of got signal for skip"); 52 | dooneskip.store(false, Ordering::Release); 53 | continue; 54 | } 55 | 56 | if clipboard_data.len() < self.config.min_length { 57 | println!("[rclip] Skip due to config setting 'min_length'"); 58 | continue; 59 | } 60 | 61 | let mut hasher = DefaultHasher::new(); 62 | let new_hash = get_hash(&clipboard_data, &mut hasher); 63 | if new_hash == prev_hash { 64 | println!("[rclip] Found duplicate"); 65 | continue; 66 | } 67 | prev_hash = new_hash; 68 | 69 | println!("[rclip] Clipboard changed. Len: {}", clipboard_data.len()); 70 | 71 | let filename = SystemTime::now() 72 | .duration_since(UNIX_EPOCH) 73 | .unwrap() 74 | .as_millis() 75 | .to_string(); 76 | let filepathstring = format!( 77 | "{}/{}/{}", 78 | self.paths.history_dir_path, target_name, filename, 79 | ); 80 | let filepath = std::path::Path::new(&filepathstring); 81 | let mut f = std::fs::OpenOptions::new() 82 | .write(true) 83 | .create(true) 84 | .open(filepath) 85 | .unwrap(); 86 | f.write_all(&clipboard_data).unwrap(); 87 | } 88 | Err(GetterError::UnknownTarget) => { 89 | eprintln!("[rclip] Unknown target. Check setting 'targets_list' in config.") 90 | } 91 | }; 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use clap::{App, SubCommand}; 2 | 3 | mod clipboard; 4 | mod config; 5 | mod daemon; 6 | mod paths; 7 | mod utils; 8 | 9 | use clipboard::ClipboardCtx; 10 | use config::Config; 11 | use daemon::Daemon; 12 | use paths::Paths; 13 | 14 | fn main() { 15 | // TODO: detect if another program instanec already launched 16 | let arg_matches = App::new("rclip") 17 | .version("0.2.0") 18 | .author("UnkwUsr ") 19 | .about("Clipboard manager written in Rust") 20 | .subcommand(SubCommand::with_name("daemon").about("Run daemon of clipboard manager")) 21 | .get_matches(); 22 | 23 | let paths = Paths::new(); 24 | let config = Config::new(&paths); 25 | paths.create_targets_dirs(&config); 26 | 27 | match arg_matches.subcommand() { 28 | ("daemon", Some(_)) => { 29 | let clipboard_ctx = ClipboardCtx::new(); 30 | let mut daemon = Daemon::new(&config, &paths, &clipboard_ctx); 31 | daemon.start_loop(); 32 | } 33 | _ => { 34 | println!("{}", arg_matches.usage()); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/paths.rs: -------------------------------------------------------------------------------- 1 | use crate::config::Config; 2 | 3 | const CONFIG_DIR: &str = "rclip"; 4 | const CONFIG_FILE: &str = "config.toml"; 5 | 6 | pub struct Paths { 7 | // TODO: remove "_path" at the end of fields names 8 | pub history_dir_path: String, 9 | pub config_path: String, 10 | } 11 | 12 | impl Paths { 13 | pub fn new() -> Self { 14 | let history_dir_path = format!("{}/{}", dirs::data_dir().unwrap().display(), CONFIG_DIR); 15 | std::fs::create_dir_all(&history_dir_path).unwrap(); 16 | 17 | let config_dir_path = format!("{}/{}", dirs::config_dir().unwrap().display(), CONFIG_DIR); 18 | std::fs::create_dir_all(&config_dir_path).unwrap(); 19 | let config_path = format!("{}/{}", config_dir_path, CONFIG_FILE); 20 | 21 | Self { 22 | history_dir_path, 23 | config_path, 24 | } 25 | } 26 | 27 | pub fn create_targets_dirs(&self, config: &Config) { 28 | for target in &config.targets_list { 29 | let path = format!("{}/{}", self.history_dir_path, target); 30 | std::fs::create_dir_all(&path).unwrap(); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use std::hash::Hasher; 2 | 3 | pub fn get_hash(msg: &[u8], mut hasher: H) -> u64 { 4 | hasher.write(msg); 5 | hasher.finish() 6 | } 7 | --------------------------------------------------------------------------------