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:
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)