#!/usr/bin/env bash : "${CM_ONESHOT=0}" : "${CM_OWN_CLIPBOARD=0}" : "${CM_SYNC_PRIMARY_TO_CLIPBOARD=0}" : "${CM_DEBUG=0}" : "${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 )) : "${CM_SELECTIONS:=clipboard primary}" read -r -a selections <<< "$CM_SELECTIONS" cache_dir=$(clipctl cache-dir) cache_file=$cache_dir/line_cache status_file=$cache_dir/status # 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_xdotool=0 _xsel() { timeout 1 xsel --logfile /dev/null "$@"; } error() { printf 'ERROR: %s\n' "${1?}" >&2; } info() { printf 'INFO: %s\n' "${1?}"; } die() { error "${2?}" exit "${1?}" } make_line_cksums() { while read -r line; do cksum <<< "${line#* }"; done; } get_first_line() { data=${1?} # We look for the first line matching regex /./ here because we want the # 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) printf(" (%d lines)", NR); printf("\n"); }' <<< "$data" } debug() { (( CM_DEBUG )) && printf '%s\n' "$@" >&2; } sig_disable() { info "Received disable signal, suspending clipboard capture" _CM_DISABLED=1 echo "disabled" > "$status_file" } sig_enable() { if ! (( _CM_DISABLED )); then info "Received enable signal but we're not disabled, so doing nothing" return fi # Still store the last data so we don't end up eventually putting it in the # clipboard if it wasn't changed for selection in "${selections[@]}"; do data=$(_xsel -o --"$selection"; printf x) last_data_sel[$selection]=${data%x} done info "Received enable signal, resuming clipboard capture" _CM_DISABLED=0 echo "enabled" > "$status_file" } kill_background_jobs() { # While we usually _are_, there are no guarantees that we're the process # group leader. As such, all we can do is look at the pending jobs. Bash # avoids a subshell here, so the job list is in the right shell. local bg bg=$(jobs -p) # Don't log `kill' failures, since with KillMode=control-group, we're # racing with init. [[ $bg ]] && kill -- "$bg" 2>/dev/null } if [[ $1 == --help ]] || [[ $1 == -h ]]; then cat << 'EOF' clipmenud collects and caches what's on the clipboard. You can manage its operation with clipctl. 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: 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. 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_SYNC_PRIMARY_TO_CLIPBOARD: sync selections from primary to clipboard immediately (default: 0) - $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 exit 0 fi [[ $DISPLAY ]] || die 2 'The X display is unset, is your X server running?' # It's ok that this only applies to the final directory. # shellcheck disable=SC2174 mkdir -p -m0700 "$cache_dir" echo "enabled" > "$status_file" 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_sel declare -A updated_sel 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 echo "WARN: CM_IGNORE_WINDOW does not work without xdotool, which is not installed" >&2 fi exec {lock_fd}> "$lock_file" trap '_CM_TRAP=1; sig_disable' USR1 trap '_CM_TRAP=1; sig_enable' USR2 trap 'trap - INT TERM EXIT; kill_background_jobs; exit 0' INT TERM EXIT while true; do if ! (( CM_ONESHOT )); then # Make sure we're interruptible for the sig_{en,dis}able traps clipnotify & _CM_CLIPNOTIFY_PID="$!" wait "$_CM_CLIPNOTIFY_PID" fi # Trapping a signal breaks the `wait` if (( _CM_TRAP )); then # Prevent spawning another clipnotify job restarting the loop kill_background_jobs unset _CM_TRAP continue fi if (( _CM_DISABLED )); then info "Got a clipboard notification, but we are disabled, skipping" continue fi if [[ $CM_IGNORE_WINDOW ]] && (( has_xdotool )); then windowname="$(xdotool getactivewindow getwindowname)" if [[ "$windowname" =~ $CM_IGNORE_WINDOW ]]; then debug "ignoring clipboard because windowname \"$windowname\" matches \"${CM_IGNORE_WINDOW}\"" continue fi fi if ! flock -x -w "$lock_timeout" "$lock_fd"; then if (( CM_ONESHOT )); then die 1 "Timed out waiting for lock" else error "Timed out waiting for lock, skipping this iteration" continue fi fi for selection in "${selections[@]}"; do updated_sel[$selection]=0 data=$(_xsel -o --"$selection"; printf x) data=${data%x} # avoid trailing newlines being stripped [[ $data == *[^[:space:]]* ]] || continue [[ $last_data == "$data" ]] && continue [[ ${last_data_sel[$selection]} == "$data" ]] && continue if [[ $last_data && $data == "$last_data"* ]] || [[ $last_data && $data == *"$last_data" ]]; then # 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: $last_data is a possible partial of $data" previous_size=$(wc -c <<< "$last_cache_file_output") truncate -s -"$previous_size" "$cache_file" 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=$cache_file_output last_data=$data last_data_sel[$selection]=$data updated_sel[$selection]=1 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" 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_SYNC_PRIMARY_TO_CLIPBOARD )) && (( updated_sel[primary] )); then _xsel -o --primary | _xsel -i --clipboard fi # The cache file may not exist if this is the first run and data is skipped if (( CM_MAX_CLIPS )) && [[ -f "$cache_file" ]] && (( "$(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 done unset cksums info "Vacuumed $num_vacuumed clip files." fi flock -u "$lock_fd" (( CM_ONESHOT )) && break done