clipmenu

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

clipmenud (9451B)


      1#!/usr/bin/env bash
      2
      3: "${CM_ONESHOT=0}"
      4: "${CM_OWN_CLIPBOARD=0}"
      5: "${CM_SYNC_PRIMARY_TO_CLIPBOARD=0}"
      6: "${CM_SYNC_CLIPBOARD_TO_PRIMARY=0}"
      7: "${CM_DEBUG=0}"
      8
      9: "${CM_MAX_CLIPS:=1000}"
     10# Buffer to batch to avoid calling too much. Only used if CM_MAX_CLIPS >0.
     11CM_MAX_CLIPS_THRESH=$(( CM_MAX_CLIPS + 10 ))
     12
     13: "${CM_SELECTIONS:=clipboard primary}"
     14read -r -a selections <<< "$CM_SELECTIONS"
     15
     16cache_dir=$(clipctl cache-dir)
     17cache_file=$cache_dir/line_cache
     18status_file=$cache_dir/status
     19
     20# lock_file: lock for *one* iteration of clipboard capture/propagation
     21# session_lock_file: lock to prevent multiple clipmenud daemons
     22lock_file=$cache_dir/lock
     23session_lock_file=$cache_dir/session_lock
     24lock_timeout=2
     25has_xdotool=0
     26
     27_xsel() { timeout 1 xsel --logfile /dev/null "$@"; }
     28
     29error() { printf 'ERROR: %s\n' "${1?}" >&2; }
     30info() { printf 'INFO: %s\n' "${1?}"; }
     31die() {
     32    error "${2?}"
     33    exit "${1?}"
     34}
     35
     36make_line_cksums() { while read -r line; do cksum <<< "${line#* }"; done; }
     37
     38get_first_line() {
     39    data=${1?}
     40
     41    # We look for the first line matching regex /./ here because we want the
     42    # first line that can provide reasonable context to the user.
     43    awk -v limit=300 '
     44        BEGIN { printed = 0; }
     45        printed == 0 && NF {
     46            $0 = substr($0, 0, limit);
     47            printf("%s", $0);
     48            printed = 1;
     49        }
     50        END {
     51            if (NR > 1)
     52                printf(" (%d lines)", NR);
     53            printf("\n");
     54        }' <<< "$data"
     55}
     56
     57debug() { (( CM_DEBUG )) && printf '%s\n' "$@" >&2; }
     58
     59sig_disable() {
     60    if (( _CM_DISABLED )); then
     61        info "Received disable signal but we're already disabled, so doing nothing"
     62        return
     63    fi
     64
     65    info "Received disable signal, suspending clipboard capture"
     66    _CM_DISABLED=1
     67    echo "disabled" > "$status_file"
     68}
     69
     70sig_enable() {
     71    if ! (( _CM_DISABLED )); then
     72        info "Received enable signal but we're already enabled, so doing nothing"
     73        return
     74    fi
     75
     76    # Still store the last data so we don't end up eventually putting it in the
     77    # clipboard if it wasn't changed
     78    for selection in "${selections[@]}"; do
     79        data=$(_xsel -o --"$selection"; printf x)
     80        last_data_sel[$selection]=${data%x}
     81    done
     82
     83    info "Received enable signal, resuming clipboard capture"
     84    _CM_DISABLED=0
     85    echo "enabled" > "$status_file"
     86}
     87
     88kill_background_jobs() {
     89    # While we usually _are_, there are no guarantees that we're the process
     90    # group leader. As such, all we can do is look at the pending jobs. Bash
     91    # avoids a subshell here, so the job list is in the right shell.
     92    local -a bg
     93    readarray -t bg < <(jobs -p)
     94
     95    # Don't log `kill' failures, since with KillMode=control-group, we're
     96    # racing with init.
     97    (( ${#bg[@]} )) && kill -- "${bg[@]}" 2>/dev/null
     98}
     99
    100# Avoid clipmenu showing "no cache file yet" if we launched it before selecting
    101# anything
    102touch -- "$cache_file"
    103
    104if [[ $1 == --help ]] || [[ $1 == -h ]]; then
    105    cat << 'EOF'
    106clipmenud collects and caches what's on the clipboard. You can manage its
    107operation with clipctl.
    108
    109Environment variables:
    110
    111- $CM_DEBUG: turn on debugging output (default: 0)
    112- $CM_DIR: specify the base directory to store the cache dir in (default: $XDG_RUNTIME_DIR, $TMPDIR, or /tmp)
    113- $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)
    114- $CM_ONESHOT: run once immediately, do not loop (default: 0)
    115- $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)
    116- $CM_SELECTIONS: space separated list of the selections to manage (default: "clipboard primary")
    117- $CM_SYNC_PRIMARY_TO_CLIPBOARD: sync selections from primary to clipboard immediately (default: 0)
    118- $CM_SYNC_CLIPBOARD_TO_PRIMARY: sync selections from clipboard to primary immediately (default: 0)
    119- $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)
    120EOF
    121    exit 0
    122fi
    123
    124[[ $DISPLAY ]] || die 2 'The X display is unset, is your X server running?'
    125
    126# It's ok that this only applies to the final directory.
    127# shellcheck disable=SC2174
    128mkdir -p -m0700 "$cache_dir"
    129echo "enabled" > "$status_file"
    130
    131exec {session_lock_fd}> "$session_lock_file"
    132flock -x -n "$session_lock_fd" ||
    133    die 2 "Can't lock session file -- is another clipmenud running?"
    134
    135declare -A last_data_sel
    136declare -A updated_sel
    137
    138command -v clipnotify >/dev/null 2>&1 || die 2 "clipnotify not in PATH"
    139command -v xdotool >/dev/null 2>&1 && has_xdotool=1
    140
    141if [[ $CM_IGNORE_WINDOW ]] && ! (( has_xdotool )); then
    142    echo "WARN: CM_IGNORE_WINDOW does not work without xdotool, which is not installed" >&2
    143fi
    144
    145exec {lock_fd}> "$lock_file"
    146
    147trap '_CM_TRAP=1; sig_disable' USR1
    148trap '_CM_TRAP=1; sig_enable' USR2
    149trap 'trap - INT TERM EXIT; kill_background_jobs; exit 0' INT TERM EXIT
    150
    151while true; do
    152    if ! (( CM_ONESHOT )); then
    153        # Make sure we're interruptible for the sig_{en,dis}able traps
    154        clipnotify &
    155        _CM_CLIPNOTIFY_PID="$!"
    156        if ! wait "$_CM_CLIPNOTIFY_PID" && ! (( _CM_TRAP )); then
    157            # X server dead?
    158            sleep 10
    159            continue
    160        fi
    161    fi
    162
    163    # Trapping a signal breaks the `wait`
    164    if (( _CM_TRAP )); then
    165        # Prevent spawning another clipnotify job restarting the loop
    166        kill_background_jobs
    167        unset _CM_TRAP
    168        continue
    169    fi
    170
    171    if (( _CM_DISABLED )); then
    172        info "Got a clipboard notification, but we are disabled, skipping"
    173        continue
    174    fi
    175
    176    if [[ $CM_IGNORE_WINDOW ]] && (( has_xdotool )); then
    177        windowname="$(xdotool getactivewindow getwindowname)"
    178        if [[ "$windowname" =~ $CM_IGNORE_WINDOW ]]; then
    179            debug "ignoring clipboard because windowname \"$windowname\" matches \"${CM_IGNORE_WINDOW}\""
    180            continue
    181        fi
    182    fi
    183
    184    if ! flock -x -w "$lock_timeout" "$lock_fd"; then
    185        if (( CM_ONESHOT )); then
    186            die 1 "Timed out waiting for lock"
    187        else
    188            error "Timed out waiting for lock, skipping this iteration"
    189            continue
    190        fi
    191    fi
    192
    193    for selection in "${selections[@]}"; do
    194        updated_sel[$selection]=0
    195
    196        data=$(_xsel -o --"$selection"; printf x)
    197        data=${data%x}  # avoid trailing newlines being stripped
    198
    199        [[ $data == *[^[:space:]]* ]] || continue
    200        [[ $last_data == "$data" ]] && continue
    201        [[ ${last_data_sel[$selection]} == "$data" ]] && continue
    202
    203        if [[ $last_data && $data == "$last_data"* ]] ||
    204           [[ $last_data && $data == *"$last_data" ]]; then
    205            # Don't actually remove the file yet, because it might be
    206            # referenced by an older entry. These will be dealt with at vacuum.
    207            debug "$selection: $last_data is a possible partial of $data"
    208            previous_size=$(wc -c <<< "$last_cache_file_output")
    209            truncate -s -"$previous_size" "$cache_file"
    210        fi
    211
    212        first_line=$(get_first_line "$data")
    213        debug "New clipboard entry on $selection selection: \"$first_line\""
    214
    215        cache_file_output="$(date +%s%N) $first_line"
    216        filename="$cache_dir/$(cksum <<< "$first_line")"
    217        last_cache_file_output=$cache_file_output
    218        last_data=$data
    219        last_data_sel[$selection]=$data
    220        updated_sel[$selection]=1
    221
    222        debug "Writing $data to $filename"
    223        printf '%s' "$data" > "$filename"
    224        debug "Writing $cache_file_output to $cache_file"
    225        printf '%s\n' "$cache_file_output" >> "$cache_file"
    226
    227        if (( CM_OWN_CLIPBOARD )) && [[ $selection == clipboard ]]; then
    228            # Only clipboard, since apps like urxvt will unhilight for PRIMARY
    229            _xsel -o --clipboard | _xsel -i --clipboard
    230        fi
    231    done
    232
    233    if (( CM_SYNC_PRIMARY_TO_CLIPBOARD )) && (( updated_sel[primary] )); then
    234        _xsel -o --primary | _xsel -i --clipboard
    235    fi
    236    if (( CM_SYNC_CLIPBOARD_TO_PRIMARY )) && (( updated_sel[clipboard] )); then
    237        _xsel -o --clipboard | _xsel -i --primary
    238    fi
    239
    240    # The cache file may not exist if this is the first run and data is skipped
    241    if (( CM_MAX_CLIPS )) && [[ -f "$cache_file" ]] && (( "$(wc -l < "$cache_file")" > CM_MAX_CLIPS_THRESH )); then
    242        info "Trimming clip cache to CM_MAX_CLIPS ($CM_MAX_CLIPS)"
    243        trunc_tmp=$(mktemp)
    244        tail -n "$CM_MAX_CLIPS" "$cache_file" | uniq > "$trunc_tmp"
    245        mv -- "$trunc_tmp" "$cache_file"
    246
    247        # Vacuum up unreferenced clips. They may either have been
    248        # unreferenced by the above CM_MAX_CLIPS code, or they may be old
    249        # possible partials.
    250        declare -A cksums
    251        while IFS= read -r line; do
    252            cksum=$(cksum <<< "$line")
    253            cksums["$cksum"]="$line"
    254        done < <(cut -d' ' -f2- < "$cache_file")
    255
    256        num_vacuumed=0
    257        for file in "$cache_dir"/[012346789]*; do
    258            cksum=${file##*/}
    259            if [[ ${cksums["$cksum"]-_missing_} == _missing_ ]]; then
    260                debug "Vacuuming due to lack of reference: $file"
    261                (( ++num_vacuumed ))
    262                rm -- "$file"
    263            fi
    264        done
    265        unset cksums
    266        info "Vacuumed $num_vacuumed clip files."
    267    fi
    268
    269    flock -u "$lock_fd"
    270
    271    (( CM_ONESHOT )) && break
    272done