clipmenu

Simple clipboard management using dmenu
git clone https://git.sinitax.com/cdown/clipmenu
Log | Files | Refs | README | LICENSE | sfeed.txt

commit 940bd8e963ed17dcb9caf5ac809a70161bfdf107
parent 77aa1c4ae0bb3b529e1e732ad17ae7ff6096852b
Author: Chris Down <chris@chrisdown.name>
Date:   Tue, 24 Mar 2020 01:17:34 +0000

Merge branch 'release/6.0.0'

Diffstat:
M.travis.yml | 2+-
MREADME.md | 27+++++++++++++++------------
Mclipdel | 30+++++++++++++++++-------------
Aclipfsck | 32++++++++++++++++++++++++++++++++
Mclipmenu | 8++++----
Mclipmenud | 222+++++++++++++++++++++++++++-----------------------------------------------------
Minit/clipmenud.service | 2+-
Mtests/test-clipmenu | 4++--
Mtests/test-perf | 4++--
9 files changed, 148 insertions(+), 183 deletions(-)

diff --git a/.travis.yml b/.travis.yml @@ -3,7 +3,7 @@ language: bash dist: xenial script: - - shellcheck -s bash clipmenu clipmenud + - shellcheck -s bash clipmenu clipmenud clipdel clipfsck - tests/test-clipmenu matrix: diff --git a/README.md b/README.md @@ -14,6 +14,9 @@ clipboard. A systemd user service for starting clipmenud is included at [init/clipmenud.service](https://github.com/cdown/clipmenu/blob/develop/init/clipmenud.service). +You can then start clipmenud like this: + + systemctl --user start clipmenud All args passed to clipmenu are transparently dispatched to dmenu. That is, if you usually call dmenu with args to set colours and other properties, you can @@ -21,7 +24,13 @@ invoke clipmenu in exactly the same way to get the same effect, like so: clipmenu -i -fn Terminus:size=8 -nb '#002b36' -nf '#839496' -sb '#073642' -sf '#93a1a1' -You can remove clips with the `clipdel` utility, see `clipdel --help`. +If you prefer to collect clips on demand rather than running clipmenud as a +daemon, you can bind a key to the following command for one-off collection: + + CM_ONESHOT=1 clipmenud + +For a full list of environment variables that clipmenud can take, please see +`clipmenud --help`. # Installation @@ -33,25 +42,19 @@ standalone (or better yet, package them!). # How does it work? -The code is fairly simple and easy to follow, you may find it easier to read -there, but it basically works like this: +clipmenud is less than 200 lines, and clipmenu is less than 100, so hopefully +it should be fairly self-explanatory. However, at the most basic level: ## clipmenud 1. `clipmenud` uses [clipnotify](https://github.com/cdown/clipnotify) to wait - for new clipboard events. If clipnotify is not present on the system, we - poll every 0.5 seconds (or another interval as configured with the - `CM_SLEEP` environment variable). - - You can also bind your copy key binding to also issue `CM_ONESHOT=1 - clipmenud`. However, there's no generic way to do this, since any keys or - mouse buttons could be bound to do this action in a number of ways. + for new clipboard events. 2. If `clipmenud` detects changes to the clipboard contents, it writes them out - to the cache directory. + to the cache directory and an index using a hash as the filename. ## clipmenu -1. `clipmenu` reads the cache directory to find all available clips. +1. `clipmenu` reads the index to find all available clips. 2. `dmenu` is executed to allow the user to select a clip. 3. After selection, the clip is put onto the PRIMARY and CLIPBOARD X selections. diff --git a/clipdel b/clipdel @@ -7,12 +7,12 @@ if [[ $1 == -d ]]; then shift fi -major_version=5 +major_version=6 shopt -s nullglob cache_dir=$CM_DIR/clipmenu.$major_version.$USER -cache_file_prefix=$cache_dir/line_cache +cache_file=$cache_dir/line_cache lock_file=$cache_dir/lock lock_timeout=2 @@ -35,10 +35,8 @@ EOF exit 0 fi -line_cache_files=( "$cache_file_prefix"_* ) - -if (( ${#line_cache_files[@]} == 0 )); then - printf '%s\n' "No line cache files found, no clips exist" >&2 +if ! [[ -f $cache_file ]]; then + printf '%s\n' "No line cache file found, no clips exist" >&2 exit 0 # Well, this is a kind of success... fi @@ -47,6 +45,10 @@ fi raw_pattern=$1 esc_pattern=${raw_pattern//\#/'\#'} +# We use 2 separate sed commands so "esc_pattern" matches only the 'clip' text +# without the timestamp (e.g. $> clipdel '^delete_exact_match$') +sed_common_command="s#^[0-9]\+ ##;\\#${esc_pattern}#" + if ! [[ $raw_pattern ]]; then printf '%s\n' 'No pattern provided, see --help' >&2 exit 2 @@ -60,8 +62,8 @@ if (( CM_REAL_DELETE )) && [[ "$raw_pattern" == ".*" ]]; then exit 0 else mapfile -t matches < <( - cat "${line_cache_files[@]}" | cut -d' ' -f2- | sort -u | - sed -n "\\#${esc_pattern}#p" + sed -n "${sed_common_command}p" "$cache_file" | + sort -u ) if (( CM_REAL_DELETE )); then @@ -72,11 +74,13 @@ else rm -f -- "$cache_dir/$ck" done - for file in "${line_cache_files[@]}"; do - temp=$(mktemp) - cut -d' ' -f2- < "$file" | sed "\\#${esc_pattern}#d" > "$temp" - mv -- "$temp" "$file" - done + temp=$(mktemp) + # sed 'h' and 'g' here means save and restore the line, so + # timestamps are not removed from non-deleted lines. 'd' deletes the + # line and restarts, skipping 'g'/restore. + # https://www.gnu.org/software/sed/manual/html_node/Other-Commands.html#Other-Commands + sed "h;${sed_common_command}d;g" "$cache_file" > "$temp" + mv -- "$temp" "$cache_file" flock -u "$lock_fd" else diff --git a/clipfsck b/clipfsck @@ -0,0 +1,32 @@ +#!/usr/bin/env bash + +: "${CM_DIR="${XDG_RUNTIME_DIR-"${TMPDIR-/tmp}"}"}" + +major_version=6 + +shopt -s nullglob + +cache_dir=$CM_DIR/clipmenu.$major_version.$USER +cache_file=$cache_dir/line_cache + +declare -A cksums + +while IFS= read -r line; do + cksum=$(cksum <<< "$line") + cksums["$cksum"]="$line" + + # Are all cache entries represented by a file? + full_file=$cache_dir/$cksum + if ! [[ -f $full_file ]]; then + printf 'cache entry without file: %s -> %s\n' "$line" "$full_file" >&2 + fi +done < <(cut -d' ' -f2- < "$cache_file") + +# Are all files represented by a cache entry? +for file in "$cache_dir"/[012346789]*; do + cksum=${file##*/} + line=${cksums["$cksum"]-_missing_} + if [[ $line == _missing_ ]]; then + printf 'file without cache entry: %s\n' "$file" + fi +done diff --git a/clipmenu b/clipmenu @@ -4,12 +4,12 @@ : "${CM_DIR="${XDG_RUNTIME_DIR-"${TMPDIR-/tmp}"}"}" : "${CM_HISTLENGTH=8}" -major_version=5 +major_version=6 shopt -s nullglob cache_dir=$CM_DIR/clipmenu.$major_version.$USER -cache_file_prefix=$cache_dir/line_cache +cache_file=$cache_dir/line_cache if [[ $1 == --help ]] || [[ $1 == -h ]]; then cat << 'EOF' @@ -33,7 +33,7 @@ if [[ "$CM_LAUNCHER" == rofi ]]; then fi list_clips() { - cat "$cache_file_prefix"_* /dev/null | LC_ALL=C sort -rnk 1 | cut -d' ' -f2- | awk '!seen[$0]++' + LC_ALL=C sort -rnk 1 < "$cache_file" | cut -d' ' -f2- | awk '!seen[$0]++' } if [[ "$CM_LAUNCHER" == rofi-script ]]; then @@ -59,7 +59,7 @@ if ! [[ -f "$file" ]]; then # We didn't find this in cache printf 'FATAL: %s not in cache (%s missing)\n' "$chosen_line" "$file" >&2 printf 'Please report the following debug information:\n\n' >&2 - wc -l "$cache_file_prefix"_* >&2 + wc -l "$cache_file" >&2 grep -nFR "$chosen_line" "$cache_dir" >&2 stat "$file" >&2 exit 2 diff --git a/clipmenud b/clipmenud @@ -1,98 +1,71 @@ #!/usr/bin/env bash : "${CM_ONESHOT=0}" -: "${CM_OWN_CLIPBOARD=1}" +: "${CM_OWN_CLIPBOARD=0}" : "${CM_DEBUG=0}" : "${CM_DIR="${XDG_RUNTIME_DIR-"${TMPDIR-/tmp}"}"}" + : "${CM_MAX_CLIPS=1000}" +# Buffer to batch to avoid calling too much. Only used if CM_MAX_CLIPS >0. +CM_MAX_CLIPS_THRESH=$(( CM_MAX_CLIPS + 10 )) -# Shellcheck is mistaken here, this is used later as lowercase. -# shellcheck disable=SC2153 : "${CM_SELECTIONS=clipboard primary}" +read -r -a selections <<< "$CM_SELECTIONS" -major_version=5 +major_version=6 cache_dir=$CM_DIR/clipmenu.$major_version.$USER/ -cache_file_prefix=$cache_dir/line_cache +cache_file=$cache_dir/line_cache + +# lock_file: lock for *one* iteration of clipboard capture/propagation +# session_lock_file: lock to prevent multiple clipmenud daemons lock_file=$cache_dir/lock +session_lock_file=$cache_dir/session_lock lock_timeout=2 -has_clipnotify=0 has_xdotool=0 -# This comes from the environment, so we rely on word splitting. -# shellcheck disable=SC2206 -cm_selections=( $CM_SELECTIONS ) - -if command -v timeout >/dev/null 2>&1; then - timeout_cmd=(timeout 1) -else - echo "WARN: No timeout binary. Continuing without any timeout on xsel." >&2 - timeout_cmd=() -fi +_xsel() { timeout 1 xsel --logfile /dev/null "$@"; } -_xsel() { - "${timeout_cmd[@]}" xsel --logfile /dev/null "$@" +error() { printf 'ERROR: %s\n' "${1?}" >&2; } +info() { printf 'INFO: %s\n' "${1?}"; } +die() { + error "${2?}" + exit "${1?}" } -get_first_line() { - # Args: - # - $1, the file or data - # - $2, optional, the line length limit +make_line_cksums() { while read -r line; do cksum <<< "${line#* }"; done; } +get_first_line() { data=${1?} - line_length_limit=${2-300} # We look for the first line matching regex /./ here because we want the - # first line that can provide reasonable context to the user. That is, if - # you have 5 leading lines of whitespace, displaying " (6 lines)" is much - # less useful than displaying "foo (6 lines)", where "foo" is the first - # line in the entry with actionable context. - awk -v limit="$line_length_limit" ' + # first line that can provide reasonable context to the user. + awk -v limit=300 ' BEGIN { printed = 0; } - printed == 0 && NF { $0 = substr($0, 0, limit); printf("%s", $0); printed = 1; } - END { - if (NR > 1) { - print " (" NR " lines)"; - } else { - printf("\n"); - } + if (NR > 1) + printf(" (%d lines)", NR); + printf("\n"); }' <<< "$data" } -debug() { - if (( CM_DEBUG )); then - printf '%s\n' "$@" >&2 - fi -} - -element_in() { - local item element - item="$1" - for element in "${@:2}"; do - if [[ "$item" == "$element" ]]; then - return 0 - fi - done - return 1 -} +debug() { (( CM_DEBUG )) && printf '%s\n' "$@" >&2; } if [[ $1 == --help ]] || [[ $1 == -h ]]; then cat << 'EOF' -clipmenud is the daemon that collects and caches what's on the clipboard. -when you want to select a clip. +clipmenud collects and caches what's on the clipboard. Environment variables: - $CM_DEBUG: turn on debugging output (default: 0) - $CM_DIR: specify the base directory to store the cache dir in (default: $XDG_RUNTIME_DIR, $TMPDIR, or /tmp) -- $CM_MAX_CLIPS: maximum number of clips to store, 0 for inf (default: 1000) +- $CM_MAX_CLIPS: soft maximum number of clips to store, 0 for inf. At $CM_MAX_CLIPS + 10, the number of clips is reduced to $CM_MAX_CLIPS (default: 1000) - $CM_ONESHOT: run once immediately, do not loop (default: 0) -- $CM_OWN_CLIPBOARD: take ownership of the clipboard (default: 1) +- $CM_OWN_CLIPBOARD: take ownership of the clipboard. Note: this may cause missed copies if some other application also handles the clipboard directly (default: 0) - $CM_SELECTIONS: space separated list of the selections to manage (default: "clipboard primary") - $CM_IGNORE_WINDOW: disable recording the clipboard in windows where the windowname matches the given regex (e.g. a password manager), do not ignore any windows if unset or empty (default: unset) EOF @@ -104,17 +77,14 @@ fi # shellcheck disable=SC2174 mkdir -p -m0700 "$cache_dir" +exec {session_lock_fd}> "$session_lock_file" +flock -x -n "$session_lock_fd" || + die 2 "Can't lock session file -- is another clipmenud running?" + declare -A last_data -declare -A last_filename declare -A last_cache_file_output -command -v clipnotify >/dev/null 2>&1 && has_clipnotify=1 - -if ! (( has_clipnotify )); then - echo "WARN: Consider installing clipnotify for better performance." >&2 - echo "WARN: See https://github.com/cdown/clipnotify." >&2 -fi - +command -v clipnotify >/dev/null 2>&1 || die 2 "clipnotify not in PATH" command -v xdotool >/dev/null 2>&1 && has_xdotool=1 if [[ $CM_IGNORE_WINDOW ]] && ! (( has_xdotool )); then @@ -123,18 +93,8 @@ fi exec {lock_fd}> "$lock_file" -sleep_cmd=(sleep "${CM_SLEEP:-0.5}") - while true; do - if ! (( CM_ONESHOT )); then - if (( has_clipnotify )); then - # Fall back to polling if clipnotify fails - clipnotify || "${sleep_cmd[@]}" - else - # Use old polling method - "${sleep_cmd[@]}" - fi - fi + (( CM_ONESHOT )) || clipnotify if [[ $CM_IGNORE_WINDOW ]] && (( has_xdotool )); then windowname="$(xdotool getactivewindow getwindowname)" @@ -146,112 +106,78 @@ while true; do if ! flock -x -w "$lock_timeout" "$lock_fd"; then if (( CM_ONESHOT )); then - printf 'ERROR: %s\n' 'Timed out waiting for lock' >&2 - exit 1 + die 1 "Timed out waiting for lock" else - printf 'ERROR: %s\n' \ - 'Timed out waiting for lock, skipping this run' >&2 + error "Timed out waiting for lock, skipping this iteration" continue fi fi - for selection in "${cm_selections[@]}"; do - cache_file=${cache_file_prefix}_$selection + for selection in "${selections[@]}"; do data=$(_xsel -o --"$selection"; printf x) + data=${data%x} # avoid trailing newlines being stripped - debug "Data before stripping: $data" - - # We add and remove the x so that trailing newlines are not stripped. - # Otherwise, they would be stripped by the very nature of how POSIX - # defines command substitution. - data=${data%x} + [[ $data == *[^[:space:]]* ]] || continue + [[ ${last_data[$selection]} == "$data" ]] && continue - debug "Data after stripping: $data" - - if [[ $data != *[^[:space:]]* ]]; then - debug "Skipping as clipboard is only blank" - continue - fi - - if [[ ${last_data[$selection]} == "$data" ]]; then - debug 'Skipping as last selection is the same as this one' - continue - fi - - - # If we were in the middle of doing a selection when the previous poll - # ran, then we may have got a partial clip. possible_partial=${last_data[$selection]} if [[ $possible_partial && $data == "$possible_partial"* ]] || [[ $possible_partial && $data == *"$possible_partial" ]]; then - debug "$possible_partial is a possible partial of $data" - debug "Removing ${last_filename[$selection]}" - + # Don't actually remove the file yet, because it might be + # referenced by an older entry. These will be dealt with at vacuum. + debug "$selection: $possible_partial is a possible partial of $data" previous_size=$(wc -c <<< "${last_cache_file_output[$selection]}") truncate -s -"$previous_size" "$cache_file" - - rm -- "${last_filename[$selection]}" fi first_line=$(get_first_line "$data") - debug "New clipboard entry on $selection selection: \"$first_line\"" cache_file_output="$(date +%s%N) $first_line" filename="$cache_dir/$(cksum <<< "$first_line")" - + last_cache_file_output[$selection]=$cache_file_output last_data[$selection]=$data - last_filename[$selection]=$filename - - # Recover without restart if we deleted the entire clip dir. - # It's ok that this only applies to the final directory. - # shellcheck disable=SC2174 - mkdir -p -m0700 "$cache_dir" debug "Writing $data to $filename" printf '%s' "$data" > "$filename" - debug "Writing $cache_file_output to $cache_file" printf '%s\n' "$cache_file_output" >> "$cache_file" - last_cache_file_output[$selection]=$cache_file_output - - if (( CM_OWN_CLIPBOARD )) && [[ $selection != primary ]] && - element_in clipboard "${cm_selections[@]}"; then - # Take ownership of the clipboard, in case the original application - # is unable to serve the clipboard request (due to being suspended, - # etc). - # - # Primary is excluded from the change of ownership as applications - # sometimes act up if clipboard focus is taken away from them -- - # for example, urxvt will unhilight text, which is undesirable. - # - # We can't colocate this with the above copying code because - # https://github.com/cdown/clipmenu/issues/34 requires knowing if - # we would skip first. + if (( CM_OWN_CLIPBOARD )) && [[ $selection == clipboard ]]; then + # Only clipboard, since apps like urxvt will unhilight for PRIMARY _xsel -o --clipboard | _xsel -i --clipboard fi + done - if (( CM_MAX_CLIPS )) && [[ -f $cache_file ]]; then - mapfile -t to_remove < <( - head -n -"$CM_MAX_CLIPS" "$cache_file" | - while read -r line; do cksum <<< "${line#* }"; done - ) - num_to_remove="${#to_remove[@]}" - if (( num_to_remove )); then - debug "Removing $num_to_remove old clips" - rm -- "${to_remove[@]/#/"$cache_dir/"}" - trunc_tmp=$(mktemp) - tail -n "$CM_MAX_CLIPS" "$cache_file" | uniq > "$trunc_tmp" - mv -- "$trunc_tmp" "$cache_file" + if (( CM_MAX_CLIPS )) && (( "$(wc -l < "$cache_file")" > CM_MAX_CLIPS_THRESH )); then + info "Trimming clip cache to CM_MAX_CLIPS ($CM_MAX_CLIPS)" + trunc_tmp=$(mktemp) + tail -n "$CM_MAX_CLIPS" "$cache_file" | uniq > "$trunc_tmp" + mv -- "$trunc_tmp" "$cache_file" + + # Vacuum up unreferenced clips. They may either have been + # unreferenced by the above CM_MAX_CLIPS code, or they may be old + # possible partials. + declare -A cksums + while IFS= read -r line; do + cksum=$(cksum <<< "$line") + cksums["$cksum"]="$line" + done < <(cut -d' ' -f2- < "$cache_file") + + num_vacuumed=0 + for file in "$cache_dir"/[012346789]*; do + cksum=${file##*/} + if [[ ${cksums["$cksum"]-_missing_} == _missing_ ]]; then + debug "Vacuuming due to lack of reference: $file" + (( ++num_vacuumed )) + rm -- "$file" fi - fi - done + done + unset cksums + info "Vacuumed $num_vacuumed clip files." + fi flock -u "$lock_fd" - if (( CM_ONESHOT )); then - debug 'Oneshot mode enabled, exiting' - break - fi + (( CM_ONESHOT )) && break done diff --git a/init/clipmenud.service b/init/clipmenud.service @@ -4,7 +4,7 @@ Description=Clipmenu daemon [Service] ExecStart=/usr/bin/clipmenud Restart=always -RestartSec=0 +RestartSec=500ms Environment=DISPLAY=:0 MemoryDenyWriteExecute=yes diff --git a/tests/test-clipmenu b/tests/test-clipmenu @@ -6,9 +6,9 @@ set -o pipefail : "${CM_DIR="${XDG_RUNTIME_DIR-"${TMPDIR-/tmp}"}"}" -major_version=5 +major_version=6 dir=$CM_DIR/clipmenu.$major_version.$USER -cache_file=$dir/line_cache_primary +cache_file=$dir/line_cache if [[ $0 == /* ]]; then location=${0%/*} diff --git a/tests/test-perf b/tests/test-perf @@ -1,6 +1,6 @@ #!/usr/bin/env bash -major_version=5 +major_version=6 msg() { printf '>>> %s\n' "$@" >&2 @@ -9,7 +9,7 @@ msg() { : "${CM_DIR="${XDG_RUNTIME_DIR-"${TMPDIR-/tmp}"}"}" dir=$CM_DIR/clipmenu.$major_version.$USER -cache_file=$dir/line_cache_primary +cache_file=$dir/line_cache log=$(mktemp) tim=$(mktemp)