├── .envrc ├── .github ├── dependabot.yml └── workflows │ └── build.yaml ├── src ├── editor │ ├── mod.rs │ ├── error.rs │ └── external.rs ├── grpc │ ├── mod.rs │ ├── protobuf.rs │ ├── service.rs │ └── client.rs ├── bin │ ├── clipcat-menu │ │ ├── main.rs │ │ ├── finder │ │ │ ├── external │ │ │ │ ├── fzf.rs │ │ │ │ ├── skim.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── custom.rs │ │ │ │ ├── dmenu.rs │ │ │ │ └── rofi.rs │ │ │ ├── error.rs │ │ │ ├── builtin.rs │ │ │ ├── finder_stream.rs │ │ │ └── mod.rs │ │ ├── error.rs │ │ ├── config.rs │ │ └── command.rs │ ├── clipcatd │ │ ├── history │ │ │ ├── error.rs │ │ │ ├── mod.rs │ │ │ ├── fs.rs │ │ │ └── rocksdb.rs │ │ ├── main.rs │ │ ├── worker │ │ │ ├── signal.rs │ │ │ ├── grpc.rs │ │ │ ├── mod.rs │ │ │ └── clipboard.rs │ │ ├── error.rs │ │ ├── config.rs │ │ └── command.rs │ ├── clipcatctl │ │ ├── main.rs │ │ ├── error.rs │ │ └── config.rs │ └── clipcat-notify │ │ └── main.rs ├── error.rs ├── event.rs ├── lib.rs └── monitor.rs ├── clipcat.service ├── .gitignore ├── .dockerignore ├── shell.nix ├── proto ├── monitor.proto └── manager.proto ├── .editorconfig ├── flake.nix ├── Dockerfile ├── flake.lock ├── default.nix ├── completions ├── fish │ └── completions │ │ ├── clipcat-notify.fish │ │ ├── clipcatd.fish │ │ ├── clipcat-menu.fish │ │ └── clipcatctl.fish ├── zsh │ └── site-functions │ │ ├── _clipcat-notify │ │ ├── _clipcatd │ │ └── _clipcat-menu └── bash-completion │ └── completions │ ├── clipcat-notify │ ├── clipcatd │ └── clipcat-menu ├── Makefile.toml ├── Cargo.toml └── README.md /.envrc: -------------------------------------------------------------------------------- 1 | nix_direnv_watch_file flake.nix flake.lock shell.nix default.nix 2 | use flake . 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: "cargo" 5 | directory: "/" 6 | schedule: 7 | interval: "daily" 8 | -------------------------------------------------------------------------------- /src/editor/mod.rs: -------------------------------------------------------------------------------- 1 | mod error; 2 | 3 | #[cfg(feature = "external_editor")] 4 | mod external; 5 | 6 | pub use self::error::EditorError; 7 | 8 | #[cfg(feature = "external_editor")] 9 | pub use self::external::ExternalEditor; 10 | -------------------------------------------------------------------------------- /clipcat.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Starts the clipboard daemon 3 | 4 | [Service] 5 | Type=simple 6 | ExecStart=sh -c "PATH=~/.cargo/bin:$PATH clipcatd --no-daemon" 7 | Restart=on-failure 8 | 9 | [Install] 10 | WantedBy=default.target 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/target/ 2 | **/*.rs.bk 3 | 4 | *.swp 5 | *.swo 6 | *.swn 7 | *.DS_Store 8 | 9 | # Visual Studio Code stuff 10 | .vscode 11 | 12 | # GitEye stuff 13 | .project 14 | 15 | # idea ide 16 | .idea 17 | 18 | # git stuff 19 | .git 20 | 21 | # nix build result 22 | result 23 | 24 | # direnv cache 25 | .direnv 26 | -------------------------------------------------------------------------------- /src/grpc/mod.rs: -------------------------------------------------------------------------------- 1 | mod client; 2 | mod protobuf; 3 | 4 | mod service; 5 | 6 | pub use self::{ 7 | client::{GrpcClient, GrpcClientError}, 8 | protobuf::{manager_server::ManagerServer, monitor_server::MonitorServer}, 9 | }; 10 | 11 | pub use self::service::ManagerService; 12 | pub use self::service::MonitorService; 13 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/target/ 2 | **/*.rs.bk 3 | completions/ 4 | 5 | Dockerfile 6 | .dockerignore 7 | docker-compose.yml 8 | docker 9 | docs 10 | ci 11 | 12 | .git 13 | .gitignore 14 | 15 | .envrc 16 | .editorconfig 17 | shell.nix 18 | 19 | *.sw[op] 20 | 21 | README.md 22 | LICENSE 23 | Makefile.toml 24 | rustfmt.toml 25 | justfile 26 | -------------------------------------------------------------------------------- /src/bin/clipcat-menu/main.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate serde; 3 | 4 | mod command; 5 | mod config; 6 | mod error; 7 | mod finder; 8 | 9 | use self::command::Command; 10 | 11 | fn main() { 12 | let command = Command::new(); 13 | if let Err(err) = command.run() { 14 | eprintln!("Error: {err}"); 15 | std::process::exit(1); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | { pkgs ? (import {}) }: 2 | let 3 | inherit (pkgs) callPackage mkShell clippy cargo; 4 | clipcat = callPackage ./default.nix { }; 5 | in 6 | mkShell { 7 | inputsFrom = [ clipcat ]; 8 | buildInputs = [ 9 | clippy 10 | ]; 11 | # needed for internal protobuf c wrapper library 12 | inherit (clipcat) 13 | PROTOC 14 | PROTOC_INCLUDE; 15 | } 16 | -------------------------------------------------------------------------------- /src/bin/clipcatd/history/error.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, Snafu)] 2 | #[snafu(visibility(pub))] 3 | pub enum HistoryError { 4 | #[snafu(display("IO error: {}", source))] 5 | Io { source: std::io::Error }, 6 | #[snafu(display("Serde error: {}", source))] 7 | Serde { source: bincode::Error }, 8 | } 9 | 10 | impl From for HistoryError { 11 | fn from(err: std::io::Error) -> HistoryError { 12 | HistoryError::Io { source: err } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/bin/clipcatd/main.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate snafu; 3 | 4 | #[macro_use] 5 | extern crate serde; 6 | 7 | use std::sync::atomic; 8 | 9 | mod command; 10 | mod config; 11 | mod error; 12 | mod history; 13 | mod worker; 14 | 15 | use self::command::Command; 16 | 17 | pub static SHUTDOWN: atomic::AtomicBool = atomic::AtomicBool::new(false); 18 | 19 | fn main() { 20 | let command = Command::new(); 21 | if let Err(err) = command.run() { 22 | eprintln!("Error: {err}"); 23 | std::process::exit(1); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/bin/clipcatctl/main.rs: -------------------------------------------------------------------------------- 1 | #![cfg(feature = "app")] 2 | 3 | #[macro_use] 4 | extern crate serde; 5 | 6 | #[macro_use] 7 | extern crate snafu; 8 | 9 | mod command; 10 | mod config; 11 | mod error; 12 | 13 | use self::command::Command; 14 | 15 | fn main() { 16 | let command = Command::new(); 17 | match command.run() { 18 | Ok(exit_code) => { 19 | std::process::exit(exit_code); 20 | } 21 | Err(err) => { 22 | eprintln!("Error: {err}"); 23 | std::process::exit(1); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /proto/monitor.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package monitor; 4 | 5 | service Monitor { 6 | rpc EnableMonitor(EnableMonitorRequest) returns (MonitorStateReply); 7 | rpc DisableMonitor(DisableMonitorRequest) returns (MonitorStateReply); 8 | rpc ToggleMonitor(ToggleMonitorRequest) returns (MonitorStateReply); 9 | rpc GetMonitorState(GetMonitorStateRequest) returns (MonitorStateReply); 10 | } 11 | 12 | enum MonitorState { 13 | Enabled = 0; 14 | Disabled = 1; 15 | } 16 | 17 | message MonitorStateReply { MonitorState state = 1; } 18 | 19 | message EnableMonitorRequest {} 20 | message DisableMonitorRequest {} 21 | message ToggleMonitorRequest {} 22 | message GetMonitorStateRequest {} 23 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | # Text is UTF-8 8 | charset = utf-8 9 | # Unix-style newlines 10 | end_of_line = lf 11 | # Newline ending every file 12 | insert_final_newline = true 13 | # Soft tabs 14 | indent_style = space 15 | # Two-space indentation 16 | indent_size = 2 17 | # Trim trailing whitespace 18 | trim_trailing_whitespace = true 19 | # Max line length 20 | max_line_length=100 21 | 22 | [.travis.yml] 23 | # Soft tabs 24 | indent_style=space 25 | # Two-space indentation 26 | indent_size=2 27 | # Tab width 28 | tab_width=8 29 | 30 | [justfile] 31 | # Use tabs in justfile 32 | indent_style = tab 33 | 34 | -------------------------------------------------------------------------------- /src/bin/clipcat-menu/finder/external/fzf.rs: -------------------------------------------------------------------------------- 1 | use crate::finder::{external::ExternalProgram, FinderStream, SelectionMode}; 2 | 3 | #[derive(Debug, Clone, Eq, PartialEq)] 4 | pub struct Fzf; 5 | 6 | impl Fzf { 7 | #[inline] 8 | pub fn new() -> Fzf { 9 | Fzf 10 | } 11 | } 12 | 13 | impl ExternalProgram for Fzf { 14 | fn program(&self) -> String { 15 | "fzf".to_string() 16 | } 17 | 18 | fn args(&self, selection_mode: SelectionMode) -> Vec { 19 | match selection_mode { 20 | SelectionMode::Single => vec!["--no-multi".to_owned()], 21 | SelectionMode::Multiple => vec!["--multi".to_owned()], 22 | } 23 | } 24 | } 25 | 26 | impl FinderStream for Fzf {} 27 | -------------------------------------------------------------------------------- /src/bin/clipcat-menu/finder/external/skim.rs: -------------------------------------------------------------------------------- 1 | use crate::finder::{external::ExternalProgram, FinderStream, SelectionMode}; 2 | 3 | #[derive(Debug, Clone, Eq, PartialEq)] 4 | pub struct Skim; 5 | 6 | impl Skim { 7 | #[inline] 8 | pub fn new() -> Skim { 9 | Skim 10 | } 11 | } 12 | 13 | impl ExternalProgram for Skim { 14 | fn program(&self) -> String { 15 | "sk".to_string() 16 | } 17 | 18 | fn args(&self, selection_mode: SelectionMode) -> Vec { 19 | match selection_mode { 20 | SelectionMode::Single => vec!["--no-multi".to_owned()], 21 | SelectionMode::Multiple => vec!["--multi".to_owned()], 22 | } 23 | } 24 | } 25 | 26 | impl FinderStream for Skim {} 27 | -------------------------------------------------------------------------------- /src/bin/clipcat-menu/finder/error.rs: -------------------------------------------------------------------------------- 1 | use snafu::Snafu; 2 | 3 | #[derive(Debug, Snafu)] 4 | #[snafu(visibility(pub))] 5 | pub enum FinderError { 6 | #[snafu(display("Invalid finder: {}", finder))] 7 | InvalidFinder { finder: String }, 8 | 9 | #[snafu(display("Could not spawn external process, error: {}", source))] 10 | SpawnExternalProcess { source: std::io::Error }, 11 | 12 | #[snafu(display("Could not join spawned task, error: {}", source))] 13 | JoinTask { source: tokio::task::JoinError }, 14 | 15 | #[snafu(display("Could not open stdin"))] 16 | OpenStdin, 17 | 18 | #[snafu(display("Could not write stdin, error: {}", source))] 19 | WriteStdin { source: std::io::Error }, 20 | 21 | #[snafu(display("Could not read stdout, error: {}", source))] 22 | ReadStdout { source: std::io::Error }, 23 | } 24 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "clipcat is a clipboard manager written in Rust Programming Language."; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; 6 | flake-utils.url = "github:numtide/flake-utils"; 7 | }; 8 | 9 | outputs = { self, nixpkgs, flake-utils }: 10 | flake-utils.lib.eachDefaultSystem (system: 11 | let 12 | pkgs = import nixpkgs { inherit system; config.allowUnfree = true; }; 13 | inherit (pkgs) callPackage; 14 | in 15 | { 16 | packages = rec { 17 | clipcat = callPackage ./default.nix { }; 18 | default = clipcat; 19 | }; 20 | devShells = rec { 21 | clipcat = callPackage ./shell.nix { }; 22 | default = clipcat; 23 | }; 24 | formatter = pkgs.nixpkgs-fmt; 25 | } 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/bin/clipcat-menu/finder/external/mod.rs: -------------------------------------------------------------------------------- 1 | use std::process::Stdio; 2 | 3 | use tokio::process::Command; 4 | 5 | use crate::finder::{FinderStream, SelectionMode}; 6 | 7 | mod custom; 8 | mod dmenu; 9 | mod fzf; 10 | mod rofi; 11 | mod skim; 12 | 13 | pub use self::{custom::Custom, dmenu::Dmenu, fzf::Fzf, rofi::Rofi, skim::Skim}; 14 | 15 | pub trait ExternalProgram: FinderStream + Send + Sync { 16 | fn program(&self) -> String; 17 | 18 | fn args(&self, selection_mode: SelectionMode) -> Vec; 19 | 20 | fn spawn_child( 21 | &self, 22 | selection_mode: SelectionMode, 23 | ) -> Result { 24 | Command::new(self.program()) 25 | .args(self.args(selection_mode)) 26 | .stdin(Stdio::piped()) 27 | .stdout(Stdio::piped()) 28 | .spawn() 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/bin/clipcat-menu/finder/external/custom.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | config, 3 | finder::{external::ExternalProgram, FinderStream, SelectionMode}, 4 | }; 5 | 6 | #[derive(Debug, Clone, Eq, PartialEq)] 7 | pub struct Custom { 8 | program: String, 9 | args: Vec, 10 | } 11 | 12 | impl Custom { 13 | #[inline] 14 | pub fn from_config(config: &config::CustomFinder) -> Custom { 15 | let config::CustomFinder { program, args } = config; 16 | Custom { 17 | program: program.clone(), 18 | args: args.clone(), 19 | } 20 | } 21 | } 22 | 23 | impl ExternalProgram for Custom { 24 | fn program(&self) -> String { 25 | self.program.clone() 26 | } 27 | 28 | fn args(&self, _seletion_mode: SelectionMode) -> Vec { 29 | self.args.clone() 30 | } 31 | } 32 | 33 | impl FinderStream for Custom {} 34 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rust:slim as builder 2 | 3 | RUN apt update && \ 4 | apt install -y \ 5 | build-essential \ 6 | clang \ 7 | protobuf-compiler \ 8 | python3 \ 9 | libx11-xcb-dev \ 10 | libxcb-xfixes0-dev \ 11 | libxcb-render0-dev \ 12 | libxcb-shape0-dev && \ 13 | rustup default stable && \ 14 | rustup component add rustfmt 15 | 16 | ENV PROTOC /usr/bin/protoc 17 | 18 | WORKDIR /build 19 | 20 | ADD . /build 21 | 22 | RUN cargo build --release --features=all-bins 23 | 24 | FROM debian:stable-slim 25 | 26 | COPY --from=builder /build/target/release/clipcatd /usr/bin 27 | COPY --from=builder /build/target/release/clipcatctl /usr/bin 28 | COPY --from=builder /build/target/release/clipcat-menu /usr/bin 29 | COPY --from=builder /build/target/release/clipcat-notify /usr/bin 30 | 31 | RUN apt update && apt install -y xcb libxcb-xfixes0 32 | 33 | ENTRYPOINT [ "clipcatd", "--version" ] 34 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "locked": { 5 | "lastModified": 1667395993, 6 | "narHash": "sha256-nuEHfE/LcWyuSWnS8t12N1wc105Qtau+/OdUAjtQ0rA=", 7 | "owner": "numtide", 8 | "repo": "flake-utils", 9 | "rev": "5aed5285a952e0b949eb3ba02c12fa4fcfef535f", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "numtide", 14 | "repo": "flake-utils", 15 | "type": "github" 16 | } 17 | }, 18 | "nixpkgs": { 19 | "locked": { 20 | "lastModified": 1672249180, 21 | "narHash": "sha256-ipos/gTMHqxS39asqNWEJZ7nXdcTHa0TB0AIZXkGapg=", 22 | "owner": "NixOS", 23 | "repo": "nixpkgs", 24 | "rev": "e58a7747db96c23b8a977e7c1bbfc5753b81b6fa", 25 | "type": "github" 26 | }, 27 | "original": { 28 | "owner": "NixOS", 29 | "ref": "nixpkgs-unstable", 30 | "repo": "nixpkgs", 31 | "type": "github" 32 | } 33 | }, 34 | "root": { 35 | "inputs": { 36 | "flake-utils": "flake-utils", 37 | "nixpkgs": "nixpkgs" 38 | } 39 | } 40 | }, 41 | "root": "root", 42 | "version": 7 43 | } 44 | -------------------------------------------------------------------------------- /src/bin/clipcatctl/error.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use clipcat::{editor::EditorError, grpc::GrpcClientError}; 4 | 5 | #[derive(Debug, Snafu)] 6 | #[snafu(visibility(pub))] 7 | pub enum Error { 8 | #[snafu(display("Could not read file {}, error: {}", filename.display(), source))] 9 | ReadFile { 10 | filename: PathBuf, 11 | source: std::io::Error, 12 | }, 13 | 14 | #[snafu(display("Could not read from stdin, error: {}", source))] 15 | ReadStdin { source: std::io::Error }, 16 | 17 | #[snafu(display("Could not write to stdout, error: {}", source))] 18 | WriteStdout { source: std::io::Error }, 19 | 20 | #[snafu(display("Could not create tokio runtime, error: {}", source))] 21 | CreateTokioRuntime { source: std::io::Error }, 22 | 23 | #[snafu(display("Could not call gRPC client, error: {}", source))] 24 | CallGrpcClient { source: GrpcClientError }, 25 | 26 | #[snafu(display("Could not call external editor, error: {}", source))] 27 | CallEditor { source: EditorError }, 28 | } 29 | 30 | impl From for Error { 31 | fn from(err: GrpcClientError) -> Error { 32 | Error::CallGrpcClient { source: err } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/bin/clipcat-menu/error.rs: -------------------------------------------------------------------------------- 1 | use snafu::Snafu; 2 | 3 | use clipcat::{editor::EditorError, grpc::GrpcClientError}; 4 | 5 | use crate::finder::FinderError; 6 | 7 | #[derive(Debug, Snafu)] 8 | #[snafu(visibility(pub))] 9 | pub enum Error { 10 | Grpc { 11 | source: GrpcClientError, 12 | }, 13 | 14 | StdIo { 15 | source: std::io::Error, 16 | }, 17 | 18 | #[snafu(display("Could not create tokio runtime, error: {}", source))] 19 | CreateTokioRuntime { 20 | source: std::io::Error, 21 | }, 22 | 23 | RunFinder { 24 | source: FinderError, 25 | }, 26 | 27 | #[snafu(display("Could not call external editor, error: {}", source))] 28 | CallEditor { 29 | source: EditorError, 30 | }, 31 | } 32 | 33 | impl From for Error { 34 | fn from(err: std::io::Error) -> Error { 35 | Error::StdIo { source: err } 36 | } 37 | } 38 | 39 | impl From for Error { 40 | fn from(err: FinderError) -> Error { 41 | Error::RunFinder { source: err } 42 | } 43 | } 44 | 45 | impl From for Error { 46 | fn from(err: GrpcClientError) -> Error { 47 | Error::Grpc { source: err } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/editor/error.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | #[derive(Debug, Snafu)] 4 | #[snafu(visibility(pub))] 5 | pub enum EditorError { 6 | #[snafu(display("Could not get editor from environment variable, error: {}", source))] 7 | GetEnvEditor { source: std::env::VarError }, 8 | 9 | #[snafu(display("Could not create temporary file: {}, error: {}", filename.display(), source))] 10 | CreateTemporaryFile { 11 | filename: PathBuf, 12 | source: std::io::Error, 13 | }, 14 | 15 | #[snafu(display("Could not read temporary file: {}, error: {}", filename.display(), source))] 16 | ReadTemporaryFile { 17 | filename: PathBuf, 18 | source: std::io::Error, 19 | }, 20 | 21 | #[snafu(display("Could not remove temporary file: {}, error: {}", filename.display(), source))] 22 | RemoveTemporaryFile { 23 | filename: PathBuf, 24 | source: std::io::Error, 25 | }, 26 | 27 | #[snafu(display("Could not call external text editor: {}, error: {}", program, source))] 28 | CallExternalTextEditor { 29 | program: String, 30 | source: std::io::Error, 31 | }, 32 | 33 | #[snafu(display("Could not call external text editor: {}, error: {}", program, source))] 34 | ExecuteExternalTextEditor { 35 | program: String, 36 | source: std::io::Error, 37 | }, 38 | } 39 | -------------------------------------------------------------------------------- /src/bin/clipcat-menu/finder/external/dmenu.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | config, 3 | finder::{external::ExternalProgram, FinderStream, SelectionMode}, 4 | }; 5 | 6 | #[derive(Debug, Clone, Eq, PartialEq)] 7 | pub struct Dmenu { 8 | menu_length: usize, 9 | line_length: usize, 10 | } 11 | 12 | impl Dmenu { 13 | pub fn from_config(config: &config::Dmenu) -> Dmenu { 14 | let config::Dmenu { 15 | menu_length, 16 | line_length, 17 | } = *config; 18 | 19 | Dmenu { 20 | menu_length, 21 | line_length, 22 | } 23 | } 24 | } 25 | 26 | impl ExternalProgram for Dmenu { 27 | fn program(&self) -> String { 28 | "dmenu".to_string() 29 | } 30 | 31 | fn args(&self, _selection_mode: SelectionMode) -> Vec { 32 | vec!["-l".to_owned(), self.menu_length.to_string()] 33 | } 34 | } 35 | 36 | impl FinderStream for Dmenu { 37 | fn line_length(&self) -> Option { 38 | Some(self.line_length) 39 | } 40 | 41 | fn menu_length(&self) -> Option { 42 | Some(self.menu_length) 43 | } 44 | 45 | fn set_line_length(&mut self, line_length: usize) { 46 | self.line_length = line_length 47 | } 48 | 49 | fn set_menu_length(&mut self, menu_length: usize) { 50 | self.menu_length = menu_length; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | { lib 2 | , installShellFiles 3 | , rustPlatform 4 | , rustfmt 5 | , xorg 6 | , pkg-config 7 | , llvmPackages 8 | , clang 9 | , protobuf 10 | , python3 11 | }: 12 | 13 | rustPlatform.buildRustPackage { 14 | pname = "clipcat"; 15 | version = "dev"; 16 | 17 | src = ./.; 18 | 19 | cargoLock = { 20 | lockFile = ./Cargo.lock; 21 | outputHashes = { 22 | "x11-clipboard-0.7.0" = "sha256-ToDy7vWPRYk8mrmL+77HJypE91b6z/NaDTUDgRe20d0="; 23 | }; 24 | }; 25 | 26 | # needed for internal protobuf c wrapper library 27 | PROTOC = "${protobuf}/bin/protoc"; 28 | PROTOC_INCLUDE = "${protobuf}/include"; 29 | 30 | nativeBuildInputs = [ 31 | pkg-config 32 | 33 | rustPlatform.bindgenHook 34 | 35 | rustfmt 36 | protobuf 37 | 38 | python3 39 | 40 | installShellFiles 41 | ]; 42 | buildInputs = [ xorg.libxcb ]; 43 | 44 | buildFeatures = [ "all-bins,all-backends" ]; 45 | 46 | postInstall = '' 47 | installShellCompletion --bash completions/bash-completion/completions/* 48 | installShellCompletion --fish completions/fish/completions/* 49 | installShellCompletion --zsh completions/zsh/site-functions/* 50 | ''; 51 | 52 | meta = with lib; { 53 | description = "Clipboard Manager written in Rust Programming Language"; 54 | homepage = "https://github.com/xrelkd/clipcat"; 55 | license = licenses.gpl3Only; 56 | platforms = platforms.linux; 57 | maintainers = with maintainers; [ xrelkd ]; 58 | }; 59 | } 60 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | pub mod display_from_str { 2 | use std::fmt::Display; 3 | use std::str::FromStr; 4 | 5 | use serde::{Deserialize, Serialize}; 6 | 7 | pub fn serialize(v: &T, s: S) -> Result 8 | where 9 | S: serde::Serializer, 10 | { 11 | v.to_string().serialize(s) 12 | } 13 | pub fn deserialize<'de, D, T: FromStr>(d: D) -> Result 14 | where 15 | D: serde::Deserializer<'de>, 16 | T::Err: Display, 17 | { 18 | String::deserialize(d).and_then(|s| s.parse().map_err(serde::de::Error::custom)) 19 | } 20 | } 21 | 22 | #[derive(Debug, Snafu)] 23 | #[snafu(visibility(pub))] 24 | pub enum ClipboardError { 25 | #[snafu(display( 26 | "X11 nor wayland were compiled as backends - we have no clipboard to watch!" 27 | ))] 28 | NoBackendFound, 29 | 30 | #[snafu(display("Could not spawn tokio task, error: {}", source))] 31 | SpawnBlockingTask { source: tokio::task::JoinError }, 32 | 33 | #[cfg(feature = "wayland")] 34 | #[snafu(display("Could not write to Wayland clipboard"))] 35 | WaylandWrite, 36 | 37 | #[cfg(feature = "x11")] 38 | #[snafu(display("Could not initialize X11 clipboard, error: {}", source))] 39 | InitializeX11Clipboard { source: x11_clipboard::error::Error }, 40 | 41 | #[cfg(feature = "x11")] 42 | #[snafu(display("Could not paste to X11 clipboard, error: {}", source))] 43 | PasteToX11Clipboard { source: x11_clipboard::error::Error }, 44 | } 45 | -------------------------------------------------------------------------------- /completions/fish/completions/clipcat-notify.fish: -------------------------------------------------------------------------------- 1 | complete -c clipcat-notify -n "__fish_use_subcommand" -l no-clipboard -d 'Does not monitor clipboard' 2 | complete -c clipcat-notify -n "__fish_use_subcommand" -l no-primary -d 'Does not monitor primary' 3 | complete -c clipcat-notify -n "__fish_use_subcommand" -s h -l help -d 'Prints help information' 4 | complete -c clipcat-notify -n "__fish_use_subcommand" -s V -l version -d 'Prints version information' 5 | complete -c clipcat-notify -n "__fish_use_subcommand" -f -a "version" -d 'Prints version information' 6 | complete -c clipcat-notify -n "__fish_use_subcommand" -f -a "completions" -d 'Outputs shell completion code for the specified shell (bash, zsh, fish)' 7 | complete -c clipcat-notify -n "__fish_use_subcommand" -f -a "help" -d 'Prints this message or the help of the given subcommand(s)' 8 | complete -c clipcat-notify -n "__fish_seen_subcommand_from version" -s h -l help -d 'Prints help information' 9 | complete -c clipcat-notify -n "__fish_seen_subcommand_from version" -s V -l version -d 'Prints version information' 10 | complete -c clipcat-notify -n "__fish_seen_subcommand_from completions" -s h -l help -d 'Prints help information' 11 | complete -c clipcat-notify -n "__fish_seen_subcommand_from completions" -s V -l version -d 'Prints version information' 12 | complete -c clipcat-notify -n "__fish_seen_subcommand_from help" -s h -l help -d 'Prints help information' 13 | complete -c clipcat-notify -n "__fish_seen_subcommand_from help" -s V -l version -d 'Prints version information' 14 | -------------------------------------------------------------------------------- /src/bin/clipcatctl/config.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | net::IpAddr, 3 | path::{Path, PathBuf}, 4 | }; 5 | 6 | #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] 7 | pub struct Config { 8 | pub server_host: IpAddr, 9 | 10 | pub server_port: u16, 11 | 12 | #[serde( 13 | default = "Config::default_log_level", 14 | with = "clipcat::display_from_str" 15 | )] 16 | pub log_level: tracing::Level, 17 | } 18 | 19 | impl Default for Config { 20 | fn default() -> Config { 21 | Config { 22 | server_host: clipcat::DEFAULT_GRPC_HOST 23 | .parse() 24 | .expect("Parse default gRPC host"), 25 | server_port: clipcat::DEFAULT_GRPC_PORT, 26 | log_level: Self::default_log_level(), 27 | } 28 | } 29 | } 30 | 31 | impl Config { 32 | #[inline] 33 | pub fn default_path() -> PathBuf { 34 | directories::BaseDirs::new() 35 | .expect("app_dirs") 36 | .config_dir() 37 | .join(clipcat::PROJECT_NAME) 38 | .join(clipcat::CTL_CONFIG_NAME) 39 | } 40 | 41 | #[inline] 42 | pub fn load>(path: P) -> Result { 43 | let file = std::fs::read(path)?; 44 | let config = toml::from_slice(&file)?; 45 | Ok(config) 46 | } 47 | 48 | #[inline] 49 | pub fn load_or_default>(path: P) -> Config { 50 | Self::load(path).unwrap_or_default() 51 | } 52 | 53 | #[inline] 54 | pub fn default_log_level() -> tracing::Level { 55 | tracing::Level::INFO 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/bin/clipcatd/worker/signal.rs: -------------------------------------------------------------------------------- 1 | use std::sync::atomic; 2 | 3 | use futures::FutureExt; 4 | use tokio::{ 5 | signal::unix::{signal, SignalKind}, 6 | sync::mpsc, 7 | }; 8 | 9 | use crate::{ 10 | worker::{CtlMessage, CtlMessageSender}, 11 | SHUTDOWN, 12 | }; 13 | 14 | pub struct SignalWorker { 15 | ctl_tx: CtlMessageSender, 16 | } 17 | 18 | impl SignalWorker { 19 | #[allow(clippy::never_loop)] 20 | async fn run(self) { 21 | let mut term_signal = signal(SignalKind::terminate()).unwrap(); 22 | let mut int_signal = signal(SignalKind::interrupt()).unwrap(); 23 | 24 | loop { 25 | loop { 26 | futures::select! { 27 | _ = term_signal.recv().fuse() => { 28 | tracing::info!("SIGTERM received!"); 29 | break; 30 | }, 31 | _ = int_signal.recv().fuse() => { 32 | tracing::info!("SIGINT received!"); 33 | break; 34 | }, 35 | } 36 | } 37 | 38 | if SHUTDOWN.load(atomic::Ordering::SeqCst) { 39 | tracing::info!("Terminating process!"); 40 | std::process::abort(); 41 | } else { 42 | tracing::info!("Shutting down cleanly. Interrupt again to shut down immediately."); 43 | SHUTDOWN.store(true, atomic::Ordering::SeqCst); 44 | let _ = self.ctl_tx.send(CtlMessage::Shutdown); 45 | } 46 | } 47 | } 48 | } 49 | 50 | pub fn start(ctl_tx: mpsc::UnboundedSender) -> tokio::task::JoinHandle<()> { 51 | let worker = SignalWorker { ctl_tx }; 52 | tokio::task::spawn(worker.run()) 53 | } 54 | -------------------------------------------------------------------------------- /src/event.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | cmp::{Ord, Ordering, PartialEq, PartialOrd}, 3 | hash::{Hash, Hasher}, 4 | }; 5 | 6 | use crate::{ClipboardData, ClipboardType}; 7 | 8 | #[derive(Debug, Clone, Eq)] 9 | pub struct ClipboardEvent { 10 | pub data: String, 11 | pub clipboard_type: ClipboardType, 12 | } 13 | 14 | impl ClipboardEvent { 15 | pub fn new_clipboard(data: S) -> ClipboardEvent { 16 | ClipboardEvent { 17 | data: data.to_string(), 18 | clipboard_type: ClipboardType::Clipboard, 19 | } 20 | } 21 | 22 | pub fn new_primary(data: S) -> ClipboardEvent { 23 | ClipboardEvent { 24 | data: data.to_string(), 25 | clipboard_type: ClipboardType::Primary, 26 | } 27 | } 28 | } 29 | 30 | impl From for ClipboardEvent { 31 | fn from(data: ClipboardData) -> ClipboardEvent { 32 | let ClipboardData { 33 | data, 34 | clipboard_type, 35 | .. 36 | } = data; 37 | ClipboardEvent { 38 | data, 39 | clipboard_type, 40 | } 41 | } 42 | } 43 | 44 | impl PartialEq for ClipboardEvent { 45 | fn eq(&self, other: &Self) -> bool { 46 | self.data == other.data 47 | } 48 | } 49 | 50 | impl PartialOrd for ClipboardEvent { 51 | fn partial_cmp(&self, other: &Self) -> Option { 52 | Some(self.cmp(other)) 53 | } 54 | } 55 | 56 | impl Ord for ClipboardEvent { 57 | fn cmp(&self, other: &Self) -> Ordering { 58 | self.clipboard_type.cmp(&other.clipboard_type) 59 | } 60 | } 61 | 62 | impl Hash for ClipboardEvent { 63 | fn hash(&self, state: &mut H) { 64 | self.data.hash(state); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/bin/clipcat-menu/finder/builtin.rs: -------------------------------------------------------------------------------- 1 | use std::io::Cursor; 2 | 3 | use skim::prelude::*; 4 | use snafu::ResultExt; 5 | 6 | use clipcat::ClipboardData; 7 | 8 | use crate::finder::{ 9 | error, finder_stream::ENTRY_SEPARATOR, FinderError, FinderStream, SelectionMode, 10 | }; 11 | 12 | pub struct BuiltinFinder; 13 | 14 | impl BuiltinFinder { 15 | pub fn new() -> BuiltinFinder { 16 | BuiltinFinder 17 | } 18 | 19 | pub async fn select( 20 | &self, 21 | clips: &[ClipboardData], 22 | selection_mode: SelectionMode, 23 | ) -> Result, FinderError> { 24 | let input = self.generate_input(clips); 25 | 26 | let output = tokio::task::spawn_blocking(move || { 27 | let multi = match selection_mode { 28 | SelectionMode::Single => false, 29 | SelectionMode::Multiple => true, 30 | }; 31 | let options = SkimOptionsBuilder::default() 32 | .height(Some("100%")) 33 | .multi(multi) 34 | .build() 35 | .unwrap(); 36 | 37 | let item_reader = SkimItemReader::default(); 38 | let items = item_reader.of_bufread(Cursor::new(input)); 39 | 40 | // `run_with` would read and show items from the stream 41 | let selected_items = Skim::run_with(&options, Some(items)) 42 | .map(|out| out.selected_items) 43 | .unwrap_or_else(Vec::new); 44 | 45 | selected_items 46 | .iter() 47 | .map(|item| item.text()) 48 | .collect::>() 49 | .join(ENTRY_SEPARATOR) 50 | }) 51 | .await 52 | .context(error::JoinTaskSnafu)?; 53 | 54 | Ok(self.parse_output(output.as_bytes())) 55 | } 56 | } 57 | 58 | impl FinderStream for BuiltinFinder {} 59 | -------------------------------------------------------------------------------- /src/bin/clipcatd/worker/grpc.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use snafu::ResultExt; 4 | use tokio::{ 5 | sync::{mpsc, Mutex}, 6 | task::JoinHandle, 7 | }; 8 | 9 | use clipcat::{ 10 | grpc::{self, ManagerService, MonitorService}, 11 | ClipboardManager, ClipboardMonitor, 12 | }; 13 | 14 | use crate::error::{self, Error}; 15 | 16 | pub enum Message { 17 | Shutdown, 18 | } 19 | 20 | #[allow(clippy::never_loop)] 21 | pub fn start( 22 | grpc_addr: std::net::SocketAddr, 23 | clipboard_monitor: Arc>, 24 | clipboard_manager: Arc>, 25 | ) -> ( 26 | mpsc::UnboundedSender, 27 | JoinHandle>, 28 | ) { 29 | let server = { 30 | let monitor_service = MonitorService::new(clipboard_monitor); 31 | let manager_service = ManagerService::new(clipboard_manager); 32 | 33 | tonic::transport::Server::builder() 34 | .add_service(grpc::MonitorServer::new(monitor_service)) 35 | .add_service(grpc::ManagerServer::new(manager_service)) 36 | }; 37 | 38 | let (tx, mut rx) = mpsc::unbounded_channel::(); 39 | 40 | let join_handle = tokio::spawn(async move { 41 | tracing::info!("gRPC service listening on {}", grpc_addr); 42 | 43 | server 44 | .serve_with_shutdown(grpc_addr, async move { 45 | while let Some(msg) = rx.recv().await { 46 | match msg { 47 | Message::Shutdown => { 48 | tracing::info!("gRPC service is shutting down gracefully"); 49 | return; 50 | } 51 | } 52 | } 53 | }) 54 | .await 55 | .context(error::ServeGrpcSnafu)?; 56 | 57 | Ok(()) 58 | }); 59 | (tx, join_handle) 60 | } 61 | -------------------------------------------------------------------------------- /src/grpc/protobuf.rs: -------------------------------------------------------------------------------- 1 | tonic::include_proto!("manager"); 2 | tonic::include_proto!("monitor"); 3 | 4 | impl From for crate::ClipboardType { 5 | fn from(t: ClipboardType) -> crate::ClipboardType { 6 | match t { 7 | ClipboardType::Clipboard => crate::ClipboardType::Clipboard, 8 | ClipboardType::Primary => crate::ClipboardType::Primary, 9 | } 10 | } 11 | } 12 | 13 | impl From for ClipboardType { 14 | fn from(t: crate::ClipboardType) -> ClipboardType { 15 | match t { 16 | crate::ClipboardType::Clipboard => ClipboardType::Clipboard, 17 | crate::ClipboardType::Primary => ClipboardType::Primary, 18 | } 19 | } 20 | } 21 | 22 | impl From for i32 { 23 | fn from(t: crate::ClipboardType) -> i32 { 24 | t as i32 25 | } 26 | } 27 | 28 | impl From for ClipboardData { 29 | fn from(data: crate::ClipboardData) -> ClipboardData { 30 | ClipboardData { 31 | id: data.id, 32 | data: data.data, 33 | clipboard_type: data.clipboard_type.into(), 34 | timestamp: data 35 | .timestamp 36 | .duration_since(std::time::UNIX_EPOCH) 37 | .expect("duration since") 38 | .as_millis() as u64, 39 | } 40 | } 41 | } 42 | 43 | impl From for crate::MonitorState { 44 | fn from(state: MonitorState) -> crate::MonitorState { 45 | match state { 46 | MonitorState::Enabled => crate::MonitorState::Enabled, 47 | MonitorState::Disabled => crate::MonitorState::Disabled, 48 | } 49 | } 50 | } 51 | 52 | impl From for MonitorState { 53 | fn from(m: crate::MonitorState) -> Self { 54 | match m { 55 | crate::MonitorState::Enabled => MonitorState::Enabled, 56 | crate::MonitorState::Disabled => MonitorState::Disabled, 57 | } 58 | } 59 | } 60 | 61 | impl From for i32 { 62 | fn from(state: crate::MonitorState) -> i32 { 63 | state as i32 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /completions/fish/completions/clipcatd.fish: -------------------------------------------------------------------------------- 1 | complete -c clipcatd -n "__fish_use_subcommand" -s c -l config -d 'Specifies a configuration file' 2 | complete -c clipcatd -n "__fish_use_subcommand" -l history-file -d 'Specifies a history file' 3 | complete -c clipcatd -n "__fish_use_subcommand" -l grpc-host -d 'Specifies gRPC host address' 4 | complete -c clipcatd -n "__fish_use_subcommand" -l grpc-port -d 'Specifies gRPC port number' 5 | complete -c clipcatd -n "__fish_use_subcommand" -l no-daemon -d 'Does not run as daemon' 6 | complete -c clipcatd -n "__fish_use_subcommand" -s r -l replace -d 'Tries to replace existing daemon' 7 | complete -c clipcatd -n "__fish_use_subcommand" -s h -l help -d 'Prints help information' 8 | complete -c clipcatd -n "__fish_use_subcommand" -s V -l version -d 'Prints version information' 9 | complete -c clipcatd -n "__fish_use_subcommand" -f -a "version" -d 'Prints version information' 10 | complete -c clipcatd -n "__fish_use_subcommand" -f -a "completions" -d 'Outputs shell completion code for the specified shell (bash, zsh, fish)' 11 | complete -c clipcatd -n "__fish_use_subcommand" -f -a "default-config" -d 'Outputs default configuration' 12 | complete -c clipcatd -n "__fish_use_subcommand" -f -a "help" -d 'Prints this message or the help of the given subcommand(s)' 13 | complete -c clipcatd -n "__fish_seen_subcommand_from version" -s h -l help -d 'Prints help information' 14 | complete -c clipcatd -n "__fish_seen_subcommand_from version" -s V -l version -d 'Prints version information' 15 | complete -c clipcatd -n "__fish_seen_subcommand_from completions" -s h -l help -d 'Prints help information' 16 | complete -c clipcatd -n "__fish_seen_subcommand_from completions" -s V -l version -d 'Prints version information' 17 | complete -c clipcatd -n "__fish_seen_subcommand_from default-config" -s h -l help -d 'Prints help information' 18 | complete -c clipcatd -n "__fish_seen_subcommand_from default-config" -s V -l version -d 'Prints version information' 19 | complete -c clipcatd -n "__fish_seen_subcommand_from help" -s h -l help -d 'Prints help information' 20 | complete -c clipcatd -n "__fish_seen_subcommand_from help" -s V -l version -d 'Prints version information' 21 | -------------------------------------------------------------------------------- /Makefile.toml: -------------------------------------------------------------------------------- 1 | [tasks.format] 2 | command = "rustup" 3 | args = ["run", "nightly", "cargo", "fmt"] 4 | 5 | [tasks.build] 6 | command = "cargo" 7 | args = ["build"] 8 | dependencies = ["format"] 9 | 10 | [tasks.build-all] 11 | command = "cargo" 12 | args = ["build", "--features=all-bins"] 13 | dependencies = ["format"] 14 | 15 | [tasks.build-release] 16 | command = "cargo" 17 | args = ["build", "--release", "--features=all-bins"] 18 | dependencies = ["format"] 19 | 20 | [tasks.test] 21 | command = "cargo" 22 | args = ["test"] 23 | 24 | [tasks.build-docker] 25 | command = "docker" 26 | args = ["build", "--tag=clipcat:latest", "."] 27 | dependencies = ["format"] 28 | 29 | [tasks.generate-completions] 30 | script = [ 31 | ''' 32 | rm -rf completions 33 | 34 | mkdir -p completions/{bash-completion/completions,fish/completions,zsh/site-functions} 35 | 36 | ./target/release/clipcatd completions bash > "completions/bash-completion/completions/clipcatd" 37 | ./target/release/clipcatd completions fish > "completions/fish/completions/clipcatd.fish" 38 | ./target/release/clipcatd completions zsh > "completions/zsh/site-functions/_clipcatd" 39 | 40 | ./target/release/clipcatctl completions bash > "completions/bash-completion/completions/clipcatctl" 41 | ./target/release/clipcatctl completions fish > "completions/fish/completions/clipcatctl.fish" 42 | ./target/release/clipcatctl completions zsh > "completions/zsh/site-functions/_clipcatctl" 43 | 44 | ./target/release/clipcat-menu completions bash > "completions/bash-completion/completions/clipcat-menu" 45 | ./target/release/clipcat-menu completions fish > "completions/fish/completions/clipcat-menu.fish" 46 | ./target/release/clipcat-menu completions zsh > "completions/zsh/site-functions/_clipcat-menu" 47 | 48 | ./target/release/clipcat-notify completions bash > "completions/bash-completion/completions/clipcat-notify" 49 | ./target/release/clipcat-notify completions fish > "completions/fish/completions/clipcat-notify.fish" 50 | ./target/release/clipcat-notify completions zsh > "completions/zsh/site-functions/_clipcat-notify" 51 | ''' 52 | ] 53 | dependencies = ["build-release"] 54 | 55 | [tasks.clean] 56 | command = "cargo" 57 | args = ["clean"] 58 | 59 | -------------------------------------------------------------------------------- /src/bin/clipcatd/history/mod.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | use clipcat::ClipboardData; 4 | 5 | mod error; 6 | 7 | mod fs; 8 | 9 | pub use self::error::HistoryError; 10 | 11 | pub trait HistoryDriver: Send + Sync { 12 | fn load(&self) -> Result, HistoryError>; 13 | 14 | fn save(&mut self, data: &[ClipboardData]) -> Result<(), HistoryError>; 15 | 16 | fn clear(&mut self) -> Result<(), HistoryError>; 17 | 18 | fn put(&mut self, data: &ClipboardData) -> Result<(), HistoryError>; 19 | 20 | fn shrink_to(&mut self, min_capacity: usize) -> Result<(), HistoryError>; 21 | 22 | fn save_and_shrink_to( 23 | &mut self, 24 | data: &[ClipboardData], 25 | min_capacity: usize, 26 | ) -> Result<(), HistoryError> { 27 | self.save(data)?; 28 | self.shrink_to(min_capacity) 29 | } 30 | } 31 | 32 | pub struct HistoryManager { 33 | file_path: PathBuf, 34 | driver: Box, 35 | } 36 | 37 | impl HistoryManager { 38 | #[inline] 39 | pub fn new>(file_path: P) -> Result { 40 | let mut path = file_path.as_ref().to_path_buf(); 41 | if path.is_dir() { 42 | path.set_file_name("history.cdb") 43 | } 44 | let driver = Box::new(fs::SimpleDBDriver::new(&path)); 45 | Ok(HistoryManager { 46 | driver, 47 | file_path: path, 48 | }) 49 | } 50 | 51 | #[inline] 52 | pub fn path(&self) -> &Path { 53 | &self.file_path 54 | } 55 | } 56 | impl HistoryDriver for HistoryManager { 57 | #[inline] 58 | fn put(&mut self, data: &ClipboardData) -> Result<(), HistoryError> { 59 | self.driver.put(data) 60 | } 61 | 62 | #[inline] 63 | #[allow(dead_code)] 64 | fn clear(&mut self) -> Result<(), HistoryError> { 65 | self.driver.clear() 66 | } 67 | 68 | #[inline] 69 | fn load(&self) -> Result, HistoryError> { 70 | self.driver.load() 71 | } 72 | 73 | #[inline] 74 | fn save(&mut self, data: &[ClipboardData]) -> Result<(), HistoryError> { 75 | self.driver.save(data) 76 | } 77 | 78 | #[inline] 79 | fn shrink_to(&mut self, min_capacity: usize) -> Result<(), HistoryError> { 80 | self.driver.shrink_to(min_capacity) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/bin/clipcatd/history/fs.rs: -------------------------------------------------------------------------------- 1 | use clipcat::ClipboardData; 2 | use serde::Serialize; 3 | use snafu::ResultExt; 4 | use std::{ 5 | io, 6 | path::{Path, PathBuf}, 7 | }; 8 | 9 | use crate::history::{error, HistoryDriver, HistoryError}; 10 | 11 | pub struct SimpleDBDriver { 12 | path: PathBuf, 13 | } 14 | impl SimpleDBDriver { 15 | pub fn new(path: impl AsRef) -> Self { 16 | Self { 17 | path: path.as_ref().to_path_buf(), 18 | } 19 | } 20 | 21 | fn write(&self, data: Vec) -> Result<(), HistoryError> { 22 | let mut file = std::fs::OpenOptions::new() 23 | .create(true) 24 | .write(true) 25 | .append(false) 26 | .open(&self.path) 27 | .context(error::IoSnafu)?; 28 | file.set_len(0).context(error::IoSnafu)?; 29 | bincode::serialize_into(&mut file, &FileContents { data }).context(error::SerdeSnafu)?; 30 | Ok(()) 31 | } 32 | } 33 | 34 | #[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)] 35 | struct FileContents { 36 | data: Vec, 37 | } 38 | 39 | impl HistoryDriver for SimpleDBDriver { 40 | fn load(&self) -> Result, HistoryError> { 41 | let data = match std::fs::File::open(&self.path) { 42 | Ok(mut file) => bincode::deserialize_from(&mut file).context(error::SerdeSnafu)?, 43 | Err(err) => match err.kind() { 44 | io::ErrorKind::NotFound => Vec::new(), 45 | _ => return Err(err).context(error::IoSnafu), 46 | }, 47 | }; 48 | Ok(data) 49 | } 50 | 51 | fn save(&mut self, data: &[ClipboardData]) -> Result<(), HistoryError> { 52 | self.write(data.to_vec()) 53 | } 54 | 55 | fn clear(&mut self) -> Result<(), HistoryError> { 56 | self.write(Vec::new()) 57 | } 58 | 59 | fn put(&mut self, data: &ClipboardData) -> Result<(), HistoryError> { 60 | let mut saved = self.load()?; 61 | saved.push(data.clone()); 62 | self.write(saved) 63 | } 64 | 65 | fn shrink_to(&mut self, min_capacity: usize) -> Result<(), HistoryError> { 66 | let mut saved = self.load()?; 67 | 68 | let to_shrink = saved.len().saturating_sub(min_capacity); 69 | for _ in 0..to_shrink { 70 | saved.remove(0); 71 | } 72 | 73 | self.write(saved) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /proto/manager.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package manager; 4 | 5 | service Manager { 6 | rpc List(ListRequest) returns (ListResponse); 7 | 8 | rpc Get(GetRequest) returns (GetResponse); 9 | rpc GetCurrentClipboard(GetCurrentClipboardRequest) 10 | returns (GetCurrentClipboardResponse); 11 | rpc GetCurrentPrimary(GetCurrentPrimaryRequest) 12 | returns (GetCurrentPrimaryResponse); 13 | 14 | rpc Remove(RemoveRequest) returns (RemoveResponse); 15 | rpc BatchRemove(BatchRemoveRequest) returns (BatchRemoveResponse); 16 | rpc Clear(ClearRequest) returns (ClearResponse); 17 | 18 | rpc Insert(InsertRequest) returns (InsertResponse); 19 | rpc Update(UpdateRequest) returns (UpdateResponse); 20 | 21 | rpc MarkAsClipboard(MarkAsClipboardRequest) returns (MarkAsClipboardResponse); 22 | rpc MarkAsPrimary(MarkAsPrimaryRequest) returns (MarkAsPrimaryResponse); 23 | 24 | rpc Length(LengthRequest) returns (LengthResponse); 25 | } 26 | 27 | enum ClipboardType { 28 | Clipboard = 0; 29 | Primary = 1; 30 | } 31 | 32 | message ClipboardData { 33 | uint64 id = 1; 34 | string data = 2; 35 | ClipboardType clipboard_type = 3; 36 | uint64 timestamp = 4; 37 | } 38 | 39 | message InsertRequest { 40 | ClipboardType clipboard_type = 1; 41 | string data = 2; 42 | } 43 | message InsertResponse { uint64 id = 1; } 44 | 45 | message GetRequest { uint64 id = 1; } 46 | message GetResponse { ClipboardData data = 1; } 47 | 48 | message GetCurrentClipboardRequest {} 49 | message GetCurrentClipboardResponse { ClipboardData data = 1; } 50 | 51 | message GetCurrentPrimaryRequest {} 52 | message GetCurrentPrimaryResponse { ClipboardData data = 1; } 53 | 54 | message ListRequest {} 55 | message ListResponse { repeated ClipboardData data = 1; } 56 | 57 | message UpdateRequest { 58 | uint64 id = 1; 59 | string data = 2; 60 | } 61 | message UpdateResponse { 62 | bool ok = 1; 63 | uint64 new_id = 2; 64 | } 65 | 66 | message MarkAsClipboardRequest { uint64 id = 1; } 67 | message MarkAsClipboardResponse { bool ok = 1; } 68 | 69 | message MarkAsPrimaryRequest { uint64 id = 1; } 70 | message MarkAsPrimaryResponse { bool ok = 1; } 71 | 72 | message LengthRequest {} 73 | message LengthResponse { uint64 length = 1; } 74 | 75 | message RemoveRequest { uint64 id = 1; } 76 | message RemoveResponse { bool ok = 1; } 77 | 78 | message BatchRemoveRequest { repeated uint64 ids = 1; } 79 | message BatchRemoveResponse { repeated uint64 ids = 1; } 80 | 81 | message ClearRequest {} 82 | message ClearResponse {} 83 | -------------------------------------------------------------------------------- /src/bin/clipcatd/error.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use snafu::Snafu; 4 | 5 | use crate::{config::ConfigError, history::HistoryError}; 6 | 7 | #[derive(Debug, Snafu)] 8 | #[snafu(visibility(pub))] 9 | pub enum Error { 10 | #[snafu(display("Could not initialize tokio runtime, error: {}", source))] 11 | InitializeTokioRuntime { source: std::io::Error }, 12 | 13 | #[snafu(display("Could not load config error: {}", source))] 14 | LoadConfig { source: ConfigError }, 15 | 16 | #[snafu(display("Could not create HistoryManager, error: {}", source))] 17 | CreateHistoryManager { source: HistoryError }, 18 | 19 | #[snafu(display("Could not load HistoryManager, error: {}", source))] 20 | LoadHistoryManager { source: HistoryError }, 21 | 22 | #[snafu(display("Could not clear HistoryManager, error: {}", source))] 23 | ClearHistoryManager { source: HistoryError }, 24 | 25 | #[snafu(display("Could not create ClipboardMonitor, error: {}", source))] 26 | CreateClipboardMonitor { source: clipcat::ClipboardError }, 27 | #[snafu(display("Could not create ClipboardManager, error: {}", source))] 28 | CreateClipboardManager { source: clipcat::ClipboardError }, 29 | 30 | #[snafu(display("Failed to parse socket address, error: {}", source))] 31 | ParseSockAddr { source: std::net::AddrParseError }, 32 | 33 | #[snafu(display("Failed to daemonize, error: {}", source))] 34 | Daemonize { source: daemonize::DaemonizeError }, 35 | 36 | #[snafu(display("Could not read PID file, filename: {}, error: {}", filename.display(), source))] 37 | ReadPidFile { 38 | filename: PathBuf, 39 | source: std::io::Error, 40 | }, 41 | 42 | #[snafu(display("Could not remove PID file, filename: {}, error: {}", pid_file.display(), source))] 43 | RemovePidFile { 44 | pid_file: PathBuf, 45 | source: std::io::Error, 46 | }, 47 | 48 | #[snafu(display("Could not set PID file, filename: {}, error: {}", pid_file.display(), source))] 49 | SetPidFile { 50 | pid_file: PathBuf, 51 | source: std::io::Error, 52 | }, 53 | 54 | #[snafu(display("Parse process id, value: {}, error: {}", value, source))] 55 | ParseProcessId { 56 | value: String, 57 | source: std::num::ParseIntError, 58 | }, 59 | 60 | #[snafu(display("Failed to send SIGTERM to PID {}", pid))] 61 | SendSignalTerminal { pid: u64 }, 62 | 63 | #[snafu(display("Failed to serve gRPC, error: {}", source))] 64 | ServeGrpc { source: tonic::transport::Error }, 65 | } 66 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "clipcat" 3 | version = "0.6.0" 4 | authors = ["Icelk ", "xrelkd 46590321+xrelkd@users.noreply.github.com"] 5 | edition = "2018" 6 | 7 | [lib] 8 | name = "clipcat" 9 | path = "src/lib.rs" 10 | 11 | [[bin]] 12 | name = "clipcatd" 13 | path = "src/bin/clipcatd/main.rs" 14 | required-features = ["clipcatd"] 15 | 16 | [[bin]] 17 | name = "clipcatctl" 18 | path = "src/bin/clipcatctl/main.rs" 19 | required-features = ["clipcatctl"] 20 | 21 | [[bin]] 22 | name = "clipcat-menu" 23 | path = "src/bin/clipcat-menu/main.rs" 24 | required-features = ["clipcat-menu"] 25 | 26 | [[bin]] 27 | name = "clipcat-notify" 28 | path = "src/bin/clipcat-notify/main.rs" 29 | required-features = ["clipcat-notify"] 30 | 31 | [dependencies] 32 | futures = "0.3" 33 | tokio = { version = "1", features = ["rt-multi-thread", "sync"] } 34 | 35 | snafu = "0.7" 36 | 37 | tonic = "0.8" 38 | prost = "0.11" 39 | http = "0.2" 40 | 41 | tracing = "0.1" 42 | tracing-subscriber = { version = "0.3", optional = true } 43 | tracing-journald = { version = "0.3", optional = true } 44 | tracing-futures = { version = "0.2", optional = true } 45 | 46 | structopt = { version = "0.3", optional = true } 47 | directories = { version = "4", optional = true } 48 | toml = { version = "0.5", optional = true } 49 | 50 | serde = { version = "1", features = ["derive"] } 51 | serde_json = { version = "1", optional = true } 52 | 53 | x11-clipboard = { git = "https://github.com/wrvsrx/x11-clipboard", rev = "d7eb4d7de3b6560ae461ad55562517fed3ca0f04", optional = true } 54 | wl-clipboard-rs = { version = "0.7", optional = true } 55 | 56 | bincode = { version = "1", optional = true } 57 | 58 | libc = { version = "0.2", optional = true } 59 | daemonize = { version = "0.4", optional = true } 60 | 61 | skim = { version = "0.10", optional = true } 62 | 63 | [features] 64 | all-bins = ["clipcatd", "clipcatctl", "clipcat-menu", "clipcat-notify"] 65 | all-backends = ["x11", "wayland"] 66 | full = ["all-bins", "all-backends"] 67 | default = ["clipcatd", "clipcatctl", "clipcat-menu", "all-backends"] 68 | 69 | app = ["directories", "structopt", "toml", "tracing-subscriber", "tracing-futures"] 70 | daemon = [ 71 | "daemonize", "libc", 72 | "tracing-subscriber", "tracing-journald", 73 | "tokio/signal", "serde_json", 74 | "bincode" 75 | ] 76 | external_editor = ["tokio/process"] 77 | builtin_finder = ["skim"] 78 | 79 | x11 = ["x11-clipboard"] 80 | wayland = ["wl-clipboard-rs"] 81 | 82 | clipcatd = ["app", "daemon"] 83 | clipcatctl = ["app", "tokio/process", "tokio/io-std", "tokio/fs", "external_editor"] 84 | clipcat-menu = ["app", "tokio/process", "external_editor", "builtin_finder"] 85 | clipcat-notify = ["structopt"] 86 | 87 | [build-dependencies] 88 | tonic-build = { version = "0.8", features = ["prost"] } 89 | 90 | [profile.release] 91 | opt-level = 3 92 | lto = true 93 | -------------------------------------------------------------------------------- /completions/zsh/site-functions/_clipcat-notify: -------------------------------------------------------------------------------- 1 | #compdef clipcat-notify 2 | 3 | autoload -U is-at-least 4 | 5 | _clipcat-notify() { 6 | typeset -A opt_args 7 | typeset -a _arguments_options 8 | local ret=1 9 | 10 | if is-at-least 5.2; then 11 | _arguments_options=(-s -S -C) 12 | else 13 | _arguments_options=(-s -C) 14 | fi 15 | 16 | local context curcontext="$curcontext" state line 17 | _arguments "${_arguments_options[@]}" \ 18 | '--no-clipboard[Does not monitor clipboard]' \ 19 | '--no-primary[Does not monitor primary]' \ 20 | '-h[Prints help information]' \ 21 | '--help[Prints help information]' \ 22 | '-V[Prints version information]' \ 23 | '--version[Prints version information]' \ 24 | ":: :_clipcat-notify_commands" \ 25 | "*::: :->clipcat-notify" \ 26 | && ret=0 27 | case $state in 28 | (clipcat-notify) 29 | words=($line[1] "${words[@]}") 30 | (( CURRENT += 1 )) 31 | curcontext="${curcontext%:*:*}:clipcat-notify-command-$line[1]:" 32 | case $line[1] in 33 | (version) 34 | _arguments "${_arguments_options[@]}" \ 35 | '-h[Prints help information]' \ 36 | '--help[Prints help information]' \ 37 | '-V[Prints version information]' \ 38 | '--version[Prints version information]' \ 39 | && ret=0 40 | ;; 41 | (completions) 42 | _arguments "${_arguments_options[@]}" \ 43 | '-h[Prints help information]' \ 44 | '--help[Prints help information]' \ 45 | '-V[Prints version information]' \ 46 | '--version[Prints version information]' \ 47 | ':shell:_files' \ 48 | && ret=0 49 | ;; 50 | (help) 51 | _arguments "${_arguments_options[@]}" \ 52 | '-h[Prints help information]' \ 53 | '--help[Prints help information]' \ 54 | '-V[Prints version information]' \ 55 | '--version[Prints version information]' \ 56 | && ret=0 57 | ;; 58 | esac 59 | ;; 60 | esac 61 | } 62 | 63 | (( $+functions[_clipcat-notify_commands] )) || 64 | _clipcat-notify_commands() { 65 | local commands; commands=( 66 | "version:Prints version information" \ 67 | "completions:Outputs shell completion code for the specified shell (bash, zsh, fish)" \ 68 | "help:Prints this message or the help of the given subcommand(s)" \ 69 | ) 70 | _describe -t commands 'clipcat-notify commands' commands "$@" 71 | } 72 | (( $+functions[_clipcat-notify__completions_commands] )) || 73 | _clipcat-notify__completions_commands() { 74 | local commands; commands=( 75 | 76 | ) 77 | _describe -t commands 'clipcat-notify completions commands' commands "$@" 78 | } 79 | (( $+functions[_clipcat-notify__help_commands] )) || 80 | _clipcat-notify__help_commands() { 81 | local commands; commands=( 82 | 83 | ) 84 | _describe -t commands 'clipcat-notify help commands' commands "$@" 85 | } 86 | (( $+functions[_clipcat-notify__version_commands] )) || 87 | _clipcat-notify__version_commands() { 88 | local commands; commands=( 89 | 90 | ) 91 | _describe -t commands 'clipcat-notify version commands' commands "$@" 92 | } 93 | 94 | _clipcat-notify "$@" -------------------------------------------------------------------------------- /completions/bash-completion/completions/clipcat-notify: -------------------------------------------------------------------------------- 1 | _clipcat-notify() { 2 | local i cur prev opts cmds 3 | COMPREPLY=() 4 | cur="${COMP_WORDS[COMP_CWORD]}" 5 | prev="${COMP_WORDS[COMP_CWORD-1]}" 6 | cmd="" 7 | opts="" 8 | 9 | for i in ${COMP_WORDS[@]} 10 | do 11 | case "${i}" in 12 | clipcat-notify) 13 | cmd="clipcat-notify" 14 | ;; 15 | 16 | completions) 17 | cmd+="__completions" 18 | ;; 19 | help) 20 | cmd+="__help" 21 | ;; 22 | version) 23 | cmd+="__version" 24 | ;; 25 | *) 26 | ;; 27 | esac 28 | done 29 | 30 | case "${cmd}" in 31 | clipcat-notify) 32 | opts=" -h -V --no-clipboard --no-primary --help --version version completions help" 33 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then 34 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 35 | return 0 36 | fi 37 | case "${prev}" in 38 | 39 | *) 40 | COMPREPLY=() 41 | ;; 42 | esac 43 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 44 | return 0 45 | ;; 46 | 47 | clipcat__notify__completions) 48 | opts=" -h -V --help --version " 49 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then 50 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 51 | return 0 52 | fi 53 | case "${prev}" in 54 | 55 | *) 56 | COMPREPLY=() 57 | ;; 58 | esac 59 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 60 | return 0 61 | ;; 62 | clipcat__notify__help) 63 | opts=" -h -V --help --version " 64 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then 65 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 66 | return 0 67 | fi 68 | case "${prev}" in 69 | 70 | *) 71 | COMPREPLY=() 72 | ;; 73 | esac 74 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 75 | return 0 76 | ;; 77 | clipcat__notify__version) 78 | opts=" -h -V --help --version " 79 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then 80 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 81 | return 0 82 | fi 83 | case "${prev}" in 84 | 85 | *) 86 | COMPREPLY=() 87 | ;; 88 | esac 89 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 90 | return 0 91 | ;; 92 | esac 93 | } 94 | 95 | complete -F _clipcat-notify -o bashdefault -o default clipcat-notify 96 | -------------------------------------------------------------------------------- /src/bin/clipcat-menu/config.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | net::IpAddr, 3 | path::{Path, PathBuf}, 4 | }; 5 | 6 | use crate::finder::FinderType; 7 | 8 | #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] 9 | pub struct Config { 10 | pub server_host: IpAddr, 11 | pub server_port: u16, 12 | pub finder: FinderType, 13 | pub rofi: Option, 14 | pub dmenu: Option, 15 | pub custom_finder: Option, 16 | } 17 | 18 | #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] 19 | pub struct Rofi { 20 | pub line_length: usize, 21 | pub menu_length: usize, 22 | } 23 | 24 | #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] 25 | pub struct Dmenu { 26 | pub line_length: usize, 27 | pub menu_length: usize, 28 | } 29 | 30 | #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] 31 | pub struct CustomFinder { 32 | pub program: String, 33 | pub args: Vec, 34 | } 35 | 36 | impl Default for Config { 37 | fn default() -> Config { 38 | Config { 39 | server_host: clipcat::DEFAULT_GRPC_HOST 40 | .parse() 41 | .expect("Parse default gRPC host"), 42 | server_port: clipcat::DEFAULT_GRPC_PORT, 43 | finder: FinderType::Rofi, 44 | rofi: Some(Rofi::default()), 45 | dmenu: Some(Dmenu::default()), 46 | custom_finder: Some(CustomFinder::default()), 47 | } 48 | } 49 | } 50 | 51 | impl Default for Rofi { 52 | fn default() -> Rofi { 53 | Rofi { 54 | line_length: 100, 55 | menu_length: 30, 56 | } 57 | } 58 | } 59 | 60 | impl Default for Dmenu { 61 | fn default() -> Dmenu { 62 | Dmenu { 63 | line_length: 100, 64 | menu_length: 30, 65 | } 66 | } 67 | } 68 | 69 | impl Default for CustomFinder { 70 | fn default() -> CustomFinder { 71 | CustomFinder { 72 | program: "fzf".to_string(), 73 | args: vec![], 74 | } 75 | } 76 | } 77 | 78 | impl Config { 79 | #[inline] 80 | pub fn default_path() -> PathBuf { 81 | directories::BaseDirs::new() 82 | .expect("app_dirs") 83 | .config_dir() 84 | .join(clipcat::PROJECT_NAME) 85 | .join(clipcat::MENU_CONFIG_NAME) 86 | } 87 | 88 | #[inline] 89 | pub fn load>(path: P) -> Result { 90 | let file = std::fs::read(path)?; 91 | let config = toml::from_slice(&file)?; 92 | Ok(config) 93 | } 94 | 95 | #[inline] 96 | pub fn load_or_default>(path: P) -> Config { 97 | match Self::load(&path) { 98 | Ok(config) => config, 99 | Err(err) => { 100 | tracing::warn!( 101 | "Failed to read config file ({:?}), error: {:?}", 102 | &path.as_ref(), 103 | err 104 | ); 105 | Config::default() 106 | } 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/bin/clipcatd/worker/mod.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use snafu::ResultExt; 4 | use tokio::sync::{mpsc, Mutex}; 5 | 6 | use clipcat::{ClipboardManager, ClipboardMonitor}; 7 | 8 | use crate::{ 9 | config::Config, 10 | error::{self, Error}, 11 | history::{HistoryDriver, HistoryManager}, 12 | }; 13 | 14 | mod clipboard; 15 | mod grpc; 16 | mod signal; 17 | 18 | pub enum CtlMessage { 19 | Shutdown, 20 | } 21 | 22 | pub type CtlMessageSender = mpsc::UnboundedSender; 23 | 24 | #[allow(clippy::never_loop)] 25 | pub async fn start(config: Config) -> Result<(), Error> { 26 | let grpc_addr = format!("{}:{}", config.grpc.host, config.grpc.port) 27 | .parse() 28 | .context(error::ParseSockAddrSnafu)?; 29 | 30 | let (clipboard_manager, history_manager) = { 31 | let file_path = config.history_file_path; 32 | 33 | tracing::info!("History file path: {:?}", file_path); 34 | let history_manager = 35 | HistoryManager::new(&file_path).context(error::CreateHistoryManagerSnafu)?; 36 | 37 | tracing::info!("Load history from {:?}", history_manager.path()); 38 | let history_clips = history_manager 39 | .load() 40 | .context(error::LoadHistoryManagerSnafu)?; 41 | let clip_count = history_clips.len(); 42 | tracing::info!("{} clip(s) loaded", clip_count); 43 | 44 | tracing::info!( 45 | "Initialize ClipboardManager with capacity {}", 46 | config.max_history 47 | ); 48 | let mut clipboard_manager = ClipboardManager::with_capacity(config.max_history) 49 | .context(error::CreateClipboardManagerSnafu)?; 50 | 51 | tracing::info!("Import {} clip(s) into ClipboardManager", clip_count); 52 | clipboard_manager.import(&history_clips); 53 | 54 | ( 55 | Arc::new(Mutex::new(clipboard_manager)), 56 | Arc::new(Mutex::new(history_manager)), 57 | ) 58 | }; 59 | 60 | let (ctl_tx, mut ctl_rx) = mpsc::unbounded_channel::(); 61 | 62 | let _signal_join = signal::start(ctl_tx.clone()); 63 | 64 | let monitor_opts = config.monitor.into(); 65 | let clipboard_monitor = { 66 | let monitor = 67 | ClipboardMonitor::new(monitor_opts).context(error::CreateClipboardMonitorSnafu)?; 68 | Arc::new(Mutex::new(monitor)) 69 | }; 70 | 71 | let (clip_tx, clipboard_join) = clipboard::start( 72 | ctl_tx.clone(), 73 | clipboard_monitor.clone(), 74 | clipboard_manager.clone(), 75 | history_manager, 76 | ); 77 | let (grpc_tx, grpc_join) = grpc::start(grpc_addr, clipboard_monitor, clipboard_manager); 78 | 79 | while let Some(msg) = ctl_rx.recv().await { 80 | match msg { 81 | CtlMessage::Shutdown => { 82 | let _ = clip_tx.send(clipboard::Message::Shutdown); 83 | let _ = grpc_tx.send(grpc::Message::Shutdown); 84 | break; 85 | } 86 | } 87 | } 88 | 89 | let _ = grpc_join.await; 90 | tracing::info!("gRPC service is down"); 91 | 92 | let _ = clipboard_join.await; 93 | tracing::info!("ClipboardWorker is down"); 94 | 95 | Ok(()) 96 | } 97 | -------------------------------------------------------------------------------- /src/editor/external.rs: -------------------------------------------------------------------------------- 1 | use std::time::SystemTime; 2 | 3 | use snafu::ResultExt; 4 | use tokio::process::Command; 5 | 6 | use crate::editor::{error, error::EditorError}; 7 | 8 | pub struct ExternalEditor { 9 | editor: String, 10 | } 11 | 12 | impl ExternalEditor { 13 | pub fn new(editor: S) -> ExternalEditor { 14 | ExternalEditor { 15 | editor: editor.to_string(), 16 | } 17 | } 18 | 19 | pub fn new_or_from_env(editor: Option) -> Result { 20 | if let Some(editor) = editor { 21 | return Ok(ExternalEditor { 22 | editor: editor.to_string(), 23 | }); 24 | } 25 | 26 | Self::from_env() 27 | } 28 | 29 | pub fn from_env() -> Result { 30 | let editor = std::env::var("EDITOR").context(error::GetEnvEditorSnafu)?; 31 | Ok(ExternalEditor { editor }) 32 | } 33 | 34 | pub async fn execute(&self, data: &str) -> Result { 35 | let tmp_file = { 36 | let timestamp = SystemTime::now() 37 | .duration_since(SystemTime::UNIX_EPOCH) 38 | .expect("system time"); 39 | let filename = format!(".{}-{:?}", crate::PROJECT_NAME, timestamp); 40 | let mut path = std::env::temp_dir(); 41 | path.push(filename); 42 | path 43 | }; 44 | 45 | tokio::fs::write(&tmp_file, data) 46 | .await 47 | .context(error::CreateTemporaryFileSnafu { 48 | filename: tmp_file.to_owned(), 49 | })?; 50 | 51 | Command::new(&self.editor) 52 | .arg(&tmp_file) 53 | .spawn() 54 | .context(error::CallExternalTextEditorSnafu { 55 | program: self.editor.to_owned(), 56 | })? 57 | .wait() 58 | .await 59 | .context(error::ExecuteExternalTextEditorSnafu { 60 | program: self.editor.to_owned(), 61 | })?; 62 | 63 | let data = 64 | tokio::fs::read_to_string(&tmp_file) 65 | .await 66 | .context(error::ReadTemporaryFileSnafu { 67 | filename: tmp_file.to_owned(), 68 | })?; 69 | tokio::fs::remove_file(&tmp_file.to_owned()).await.context( 70 | error::RemoveTemporaryFileSnafu { 71 | filename: tmp_file.to_owned(), 72 | }, 73 | )?; 74 | 75 | Ok(data) 76 | } 77 | } 78 | 79 | #[cfg(test)] 80 | mod tests { 81 | use crate::editor::ExternalEditor; 82 | 83 | #[test] 84 | fn test_from_env() { 85 | let external_editor = "my-editor"; 86 | 87 | std::env::set_var("EDITOR", external_editor); 88 | let editor = ExternalEditor::from_env().unwrap(); 89 | assert_eq!(&editor.editor, external_editor); 90 | 91 | std::env::remove_var("EDITOR"); 92 | let editor = ExternalEditor::from_env(); 93 | assert!(editor.is_err()); 94 | } 95 | 96 | #[test] 97 | fn test_execute() { 98 | std::env::set_var("TMPDIR", "/tmp"); 99 | 100 | let runtime = tokio::runtime::Runtime::new().unwrap(); 101 | let editor = ExternalEditor::new("echo"); 102 | 103 | let data = "this is a string.\nЭто вох"; 104 | let ret = runtime.block_on(async { editor.execute(data).await.unwrap() }); 105 | assert_eq!(&ret, data); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/bin/clipcat-notify/main.rs: -------------------------------------------------------------------------------- 1 | use snafu::{ResultExt, Snafu}; 2 | use structopt::StructOpt; 3 | use tokio::runtime::Runtime; 4 | 5 | use clipcat::{ClipboardMonitor, ClipboardMonitorOptions, ClipboardType}; 6 | 7 | #[derive(Debug, Snafu)] 8 | enum Error { 9 | #[snafu(display("Could not initialize tokio Runtime, error: {}", source))] 10 | InitializeTokioRuntime { source: std::io::Error }, 11 | 12 | #[snafu(display("Could not create ClipboardMonitor, error: {}", source))] 13 | InitializeClipboardMonitor { source: clipcat::ClipboardError }, 14 | 15 | #[snafu(display("Could not wait for clipboard event"))] 16 | WaitForClipboardEvent, 17 | 18 | #[snafu(display("Nothing to be monitored"))] 19 | MonitorNothing, 20 | } 21 | 22 | #[derive(Debug, StructOpt)] 23 | #[structopt(name = clipcat::NOTIFY_PROGRAM_NAME)] 24 | struct Command { 25 | #[structopt(subcommand)] 26 | subcommand: Option, 27 | 28 | #[structopt(long = "no-clipboard", help = "Does not monitor clipboard")] 29 | no_clipboard: bool, 30 | 31 | #[structopt(long = "no-primary", help = "Does not monitor primary")] 32 | no_primary: bool, 33 | } 34 | 35 | #[derive(Debug, StructOpt)] 36 | enum SubCommand { 37 | #[structopt(about = "Prints version information")] 38 | Version, 39 | 40 | #[structopt(about = "Outputs shell completion code for the specified shell (bash, zsh, fish)")] 41 | Completions { shell: structopt::clap::Shell }, 42 | } 43 | 44 | impl Command { 45 | fn run(self) -> Result<(), Error> { 46 | match self.subcommand { 47 | Some(SubCommand::Version) => { 48 | Self::clap() 49 | .write_long_version(&mut std::io::stdout()) 50 | .expect("Failed to write to stdout"); 51 | return Ok(()); 52 | } 53 | Some(SubCommand::Completions { shell }) => { 54 | Self::clap().gen_completions_to( 55 | clipcat::NOTIFY_PROGRAM_NAME, 56 | shell, 57 | &mut std::io::stdout(), 58 | ); 59 | return Ok(()); 60 | } 61 | None => {} 62 | } 63 | 64 | let enable_clipboard = !self.no_clipboard; 65 | let enable_primary = !self.no_primary; 66 | 67 | if !enable_clipboard && !enable_primary { 68 | return Err(Error::MonitorNothing); 69 | } 70 | 71 | let monitor_opts = ClipboardMonitorOptions { 72 | load_current: false, 73 | enable_clipboard, 74 | enable_primary, 75 | filter_min_size: 1, 76 | }; 77 | let monitor = 78 | ClipboardMonitor::new(monitor_opts).context(InitializeClipboardMonitorSnafu)?; 79 | let runtime = Runtime::new().context(InitializeTokioRuntimeSnafu)?; 80 | runtime.block_on(async { 81 | let mut event_recv = monitor.subscribe(); 82 | while let Ok(event) = event_recv.recv().await { 83 | match event.clipboard_type { 84 | ClipboardType::Clipboard if enable_clipboard => return Ok(()), 85 | ClipboardType::Primary if enable_primary => return Ok(()), 86 | _ => continue, 87 | } 88 | } 89 | 90 | Err(Error::WaitForClipboardEvent) 91 | })?; 92 | 93 | Ok(()) 94 | } 95 | } 96 | 97 | fn main() { 98 | let cmd = Command::from_args(); 99 | if let Err(err) = cmd.run() { 100 | eprintln!("Error: {err}"); 101 | std::process::exit(1); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/bin/clipcat-menu/finder/finder_stream.rs: -------------------------------------------------------------------------------- 1 | use clipcat::ClipboardData; 2 | 3 | pub const ENTRY_SEPARATOR: &str = "\n"; 4 | pub const INDEX_SEPARATOR: char = ':'; 5 | 6 | pub trait FinderStream: Send + Sync { 7 | fn generate_input(&self, clips: &[ClipboardData]) -> String { 8 | clips 9 | .iter() 10 | .enumerate() 11 | .map(|(i, data)| { 12 | format!( 13 | "{}{} {}", 14 | i, 15 | INDEX_SEPARATOR, 16 | data.printable_data(self.line_length()) 17 | ) 18 | }) 19 | .collect::>() 20 | .join(ENTRY_SEPARATOR) 21 | } 22 | 23 | fn parse_output(&self, data: &[u8]) -> Vec { 24 | let line = String::from_utf8_lossy(data); 25 | line.split(ENTRY_SEPARATOR) 26 | .filter_map(|entry| { 27 | entry 28 | .split(INDEX_SEPARATOR) 29 | .next() 30 | .expect("first part must exist") 31 | .parse::() 32 | .ok() 33 | }) 34 | .collect() 35 | } 36 | 37 | fn set_line_length(&mut self, _line_length: usize) {} 38 | 39 | fn set_menu_length(&mut self, _menu_length: usize) {} 40 | 41 | fn menu_length(&self) -> Option { 42 | None 43 | } 44 | 45 | fn line_length(&self) -> Option { 46 | None 47 | } 48 | } 49 | 50 | #[cfg(test)] 51 | mod tests { 52 | use clipcat::ClipboardData; 53 | 54 | use crate::finder::FinderStream; 55 | 56 | struct Dummy; 57 | impl FinderStream for Dummy {} 58 | 59 | #[test] 60 | fn test_generate_input() { 61 | let d = Dummy; 62 | let clips = vec![]; 63 | let v = d.generate_input(&clips); 64 | assert_eq!(v, ""); 65 | 66 | let clips = vec![ClipboardData::new_clipboard("abcde")]; 67 | let v = d.generate_input(&clips); 68 | assert_eq!(v, "0: abcde"); 69 | 70 | let clips = vec![ 71 | ClipboardData::new_clipboard("abcde"), 72 | ClipboardData::new_clipboard("АбВГД"), 73 | ClipboardData::new_clipboard("あいうえお"), 74 | ]; 75 | 76 | let v = d.generate_input(&clips); 77 | assert_eq!(v, "0: abcde\n1: АбВГД\n2: あいうえお"); 78 | } 79 | 80 | #[test] 81 | fn test_parse_output() { 82 | let d = Dummy; 83 | let output = ""; 84 | let v = d.parse_output(output.as_bytes()); 85 | assert!(v.is_empty()); 86 | 87 | let output = ":"; 88 | let v = d.parse_output(output.as_bytes()); 89 | assert!(v.is_empty()); 90 | 91 | let output = "::::::::"; 92 | let v = d.parse_output(output.as_bytes()); 93 | assert!(v.is_empty()); 94 | 95 | let output = "\n\n\n\n\n"; 96 | let v = d.parse_output(output.as_bytes()); 97 | assert!(v.is_empty()); 98 | 99 | let output = "9\n3\n0\n4\n1\n"; 100 | let v = d.parse_output(output.as_bytes()); 101 | assert_eq!(v, &[9, 3, 0, 4, 1]); 102 | 103 | let output = "203: abcde|АбВГД3|200あいうえお385"; 104 | let v = d.parse_output(output.as_bytes()); 105 | assert_eq!(v, &[203]); 106 | 107 | let output = "2:3:4:5"; 108 | let v = d.parse_output(output.as_bytes()); 109 | assert_eq!(v, &[2]); 110 | 111 | let output = "10: abcde\n2: АбВГД3020\n9:333\n7:30あいうえお38405\n1:323"; 112 | let v = d.parse_output(output.as_bytes()); 113 | assert_eq!(v, &[10, 2, 9, 7, 1]); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | CARGO_TERM_COLOR: always 7 | RUSTFLAGS: "-D warnings" 8 | 9 | jobs: 10 | all: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - name: Install dependencies 16 | run: | 17 | sudo apt-get update && \ 18 | sudo apt-get install -y \ 19 | libx11-xcb-dev \ 20 | libxcb-xfixes0-dev \ 21 | libxcb-render0-dev \ 22 | libxcb-shape0-dev \ 23 | protobuf-compiler 24 | 25 | - name: Install toolchain 26 | run: rustup install nightly --profile default 27 | - run: rustup default nightly 28 | 29 | - uses: Swatinem/rust-cache@v2 30 | 31 | - name: Format 32 | run: cargo fmt --all --check 33 | 34 | - name: Clippy X11 35 | run: cargo clippy --all --all-targets --features all-bins,x11 --no-default-features 36 | - name: Clippy wayland 37 | run: cargo clippy --all --all-targets --features all-bins,wayland --no-default-features 38 | - name: Clippy all backends 39 | run: cargo clippy --all --all-targets --features all-bins,all-backends 40 | 41 | - name: Test X11 42 | run: cargo test --all --features all-bins,x11 --no-default-features 43 | - name: Test wayland 44 | run: cargo test --all --features all-bins,wayland --no-default-features 45 | - name: Test all backends 46 | run: cargo test --all --features all-bins,all-backends 47 | 48 | - name: Build X11 49 | run: cargo build --features all-bins,x11 --release --no-default-features 50 | - name: Upload X11 daemon 51 | uses: actions/upload-artifact@v3 52 | with: 53 | name: clipcatd-x11 54 | path: target/release/clipcatd 55 | - name: Upload X11 clipcat-notify 56 | uses: actions/upload-artifact@v3 57 | with: 58 | name: clipcat-notify-x11 59 | path: target/release/clipcat-notify 60 | 61 | - name: Build wayland 62 | run: cargo build --features all-bins,wayland --release --no-default-features 63 | - name: Upload wayland daemon 64 | uses: actions/upload-artifact@v3 65 | with: 66 | name: clipcatd-wayland 67 | path: target/release/clipcatd 68 | - name: Upload wayland clipcat-notify 69 | uses: actions/upload-artifact@v3 70 | with: 71 | name: clipcat-notify-wayland 72 | path: target/release/clipcat-notify 73 | 74 | - name: Build with all backends 75 | run: cargo build --features all-bins,all-backends --release 76 | - name: Upload daemon with all backends 77 | uses: actions/upload-artifact@v3 78 | with: 79 | name: clipcatd 80 | path: target/release/clipcatd 81 | - name: Upload clipcat-notify with all backends 82 | uses: actions/upload-artifact@v3 83 | with: 84 | name: clipcat-notify 85 | path: target/release/clipcat-notify 86 | 87 | - uses: actions/upload-artifact@v3 88 | with: 89 | name: clipcatctl 90 | path: target/release/clipcatctl 91 | - uses: actions/upload-artifact@v3 92 | with: 93 | name: clipcat-menu 94 | path: target/release/clipcat-menu 95 | -------------------------------------------------------------------------------- /completions/zsh/site-functions/_clipcatd: -------------------------------------------------------------------------------- 1 | #compdef clipcatd 2 | 3 | autoload -U is-at-least 4 | 5 | _clipcatd() { 6 | typeset -A opt_args 7 | typeset -a _arguments_options 8 | local ret=1 9 | 10 | if is-at-least 5.2; then 11 | _arguments_options=(-s -S -C) 12 | else 13 | _arguments_options=(-s -C) 14 | fi 15 | 16 | local context curcontext="$curcontext" state line 17 | _arguments "${_arguments_options[@]}" \ 18 | '-c+[Specifies a configuration file]' \ 19 | '--config=[Specifies a configuration file]' \ 20 | '--history-file=[Specifies a history file]' \ 21 | '--grpc-host=[Specifies gRPC host address]' \ 22 | '--grpc-port=[Specifies gRPC port number]' \ 23 | '--no-daemon[Does not run as daemon]' \ 24 | '-r[Tries to replace existing daemon]' \ 25 | '--replace[Tries to replace existing daemon]' \ 26 | '-h[Prints help information]' \ 27 | '--help[Prints help information]' \ 28 | '-V[Prints version information]' \ 29 | '--version[Prints version information]' \ 30 | ":: :_clipcatd_commands" \ 31 | "*::: :->clipcatd" \ 32 | && ret=0 33 | case $state in 34 | (clipcatd) 35 | words=($line[1] "${words[@]}") 36 | (( CURRENT += 1 )) 37 | curcontext="${curcontext%:*:*}:clipcatd-command-$line[1]:" 38 | case $line[1] in 39 | (version) 40 | _arguments "${_arguments_options[@]}" \ 41 | '-h[Prints help information]' \ 42 | '--help[Prints help information]' \ 43 | '-V[Prints version information]' \ 44 | '--version[Prints version information]' \ 45 | && ret=0 46 | ;; 47 | (completions) 48 | _arguments "${_arguments_options[@]}" \ 49 | '-h[Prints help information]' \ 50 | '--help[Prints help information]' \ 51 | '-V[Prints version information]' \ 52 | '--version[Prints version information]' \ 53 | ':shell:_files' \ 54 | && ret=0 55 | ;; 56 | (default-config) 57 | _arguments "${_arguments_options[@]}" \ 58 | '-h[Prints help information]' \ 59 | '--help[Prints help information]' \ 60 | '-V[Prints version information]' \ 61 | '--version[Prints version information]' \ 62 | && ret=0 63 | ;; 64 | (help) 65 | _arguments "${_arguments_options[@]}" \ 66 | '-h[Prints help information]' \ 67 | '--help[Prints help information]' \ 68 | '-V[Prints version information]' \ 69 | '--version[Prints version information]' \ 70 | && ret=0 71 | ;; 72 | esac 73 | ;; 74 | esac 75 | } 76 | 77 | (( $+functions[_clipcatd_commands] )) || 78 | _clipcatd_commands() { 79 | local commands; commands=( 80 | "version:Prints version information" \ 81 | "completions:Outputs shell completion code for the specified shell (bash, zsh, fish)" \ 82 | "default-config:Outputs default configuration" \ 83 | "help:Prints this message or the help of the given subcommand(s)" \ 84 | ) 85 | _describe -t commands 'clipcatd commands' commands "$@" 86 | } 87 | (( $+functions[_clipcatd__completions_commands] )) || 88 | _clipcatd__completions_commands() { 89 | local commands; commands=( 90 | 91 | ) 92 | _describe -t commands 'clipcatd completions commands' commands "$@" 93 | } 94 | (( $+functions[_clipcatd__default-config_commands] )) || 95 | _clipcatd__default-config_commands() { 96 | local commands; commands=( 97 | 98 | ) 99 | _describe -t commands 'clipcatd default-config commands' commands "$@" 100 | } 101 | (( $+functions[_clipcatd__help_commands] )) || 102 | _clipcatd__help_commands() { 103 | local commands; commands=( 104 | 105 | ) 106 | _describe -t commands 'clipcatd help commands' commands "$@" 107 | } 108 | (( $+functions[_clipcatd__version_commands] )) || 109 | _clipcatd__version_commands() { 110 | local commands; commands=( 111 | 112 | ) 113 | _describe -t commands 'clipcatd version commands' commands "$@" 114 | } 115 | 116 | _clipcatd "$@" -------------------------------------------------------------------------------- /completions/fish/completions/clipcat-menu.fish: -------------------------------------------------------------------------------- 1 | complete -c clipcat-menu -n "__fish_use_subcommand" -s c -l config -d 'Specifies a configuration file' 2 | complete -c clipcat-menu -n "__fish_use_subcommand" -s f -l finder -d 'Specifies a finder' 3 | complete -c clipcat-menu -n "__fish_use_subcommand" -s m -l menu-length -d 'Specifies the menu length of finder' 4 | complete -c clipcat-menu -n "__fish_use_subcommand" -s l -l line-length -d 'Specifies the length of a line showing on finder' 5 | complete -c clipcat-menu -n "__fish_use_subcommand" -s h -l help -d 'Prints help information' 6 | complete -c clipcat-menu -n "__fish_use_subcommand" -s V -l version -d 'Prints version information' 7 | complete -c clipcat-menu -n "__fish_use_subcommand" -f -a "version" -d 'Prints version information' 8 | complete -c clipcat-menu -n "__fish_use_subcommand" -f -a "completions" -d 'Outputs shell completion code for the specified shell (bash, zsh, fish)' 9 | complete -c clipcat-menu -n "__fish_use_subcommand" -f -a "default-config" -d 'Outputs default configuration' 10 | complete -c clipcat-menu -n "__fish_use_subcommand" -f -a "list-finder" -d 'Prints available text finders' 11 | complete -c clipcat-menu -n "__fish_use_subcommand" -f -a "insert" -d 'Insert selected clip into clipboard' 12 | complete -c clipcat-menu -n "__fish_use_subcommand" -f -a "insert-primary" -d 'Insert selected clip into primary clipboard' 13 | complete -c clipcat-menu -n "__fish_use_subcommand" -f -a "remove" -d 'Removes selected clip' 14 | complete -c clipcat-menu -n "__fish_use_subcommand" -f -a "edit" -d 'Edit selected clip' 15 | complete -c clipcat-menu -n "__fish_use_subcommand" -f -a "help" -d 'Prints this message or the help of the given subcommand(s)' 16 | complete -c clipcat-menu -n "__fish_seen_subcommand_from version" -s h -l help -d 'Prints help information' 17 | complete -c clipcat-menu -n "__fish_seen_subcommand_from version" -s V -l version -d 'Prints version information' 18 | complete -c clipcat-menu -n "__fish_seen_subcommand_from completions" -s h -l help -d 'Prints help information' 19 | complete -c clipcat-menu -n "__fish_seen_subcommand_from completions" -s V -l version -d 'Prints version information' 20 | complete -c clipcat-menu -n "__fish_seen_subcommand_from default-config" -s h -l help -d 'Prints help information' 21 | complete -c clipcat-menu -n "__fish_seen_subcommand_from default-config" -s V -l version -d 'Prints version information' 22 | complete -c clipcat-menu -n "__fish_seen_subcommand_from list-finder" -s h -l help -d 'Prints help information' 23 | complete -c clipcat-menu -n "__fish_seen_subcommand_from list-finder" -s V -l version -d 'Prints version information' 24 | complete -c clipcat-menu -n "__fish_seen_subcommand_from insert" -s h -l help -d 'Prints help information' 25 | complete -c clipcat-menu -n "__fish_seen_subcommand_from insert" -s V -l version -d 'Prints version information' 26 | complete -c clipcat-menu -n "__fish_seen_subcommand_from insert-primary" -s h -l help -d 'Prints help information' 27 | complete -c clipcat-menu -n "__fish_seen_subcommand_from insert-primary" -s V -l version -d 'Prints version information' 28 | complete -c clipcat-menu -n "__fish_seen_subcommand_from remove" -s h -l help -d 'Prints help information' 29 | complete -c clipcat-menu -n "__fish_seen_subcommand_from remove" -s V -l version -d 'Prints version information' 30 | complete -c clipcat-menu -n "__fish_seen_subcommand_from edit" -s e -l editor -d 'Specifies a external editor' 31 | complete -c clipcat-menu -n "__fish_seen_subcommand_from edit" -s h -l help -d 'Prints help information' 32 | complete -c clipcat-menu -n "__fish_seen_subcommand_from edit" -s V -l version -d 'Prints version information' 33 | complete -c clipcat-menu -n "__fish_seen_subcommand_from help" -s h -l help -d 'Prints help information' 34 | complete -c clipcat-menu -n "__fish_seen_subcommand_from help" -s V -l version -d 'Prints version information' 35 | -------------------------------------------------------------------------------- /src/bin/clipcat-menu/finder/external/rofi.rs: -------------------------------------------------------------------------------- 1 | use clipcat::ClipboardData; 2 | 3 | use crate::{ 4 | config, 5 | finder::{ 6 | external::ExternalProgram, finder_stream::ENTRY_SEPARATOR, FinderStream, SelectionMode, 7 | }, 8 | }; 9 | 10 | #[derive(Debug, Clone, Eq, PartialEq)] 11 | pub struct Rofi { 12 | line_length: usize, 13 | menu_length: usize, 14 | } 15 | 16 | impl Rofi { 17 | pub fn from_config(config: &config::Rofi) -> Rofi { 18 | let config::Rofi { 19 | menu_length, 20 | line_length, 21 | } = *config; 22 | 23 | Rofi { 24 | menu_length, 25 | line_length, 26 | } 27 | } 28 | } 29 | 30 | impl ExternalProgram for Rofi { 31 | fn program(&self) -> String { 32 | "rofi".to_string() 33 | } 34 | 35 | fn args(&self, selection_mode: SelectionMode) -> Vec { 36 | match selection_mode { 37 | SelectionMode::Single => vec![ 38 | "-dmenu".to_owned(), 39 | "-l".to_owned(), 40 | self.menu_length.to_string(), 41 | "-sep".to_owned(), 42 | ENTRY_SEPARATOR.to_owned(), 43 | "-format".to_owned(), 44 | "i".to_owned(), 45 | ], 46 | SelectionMode::Multiple => vec![ 47 | "-dmenu".to_owned(), 48 | "-multi-select".to_owned(), 49 | "-l".to_owned(), 50 | self.menu_length.to_string(), 51 | "-sep".to_owned(), 52 | ENTRY_SEPARATOR.to_owned(), 53 | "-format".to_owned(), 54 | "i".to_owned(), 55 | ], 56 | } 57 | } 58 | } 59 | 60 | impl FinderStream for Rofi { 61 | fn generate_input(&self, clips: &[ClipboardData]) -> String { 62 | clips 63 | .iter() 64 | .map(|data| data.printable_data(self.line_length())) 65 | .collect::>() 66 | .join(ENTRY_SEPARATOR) 67 | } 68 | 69 | fn parse_output(&self, data: &[u8]) -> Vec { 70 | String::from_utf8_lossy(data) 71 | .trim() 72 | .split(ENTRY_SEPARATOR) 73 | .filter_map(|index| index.parse().ok()) 74 | .collect() 75 | } 76 | 77 | fn line_length(&self) -> Option { 78 | Some(self.line_length) 79 | } 80 | 81 | fn menu_length(&self) -> Option { 82 | Some(self.menu_length) 83 | } 84 | 85 | fn set_line_length(&mut self, line_length: usize) { 86 | self.line_length = line_length 87 | } 88 | 89 | fn set_menu_length(&mut self, menu_length: usize) { 90 | self.menu_length = menu_length; 91 | } 92 | } 93 | 94 | #[cfg(test)] 95 | mod tests { 96 | use crate::{ 97 | config, 98 | finder::{external::ExternalProgram, Rofi, SelectionMode}, 99 | }; 100 | 101 | #[test] 102 | fn test_args() { 103 | let rofi = Rofi::from_config(&config::Rofi { 104 | menu_length: 30, 105 | line_length: 40, 106 | }); 107 | assert_eq!( 108 | rofi.args(SelectionMode::Single), 109 | vec![ 110 | "-dmenu".to_owned(), 111 | "-l".to_owned(), 112 | "30".to_owned(), 113 | "-sep".to_owned(), 114 | "\n".to_owned(), 115 | "-format".to_owned(), 116 | "i".to_owned(), 117 | ] 118 | ); 119 | assert_eq!( 120 | rofi.args(SelectionMode::Multiple), 121 | vec![ 122 | "-dmenu".to_owned(), 123 | "-multi-select".to_owned(), 124 | "-l".to_owned(), 125 | "30".to_owned(), 126 | "-sep".to_owned(), 127 | "\n".to_owned(), 128 | "-format".to_owned(), 129 | "i".to_owned(), 130 | ] 131 | ); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/bin/clipcatd/worker/clipboard.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use futures::FutureExt; 4 | use tokio::{ 5 | sync::{broadcast, mpsc, Mutex}, 6 | task::JoinHandle, 7 | }; 8 | 9 | use clipcat::{ClipboardData, ClipboardEvent, ClipboardManager, ClipboardMonitor, ClipboardType}; 10 | 11 | use crate::{ 12 | error::Error, 13 | history::{HistoryDriver, HistoryManager}, 14 | worker::{CtlMessage, CtlMessageSender}, 15 | }; 16 | 17 | pub enum Message { 18 | Shutdown, 19 | } 20 | 21 | pub type MessageSender = mpsc::UnboundedSender; 22 | pub type MessageReceiver = mpsc::UnboundedReceiver; 23 | 24 | pub struct ClipboardWorker { 25 | ctl_tx: CtlMessageSender, 26 | msg_rx: MessageReceiver, 27 | clipboard_monitor: Arc>, 28 | clipboard_manager: Arc>, 29 | history_manager: Arc>, 30 | } 31 | 32 | impl ClipboardWorker { 33 | async fn run(mut self) -> Result<(), Error> { 34 | let mut quit = false; 35 | let mut event_recv = { 36 | let monitor = self.clipboard_monitor.lock().await; 37 | monitor.subscribe() 38 | }; 39 | 40 | while !quit { 41 | quit = futures::select! { 42 | event = event_recv.recv().fuse() => self.handle_event(event).await, 43 | msg = self.msg_rx.recv().fuse() => self.handle_message(msg), 44 | }; 45 | } 46 | 47 | let (clips, history_capacity) = { 48 | let cm = self.clipboard_manager.lock().await; 49 | (cm.list(), cm.capacity()) 50 | }; 51 | 52 | { 53 | let mut hm = self.history_manager.lock().await; 54 | 55 | tracing::info!("Save history and shrink to capacity {}", history_capacity); 56 | if let Err(err) = hm.save_and_shrink_to(&clips, history_capacity) { 57 | tracing::warn!("Failed to save history, error: {:?}", err); 58 | } 59 | } 60 | 61 | Ok(()) 62 | } 63 | 64 | async fn handle_event( 65 | &self, 66 | event: Result, 67 | ) -> bool { 68 | match event { 69 | Err(broadcast::error::RecvError::Closed) => { 70 | tracing::info!("ClipboardMonitor is closing, no further values will be received"); 71 | 72 | tracing::info!("Internal shutdown signal is sent"); 73 | let _ = self.ctl_tx.send(CtlMessage::Shutdown); 74 | 75 | return true; 76 | } 77 | Err(broadcast::error::RecvError::Lagged(_)) => {} 78 | Ok(event) => { 79 | match event.clipboard_type { 80 | ClipboardType::Clipboard => tracing::info!("Clipboard [{:?}]", event.data), 81 | ClipboardType::Primary => tracing::info!("Primary [{:?}]", event.data), 82 | } 83 | 84 | let data = ClipboardData::from(event); 85 | self.clipboard_manager.lock().await.insert(data.clone()); 86 | let _ = self.history_manager.lock().await.put(&data); 87 | } 88 | } 89 | 90 | false 91 | } 92 | 93 | pub fn handle_message(&mut self, msg: Option) -> bool { 94 | match msg { 95 | None => true, 96 | Some(msg) => match msg { 97 | Message::Shutdown => { 98 | tracing::info!("ClipboardWorker is shutting down gracefully"); 99 | true 100 | } 101 | }, 102 | } 103 | } 104 | } 105 | 106 | pub fn start( 107 | ctl_tx: CtlMessageSender, 108 | clipboard_monitor: Arc>, 109 | clipboard_manager: Arc>, 110 | history_manager: Arc>, 111 | ) -> (MessageSender, JoinHandle>) { 112 | let (tx, msg_rx) = mpsc::unbounded_channel::(); 113 | let worker = ClipboardWorker { 114 | ctl_tx, 115 | msg_rx, 116 | clipboard_monitor, 117 | clipboard_manager, 118 | history_manager, 119 | }; 120 | (tx, tokio::spawn(worker.run())) 121 | } 122 | -------------------------------------------------------------------------------- /completions/bash-completion/completions/clipcatd: -------------------------------------------------------------------------------- 1 | _clipcatd() { 2 | local i cur prev opts cmds 3 | COMPREPLY=() 4 | cur="${COMP_WORDS[COMP_CWORD]}" 5 | prev="${COMP_WORDS[COMP_CWORD-1]}" 6 | cmd="" 7 | opts="" 8 | 9 | for i in ${COMP_WORDS[@]} 10 | do 11 | case "${i}" in 12 | clipcatd) 13 | cmd="clipcatd" 14 | ;; 15 | 16 | completions) 17 | cmd+="__completions" 18 | ;; 19 | default-config) 20 | cmd+="__default__config" 21 | ;; 22 | help) 23 | cmd+="__help" 24 | ;; 25 | version) 26 | cmd+="__version" 27 | ;; 28 | *) 29 | ;; 30 | esac 31 | done 32 | 33 | case "${cmd}" in 34 | clipcatd) 35 | opts=" -r -h -V -c --no-daemon --replace --help --version --config --history-file --grpc-host --grpc-port version completions default-config help" 36 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then 37 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 38 | return 0 39 | fi 40 | case "${prev}" in 41 | 42 | --config) 43 | COMPREPLY=($(compgen -f "${cur}")) 44 | return 0 45 | ;; 46 | -c) 47 | COMPREPLY=($(compgen -f "${cur}")) 48 | return 0 49 | ;; 50 | --history-file) 51 | COMPREPLY=($(compgen -f "${cur}")) 52 | return 0 53 | ;; 54 | --grpc-host) 55 | COMPREPLY=($(compgen -f "${cur}")) 56 | return 0 57 | ;; 58 | --grpc-port) 59 | COMPREPLY=($(compgen -f "${cur}")) 60 | return 0 61 | ;; 62 | *) 63 | COMPREPLY=() 64 | ;; 65 | esac 66 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 67 | return 0 68 | ;; 69 | 70 | clipcatd__completions) 71 | opts=" -h -V --help --version " 72 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then 73 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 74 | return 0 75 | fi 76 | case "${prev}" in 77 | 78 | *) 79 | COMPREPLY=() 80 | ;; 81 | esac 82 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 83 | return 0 84 | ;; 85 | clipcatd__default__config) 86 | opts=" -h -V --help --version " 87 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then 88 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 89 | return 0 90 | fi 91 | case "${prev}" in 92 | 93 | *) 94 | COMPREPLY=() 95 | ;; 96 | esac 97 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 98 | return 0 99 | ;; 100 | clipcatd__help) 101 | opts=" -h -V --help --version " 102 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then 103 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 104 | return 0 105 | fi 106 | case "${prev}" in 107 | 108 | *) 109 | COMPREPLY=() 110 | ;; 111 | esac 112 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 113 | return 0 114 | ;; 115 | clipcatd__version) 116 | opts=" -h -V --help --version " 117 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then 118 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 119 | return 0 120 | fi 121 | case "${prev}" in 122 | 123 | *) 124 | COMPREPLY=() 125 | ;; 126 | esac 127 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 128 | return 0 129 | ;; 130 | esac 131 | } 132 | 133 | complete -F _clipcatd -o bashdefault -o default clipcatd 134 | -------------------------------------------------------------------------------- /src/bin/clipcatd/config.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | net::IpAddr, 3 | path::{Path, PathBuf}, 4 | }; 5 | 6 | use serde::{Deserialize, Serialize}; 7 | use snafu::{ResultExt, Snafu}; 8 | 9 | #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] 10 | pub struct Config { 11 | pub daemonize: bool, 12 | 13 | #[serde(skip_serializing, default = "Config::default_pid_file_path")] 14 | pub pid_file: PathBuf, 15 | 16 | #[serde(default = "Config::default_max_history")] 17 | pub max_history: usize, 18 | 19 | #[serde(default = "Config::default_history_file_path")] 20 | pub history_file_path: PathBuf, 21 | 22 | #[serde( 23 | default = "Config::default_log_level", 24 | with = "clipcat::display_from_str" 25 | )] 26 | pub log_level: tracing::Level, 27 | 28 | #[serde(default)] 29 | pub monitor: Monitor, 30 | 31 | pub grpc: Grpc, 32 | } 33 | 34 | #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] 35 | pub struct Monitor { 36 | pub load_current: bool, 37 | pub enable_clipboard: bool, 38 | pub enable_primary: bool, 39 | #[serde(default)] 40 | pub filter_min_size: usize, 41 | } 42 | 43 | #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] 44 | pub struct Grpc { 45 | pub host: IpAddr, 46 | pub port: u16, 47 | } 48 | 49 | impl Default for Config { 50 | fn default() -> Config { 51 | Config { 52 | daemonize: true, 53 | pid_file: Config::default_pid_file_path(), 54 | max_history: Config::default_max_history(), 55 | history_file_path: Config::default_history_file_path(), 56 | log_level: Config::default_log_level(), 57 | monitor: Default::default(), 58 | grpc: Default::default(), 59 | } 60 | } 61 | } 62 | 63 | impl Default for Monitor { 64 | fn default() -> Monitor { 65 | Monitor { 66 | load_current: true, 67 | enable_clipboard: true, 68 | enable_primary: true, 69 | filter_min_size: 0, 70 | } 71 | } 72 | } 73 | 74 | impl From for clipcat::ClipboardMonitorOptions { 75 | fn from(val: Monitor) -> Self { 76 | let Monitor { 77 | load_current, 78 | enable_clipboard, 79 | enable_primary, 80 | filter_min_size, 81 | } = val; 82 | clipcat::ClipboardMonitorOptions { 83 | load_current, 84 | enable_clipboard, 85 | enable_primary, 86 | filter_min_size, 87 | } 88 | } 89 | } 90 | 91 | impl Default for Grpc { 92 | fn default() -> Grpc { 93 | Grpc { 94 | host: clipcat::DEFAULT_GRPC_HOST 95 | .parse() 96 | .expect("Parse default gRPC host"), 97 | port: clipcat::DEFAULT_GRPC_PORT, 98 | } 99 | } 100 | } 101 | 102 | impl Config { 103 | #[inline] 104 | pub fn default_path() -> PathBuf { 105 | directories::BaseDirs::new() 106 | .expect("app_dirs") 107 | .config_dir() 108 | .join(clipcat::PROJECT_NAME) 109 | .join(clipcat::DAEMON_CONFIG_NAME) 110 | } 111 | 112 | #[inline] 113 | fn default_log_level() -> tracing::Level { 114 | tracing::Level::INFO 115 | } 116 | 117 | #[inline] 118 | pub fn default_history_file_path() -> PathBuf { 119 | directories::BaseDirs::new() 120 | .expect("app_dirs") 121 | .cache_dir() 122 | .join(clipcat::PROJECT_NAME) 123 | .join(clipcat::DAEMON_HISTORY_FILE_NAME) 124 | } 125 | 126 | #[inline] 127 | pub fn default_max_history() -> usize { 128 | 50 129 | } 130 | 131 | #[inline] 132 | pub fn default_pid_file_path() -> PathBuf { 133 | let mut path = std::env::var("XDG_RUNTIME_DIR") 134 | .map(PathBuf::from) 135 | .unwrap_or_else(|_| std::env::temp_dir()); 136 | path.push(format!("{}.pid", clipcat::DAEMON_PROGRAM_NAME)); 137 | path 138 | } 139 | 140 | #[inline] 141 | pub fn load>(path: P) -> Result { 142 | let data = std::fs::read(&path).context(OpenConfigSnafu { 143 | filename: path.as_ref().to_path_buf(), 144 | })?; 145 | let mut config = toml::from_slice::(&data).context(ParseConfigSnafu { 146 | filename: path.as_ref().to_path_buf(), 147 | })?; 148 | 149 | if config.max_history == 0 { 150 | config.max_history = Self::default_max_history(); 151 | } 152 | 153 | Ok(config) 154 | } 155 | } 156 | 157 | #[derive(Debug, Snafu)] 158 | pub enum ConfigError { 159 | #[snafu(display("Could not open config from {}: {}", filename.display(), source))] 160 | OpenConfig { 161 | filename: PathBuf, 162 | source: std::io::Error, 163 | }, 164 | 165 | #[snafu(display("Count not parse config from {}: {}", filename.display(), source))] 166 | ParseConfig { 167 | filename: PathBuf, 168 | source: toml::de::Error, 169 | }, 170 | } 171 | -------------------------------------------------------------------------------- /src/bin/clipcatd/history/rocksdb.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::{HashMap, HashSet}, 3 | path::Path, 4 | time::SystemTime, 5 | }; 6 | 7 | use rocksdb::{IteratorMode, Options as RocksDBOptions, WriteBatch, DB as RocksDB}; 8 | 9 | use clipcat::ClipboardData; 10 | 11 | use crate::history::{HistoryDriver, HistoryError}; 12 | 13 | #[derive(Debug, Clone, Serialize, Deserialize)] 14 | struct ClipboardValue { 15 | pub data: String, 16 | pub timestamp: SystemTime, 17 | } 18 | 19 | pub struct RocksDBDriver { 20 | db: Option, 21 | } 22 | 23 | impl RocksDBDriver { 24 | pub fn open>(file_path: P) -> Result { 25 | let opt = Self::open_options(); 26 | let db = RocksDB::open(&opt, file_path)?; 27 | Ok(RocksDBDriver { db: Some(db) }) 28 | } 29 | 30 | fn open_options() -> RocksDBOptions { 31 | let mut opt = RocksDBOptions::default(); 32 | opt.create_if_missing(true); 33 | opt 34 | } 35 | 36 | fn serialize_id(id: u64) -> Vec { bincode::serialize(&id).expect("u64 is serializable") } 37 | 38 | fn deserialize_id(id: &[u8]) -> u64 { 39 | bincode::deserialize(&id).expect("u64 is deserializable") 40 | } 41 | 42 | fn deserialize_data(id: u64, raw_data: &[u8]) -> Option { 43 | use clipcat::ClipboardType; 44 | 45 | bincode::deserialize::(&raw_data) 46 | .map(|value| ClipboardData { 47 | id, 48 | data: value.data.clone(), 49 | timestamp: value.timestamp, 50 | clipboard_type: ClipboardType::Primary, 51 | }) 52 | .map_err(|_| { 53 | tracing::warn!("Failed to deserialize ClipboardValue"); 54 | }) 55 | .ok() 56 | } 57 | 58 | fn serialize_data(data: &ClipboardData) -> Vec { 59 | let value = ClipboardValue { data: data.data.clone(), timestamp: data.timestamp }; 60 | bincode::serialize(&value).expect("ClipboardData is serializable") 61 | } 62 | 63 | fn serialize_entry(id: u64, data: &ClipboardData) -> (Vec, Vec) { 64 | (Self::serialize_id(id), Self::serialize_data(data)) 65 | } 66 | 67 | fn deserialize_entry(id: &[u8], data: &[u8]) -> Option { 68 | let id = Self::deserialize_id(id); 69 | Self::deserialize_data(id, data) 70 | } 71 | } 72 | 73 | impl HistoryDriver for RocksDBDriver { 74 | fn load(&self) -> Result, HistoryError> { 75 | let db = self.db.as_ref().expect("RocksDB must be some"); 76 | let iter = db.iterator(IteratorMode::Start); 77 | let clips = iter 78 | .filter_map(|(id, data)| Self::deserialize_entry(id.as_ref(), data.as_ref())) 79 | .collect(); 80 | Ok(clips) 81 | } 82 | 83 | fn save(&mut self, data: &[ClipboardData]) -> Result<(), HistoryError> { 84 | let db = self.db.as_mut().expect("RocksDB must be some"); 85 | 86 | let iter = db.iterator(IteratorMode::Start); 87 | let ids_in_db: HashSet> = iter.map(|(k, _v)| k.into_vec()).collect(); 88 | 89 | let mut batch = WriteBatch::default(); 90 | let unsaved_ids: HashSet<_> = data 91 | .iter() 92 | .map(|clip| { 93 | let (id, data) = Self::serialize_entry(clip.id, &clip); 94 | batch.put(id.clone(), data); 95 | id 96 | }) 97 | .collect(); 98 | 99 | ids_in_db.difference(&unsaved_ids).for_each(|id| { 100 | let _ = db.delete(id); 101 | }); 102 | 103 | db.write(batch)?; 104 | Ok(()) 105 | } 106 | 107 | fn shrink_to(&mut self, min_capacity: usize) -> Result<(), HistoryError> { 108 | let db = self.db.as_mut().expect("RocksDB must be some"); 109 | if db.iterator(IteratorMode::Start).count() < min_capacity { 110 | return Ok(()); 111 | } 112 | 113 | let iter = db.iterator(IteratorMode::Start); 114 | let timestamps = iter 115 | .filter_map(|(k, v)| { 116 | let id = Self::deserialize_id(&k); 117 | let v = Self::deserialize_data(id, &v); 118 | v.map(|v| (v, Vec::from(k.as_ref()))) 119 | }) 120 | .map(|(v, id)| (v.timestamp, id)) 121 | .collect::>>(); 122 | 123 | let batch = { 124 | let mut keys = timestamps.keys().cloned().collect::>(); 125 | keys.sort(); 126 | let len = keys.len(); 127 | keys.resize(len - min_capacity, SystemTime::now()); 128 | keys.iter().filter_map(|ts| timestamps.get(&ts)).fold( 129 | WriteBatch::default(), 130 | |mut batch, id| { 131 | batch.delete(id); 132 | batch 133 | }, 134 | ) 135 | }; 136 | 137 | db.write(batch)?; 138 | Ok(()) 139 | } 140 | 141 | fn clear(&mut self) -> Result<(), HistoryError> { 142 | let db_path = { 143 | let db = self.db.take().expect("RocksDB must be some"); 144 | let db_path = db.path().to_path_buf(); 145 | drop(db); 146 | db_path 147 | }; 148 | 149 | RocksDB::destroy(&RocksDBOptions::default(), &db_path)?; 150 | self.db = Some(RocksDB::open(&Self::open_options(), &db_path)?); 151 | Ok(()) 152 | } 153 | 154 | fn put(&mut self, data: &ClipboardData) -> Result<(), HistoryError> { 155 | let db = self.db.as_mut().expect("RocksDB must be some"); 156 | db.put(Self::serialize_id(data.id), Self::serialize_data(&data))?; 157 | Ok(()) 158 | } 159 | 160 | fn get(&self, id: u64) -> Result, HistoryError> { 161 | let db = self.db.as_ref().expect("RocksDB must be some"); 162 | let serialized_id = Self::serialize_id(id); 163 | match db.get(&serialized_id)? { 164 | Some(data) => Ok(Self::deserialize_data(id, &data)), 165 | None => Ok(None), 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/bin/clipcat-menu/finder/mod.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | 3 | use snafu::{OptionExt, ResultExt}; 4 | use tokio::io::AsyncWriteExt; 5 | 6 | use clipcat::ClipboardData; 7 | 8 | use crate::{config::Config, error::Error}; 9 | 10 | mod builtin; 11 | mod error; 12 | mod external; 13 | mod finder_stream; 14 | 15 | use self::{ 16 | builtin::BuiltinFinder, 17 | external::{Custom, Dmenu, ExternalProgram, Fzf, Rofi, Skim}, 18 | }; 19 | pub use self::{error::FinderError, finder_stream::FinderStream}; 20 | 21 | #[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] 22 | pub enum SelectionMode { 23 | Single, 24 | Multiple, 25 | } 26 | 27 | #[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Serialize, Deserialize, Default)] 28 | pub enum FinderType { 29 | #[default] 30 | #[serde(rename = "builtin")] 31 | Builtin, 32 | 33 | #[serde(rename = "rofi")] 34 | Rofi, 35 | 36 | #[serde(rename = "dmenu")] 37 | Dmenu, 38 | 39 | #[serde(rename = "skim")] 40 | Skim, 41 | 42 | #[serde(rename = "fzf")] 43 | Fzf, 44 | 45 | #[serde(rename = "custom")] 46 | Custom, 47 | } 48 | 49 | impl FinderType { 50 | #[inline] 51 | pub fn available_types() -> Vec { 52 | vec![ 53 | FinderType::Builtin, 54 | FinderType::Rofi, 55 | FinderType::Dmenu, 56 | FinderType::Skim, 57 | FinderType::Fzf, 58 | FinderType::Custom, 59 | ] 60 | } 61 | } 62 | 63 | impl FromStr for FinderType { 64 | type Err = FinderError; 65 | 66 | fn from_str(finder: &str) -> Result { 67 | match finder.to_lowercase().as_ref() { 68 | "builtin" => Ok(FinderType::Builtin), 69 | "rofi" => Ok(FinderType::Rofi), 70 | "dmenu" => Ok(FinderType::Dmenu), 71 | "skim" => Ok(FinderType::Skim), 72 | "fzf" => Ok(FinderType::Fzf), 73 | "custom" => Ok(FinderType::Custom), 74 | _ => Err(FinderError::InvalidFinder { 75 | finder: finder.to_owned(), 76 | }), 77 | } 78 | } 79 | } 80 | 81 | impl ToString for FinderType { 82 | fn to_string(&self) -> String { 83 | match self { 84 | FinderType::Builtin => "builtin".to_owned(), 85 | FinderType::Rofi => "rofi".to_owned(), 86 | FinderType::Dmenu => "dmenu".to_owned(), 87 | FinderType::Skim => "skim".to_owned(), 88 | FinderType::Fzf => "fzf".to_owned(), 89 | FinderType::Custom => "custom".to_owned(), 90 | } 91 | } 92 | } 93 | 94 | pub struct FinderRunner { 95 | external: Option>, 96 | } 97 | 98 | impl FinderRunner { 99 | pub fn from_config(config: &Config) -> Result { 100 | let external: Option> = match config.finder { 101 | FinderType::Builtin => None, 102 | FinderType::Skim => Some(Box::new(Skim::new())), 103 | FinderType::Fzf => Some(Box::new(Fzf::new())), 104 | FinderType::Rofi => Some(Box::new(Rofi::from_config( 105 | &config.rofi.clone().unwrap_or_default(), 106 | ))), 107 | FinderType::Dmenu => Some(Box::new(Dmenu::from_config( 108 | &config.dmenu.clone().unwrap_or_default(), 109 | ))), 110 | FinderType::Custom => Some(Box::new(Custom::from_config( 111 | &config.custom_finder.clone().unwrap_or_default(), 112 | ))), 113 | }; 114 | 115 | Ok(FinderRunner { external }) 116 | } 117 | 118 | pub async fn single_select( 119 | self, 120 | clips: &[ClipboardData], 121 | ) -> Result, FinderError> { 122 | let selected_indices = self.select(clips, SelectionMode::Single).await?; 123 | if selected_indices.is_empty() { 124 | return Ok(None); 125 | } 126 | 127 | let selected_index = *selected_indices 128 | .first() 129 | .expect("selected_indices is not empty"); 130 | let selected_data = &clips[selected_index]; 131 | 132 | Ok(Some((selected_index, selected_data.clone()))) 133 | } 134 | 135 | pub async fn multiple_select( 136 | self, 137 | clips: &[ClipboardData], 138 | ) -> Result, FinderError> { 139 | let selected_indices = self.select(clips, SelectionMode::Multiple).await?; 140 | if selected_indices.is_empty() { 141 | return Ok(vec![]); 142 | } 143 | 144 | let clips = selected_indices 145 | .into_iter() 146 | .map(|index| (index, clips[index].clone())) 147 | .collect(); 148 | Ok(clips) 149 | } 150 | 151 | pub async fn select( 152 | self, 153 | clips: &[ClipboardData], 154 | selection_mode: SelectionMode, 155 | ) -> Result, FinderError> { 156 | if self.external.is_some() { 157 | return self.select_externally(clips, selection_mode).await; 158 | } 159 | 160 | let finder = BuiltinFinder::new(); 161 | finder.select(clips, selection_mode).await 162 | } 163 | 164 | async fn select_externally( 165 | self, 166 | clips: &[ClipboardData], 167 | selection_mode: SelectionMode, 168 | ) -> Result, FinderError> { 169 | if let Some(external) = self.external { 170 | let input_data = external.generate_input(clips); 171 | let mut child = external 172 | .spawn_child(selection_mode) 173 | .context(error::SpawnExternalProcessSnafu)?; 174 | { 175 | let stdin = child.stdin.as_mut().context(error::OpenStdinSnafu)?; 176 | stdin 177 | .write_all(input_data.as_bytes()) 178 | .await 179 | .context(error::WriteStdinSnafu)?; 180 | } 181 | 182 | let output = child 183 | .wait_with_output() 184 | .await 185 | .context(error::ReadStdoutSnafu)?; 186 | if output.stdout.is_empty() { 187 | return Ok(vec![]); 188 | } 189 | 190 | let selected_indices = external.parse_output(output.stdout.as_slice()); 191 | Ok(selected_indices) 192 | } else { 193 | Ok(vec![]) 194 | } 195 | } 196 | 197 | #[inline] 198 | pub fn set_line_length(&mut self, line_length: usize) { 199 | if let Some(external) = self.external.as_mut() { 200 | external.set_line_length(line_length); 201 | } 202 | } 203 | 204 | #[inline] 205 | pub fn set_menu_length(&mut self, menu_length: usize) { 206 | if let Some(external) = self.external.as_mut() { 207 | external.set_menu_length(menu_length); 208 | } 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate serde; 3 | #[macro_use] 4 | extern crate snafu; 5 | 6 | #[cfg(not(any(feature = "wayland", feature = "x11")))] 7 | compile_error!("You have to enable either the `wayland` or `x11` feature."); 8 | 9 | use std::{ 10 | cmp::Ordering, 11 | hash::{Hash, Hasher}, 12 | time::SystemTime, 13 | }; 14 | 15 | pub mod grpc; 16 | 17 | mod error; 18 | mod event; 19 | 20 | mod manager; 21 | mod monitor; 22 | 23 | pub mod editor; 24 | 25 | pub use self::{error::display_from_str, error::ClipboardError, event::ClipboardEvent}; 26 | 27 | pub use self::manager::ClipboardManager; 28 | pub use self::monitor::{ClipboardMonitor, ClipboardMonitorOptions}; 29 | 30 | pub const PROJECT_NAME: &str = "clipcat"; 31 | 32 | pub const DAEMON_PROGRAM_NAME: &str = "clipcatd"; 33 | pub const DAEMON_CONFIG_NAME: &str = "clipcatd.toml"; 34 | pub const DAEMON_HISTORY_FILE_NAME: &str = "clipcatd/history.cch"; 35 | 36 | pub const CTL_PROGRAM_NAME: &str = "clipcatctl"; 37 | pub const CTL_CONFIG_NAME: &str = "clipcatctl.toml"; 38 | 39 | pub const MENU_PROGRAM_NAME: &str = "clipcat-menu"; 40 | pub const MENU_CONFIG_NAME: &str = "clipcat-menu.toml"; 41 | 42 | pub const NOTIFY_PROGRAM_NAME: &str = "clipcat-notify"; 43 | 44 | pub const DEFAULT_GRPC_PORT: u16 = 45045; 45 | pub const DEFAULT_GRPC_HOST: &str = "127.0.0.1"; 46 | 47 | pub const DEFAULT_WEBUI_PORT: u16 = 45046; 48 | pub const DEFAULT_WEBUI_HOST: &str = "127.0.0.1"; 49 | 50 | #[derive(Debug, PartialOrd, Ord, PartialEq, Eq, Serialize, Deserialize, Clone, Copy, Hash)] 51 | pub enum ClipboardType { 52 | Clipboard = 0, 53 | Primary = 1, 54 | } 55 | 56 | impl From for ClipboardType { 57 | fn from(n: i32) -> ClipboardType { 58 | match n { 59 | 0 => ClipboardType::Clipboard, 60 | 1 => ClipboardType::Primary, 61 | _ => ClipboardType::Primary, 62 | } 63 | } 64 | } 65 | 66 | #[derive(Debug, Eq, Clone, Serialize, Deserialize)] 67 | pub struct ClipboardData { 68 | pub id: u64, 69 | pub data: String, 70 | pub clipboard_type: ClipboardType, 71 | pub timestamp: SystemTime, 72 | } 73 | 74 | impl ClipboardData { 75 | pub fn new(data: &str, clipboard_type: ClipboardType) -> ClipboardData { 76 | match clipboard_type { 77 | ClipboardType::Clipboard => Self::new_clipboard(data), 78 | ClipboardType::Primary => Self::new_primary(data), 79 | } 80 | } 81 | 82 | pub fn new_clipboard(data: &str) -> ClipboardData { 83 | ClipboardData { 84 | id: Self::compute_id(data), 85 | data: data.to_owned(), 86 | clipboard_type: ClipboardType::Clipboard, 87 | timestamp: SystemTime::now(), 88 | } 89 | } 90 | 91 | pub fn new_primary(data: &str) -> ClipboardData { 92 | ClipboardData { 93 | id: Self::compute_id(data), 94 | data: data.to_owned(), 95 | clipboard_type: ClipboardType::Primary, 96 | timestamp: SystemTime::now(), 97 | } 98 | } 99 | 100 | #[inline] 101 | pub fn compute_id(data: &str) -> u64 { 102 | use std::collections::hash_map::DefaultHasher; 103 | let mut s = DefaultHasher::new(); 104 | data.hash(&mut s); 105 | s.finish() 106 | } 107 | 108 | pub fn printable_data(&self, line_length: Option) -> String { 109 | fn truncate(s: &str, max_chars: usize) -> &str { 110 | match s.char_indices().nth(max_chars) { 111 | None => s, 112 | Some((idx, _)) => &s[..idx], 113 | } 114 | } 115 | 116 | let data = self.data.clone(); 117 | let data = match line_length { 118 | None | Some(0) => data, 119 | Some(limit) => { 120 | let char_count = data.chars().count(); 121 | let line_count = data.lines().count(); 122 | if char_count > limit { 123 | let line_info = if line_count > 1 { 124 | format!("...({line_count} lines)") 125 | } else { 126 | "...".to_owned() 127 | }; 128 | let mut data = truncate(&data, limit - line_info.len()).to_owned(); 129 | data.push_str(&line_info); 130 | data 131 | } else { 132 | data 133 | } 134 | } 135 | }; 136 | 137 | data.replace('\n', "\\n") 138 | .replace('\r', "\\r") 139 | .replace('\t', "\\t") 140 | } 141 | 142 | #[inline] 143 | pub fn mark_as_clipboard(&mut self) { 144 | self.clipboard_type = ClipboardType::Clipboard; 145 | self.timestamp = SystemTime::now(); 146 | } 147 | 148 | #[inline] 149 | pub fn mark_as_primary(&mut self) { 150 | self.clipboard_type = ClipboardType::Primary; 151 | self.timestamp = SystemTime::now(); 152 | } 153 | } 154 | 155 | impl From for ClipboardData { 156 | fn from(event: ClipboardEvent) -> ClipboardData { 157 | let ClipboardEvent { 158 | data, 159 | clipboard_type, 160 | } = event; 161 | let id = Self::compute_id(&data); 162 | let timestamp = SystemTime::now(); 163 | ClipboardData { 164 | id, 165 | data, 166 | clipboard_type, 167 | timestamp, 168 | } 169 | } 170 | } 171 | 172 | impl Default for ClipboardData { 173 | fn default() -> ClipboardData { 174 | ClipboardData { 175 | id: 0, 176 | data: Default::default(), 177 | clipboard_type: ClipboardType::Primary, 178 | timestamp: SystemTime::UNIX_EPOCH, 179 | } 180 | } 181 | } 182 | 183 | impl PartialEq for ClipboardData { 184 | fn eq(&self, other: &Self) -> bool { 185 | self.data == other.data 186 | } 187 | } 188 | 189 | impl PartialOrd for ClipboardData { 190 | fn partial_cmp(&self, other: &Self) -> Option { 191 | Some(self.cmp(other)) 192 | } 193 | } 194 | 195 | impl Ord for ClipboardData { 196 | fn cmp(&self, other: &Self) -> Ordering { 197 | match other.timestamp.cmp(&self.timestamp) { 198 | Ordering::Equal => self.clipboard_type.cmp(&other.clipboard_type), 199 | ord => ord, 200 | } 201 | } 202 | } 203 | 204 | impl Hash for ClipboardData { 205 | fn hash(&self, state: &mut H) { 206 | self.data.hash(state); 207 | } 208 | } 209 | 210 | #[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone, Copy, Hash)] 211 | pub enum MonitorState { 212 | Enabled = 0, 213 | Disabled = 1, 214 | } 215 | 216 | impl From for crate::MonitorState { 217 | fn from(state: i32) -> crate::MonitorState { 218 | match state { 219 | 0 => crate::MonitorState::Enabled, 220 | _ => crate::MonitorState::Disabled, 221 | } 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /src/bin/clipcatd/command.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | net::IpAddr, 3 | path::{Path, PathBuf}, 4 | time::Duration, 5 | }; 6 | 7 | use snafu::ResultExt; 8 | use structopt::StructOpt; 9 | use tokio::runtime::Runtime; 10 | 11 | use crate::{ 12 | config::{Config, ConfigError}, 13 | error::{self, Error}, 14 | worker, 15 | }; 16 | 17 | #[derive(StructOpt, Clone)] 18 | #[structopt(name = clipcat::DAEMON_PROGRAM_NAME)] 19 | pub struct Command { 20 | #[structopt(subcommand)] 21 | subcommand: Option, 22 | 23 | #[structopt(long = "no-daemon", help = "Does not run as daemon")] 24 | no_daemon: bool, 25 | 26 | #[structopt( 27 | long = "replace", 28 | short = "r", 29 | help = "Tries to replace existing daemon" 30 | )] 31 | replace: bool, 32 | 33 | #[structopt(long = "config", short = "c", help = "Specifies a configuration file")] 34 | config_file: Option, 35 | 36 | #[structopt(long = "history-file", help = "Specifies a history file")] 37 | history_file_path: Option, 38 | 39 | #[structopt(long = "grpc-host", help = "Specifies gRPC host address")] 40 | grpc_host: Option, 41 | 42 | #[structopt(long = "grpc-port", help = "Specifies gRPC port number")] 43 | grpc_port: Option, 44 | } 45 | 46 | #[derive(StructOpt, Clone)] 47 | pub enum SubCommand { 48 | #[structopt(about = "Prints version information")] 49 | Version, 50 | 51 | #[structopt(about = "Outputs shell completion code for the specified shell (bash, zsh, fish)")] 52 | Completions { shell: structopt::clap::Shell }, 53 | 54 | #[structopt(about = "Outputs default configuration")] 55 | DefaultConfig, 56 | } 57 | 58 | impl Command { 59 | pub fn new() -> Command { 60 | StructOpt::from_args() 61 | } 62 | 63 | fn load_config(&self) -> Result { 64 | let config_file = &self 65 | .config_file 66 | .clone() 67 | .unwrap_or_else(Config::default_path); 68 | let mut config = Config::load(config_file)?; 69 | 70 | config.daemonize = !self.no_daemon; 71 | 72 | if let Some(history_file_path) = &self.history_file_path { 73 | config.history_file_path = history_file_path.clone(); 74 | } 75 | 76 | if let Some(host) = self.grpc_host { 77 | config.grpc.host = host; 78 | } 79 | 80 | if let Some(port) = self.grpc_port { 81 | config.grpc.port = port; 82 | } 83 | 84 | Ok(config) 85 | } 86 | 87 | pub fn run(self) -> Result<(), Error> { 88 | match self.subcommand { 89 | Some(SubCommand::Version) => { 90 | Command::clap() 91 | .write_long_version(&mut std::io::stdout()) 92 | .expect("failed to write to stdout"); 93 | return Ok(()); 94 | } 95 | Some(SubCommand::Completions { shell }) => { 96 | Command::clap().gen_completions_to( 97 | clipcat::DAEMON_PROGRAM_NAME, 98 | shell, 99 | &mut std::io::stdout(), 100 | ); 101 | return Ok(()); 102 | } 103 | Some(SubCommand::DefaultConfig) => { 104 | use std::io::Write; 105 | let config_text = 106 | toml::to_string_pretty(&Config::default()).expect("Config is serializable"); 107 | std::io::stdout() 108 | .write_all(config_text.as_bytes()) 109 | .expect("failed to write to stdout"); 110 | return Ok(()); 111 | } 112 | None => {} 113 | } 114 | 115 | let config = self.load_config().context(error::LoadConfigSnafu)?; 116 | run_clipcatd(config, self.replace) 117 | } 118 | } 119 | 120 | #[inline] 121 | fn kill_other(pid: u64) -> Result<(), Error> { 122 | let ret = unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) }; 123 | if ret != 0 { 124 | match std::io::Error::last_os_error().raw_os_error() { 125 | Some(3) => tracing::warn!("Previous clipcatd didn't remove it's PID file"), 126 | _ => return Err(Error::SendSignalTerminal { pid }), 127 | } 128 | } 129 | Ok(()) 130 | } 131 | 132 | fn run_clipcatd(config: Config, replace: bool) -> Result<(), Error> { 133 | let daemonize = config.daemonize; 134 | let pid_file = PidFile::from(config.pid_file.clone()); 135 | 136 | if pid_file.exists() && replace { 137 | let pid = pid_file.try_load()?; 138 | kill_other(pid)?; 139 | 140 | // sleep for a while 141 | std::thread::sleep(Duration::from_millis(200)); 142 | } 143 | 144 | if daemonize { 145 | let daemonize = daemonize::Daemonize::new().pid_file(pid_file.clone_path()); 146 | if let Err(err) = daemonize.start() { 147 | return Err(Error::Daemonize { source: err }); 148 | } 149 | } else { 150 | pid_file.set()?; 151 | } 152 | 153 | { 154 | use tracing_subscriber::prelude::*; 155 | 156 | let fmt_layer = tracing_subscriber::fmt::layer().with_target(false); 157 | let level_filter = tracing_subscriber::filter::LevelFilter::from_level(config.log_level); 158 | 159 | let registry = tracing_subscriber::registry() 160 | .with(level_filter) 161 | .with(fmt_layer); 162 | match tracing_journald::layer() { 163 | Ok(layer) => registry.with(layer).init(), 164 | Err(_err) => { 165 | registry.init(); 166 | } 167 | } 168 | } 169 | 170 | tracing::info!( 171 | "{} is initializing, pid: {}", 172 | clipcat::DAEMON_PROGRAM_NAME, 173 | std::process::id() 174 | ); 175 | 176 | let runtime = Runtime::new().context(error::InitializeTokioRuntimeSnafu)?; 177 | runtime.block_on(worker::start(config))?; 178 | 179 | if daemonize { 180 | pid_file.remove()?; 181 | } 182 | 183 | tracing::info!("{} is shutdown", clipcat::DAEMON_PROGRAM_NAME); 184 | Ok(()) 185 | } 186 | 187 | struct PidFile { 188 | path: PathBuf, 189 | } 190 | 191 | impl PidFile { 192 | #[inline] 193 | fn exists(&self) -> bool { 194 | self.path.exists() 195 | } 196 | 197 | #[inline] 198 | fn clone_path(&self) -> PathBuf { 199 | self.path().to_path_buf() 200 | } 201 | 202 | #[inline] 203 | fn path(&self) -> &Path { 204 | &self.path 205 | } 206 | 207 | fn try_load(&self) -> Result { 208 | let pid_data = std::fs::read_to_string(self).context(error::ReadPidFileSnafu { 209 | filename: self.clone_path(), 210 | })?; 211 | let pid = pid_data 212 | .trim() 213 | .parse() 214 | .context(error::ParseProcessIdSnafu { value: pid_data })?; 215 | Ok(pid) 216 | } 217 | 218 | #[inline] 219 | fn remove(self) -> Result<(), Error> { 220 | tracing::info!("Remove PID file: {:?}", self.path); 221 | std::fs::remove_file(&self.path).context(error::RemovePidFileSnafu { 222 | pid_file: self.path, 223 | })?; 224 | Ok(()) 225 | } 226 | 227 | fn set(&self) -> Result<(), Error> { 228 | tracing::info!("Setting PID file: {:?}", self.path); 229 | std::fs::write(self.path(), std::process::id().to_string().as_bytes()).context( 230 | error::SetPidFileSnafu { 231 | pid_file: self.clone_path(), 232 | }, 233 | ) 234 | } 235 | } 236 | 237 | impl From for PidFile { 238 | fn from(path: PathBuf) -> PidFile { 239 | PidFile { path } 240 | } 241 | } 242 | 243 | impl AsRef for PidFile { 244 | fn as_ref(&self) -> &Path { 245 | &self.path 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /completions/fish/completions/clipcatctl.fish: -------------------------------------------------------------------------------- 1 | complete -c clipcatctl -n "__fish_use_subcommand" -s c -l config -d 'Specifies a configuration file' 2 | complete -c clipcatctl -n "__fish_use_subcommand" -s h -l host -d 'Specifies a server host' 3 | complete -c clipcatctl -n "__fish_use_subcommand" -s p -l port -d 'Specifies a server port' 4 | complete -c clipcatctl -n "__fish_use_subcommand" -l log-level -d 'Specifies a log level' 5 | complete -c clipcatctl -n "__fish_use_subcommand" -l help -d 'Prints help information' 6 | complete -c clipcatctl -n "__fish_use_subcommand" -s V -l version -d 'Prints version information' 7 | complete -c clipcatctl -n "__fish_use_subcommand" -f -a "version" -d 'Prints version information' 8 | complete -c clipcatctl -n "__fish_use_subcommand" -f -a "completions" -d 'Outputs shell completion code for the specified shell (bash, zsh, fish)' 9 | complete -c clipcatctl -n "__fish_use_subcommand" -f -a "default-config" -d 'Outputs default configuration' 10 | complete -c clipcatctl -n "__fish_use_subcommand" -f -a "insert" -d 'Inserts new clip into clipboard' 11 | complete -c clipcatctl -n "__fish_use_subcommand" -f -a "insert-primary" -d 'Inserts new clip into primary clipboard' 12 | complete -c clipcatctl -n "__fish_use_subcommand" -f -a "load" -d 'Loads file into clipboard' 13 | complete -c clipcatctl -n "__fish_use_subcommand" -f -a "load-primary" -d 'Loads file into primary clipboard' 14 | complete -c clipcatctl -n "__fish_use_subcommand" -f -a "save" -d 'Pastes content of current clipboard into file' 15 | complete -c clipcatctl -n "__fish_use_subcommand" -f -a "save-primary" -d 'Pastes content of current primary clipboard into file' 16 | complete -c clipcatctl -n "__fish_use_subcommand" -f -a "get" -d 'Prints clip with ' 17 | complete -c clipcatctl -n "__fish_use_subcommand" -f -a "list" -d 'Prints history of clipboard' 18 | complete -c clipcatctl -n "__fish_use_subcommand" -f -a "update" -d 'Updates clip with ' 19 | complete -c clipcatctl -n "__fish_use_subcommand" -f -a "edit" -d 'Edits clip with ' 20 | complete -c clipcatctl -n "__fish_use_subcommand" -f -a "remove" -d 'Removes clips with [ids]' 21 | complete -c clipcatctl -n "__fish_use_subcommand" -f -a "promote" -d 'Replaces content of clipboard with clip with ' 22 | complete -c clipcatctl -n "__fish_use_subcommand" -f -a "promote-primary" -d 'Replaces content of primary clipboard with clip with ' 23 | complete -c clipcatctl -n "__fish_use_subcommand" -f -a "clear" -d 'Removes all clips in clipboard' 24 | complete -c clipcatctl -n "__fish_use_subcommand" -f -a "length" -d 'Prints length of clipboard history' 25 | complete -c clipcatctl -n "__fish_use_subcommand" -f -a "enable-monitor" -d 'Enable clipboard monitor' 26 | complete -c clipcatctl -n "__fish_use_subcommand" -f -a "disable-monitor" -d 'Disable clipboard monitor' 27 | complete -c clipcatctl -n "__fish_use_subcommand" -f -a "toggle-monitor" -d 'Toggle clipboard monitor' 28 | complete -c clipcatctl -n "__fish_use_subcommand" -f -a "get-monitor-state" -d 'Get clipboard monitor state' 29 | complete -c clipcatctl -n "__fish_use_subcommand" -f -a "help" -d 'Prints this message or the help of the given subcommand(s)' 30 | complete -c clipcatctl -n "__fish_seen_subcommand_from version" -s h -l help -d 'Prints help information' 31 | complete -c clipcatctl -n "__fish_seen_subcommand_from version" -s V -l version -d 'Prints version information' 32 | complete -c clipcatctl -n "__fish_seen_subcommand_from completions" -s h -l help -d 'Prints help information' 33 | complete -c clipcatctl -n "__fish_seen_subcommand_from completions" -s V -l version -d 'Prints version information' 34 | complete -c clipcatctl -n "__fish_seen_subcommand_from default-config" -s h -l help -d 'Prints help information' 35 | complete -c clipcatctl -n "__fish_seen_subcommand_from default-config" -s V -l version -d 'Prints version information' 36 | complete -c clipcatctl -n "__fish_seen_subcommand_from insert" -s h -l help -d 'Prints help information' 37 | complete -c clipcatctl -n "__fish_seen_subcommand_from insert" -s V -l version -d 'Prints version information' 38 | complete -c clipcatctl -n "__fish_seen_subcommand_from insert-primary" -s h -l help -d 'Prints help information' 39 | complete -c clipcatctl -n "__fish_seen_subcommand_from insert-primary" -s V -l version -d 'Prints version information' 40 | complete -c clipcatctl -n "__fish_seen_subcommand_from load" -s f -l file 41 | complete -c clipcatctl -n "__fish_seen_subcommand_from load" -s h -l help -d 'Prints help information' 42 | complete -c clipcatctl -n "__fish_seen_subcommand_from load" -s V -l version -d 'Prints version information' 43 | complete -c clipcatctl -n "__fish_seen_subcommand_from load-primary" -s f -l file 44 | complete -c clipcatctl -n "__fish_seen_subcommand_from load-primary" -s h -l help -d 'Prints help information' 45 | complete -c clipcatctl -n "__fish_seen_subcommand_from load-primary" -s V -l version -d 'Prints version information' 46 | complete -c clipcatctl -n "__fish_seen_subcommand_from save" -s f -l file 47 | complete -c clipcatctl -n "__fish_seen_subcommand_from save" -s h -l help -d 'Prints help information' 48 | complete -c clipcatctl -n "__fish_seen_subcommand_from save" -s V -l version -d 'Prints version information' 49 | complete -c clipcatctl -n "__fish_seen_subcommand_from save-primary" -s f -l file 50 | complete -c clipcatctl -n "__fish_seen_subcommand_from save-primary" -s h -l help -d 'Prints help information' 51 | complete -c clipcatctl -n "__fish_seen_subcommand_from save-primary" -s V -l version -d 'Prints version information' 52 | complete -c clipcatctl -n "__fish_seen_subcommand_from get" -s h -l help -d 'Prints help information' 53 | complete -c clipcatctl -n "__fish_seen_subcommand_from get" -s V -l version -d 'Prints version information' 54 | complete -c clipcatctl -n "__fish_seen_subcommand_from list" -l no-id 55 | complete -c clipcatctl -n "__fish_seen_subcommand_from list" -s h -l help -d 'Prints help information' 56 | complete -c clipcatctl -n "__fish_seen_subcommand_from list" -s V -l version -d 'Prints version information' 57 | complete -c clipcatctl -n "__fish_seen_subcommand_from update" -s h -l help -d 'Prints help information' 58 | complete -c clipcatctl -n "__fish_seen_subcommand_from update" -s V -l version -d 'Prints version information' 59 | complete -c clipcatctl -n "__fish_seen_subcommand_from edit" -s e -l editor 60 | complete -c clipcatctl -n "__fish_seen_subcommand_from edit" -s h -l help -d 'Prints help information' 61 | complete -c clipcatctl -n "__fish_seen_subcommand_from edit" -s V -l version -d 'Prints version information' 62 | complete -c clipcatctl -n "__fish_seen_subcommand_from remove" -s h -l help -d 'Prints help information' 63 | complete -c clipcatctl -n "__fish_seen_subcommand_from remove" -s V -l version -d 'Prints version information' 64 | complete -c clipcatctl -n "__fish_seen_subcommand_from promote" -s h -l help -d 'Prints help information' 65 | complete -c clipcatctl -n "__fish_seen_subcommand_from promote" -s V -l version -d 'Prints version information' 66 | complete -c clipcatctl -n "__fish_seen_subcommand_from promote-primary" -s h -l help -d 'Prints help information' 67 | complete -c clipcatctl -n "__fish_seen_subcommand_from promote-primary" -s V -l version -d 'Prints version information' 68 | complete -c clipcatctl -n "__fish_seen_subcommand_from clear" -s h -l help -d 'Prints help information' 69 | complete -c clipcatctl -n "__fish_seen_subcommand_from clear" -s V -l version -d 'Prints version information' 70 | complete -c clipcatctl -n "__fish_seen_subcommand_from length" -s h -l help -d 'Prints help information' 71 | complete -c clipcatctl -n "__fish_seen_subcommand_from length" -s V -l version -d 'Prints version information' 72 | complete -c clipcatctl -n "__fish_seen_subcommand_from enable-monitor" -s h -l help -d 'Prints help information' 73 | complete -c clipcatctl -n "__fish_seen_subcommand_from enable-monitor" -s V -l version -d 'Prints version information' 74 | complete -c clipcatctl -n "__fish_seen_subcommand_from disable-monitor" -s h -l help -d 'Prints help information' 75 | complete -c clipcatctl -n "__fish_seen_subcommand_from disable-monitor" -s V -l version -d 'Prints version information' 76 | complete -c clipcatctl -n "__fish_seen_subcommand_from toggle-monitor" -s h -l help -d 'Prints help information' 77 | complete -c clipcatctl -n "__fish_seen_subcommand_from toggle-monitor" -s V -l version -d 'Prints version information' 78 | complete -c clipcatctl -n "__fish_seen_subcommand_from get-monitor-state" -s h -l help -d 'Prints help information' 79 | complete -c clipcatctl -n "__fish_seen_subcommand_from get-monitor-state" -s V -l version -d 'Prints version information' 80 | complete -c clipcatctl -n "__fish_seen_subcommand_from help" -s h -l help -d 'Prints help information' 81 | complete -c clipcatctl -n "__fish_seen_subcommand_from help" -s V -l version -d 'Prints version information' 82 | -------------------------------------------------------------------------------- /completions/zsh/site-functions/_clipcat-menu: -------------------------------------------------------------------------------- 1 | #compdef clipcat-menu 2 | 3 | autoload -U is-at-least 4 | 5 | _clipcat-menu() { 6 | typeset -A opt_args 7 | typeset -a _arguments_options 8 | local ret=1 9 | 10 | if is-at-least 5.2; then 11 | _arguments_options=(-s -S -C) 12 | else 13 | _arguments_options=(-s -C) 14 | fi 15 | 16 | local context curcontext="$curcontext" state line 17 | _arguments "${_arguments_options[@]}" \ 18 | '-c+[Specifies a configuration file]' \ 19 | '--config=[Specifies a configuration file]' \ 20 | '-f+[Specifies a finder]' \ 21 | '--finder=[Specifies a finder]' \ 22 | '-m+[Specifies the menu length of finder]' \ 23 | '--menu-length=[Specifies the menu length of finder]' \ 24 | '-l+[Specifies the length of a line showing on finder]' \ 25 | '--line-length=[Specifies the length of a line showing on finder]' \ 26 | '-h[Prints help information]' \ 27 | '--help[Prints help information]' \ 28 | '-V[Prints version information]' \ 29 | '--version[Prints version information]' \ 30 | ":: :_clipcat-menu_commands" \ 31 | "*::: :->clipcat-menu" \ 32 | && ret=0 33 | case $state in 34 | (clipcat-menu) 35 | words=($line[1] "${words[@]}") 36 | (( CURRENT += 1 )) 37 | curcontext="${curcontext%:*:*}:clipcat-menu-command-$line[1]:" 38 | case $line[1] in 39 | (version) 40 | _arguments "${_arguments_options[@]}" \ 41 | '-h[Prints help information]' \ 42 | '--help[Prints help information]' \ 43 | '-V[Prints version information]' \ 44 | '--version[Prints version information]' \ 45 | && ret=0 46 | ;; 47 | (completions) 48 | _arguments "${_arguments_options[@]}" \ 49 | '-h[Prints help information]' \ 50 | '--help[Prints help information]' \ 51 | '-V[Prints version information]' \ 52 | '--version[Prints version information]' \ 53 | ':shell:_files' \ 54 | && ret=0 55 | ;; 56 | (default-config) 57 | _arguments "${_arguments_options[@]}" \ 58 | '-h[Prints help information]' \ 59 | '--help[Prints help information]' \ 60 | '-V[Prints version information]' \ 61 | '--version[Prints version information]' \ 62 | && ret=0 63 | ;; 64 | (list-finder) 65 | _arguments "${_arguments_options[@]}" \ 66 | '-h[Prints help information]' \ 67 | '--help[Prints help information]' \ 68 | '-V[Prints version information]' \ 69 | '--version[Prints version information]' \ 70 | && ret=0 71 | ;; 72 | (insert) 73 | _arguments "${_arguments_options[@]}" \ 74 | '-h[Prints help information]' \ 75 | '--help[Prints help information]' \ 76 | '-V[Prints version information]' \ 77 | '--version[Prints version information]' \ 78 | && ret=0 79 | ;; 80 | (insert-primary) 81 | _arguments "${_arguments_options[@]}" \ 82 | '-h[Prints help information]' \ 83 | '--help[Prints help information]' \ 84 | '-V[Prints version information]' \ 85 | '--version[Prints version information]' \ 86 | && ret=0 87 | ;; 88 | (rm) 89 | _arguments "${_arguments_options[@]}" \ 90 | '-h[Prints help information]' \ 91 | '--help[Prints help information]' \ 92 | '-V[Prints version information]' \ 93 | '--version[Prints version information]' \ 94 | && ret=0 95 | ;; 96 | (delete) 97 | _arguments "${_arguments_options[@]}" \ 98 | '-h[Prints help information]' \ 99 | '--help[Prints help information]' \ 100 | '-V[Prints version information]' \ 101 | '--version[Prints version information]' \ 102 | && ret=0 103 | ;; 104 | (del) 105 | _arguments "${_arguments_options[@]}" \ 106 | '-h[Prints help information]' \ 107 | '--help[Prints help information]' \ 108 | '-V[Prints version information]' \ 109 | '--version[Prints version information]' \ 110 | && ret=0 111 | ;; 112 | (remove) 113 | _arguments "${_arguments_options[@]}" \ 114 | '-h[Prints help information]' \ 115 | '--help[Prints help information]' \ 116 | '-V[Prints version information]' \ 117 | '--version[Prints version information]' \ 118 | && ret=0 119 | ;; 120 | (edit) 121 | _arguments "${_arguments_options[@]}" \ 122 | '-e+[Specifies a external editor]' \ 123 | '--editor=[Specifies a external editor]' \ 124 | '-h[Prints help information]' \ 125 | '--help[Prints help information]' \ 126 | '-V[Prints version information]' \ 127 | '--version[Prints version information]' \ 128 | && ret=0 129 | ;; 130 | (help) 131 | _arguments "${_arguments_options[@]}" \ 132 | '-h[Prints help information]' \ 133 | '--help[Prints help information]' \ 134 | '-V[Prints version information]' \ 135 | '--version[Prints version information]' \ 136 | && ret=0 137 | ;; 138 | esac 139 | ;; 140 | esac 141 | } 142 | 143 | (( $+functions[_clipcat-menu_commands] )) || 144 | _clipcat-menu_commands() { 145 | local commands; commands=( 146 | "version:Prints version information" \ 147 | "completions:Outputs shell completion code for the specified shell (bash, zsh, fish)" \ 148 | "default-config:Outputs default configuration" \ 149 | "list-finder:Prints available text finders" \ 150 | "insert:Insert selected clip into clipboard" \ 151 | "insert-primary:Insert selected clip into primary clipboard" \ 152 | "remove:Removes selected clip" \ 153 | "edit:Edit selected clip" \ 154 | "help:Prints this message or the help of the given subcommand(s)" \ 155 | ) 156 | _describe -t commands 'clipcat-menu commands' commands "$@" 157 | } 158 | (( $+functions[_clipcat-menu__completions_commands] )) || 159 | _clipcat-menu__completions_commands() { 160 | local commands; commands=( 161 | 162 | ) 163 | _describe -t commands 'clipcat-menu completions commands' commands "$@" 164 | } 165 | (( $+functions[_clipcat-menu__default-config_commands] )) || 166 | _clipcat-menu__default-config_commands() { 167 | local commands; commands=( 168 | 169 | ) 170 | _describe -t commands 'clipcat-menu default-config commands' commands "$@" 171 | } 172 | (( $+functions[_clipcat-menu__del_commands] )) || 173 | _clipcat-menu__del_commands() { 174 | local commands; commands=( 175 | 176 | ) 177 | _describe -t commands 'clipcat-menu del commands' commands "$@" 178 | } 179 | (( $+functions[_del_commands] )) || 180 | _del_commands() { 181 | local commands; commands=( 182 | 183 | ) 184 | _describe -t commands 'del commands' commands "$@" 185 | } 186 | (( $+functions[_clipcat-menu__delete_commands] )) || 187 | _clipcat-menu__delete_commands() { 188 | local commands; commands=( 189 | 190 | ) 191 | _describe -t commands 'clipcat-menu delete commands' commands "$@" 192 | } 193 | (( $+functions[_delete_commands] )) || 194 | _delete_commands() { 195 | local commands; commands=( 196 | 197 | ) 198 | _describe -t commands 'delete commands' commands "$@" 199 | } 200 | (( $+functions[_clipcat-menu__edit_commands] )) || 201 | _clipcat-menu__edit_commands() { 202 | local commands; commands=( 203 | 204 | ) 205 | _describe -t commands 'clipcat-menu edit commands' commands "$@" 206 | } 207 | (( $+functions[_clipcat-menu__help_commands] )) || 208 | _clipcat-menu__help_commands() { 209 | local commands; commands=( 210 | 211 | ) 212 | _describe -t commands 'clipcat-menu help commands' commands "$@" 213 | } 214 | (( $+functions[_clipcat-menu__insert_commands] )) || 215 | _clipcat-menu__insert_commands() { 216 | local commands; commands=( 217 | 218 | ) 219 | _describe -t commands 'clipcat-menu insert commands' commands "$@" 220 | } 221 | (( $+functions[_clipcat-menu__insert-primary_commands] )) || 222 | _clipcat-menu__insert-primary_commands() { 223 | local commands; commands=( 224 | 225 | ) 226 | _describe -t commands 'clipcat-menu insert-primary commands' commands "$@" 227 | } 228 | (( $+functions[_clipcat-menu__list-finder_commands] )) || 229 | _clipcat-menu__list-finder_commands() { 230 | local commands; commands=( 231 | 232 | ) 233 | _describe -t commands 'clipcat-menu list-finder commands' commands "$@" 234 | } 235 | (( $+functions[_clipcat-menu__remove_commands] )) || 236 | _clipcat-menu__remove_commands() { 237 | local commands; commands=( 238 | 239 | ) 240 | _describe -t commands 'clipcat-menu remove commands' commands "$@" 241 | } 242 | (( $+functions[_clipcat-menu__rm_commands] )) || 243 | _clipcat-menu__rm_commands() { 244 | local commands; commands=( 245 | 246 | ) 247 | _describe -t commands 'clipcat-menu rm commands' commands "$@" 248 | } 249 | (( $+functions[_rm_commands] )) || 250 | _rm_commands() { 251 | local commands; commands=( 252 | 253 | ) 254 | _describe -t commands 'rm commands' commands "$@" 255 | } 256 | (( $+functions[_clipcat-menu__version_commands] )) || 257 | _clipcat-menu__version_commands() { 258 | local commands; commands=( 259 | 260 | ) 261 | _describe -t commands 'clipcat-menu version commands' commands "$@" 262 | } 263 | 264 | _clipcat-menu "$@" -------------------------------------------------------------------------------- /src/bin/clipcat-menu/command.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use snafu::ResultExt; 4 | use structopt::StructOpt; 5 | use tokio::runtime::Runtime; 6 | 7 | use clipcat::{editor::ExternalEditor, grpc::GrpcClient, ClipboardData, ClipboardType}; 8 | 9 | use crate::{ 10 | config::Config, 11 | error::{self, Error}, 12 | finder::{FinderRunner, FinderType}, 13 | }; 14 | 15 | const LINE_LENGTH: usize = 100; 16 | 17 | #[derive(Debug, Clone, StructOpt)] 18 | #[structopt(name = clipcat::MENU_PROGRAM_NAME)] 19 | pub struct Command { 20 | #[structopt(subcommand)] 21 | subcommand: Option, 22 | 23 | #[structopt(long = "config", short = "c", help = "Specifies a configuration file")] 24 | config_file: Option, 25 | 26 | #[structopt(long, short = "f", help = "Specifies a finder")] 27 | finder: Option, 28 | 29 | #[structopt(long, short = "m", help = "Specifies the menu length of finder")] 30 | menu_length: Option, 31 | 32 | #[structopt( 33 | long, 34 | short = "l", 35 | help = "Specifies the length of a line showing on finder" 36 | )] 37 | line_length: Option, 38 | 39 | #[structopt(long = "log-level", help = "Specifies a log level")] 40 | log_level: Option, 41 | } 42 | 43 | #[derive(Debug, Clone, StructOpt)] 44 | pub enum SubCommand { 45 | #[structopt(about = "Prints version information")] 46 | Version, 47 | 48 | #[structopt(about = "Outputs shell completion code for the specified shell (bash, zsh, fish)")] 49 | Completions { shell: structopt::clap::Shell }, 50 | 51 | #[structopt(about = "Outputs default configuration")] 52 | DefaultConfig, 53 | 54 | #[structopt(about = "Prints available text finders")] 55 | ListFinder, 56 | 57 | #[structopt(about = "Insert selected clip into clipboard")] 58 | Insert, 59 | 60 | #[structopt(about = "Insert selected clip into primary clipboard")] 61 | InsertPrimary, 62 | 63 | #[structopt( 64 | aliases = &["rm", "delete", "del"], 65 | about = "Removes selected clip")] 66 | Remove, 67 | 68 | #[structopt(about = "Edit selected clip")] 69 | Edit { 70 | #[structopt( 71 | env = "EDITOR", 72 | long = "editor", 73 | short = "e", 74 | help = "Specifies a external editor" 75 | )] 76 | editor: String, 77 | }, 78 | } 79 | 80 | impl Command { 81 | pub fn new() -> Command { 82 | Command::from_args() 83 | } 84 | 85 | pub fn run(self) -> Result<(), Error> { 86 | match self.subcommand { 87 | Some(SubCommand::Version) => { 88 | Self::clap() 89 | .write_long_version(&mut std::io::stdout()) 90 | .expect("failed to write to stdout"); 91 | return Ok(()); 92 | } 93 | Some(SubCommand::Completions { shell }) => { 94 | Self::clap().gen_completions_to( 95 | clipcat::MENU_PROGRAM_NAME, 96 | shell, 97 | &mut std::io::stdout(), 98 | ); 99 | return Ok(()); 100 | } 101 | Some(SubCommand::DefaultConfig) => { 102 | println!( 103 | "{}", 104 | toml::to_string_pretty(&Config::default()).expect("Config is serializable") 105 | ); 106 | return Ok(()); 107 | } 108 | Some(SubCommand::ListFinder) => { 109 | for ty in FinderType::available_types() { 110 | println!("{}", ty.to_string()); 111 | } 112 | return Ok(()); 113 | } 114 | _ => {} 115 | } 116 | 117 | { 118 | use tracing_subscriber::prelude::*; 119 | 120 | let mut level_filter = tracing::Level::INFO; 121 | if let Ok(log_level) = std::env::var("RUST_LOG") { 122 | use std::str::FromStr; 123 | level_filter = tracing::Level::from_str(&log_level).unwrap_or(tracing::Level::INFO); 124 | } 125 | 126 | if let Some(log_level) = self.log_level { 127 | level_filter = log_level; 128 | } 129 | 130 | let fmt_layer = tracing_subscriber::fmt::layer().with_target(false); 131 | 132 | let registry = tracing_subscriber::registry() 133 | .with(tracing_subscriber::filter::LevelFilter::from_level( 134 | level_filter, 135 | )) 136 | .with(fmt_layer); 137 | match tracing_journald::layer() { 138 | Ok(layer) => registry.with(layer).init(), 139 | Err(_err) => { 140 | registry.init(); 141 | } 142 | } 143 | } 144 | 145 | let mut config = 146 | Config::load_or_default(self.config_file.unwrap_or_else(Config::default_path)); 147 | 148 | let finder = { 149 | if let Some(finder) = self.finder { 150 | config.finder = finder; 151 | } 152 | 153 | let mut finder = FinderRunner::from_config(&config)?; 154 | if let Some(line_length) = self.line_length { 155 | finder.set_line_length(line_length); 156 | } 157 | 158 | if let Some(menu_length) = self.menu_length { 159 | finder.set_menu_length(menu_length); 160 | } 161 | finder 162 | }; 163 | 164 | let subcommand = self.subcommand; 165 | let fut = async move { 166 | let grpc_addr = format!("http://{}:{}", config.server_host, config.server_port); 167 | let mut client = GrpcClient::new(grpc_addr).await?; 168 | let clips = client.list().await?; 169 | 170 | match subcommand { 171 | Some(SubCommand::Insert) | None => { 172 | insert_clip(&clips, finder, client, ClipboardType::Clipboard).await? 173 | } 174 | Some(SubCommand::InsertPrimary) => { 175 | insert_clip(&clips, finder, client, ClipboardType::Primary).await? 176 | } 177 | Some(SubCommand::Remove) => { 178 | let selections = finder.multiple_select(&clips).await?; 179 | let ids: Vec<_> = selections.into_iter().map(|(_, clip)| clip.id).collect(); 180 | let removed_ids = client.batch_remove(&ids).await?; 181 | for id in removed_ids { 182 | tracing::info!("Removing clip (id: {:016x})", id); 183 | } 184 | } 185 | Some(SubCommand::Edit { editor }) => { 186 | let selection = finder.single_select(&clips).await?; 187 | if let Some((_index, clip)) = selection { 188 | let editor = ExternalEditor::new(editor); 189 | let new_data = editor 190 | .execute(&clip.data) 191 | .await 192 | .context(error::CallEditorSnafu)?; 193 | let (ok, new_id) = client.update(clip.id, &new_data).await?; 194 | if ok { 195 | tracing::info!("Editing clip (id: {:016x})", new_id); 196 | } 197 | client.mark_as_clipboard(new_id).await?; 198 | } else { 199 | tracing::info!("Nothing is selected"); 200 | return Ok(()); 201 | } 202 | } 203 | _ => unreachable!(), 204 | } 205 | 206 | Ok(()) 207 | }; 208 | 209 | let runtime = Runtime::new().context(error::CreateTokioRuntimeSnafu)?; 210 | runtime.block_on(fut) 211 | } 212 | } 213 | 214 | async fn insert_clip( 215 | clips: &[ClipboardData], 216 | finder: FinderRunner, 217 | mut client: GrpcClient, 218 | clipboard_type: ClipboardType, 219 | ) -> Result<(), Error> { 220 | let selection = finder.single_select(clips).await?; 221 | if let Some((index, clip)) = selection { 222 | tracing::info!( 223 | "Inserting clip (index: {}, id: {:016x}, content: {:?})", 224 | index, 225 | clip.id, 226 | clip.printable_data(Some(LINE_LENGTH)), 227 | ); 228 | match clipboard_type { 229 | ClipboardType::Clipboard => { 230 | client.mark_as_clipboard(clip.id).await?; 231 | } 232 | ClipboardType::Primary => { 233 | client.mark_as_primary(clip.id).await?; 234 | } 235 | } 236 | } else { 237 | tracing::info!("Nothing is selected"); 238 | } 239 | 240 | Ok(()) 241 | } 242 | -------------------------------------------------------------------------------- /src/grpc/service.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use tokio::sync::Mutex; 4 | 5 | use tonic::{Request, Response, Status}; 6 | 7 | use crate::{ 8 | grpc::protobuf::{ 9 | manager_server::Manager, monitor_server::Monitor, BatchRemoveRequest, BatchRemoveResponse, 10 | ClearRequest, ClearResponse, DisableMonitorRequest, EnableMonitorRequest, 11 | GetCurrentClipboardRequest, GetCurrentClipboardResponse, GetCurrentPrimaryRequest, 12 | GetCurrentPrimaryResponse, GetMonitorStateRequest, GetRequest, GetResponse, InsertRequest, 13 | InsertResponse, LengthRequest, LengthResponse, ListRequest, ListResponse, 14 | MarkAsClipboardRequest, MarkAsClipboardResponse, MarkAsPrimaryRequest, 15 | MarkAsPrimaryResponse, MonitorStateReply, RemoveRequest, RemoveResponse, 16 | ToggleMonitorRequest, UpdateRequest, UpdateResponse, 17 | }, 18 | ClipboardManager, ClipboardMonitor, 19 | }; 20 | 21 | pub struct ManagerService { 22 | manager: Arc>, 23 | } 24 | 25 | impl ManagerService { 26 | pub fn new(manager: Arc>) -> ManagerService { 27 | ManagerService { manager } 28 | } 29 | } 30 | 31 | #[tonic::async_trait] 32 | impl Manager for ManagerService { 33 | async fn insert( 34 | &self, 35 | request: Request, 36 | ) -> Result, Status> { 37 | let InsertRequest { 38 | data, 39 | clipboard_type, 40 | } = request.into_inner(); 41 | let clipboard_type = clipboard_type.into(); 42 | let id = { 43 | let mut manager = self.manager.lock().await; 44 | let id = manager.insert(crate::ClipboardData::new(&data, clipboard_type)); 45 | 46 | if clipboard_type == crate::ClipboardType::Clipboard { 47 | let _ = manager.mark_as_clipboard(id).await; 48 | } 49 | 50 | id 51 | }; 52 | Ok(Response::new(InsertResponse { id })) 53 | } 54 | 55 | async fn remove( 56 | &self, 57 | request: Request, 58 | ) -> Result, Status> { 59 | let id = request.into_inner().id; 60 | let ok = { 61 | let mut manager = self.manager.lock().await; 62 | manager.remove(id) 63 | }; 64 | Ok(Response::new(RemoveResponse { ok })) 65 | } 66 | 67 | async fn batch_remove( 68 | &self, 69 | request: Request, 70 | ) -> Result, Status> { 71 | let ids = request.into_inner().ids; 72 | let ids = { 73 | let mut manager = self.manager.lock().await; 74 | ids.into_iter().filter(|id| manager.remove(*id)).collect() 75 | }; 76 | Ok(Response::new(BatchRemoveResponse { ids })) 77 | } 78 | 79 | async fn clear( 80 | &self, 81 | _request: Request, 82 | ) -> Result, Status> { 83 | { 84 | let mut manager = self.manager.lock().await; 85 | manager.clear(); 86 | } 87 | Ok(Response::new(ClearResponse {})) 88 | } 89 | 90 | async fn get(&self, request: Request) -> Result, Status> { 91 | let GetRequest { id } = request.into_inner(); 92 | let data = { 93 | let manager = self.manager.lock().await; 94 | manager.get(id).map(Into::into) 95 | }; 96 | Ok(Response::new(GetResponse { data })) 97 | } 98 | 99 | async fn get_current_clipboard( 100 | &self, 101 | _request: Request, 102 | ) -> Result, Status> { 103 | let data = { 104 | let manager = self.manager.lock().await; 105 | manager 106 | .get_current_clipboard() 107 | .map(|clip| clip.clone().into()) 108 | }; 109 | Ok(Response::new(GetCurrentClipboardResponse { data })) 110 | } 111 | 112 | async fn get_current_primary( 113 | &self, 114 | _request: Request, 115 | ) -> Result, Status> { 116 | let data = { 117 | let manager = self.manager.lock().await; 118 | manager 119 | .get_current_primary() 120 | .map(|clip| clip.clone().into()) 121 | }; 122 | Ok(Response::new(GetCurrentPrimaryResponse { data })) 123 | } 124 | 125 | async fn list(&self, _request: Request) -> Result, Status> { 126 | let data = { 127 | let manager = self.manager.lock().await; 128 | manager.list().into_iter().map(Into::into).collect() 129 | }; 130 | Ok(Response::new(ListResponse { data })) 131 | } 132 | 133 | async fn update( 134 | &self, 135 | request: Request, 136 | ) -> Result, Status> { 137 | let UpdateRequest { id, data } = request.into_inner(); 138 | let (ok, new_id) = { 139 | let mut manager = self.manager.lock().await; 140 | manager.replace(id, &data) 141 | }; 142 | Ok(Response::new(UpdateResponse { ok, new_id })) 143 | } 144 | 145 | async fn mark_as_clipboard( 146 | &self, 147 | request: Request, 148 | ) -> Result, Status> { 149 | let MarkAsClipboardRequest { id } = request.into_inner(); 150 | let ok = { 151 | let mut manager = self.manager.lock().await; 152 | manager.mark_as_clipboard(id).await.is_ok() 153 | }; 154 | Ok(Response::new(MarkAsClipboardResponse { ok })) 155 | } 156 | 157 | async fn mark_as_primary( 158 | &self, 159 | request: Request, 160 | ) -> Result, Status> { 161 | let MarkAsPrimaryRequest { id } = request.into_inner(); 162 | let ok = { 163 | let mut manager = self.manager.lock().await; 164 | manager.mark_as_primary(id).await.is_ok() 165 | }; 166 | Ok(Response::new(MarkAsPrimaryResponse { ok })) 167 | } 168 | 169 | async fn length( 170 | &self, 171 | _request: Request, 172 | ) -> Result, Status> { 173 | let length = { 174 | let manager = self.manager.lock().await; 175 | manager.len() as u64 176 | }; 177 | Ok(Response::new(LengthResponse { length })) 178 | } 179 | } 180 | 181 | pub struct MonitorService { 182 | monitor: Arc>, 183 | } 184 | 185 | impl MonitorService { 186 | #[inline] 187 | pub fn new(monitor: Arc>) -> MonitorService { 188 | MonitorService { monitor } 189 | } 190 | } 191 | 192 | #[tonic::async_trait] 193 | impl Monitor for MonitorService { 194 | async fn enable_monitor( 195 | &self, 196 | _request: Request, 197 | ) -> Result, Status> { 198 | let state = { 199 | let mut monitor = self.monitor.lock().await; 200 | monitor.enable(); 201 | MonitorStateReply { 202 | state: monitor.state().into(), 203 | } 204 | }; 205 | dbg!(&state); 206 | 207 | Ok(Response::new(state)) 208 | } 209 | 210 | async fn disable_monitor( 211 | &self, 212 | _request: Request, 213 | ) -> Result, Status> { 214 | let state = { 215 | let mut monitor = self.monitor.lock().await; 216 | monitor.disable(); 217 | MonitorStateReply { 218 | state: monitor.state().into(), 219 | } 220 | }; 221 | 222 | Ok(Response::new(state)) 223 | } 224 | 225 | async fn toggle_monitor( 226 | &self, 227 | _request: Request, 228 | ) -> Result, Status> { 229 | let state = { 230 | let mut monitor = self.monitor.lock().await; 231 | monitor.toggle(); 232 | MonitorStateReply { 233 | state: monitor.state().into(), 234 | } 235 | }; 236 | 237 | Ok(Response::new(state)) 238 | } 239 | 240 | async fn get_monitor_state( 241 | &self, 242 | _request: Request, 243 | ) -> Result, Status> { 244 | let state = { 245 | let monitor = self.monitor.lock().await; 246 | MonitorStateReply { 247 | state: monitor.state().into(), 248 | } 249 | }; 250 | 251 | Ok(Response::new(state)) 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Clipcat 2 | 3 | [![CI](https://github.com/xrelkd/clipcat/workflows/Build/badge.svg)](https://github.com/xrelkd/clipcat/actions) 4 | 5 | `Clipcat` is a clipboard manager written in [Rust Programming Language](https://www.rust-lang.org/). 6 | 7 | ## About this fork 8 | 9 | Some critical issues (mainly [loosing the connection](https://github.com/xrelkd/clipcat/issues/19)) 10 | were found when using it, and [xrelkd](https://github.com/xrelkd) didn't respond, so I fixed them here. 11 | 12 | Wayland support is built in, but not tested. If anyone is interested, please report back! 13 | 14 | I've also added a [systemd service file](clipcat.service), which you can copy to ~/.config/systemd/user/clipcat.service 15 | 16 | Compiling RocksDB took ages, and currently my DB is 34MB. That's a lot for 250 snippets of text. 17 | Apparently, it doesn't remove from the actual files when clearing the history. 18 | So I removed the dependency, and now data is serialized and deserialized using [bincode](https://crates.io/crates/bincode), 19 | which already was an explicit dependency. 20 | 21 | This also adds configuration value to the [monitor] section of `clipcatd.toml`; `filter_min_size`. 22 | It filters the copy actions for a minimum size. See the example configuration below for more info. 23 | If you use Vim and set this to 1, holding `x` no longer ruins the Clipcat history; Vim's 1 byte long copy actions aren't saved. 24 | 25 | You can install all the binaries (with both X11 and Wayland support) using 26 | 27 | ```shell 28 | $ cargo install --path . -F all-bins 29 | ``` 30 | 31 | or with only wayland support: 32 | 33 | ```shell 34 | $ cargo install --path . --no-default-features -F all-bins,wayland 35 | ``` 36 | 37 | ## Architecture 38 | 39 | Clipcat uses the Client-Server architecture. There are two role types in this architecture: `Server` and `Client`. 40 | 41 | ### Clipcat Server 42 | 43 | A `clipcat` server (as known as daemon) is running as the background process and does the following routines: 44 | 45 | - Watching the changes of `X11 clipboard`. 46 | - Caching the content of `X11 clipboard`. 47 | - Inserting content into `X11 clipboard`. 48 | - Serving as a `gRPC` server and waiting for remote procedure call from clients. 49 | 50 | ### Clipcat Client 51 | 52 | A `clipcat` client sends requests to the server for the following operations: 53 | 54 | - List: list the cached clips from server. 55 | - Insert: replace the current content of `X11 clipboard` with a clip. 56 | - Remove: remove the cached clips from server. 57 | 58 | ### List of Implementations 59 | 60 | | Program | Role Type | Comment | 61 | | -------------- | --------- | -------------------------------------------------------------------------------------- | 62 | | `clipcatd` | `Server` | The `clipcat` server (daemon) | 63 | | `clipcatctl` | `Client` | The `clipcat` client which provides a command line interface | 64 | | `clipcat-menu` | `Client` | The `clipcat` client which calls built-in finder or external finder for selecting clip | 65 | 66 | ## Quick Start 67 | 68 | ### Installation 69 | 70 | | Linux Distribution | Package Manager | Package | Command | 71 | | ----------------------------------- | ------------------------------------------- | --------------------------------------------------------------------------------------------------- | ----------------------------- | 72 | | Various | [Nix](https://github.com/NixOS/nix) | [clipcat](https://github.com/xrelkd/nixpkgs/blob/master/pkgs/applications/misc/clipcat/default.nix) | `nix-env -iA nixpkgs.clipcat` | 73 | | [NixOS](https://nixos.org) | [Nix](https://github.com/NixOS/nix) | [clipcat](https://github.com/xrelkd/nixpkgs/blob/master/pkgs/applications/misc/clipcat/default.nix) | `nix-env -iA nixos.clipcat` | 74 | | [Arch Linux](https://archlinux.org) | [Paru](https://github.com/Morganamilo/paru) | [clipcat](https://aur.archlinux.org/packages/clipcat/) | `paru -S clipcat` | 75 | 76 | ### Usage 77 | 78 | 0. Setup configurations for `clipcat`. 79 | 80 | ```console 81 | $ mkdir -p $XDG_CONFIG_HOME/clipcat 82 | $ clipcatd default-config > $XDG_CONFIG_HOME/clipcat/clipcatd.toml 83 | $ clipcatctl default-config > $XDG_CONFIG_HOME/clipcat/clipcatctl.toml 84 | $ clipcat-menu default-config > $XDG_CONFIG_HOME/clipcat/clipcat-menu.toml 85 | ``` 86 | 87 | 1. Start `clipcatd` for watching clipboard events. 88 | 89 | ```console 90 | $ clipcatd 91 | ``` 92 | 93 | 2. Copy arbitrary text from other X11 process with your mouse or keyboard. 94 | 95 | 3. You can run following commands with `clipcatctl` or `clipcat-menu`: 96 | 97 | | Command | Comment | 98 | | ------------------------- | ------------------------------------------------- | 99 | | `clipcatctl list` | List cached clipboard history | 100 | | `clipcatctl promote ` | Insert cached clip with `` into X11 clipboard | 101 | | `clipcatctl remove [ids]` | Remove cached clips with `[ids]` from server | 102 | | `clipcatctl clear` | Clear cached clipboard history | 103 | 104 | | Command | Comment | 105 | | --------------------- | --------------------------------------- | 106 | | `clipcat-menu insert` | Insert a cached clip into X11 clipboard | 107 | | `clipcat-menu remove` | Remove cached clips from server | 108 | | `clipcat-menu edit` | Edit a cached clip with `\$EDITOR` | 109 | 110 | **Note**: Supported finders for `clipcat-menu`: 111 | 112 | - built-in finder (integrate with crate [skim](https://github.com/lotabout/skim)) 113 | - [skim](https://github.com/lotabout/skim) 114 | - [fzf](https://github.com/junegunn/fzf) 115 | - [rofi](https://github.com/davatorium/rofi) 116 | - [dmenu](https://tools.suckless.org/dmenu/) 117 | 118 | ### Configuration 119 | 120 | | Program | Default Configuration File Path | 121 | | -------------- | -------------------------------------------- | 122 | | `clipcatd` | `$XDG_CONFIG_HOME/clipcat/clipcatd.toml` | 123 | | `clipcatctl` | `$XDG_CONFIG_HOME/clipcat/clipcatctl.toml` | 124 | | `clipcat-menu` | `$XDG_CONFIG_HOME/clipcat/clipcat-menu.toml` | 125 | 126 | #### Configuration for `clipcatd` 127 | 128 | ```toml 129 | daemonize = true # run as a traditional UNIX daemon 130 | max_history = 50 # max clip history limit 131 | log_level = 'INFO' # log level 132 | 133 | [monitor] 134 | load_current = true # load current clipboard content at startup 135 | enable_clipboard = true # watch X11 clipboard 136 | enable_primary = true # watch X11 primary clipboard 137 | filter_min_size = 0 # ignores copy actions with a size equal to or less this, in bytes 138 | 139 | [grpc] 140 | host = '127.0.0.1' # host address for gRPC 141 | port = 45045 # port number for gRPC 142 | ``` 143 | 144 | #### Configuration for `clipcatctl` 145 | 146 | ```toml 147 | server_host = '127.0.0.1' # host address of clipcat gRPC server 148 | server_port = 45045 # port number of clipcat gRPC server 149 | log_level = 'INFO' # log level 150 | ``` 151 | 152 | #### Configuration for `clipcat-menu` 153 | 154 | ```toml 155 | server_host = '127.0.0.1' # host address of clipcat gRPC server 156 | server_port = 45045 # port number of clipcat gRPC server 157 | finder = 'rofi' # the default finder to invoke when no "--finder=" option provided 158 | 159 | [rofi] # options for "rofi" 160 | line_length = 100 # length of line 161 | menu_length = 30 # length of menu 162 | 163 | [dmenu] # options for "dmenu" 164 | line_length = 100 # length of line 165 | menu_length = 30 # length of menu 166 | 167 | [custom_finder] # customize your finder 168 | program = 'fzf' # external program name 169 | args = [] # arguments for calling external program 170 | ``` 171 | 172 | ## Integration 173 | 174 | ### Integrating with [Zsh](https://www.zsh.org/) 175 | 176 | For `zsh` user, it will be useful to integrate `clipcat` with `zsh`. 177 | 178 | Add the following command in your `zsh` configuration file (`~/.zshrc`): 179 | 180 | ```bash 181 | if type clipcat-menu >/dev/null 2>&1; then 182 | alias clipedit=' clipcat-menu --finder=builtin edit' 183 | alias clipdel=' clipcat-menu --finder=builtin remove' 184 | 185 | bindkey -s '^\' "^Q clipcat-menu --finder=builtin insert ^J" 186 | bindkey -s '^]' "^Q clipcat-menu --finder=builtin remove ^J" 187 | fi 188 | ``` 189 | 190 | ### Integrating with [i3 Window Manager](https://i3wm.org/) 191 | 192 | For `i3` window manager user, it will be useful to integrate `clipcat` with `i3`. 193 | 194 | Add the following options in your `i3` configuration file (`$XDG_CONFIG_HOME/i3/config`): 195 | 196 | ``` 197 | exec_always --no-startup-id clipcatd # start clipcatd at startup 198 | 199 | set $launcher-clipboard-insert clipcat-menu insert 200 | set $launcher-clipboard-remove clipcat-menu remove 201 | 202 | bindsym $mod+p exec $launcher-clipboard-insert 203 | bindsym $mod+o exec $launcher-clipboard-remove 204 | ``` 205 | 206 | **Note**: You can use `rofi` or `dmenu` as the default finder. 207 | 208 | ## Compiling from Source 209 | 210 | `clipcat` requires the following tools and packages to build: 211 | 212 | - `git` 213 | - `rustc` 214 | - `cargo` 215 | - `pkgconfig` 216 | - `protobuf` 217 | - `clang` 218 | - `libclang` 219 | - `libxcb` 220 | 221 | With the above tools and packages already installed, you can simply run: 222 | 223 | ```console 224 | $ git clone https://github.com/xrelkd/clipcat.git 225 | $ cd clipcat 226 | $ cargo build --release --features=all 227 | ``` 228 | 229 | ## License 230 | 231 | Clipcat is licensed under the GNU General Public License version 3. See [LICENSE](./LICENSE) for more information. 232 | -------------------------------------------------------------------------------- /completions/bash-completion/completions/clipcat-menu: -------------------------------------------------------------------------------- 1 | _clipcat-menu() { 2 | local i cur prev opts cmds 3 | COMPREPLY=() 4 | cur="${COMP_WORDS[COMP_CWORD]}" 5 | prev="${COMP_WORDS[COMP_CWORD-1]}" 6 | cmd="" 7 | opts="" 8 | 9 | for i in ${COMP_WORDS[@]} 10 | do 11 | case "${i}" in 12 | clipcat-menu) 13 | cmd="clipcat-menu" 14 | ;; 15 | 16 | completions) 17 | cmd+="__completions" 18 | ;; 19 | default-config) 20 | cmd+="__default__config" 21 | ;; 22 | del) 23 | cmd+="__del" 24 | ;; 25 | delete) 26 | cmd+="__delete" 27 | ;; 28 | edit) 29 | cmd+="__edit" 30 | ;; 31 | help) 32 | cmd+="__help" 33 | ;; 34 | insert) 35 | cmd+="__insert" 36 | ;; 37 | insert-primary) 38 | cmd+="__insert__primary" 39 | ;; 40 | list-finder) 41 | cmd+="__list__finder" 42 | ;; 43 | remove) 44 | cmd+="__remove" 45 | ;; 46 | rm) 47 | cmd+="__rm" 48 | ;; 49 | version) 50 | cmd+="__version" 51 | ;; 52 | *) 53 | ;; 54 | esac 55 | done 56 | 57 | case "${cmd}" in 58 | clipcat-menu) 59 | opts=" -h -V -c -f -m -l --help --version --config --finder --menu-length --line-length version completions default-config list-finder insert insert-primary remove edit help rm delete del" 60 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then 61 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 62 | return 0 63 | fi 64 | case "${prev}" in 65 | 66 | --config) 67 | COMPREPLY=($(compgen -f "${cur}")) 68 | return 0 69 | ;; 70 | -c) 71 | COMPREPLY=($(compgen -f "${cur}")) 72 | return 0 73 | ;; 74 | --finder) 75 | COMPREPLY=($(compgen -f "${cur}")) 76 | return 0 77 | ;; 78 | -f) 79 | COMPREPLY=($(compgen -f "${cur}")) 80 | return 0 81 | ;; 82 | --menu-length) 83 | COMPREPLY=($(compgen -f "${cur}")) 84 | return 0 85 | ;; 86 | -m) 87 | COMPREPLY=($(compgen -f "${cur}")) 88 | return 0 89 | ;; 90 | --line-length) 91 | COMPREPLY=($(compgen -f "${cur}")) 92 | return 0 93 | ;; 94 | -l) 95 | COMPREPLY=($(compgen -f "${cur}")) 96 | return 0 97 | ;; 98 | *) 99 | COMPREPLY=() 100 | ;; 101 | esac 102 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 103 | return 0 104 | ;; 105 | 106 | clipcat__menu__completions) 107 | opts=" -h -V --help --version " 108 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then 109 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 110 | return 0 111 | fi 112 | case "${prev}" in 113 | 114 | *) 115 | COMPREPLY=() 116 | ;; 117 | esac 118 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 119 | return 0 120 | ;; 121 | clipcat__menu__default__config) 122 | opts=" -h -V --help --version " 123 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then 124 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 125 | return 0 126 | fi 127 | case "${prev}" in 128 | 129 | *) 130 | COMPREPLY=() 131 | ;; 132 | esac 133 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 134 | return 0 135 | ;; 136 | clipcat__menu__del) 137 | opts=" -h -V --help --version " 138 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then 139 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 140 | return 0 141 | fi 142 | case "${prev}" in 143 | 144 | *) 145 | COMPREPLY=() 146 | ;; 147 | esac 148 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 149 | return 0 150 | ;; 151 | clipcat__menu__delete) 152 | opts=" -h -V --help --version " 153 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then 154 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 155 | return 0 156 | fi 157 | case "${prev}" in 158 | 159 | *) 160 | COMPREPLY=() 161 | ;; 162 | esac 163 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 164 | return 0 165 | ;; 166 | clipcat__menu__edit) 167 | opts=" -h -V -e --help --version --editor " 168 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then 169 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 170 | return 0 171 | fi 172 | case "${prev}" in 173 | 174 | --editor) 175 | COMPREPLY=($(compgen -f "${cur}")) 176 | return 0 177 | ;; 178 | -e) 179 | COMPREPLY=($(compgen -f "${cur}")) 180 | return 0 181 | ;; 182 | *) 183 | COMPREPLY=() 184 | ;; 185 | esac 186 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 187 | return 0 188 | ;; 189 | clipcat__menu__help) 190 | opts=" -h -V --help --version " 191 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then 192 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 193 | return 0 194 | fi 195 | case "${prev}" in 196 | 197 | *) 198 | COMPREPLY=() 199 | ;; 200 | esac 201 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 202 | return 0 203 | ;; 204 | clipcat__menu__insert) 205 | opts=" -h -V --help --version " 206 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then 207 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 208 | return 0 209 | fi 210 | case "${prev}" in 211 | 212 | *) 213 | COMPREPLY=() 214 | ;; 215 | esac 216 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 217 | return 0 218 | ;; 219 | clipcat__menu__insert__primary) 220 | opts=" -h -V --help --version " 221 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then 222 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 223 | return 0 224 | fi 225 | case "${prev}" in 226 | 227 | *) 228 | COMPREPLY=() 229 | ;; 230 | esac 231 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 232 | return 0 233 | ;; 234 | clipcat__menu__list__finder) 235 | opts=" -h -V --help --version " 236 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then 237 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 238 | return 0 239 | fi 240 | case "${prev}" in 241 | 242 | *) 243 | COMPREPLY=() 244 | ;; 245 | esac 246 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 247 | return 0 248 | ;; 249 | clipcat__menu__remove) 250 | opts=" -h -V --help --version " 251 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then 252 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 253 | return 0 254 | fi 255 | case "${prev}" in 256 | 257 | *) 258 | COMPREPLY=() 259 | ;; 260 | esac 261 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 262 | return 0 263 | ;; 264 | clipcat__menu__rm) 265 | opts=" -h -V --help --version " 266 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then 267 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 268 | return 0 269 | fi 270 | case "${prev}" in 271 | 272 | *) 273 | COMPREPLY=() 274 | ;; 275 | esac 276 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 277 | return 0 278 | ;; 279 | clipcat__menu__version) 280 | opts=" -h -V --help --version " 281 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then 282 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 283 | return 0 284 | fi 285 | case "${prev}" in 286 | 287 | *) 288 | COMPREPLY=() 289 | ;; 290 | esac 291 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 292 | return 0 293 | ;; 294 | esac 295 | } 296 | 297 | complete -F _clipcat-menu -o bashdefault -o default clipcat-menu 298 | -------------------------------------------------------------------------------- /src/grpc/client.rs: -------------------------------------------------------------------------------- 1 | use snafu::{ResultExt, Snafu}; 2 | use tonic::{ 3 | transport::{channel::Channel, Error as TonicTransportError}, 4 | Request, Status as TonicStatus, 5 | }; 6 | 7 | use crate::{ 8 | grpc::protobuf::{ 9 | manager_client::ManagerClient, monitor_client::MonitorClient, BatchRemoveRequest, 10 | ClearRequest, DisableMonitorRequest, EnableMonitorRequest, GetCurrentClipboardRequest, 11 | GetCurrentPrimaryRequest, GetMonitorStateRequest, GetRequest, InsertRequest, LengthRequest, 12 | ListRequest, MarkAsClipboardRequest, MarkAsPrimaryRequest, RemoveRequest, 13 | ToggleMonitorRequest, UpdateRequest, 14 | }, 15 | ClipboardData, ClipboardType, MonitorState, 16 | }; 17 | 18 | // pub use GrpcClientError::*; 19 | 20 | #[derive(Debug, Snafu)] 21 | pub enum GrpcClientError { 22 | #[snafu(display("Failed to connect gRPC service: {}, error: {}", addr, source))] 23 | ParseEndpoint { 24 | addr: String, 25 | source: tonic::transport::Error, 26 | }, 27 | 28 | #[snafu(display("Failed to connect gRPC service: {}, error: {}", addr, source))] 29 | ConnetRemote { 30 | addr: String, 31 | source: TonicTransportError, 32 | }, 33 | 34 | #[snafu(display("Could not list clips, error: {}", source))] 35 | List { source: TonicStatus }, 36 | 37 | #[snafu(display("Could not get clip with id {}, error: {}", id, source))] 38 | GetData { id: u64, source: TonicStatus }, 39 | 40 | #[snafu(display("Could not get current clip, error: {}", source))] 41 | GetCurrentClipboard { source: TonicStatus }, 42 | 43 | #[snafu(display("Could not get current primary clip, error: {}", source))] 44 | GetCurrentPrimary { source: TonicStatus }, 45 | 46 | #[snafu(display("Could not get number of clips, error: {}", source))] 47 | GetLength { source: TonicStatus }, 48 | 49 | #[snafu(display("Could not insert clip, error: {}", source))] 50 | InsertData { source: TonicStatus }, 51 | 52 | #[snafu(display("Could not update clip, error: {}", source))] 53 | UpdateData { source: TonicStatus }, 54 | 55 | #[snafu(display( 56 | "Could not replace content of clipboard with id {}, error: {}", 57 | id, 58 | source 59 | ))] 60 | MarkAsClipboard { id: u64, source: TonicStatus }, 61 | 62 | #[snafu(display( 63 | "Could not replace content of primary clipboard with id {}, error: {}", 64 | id, 65 | source 66 | ))] 67 | MarkAsPrimary { id: u64, source: TonicStatus }, 68 | 69 | #[snafu(display("Could not remove clip, error: {}", source))] 70 | RemoveData { source: TonicStatus }, 71 | 72 | #[snafu(display("Could not batch remove clips, error: {}", source))] 73 | BatchRemoveData { source: TonicStatus }, 74 | 75 | #[snafu(display("Could not clear clips, error: {}", source))] 76 | Clear { source: TonicStatus }, 77 | 78 | #[snafu(display("Could not enable monitor, error: {}", source))] 79 | EnableMonitor { source: TonicStatus }, 80 | 81 | #[snafu(display("Could not disable monitor, error: {}", source))] 82 | DisableMonitor { source: TonicStatus }, 83 | 84 | #[snafu(display("Could not toggle monitor, error: {}", source))] 85 | ToggleMonitor { source: TonicStatus }, 86 | 87 | #[snafu(display("Could not get monitor state, error: {}", source))] 88 | GetMonitorState { source: TonicStatus }, 89 | 90 | #[snafu(display("Empty response"))] 91 | Empty, 92 | } 93 | 94 | pub struct GrpcClient { 95 | monitor_client: MonitorClient, 96 | manager_client: ManagerClient, 97 | } 98 | 99 | impl GrpcClient { 100 | pub async fn new(addr: String) -> Result { 101 | use tonic::transport::Endpoint; 102 | let channel = Endpoint::from_shared(addr.clone()) 103 | .context(ParseEndpointSnafu { addr: addr.clone() })? 104 | .connect() 105 | .await 106 | .context(ConnetRemoteSnafu { addr })?; 107 | let monitor_client = MonitorClient::new(channel.clone()); 108 | let manager_client = ManagerClient::new(channel); 109 | Ok(GrpcClient { 110 | monitor_client, 111 | manager_client, 112 | }) 113 | } 114 | 115 | pub async fn insert( 116 | &mut self, 117 | data: &str, 118 | clipboard_type: ClipboardType, 119 | ) -> Result { 120 | let request = Request::new(InsertRequest { 121 | clipboard_type: clipboard_type.into(), 122 | data: data.to_owned(), 123 | }); 124 | let response = self 125 | .manager_client 126 | .insert(request) 127 | .await 128 | .context(InsertDataSnafu)?; 129 | Ok(response.into_inner().id) 130 | } 131 | 132 | pub async fn insert_clipboard(&mut self, data: &str) -> Result { 133 | self.insert(data, ClipboardType::Clipboard).await 134 | } 135 | 136 | pub async fn insert_primary(&mut self, data: &str) -> Result { 137 | self.insert(data, ClipboardType::Primary).await 138 | } 139 | 140 | pub async fn get(&mut self, id: u64) -> Result { 141 | let request = Request::new(GetRequest { id }); 142 | let response = self 143 | .manager_client 144 | .get(request) 145 | .await 146 | .context(GetDataSnafu { id })?; 147 | match response.into_inner().data { 148 | Some(data) => Ok(data.data), 149 | None => Err(GrpcClientError::Empty), 150 | } 151 | } 152 | 153 | pub async fn get_current_clipboard(&mut self) -> Result { 154 | let request = Request::new(GetCurrentClipboardRequest {}); 155 | let response = self 156 | .manager_client 157 | .get_current_clipboard(request) 158 | .await 159 | .context(GetCurrentClipboardSnafu)?; 160 | match response.into_inner().data { 161 | Some(data) => Ok(data.data), 162 | None => Err(GrpcClientError::Empty), 163 | } 164 | } 165 | 166 | pub async fn get_current_primary(&mut self) -> Result { 167 | let request = Request::new(GetCurrentPrimaryRequest {}); 168 | let response = self 169 | .manager_client 170 | .get_current_primary(request) 171 | .await 172 | .context(GetCurrentPrimarySnafu)?; 173 | match response.into_inner().data { 174 | Some(data) => Ok(data.data), 175 | None => Err(GrpcClientError::Empty), 176 | } 177 | } 178 | 179 | pub async fn update(&mut self, id: u64, data: &str) -> Result<(bool, u64), GrpcClientError> { 180 | let data = data.to_owned(); 181 | let request = Request::new(UpdateRequest { id, data }); 182 | let response = self 183 | .manager_client 184 | .update(request) 185 | .await 186 | .context(UpdateDataSnafu)?; 187 | let response = response.into_inner(); 188 | Ok((response.ok, response.new_id)) 189 | } 190 | 191 | pub async fn mark_as_clipboard(&mut self, id: u64) -> Result { 192 | let request = Request::new(MarkAsClipboardRequest { id }); 193 | let response = self 194 | .manager_client 195 | .mark_as_clipboard(request) 196 | .await 197 | .context(MarkAsClipboardSnafu { id })?; 198 | Ok(response.into_inner().ok) 199 | } 200 | 201 | pub async fn mark_as_primary(&mut self, id: u64) -> Result { 202 | let request = Request::new(MarkAsPrimaryRequest { id }); 203 | let response = self 204 | .manager_client 205 | .mark_as_primary(request) 206 | .await 207 | .context(MarkAsPrimarySnafu { id })?; 208 | Ok(response.into_inner().ok) 209 | } 210 | 211 | pub async fn remove(&mut self, id: u64) -> Result { 212 | let request = Request::new(RemoveRequest { id }); 213 | let response = self 214 | .manager_client 215 | .remove(request) 216 | .await 217 | .context(RemoveDataSnafu)?; 218 | Ok(response.into_inner().ok) 219 | } 220 | 221 | pub async fn batch_remove(&mut self, ids: &[u64]) -> Result, GrpcClientError> { 222 | let ids = Vec::from(ids); 223 | let request = Request::new(BatchRemoveRequest { ids }); 224 | let response = self 225 | .manager_client 226 | .batch_remove(request) 227 | .await 228 | .context(BatchRemoveDataSnafu)?; 229 | Ok(response.into_inner().ids) 230 | } 231 | 232 | pub async fn clear(&mut self) -> Result<(), GrpcClientError> { 233 | let request = Request::new(ClearRequest {}); 234 | let _response = self 235 | .manager_client 236 | .clear(request) 237 | .await 238 | .context(ClearSnafu)?; 239 | Ok(()) 240 | } 241 | 242 | pub async fn length(&mut self) -> Result { 243 | let request = Request::new(LengthRequest {}); 244 | let response = self 245 | .manager_client 246 | .length(request) 247 | .await 248 | .context(GetLengthSnafu)?; 249 | Ok(response.into_inner().length as usize) 250 | } 251 | 252 | pub async fn list(&mut self) -> Result, GrpcClientError> { 253 | let request = Request::new(ListRequest {}); 254 | let response = self.manager_client.list(request).await.context(ListSnafu)?; 255 | let mut list: Vec<_> = response 256 | .into_inner() 257 | .data 258 | .into_iter() 259 | .map(|data| { 260 | let timestamp = std::time::UNIX_EPOCH 261 | .checked_add(std::time::Duration::from_millis(data.timestamp)) 262 | .unwrap_or_else(std::time::SystemTime::now); 263 | ClipboardData { 264 | id: data.id, 265 | data: data.data, 266 | clipboard_type: data.clipboard_type.into(), 267 | timestamp, 268 | } 269 | }) 270 | .collect(); 271 | list.sort(); 272 | Ok(list) 273 | } 274 | 275 | pub async fn enable_monitor(&mut self) -> Result { 276 | let request = Request::new(EnableMonitorRequest {}); 277 | let response = self 278 | .monitor_client 279 | .enable_monitor(request) 280 | .await 281 | .context(EnableMonitorSnafu)?; 282 | Ok(response.into_inner().state.into()) 283 | } 284 | 285 | pub async fn disable_monitor(&mut self) -> Result { 286 | let request = Request::new(DisableMonitorRequest {}); 287 | let response = self 288 | .monitor_client 289 | .disable_monitor(request) 290 | .await 291 | .context(DisableMonitorSnafu)?; 292 | Ok(response.into_inner().state.into()) 293 | } 294 | 295 | pub async fn toggle_monitor(&mut self) -> Result { 296 | let request = Request::new(ToggleMonitorRequest {}); 297 | let response = self 298 | .monitor_client 299 | .toggle_monitor(request) 300 | .await 301 | .context(ToggleMonitorSnafu)?; 302 | Ok(response.into_inner().state.into()) 303 | } 304 | 305 | pub async fn get_monitor_state(&mut self) -> Result { 306 | let request = Request::new(GetMonitorStateRequest {}); 307 | let response = self 308 | .monitor_client 309 | .get_monitor_state(request) 310 | .await 311 | .context(GetMonitorStateSnafu)?; 312 | Ok(response.into_inner().state.into()) 313 | } 314 | } 315 | -------------------------------------------------------------------------------- /src/monitor.rs: -------------------------------------------------------------------------------- 1 | #![allow(unused_imports)] 2 | use std::fmt::Display; 3 | use std::{ 4 | sync::{ 5 | atomic::{AtomicBool, Ordering}, 6 | Arc, 7 | }, 8 | thread, 9 | }; 10 | 11 | use snafu::ResultExt; 12 | use tokio::sync::broadcast::{self, error::SendError}; 13 | #[cfg(feature = "x11")] 14 | use x11_clipboard::Clipboard; 15 | 16 | use crate::{error, ClipboardError, ClipboardEvent, ClipboardType, MonitorState}; 17 | 18 | pub struct ClipboardMonitor { 19 | is_running: Arc, 20 | event_sender: broadcast::Sender, 21 | clipboard_thread: Option>, 22 | primary_thread: Option>, 23 | } 24 | 25 | #[derive(Debug, Clone, Copy)] 26 | pub struct ClipboardMonitorOptions { 27 | pub load_current: bool, 28 | pub enable_clipboard: bool, 29 | pub enable_primary: bool, 30 | pub filter_min_size: usize, 31 | } 32 | 33 | impl Default for ClipboardMonitorOptions { 34 | fn default() -> Self { 35 | ClipboardMonitorOptions { 36 | load_current: true, 37 | enable_clipboard: true, 38 | enable_primary: true, 39 | filter_min_size: 0, 40 | } 41 | } 42 | } 43 | 44 | impl ClipboardMonitor { 45 | pub fn new(opts: ClipboardMonitorOptions) -> Result { 46 | let (event_sender, _event_receiver) = broadcast::channel(16); 47 | 48 | let is_running = Arc::new(AtomicBool::new(true)); 49 | let mut monitor = ClipboardMonitor { 50 | is_running: is_running.clone(), 51 | event_sender: event_sender.clone(), 52 | clipboard_thread: None, 53 | primary_thread: None, 54 | }; 55 | 56 | if opts.enable_clipboard { 57 | let thread = build_thread( 58 | opts.load_current, 59 | is_running.clone(), 60 | ClipboardType::Clipboard, 61 | event_sender.clone(), 62 | opts.filter_min_size, 63 | )?; 64 | monitor.clipboard_thread = Some(thread); 65 | } 66 | 67 | if opts.enable_primary { 68 | let thread = build_thread( 69 | opts.load_current, 70 | is_running, 71 | ClipboardType::Primary, 72 | event_sender, 73 | opts.filter_min_size, 74 | )?; 75 | monitor.primary_thread = Some(thread); 76 | } 77 | 78 | if monitor.clipboard_thread.is_none() && monitor.primary_thread.is_none() { 79 | tracing::warn!("Both clipboard and primary are not monitored"); 80 | } 81 | 82 | Ok(monitor) 83 | } 84 | 85 | #[inline] 86 | pub fn subscribe(&self) -> broadcast::Receiver { 87 | self.event_sender.subscribe() 88 | } 89 | 90 | #[inline] 91 | pub fn enable(&mut self) { 92 | self.is_running.store(true, Ordering::Release); 93 | tracing::info!("ClipboardWorker is monitoring for clipboard"); 94 | } 95 | 96 | #[inline] 97 | pub fn disable(&mut self) { 98 | self.is_running.store(false, Ordering::Release); 99 | tracing::info!("ClipboardWorker is not monitoring for clipboard"); 100 | } 101 | 102 | #[inline] 103 | pub fn toggle(&mut self) { 104 | if self.is_running() { 105 | self.disable(); 106 | } else { 107 | self.enable(); 108 | } 109 | } 110 | 111 | #[inline] 112 | pub fn is_running(&self) -> bool { 113 | self.is_running.load(Ordering::Acquire) 114 | } 115 | 116 | #[inline] 117 | pub fn state(&self) -> MonitorState { 118 | if self.is_running() { 119 | MonitorState::Enabled 120 | } else { 121 | MonitorState::Disabled 122 | } 123 | } 124 | } 125 | 126 | fn build_thread( 127 | load_current: bool, 128 | is_running: Arc, 129 | clipboard_type: ClipboardType, 130 | sender: broadcast::Sender, 131 | filter_min_size: usize, 132 | ) -> Result, ClipboardError> { 133 | let send_event = move |data: &str| { 134 | let event = match clipboard_type { 135 | ClipboardType::Clipboard => ClipboardEvent::new_clipboard(data), 136 | ClipboardType::Primary => ClipboardEvent::new_primary(data), 137 | }; 138 | sender.send(event) 139 | }; 140 | 141 | let clipboard = ClipboardWaitProvider::new(clipboard_type)?; 142 | 143 | let join_handle = thread::spawn(move || { 144 | let mut clipboard = clipboard; 145 | 146 | let mut last = if load_current { 147 | let result = clipboard.load(); 148 | match result { 149 | Ok(data) => { 150 | let data = String::from_utf8_lossy(&data); 151 | if data.len() > filter_min_size { 152 | if let Err(SendError(_curr)) = send_event(&data) { 153 | tracing::info!("ClipboardEvent receiver is closed."); 154 | return; 155 | } 156 | } 157 | data.into_owned() 158 | } 159 | Err(_) => String::new(), 160 | } 161 | } else { 162 | String::new() 163 | }; 164 | 165 | loop { 166 | let result = clipboard.load_wait(); 167 | match result { 168 | Ok(curr) => { 169 | if is_running.load(Ordering::Acquire) && last.as_bytes() != curr { 170 | let curr = String::from_utf8_lossy(&curr); 171 | let len = curr.len(); 172 | last = curr.into_owned(); 173 | if len > filter_min_size { 174 | if let Err(SendError(_curr)) = send_event(&last) { 175 | tracing::info!("ClipboardEvent receiver is closed."); 176 | return; 177 | }; 178 | } 179 | } 180 | } 181 | Err(err) => { 182 | tracing::error!( 183 | "Failed to load clipboard, error: {}. Restarting clipboard provider.", 184 | err, 185 | ); 186 | thread::sleep(std::time::Duration::from_secs(5)); 187 | clipboard = match ClipboardWaitProvider::new(clipboard_type) { 188 | Ok(c) => c, 189 | Err(err) => { 190 | tracing::error!("Failed to restart clipboard provider, error: {}", err); 191 | std::process::exit(1) 192 | } 193 | } 194 | } 195 | } 196 | } 197 | }); 198 | 199 | Ok(join_handle) 200 | } 201 | 202 | #[derive(Debug)] 203 | enum ClipboardWaitError { 204 | #[cfg(feature = "x11")] 205 | X11(x11_clipboard::error::Error), 206 | #[cfg(feature = "wayland")] 207 | Wayland(wl_clipboard_rs::paste::Error), 208 | } 209 | #[cfg(feature = "wayland")] 210 | impl From for ClipboardWaitError { 211 | fn from(e: wl_clipboard_rs::paste::Error) -> Self { 212 | Self::Wayland(e) 213 | } 214 | } 215 | #[cfg(feature = "x11")] 216 | impl From for ClipboardWaitError { 217 | fn from(e: x11_clipboard::error::Error) -> Self { 218 | Self::X11(e) 219 | } 220 | } 221 | impl Display for ClipboardWaitError { 222 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 223 | match self { 224 | #[cfg(feature = "x11")] 225 | ClipboardWaitError::X11(e) => e.fmt(f), 226 | #[cfg(feature = "wayland")] 227 | ClipboardWaitError::Wayland(e) => e.fmt(f), 228 | } 229 | } 230 | } 231 | enum ClipboardWaitProvider { 232 | #[cfg(feature = "x11")] 233 | X11(ClipboardWaitProviderX11), 234 | #[cfg(feature = "wayland")] 235 | Wayland(ClipboardWaitProviderWayland), 236 | } 237 | impl ClipboardWaitProvider { 238 | #[allow(unused_assignments, unused_mut)] // if no feature is enabled 239 | pub(crate) fn new(clipboard_type: ClipboardType) -> Result { 240 | let mut err = ClipboardError::NoBackendFound; 241 | #[cfg(feature = "wayland")] 242 | if std::env::var_os("WAYLAND_DISPLAY").is_some() { 243 | return ClipboardWaitProviderWayland::new(clipboard_type).map(Self::Wayland); 244 | } 245 | #[cfg(feature = "x11")] 246 | match ClipboardWaitProviderX11::new(clipboard_type) { 247 | Ok(b) => return Ok(Self::X11(b)), 248 | Err(e) => err = e, 249 | } 250 | Err(err) 251 | } 252 | 253 | pub(crate) fn load(&self) -> Result, ClipboardWaitError> { 254 | match self { 255 | #[cfg(feature = "x11")] 256 | ClipboardWaitProvider::X11(c) => c.load().map_err(Into::into), 257 | #[cfg(feature = "wayland")] 258 | ClipboardWaitProvider::Wayland(c) => c.load().map_err(Into::into), 259 | } 260 | } 261 | 262 | pub(crate) fn load_wait(&mut self) -> Result, ClipboardWaitError> { 263 | match self { 264 | #[cfg(feature = "x11")] 265 | ClipboardWaitProvider::X11(c) => c.load_wait().map_err(Into::into), 266 | #[cfg(feature = "wayland")] 267 | ClipboardWaitProvider::Wayland(c) => c.load_wait().map_err(Into::into), 268 | } 269 | } 270 | } 271 | 272 | #[cfg(feature = "x11")] 273 | struct ClipboardWaitProviderX11 { 274 | clipboard_type: ClipboardType, 275 | clipboard: Clipboard, 276 | } 277 | #[cfg(feature = "x11")] 278 | impl ClipboardWaitProviderX11 { 279 | pub(crate) fn new(clipboard_type: ClipboardType) -> Result { 280 | let clipboard = Clipboard::new().context(error::InitializeX11ClipboardSnafu)?; 281 | Ok(Self { 282 | clipboard, 283 | clipboard_type, 284 | }) 285 | } 286 | 287 | fn atoms(&self) -> (u32, u32, u32) { 288 | let atom_clipboard = match self.clipboard_type { 289 | ClipboardType::Clipboard => self.clipboard.getter.atoms.clipboard, 290 | ClipboardType::Primary => self.clipboard.getter.atoms.primary, 291 | }; 292 | let atom_utf8string = self.clipboard.getter.atoms.utf8_string; 293 | let atom_property = self.clipboard.getter.atoms.property; 294 | (atom_clipboard, atom_utf8string, atom_property) 295 | } 296 | 297 | pub(crate) fn load(&self) -> Result, x11_clipboard::error::Error> { 298 | let (c, utf8, prop) = self.atoms(); 299 | self.clipboard.load(c, utf8, prop, None) 300 | } 301 | 302 | pub(crate) fn load_wait(&self) -> Result, x11_clipboard::error::Error> { 303 | let (c, utf8, prop) = self.atoms(); 304 | self.clipboard.load_wait(c, utf8, prop) 305 | } 306 | } 307 | #[cfg(feature = "wayland")] 308 | struct ClipboardWaitProviderWayland { 309 | clipboard_type: ClipboardType, 310 | last: Option>, 311 | } 312 | #[cfg(feature = "wayland")] 313 | impl ClipboardWaitProviderWayland { 314 | pub(crate) fn new(clipboard_type: ClipboardType) -> Result { 315 | tracing::info!("Creating new wayland clipboard watcher"); 316 | let mut s = Self { 317 | clipboard_type, 318 | last: None, 319 | }; 320 | s.last = s.load().ok(); 321 | Ok(s) 322 | } 323 | 324 | fn wl_type(&self) -> wl_clipboard_rs::paste::ClipboardType { 325 | match self.clipboard_type { 326 | ClipboardType::Primary => wl_clipboard_rs::paste::ClipboardType::Primary, 327 | ClipboardType::Clipboard => wl_clipboard_rs::paste::ClipboardType::Regular, 328 | } 329 | } 330 | 331 | pub(crate) fn load(&self) -> Result, wl_clipboard_rs::paste::Error> { 332 | use std::io::Read; 333 | use wl_clipboard_rs::paste::{get_contents, Error, MimeType, Seat}; 334 | 335 | let result = get_contents(self.wl_type(), Seat::Unspecified, MimeType::Text); 336 | match result { 337 | Ok((mut pipe, _mime_type)) => { 338 | let mut contents = vec![]; 339 | pipe.read_to_end(&mut contents) 340 | .map_err(Error::PipeCreation)?; 341 | Ok(contents) 342 | } 343 | 344 | Err(Error::NoSeats) | Err(Error::ClipboardEmpty) | Err(Error::NoMimeType) => { 345 | // The clipboard is empty, nothing to worry about. 346 | thread::sleep(std::time::Duration::from_millis(250)); 347 | Ok(vec![]) 348 | } 349 | 350 | Err(err) => Err(err)?, 351 | } 352 | } 353 | 354 | pub(crate) fn load_wait(&mut self) -> Result, wl_clipboard_rs::paste::Error> { 355 | loop { 356 | let response = self.load()?; 357 | match &response { 358 | contents 359 | if !contents.is_empty() 360 | && Some(contents.as_slice()) != self.last.as_deref() => 361 | { 362 | self.last = Some(response.clone()); 363 | return Ok(response); 364 | } 365 | _ => { 366 | thread::sleep(std::time::Duration::from_millis(500)); 367 | } 368 | } 369 | } 370 | } 371 | } 372 | --------------------------------------------------------------------------------