| 1 | #!/bin/sh |
| 2 | # Requires: xclip |
| 3 | # Clipboard history daemon. cliphist is Wayland-only, so this is the X11 |
| 4 | # stand-in: it polls the CLIPBOARD selection and keeps a copy of everything |
| 5 | # that passes through it. Copying is what makes it useful — an X selection |
| 6 | # lives in the process that owns it, so closing the app that copied a string |
| 7 | # takes the string with it. rofi-clip reads what this writes. |
| 8 | # |
| 9 | # Usage: rofi-clipd run in the foreground (autostart target) |
| 10 | # rofi-clipd --once record the clipboard right now and exit |
| 11 | # rofi-clipd --clear forget the whole history |
| 12 | set -u |
| 13 | |
| 14 | DIR="${XDG_CACHE_HOME:-$HOME/.cache}/rofi-clipboard" |
| 15 | STORE="$DIR/store" |
| 16 | INDEX="$DIR/index" |
| 17 | PIDFILE="$DIR/pid" |
| 18 | |
| 19 | # How many entries to keep, and how often to look. A second is under the |
| 20 | # threshold where a copy-then-immediately-quit would be missed in practice. |
| 21 | LIMIT=${ROFI_CLIP_LIMIT:-200} |
| 22 | INTERVAL=${ROFI_CLIP_INTERVAL:-1} |
| 23 | # How long to give an unresponsive selection owner before giving up on a read. |
| 24 | READ_TIMEOUT=${ROFI_CLIP_READ_TIMEOUT:-2} |
| 25 | |
| 26 | mkdir -p "$STORE" |
| 27 | |
| 28 | # md5sum is the entry's identity: same text, same file, so re-copying an old |
| 29 | # entry moves it to the top instead of duplicating it. |
| 30 | hash_of() { printf '%s' "$1" | md5sum | cut -d' ' -f1; } |
| 31 | |
| 32 | # One index line per entry, newest first: "<hash>\t<single-line preview>". |
| 33 | # rofi rows cannot span lines, so newlines become a visible glyph and the |
| 34 | # text is cut to something that fits a menu width. |
| 35 | preview_of() { |
| 36 | printf '%s' "$1" | awk ' |
| 37 | BEGIN { ORS = "" } |
| 38 | NR > 1 { print " ⏎ " } |
| 39 | { gsub(/\t/, " "); print } |
| 40 | ' | cut -c1-160 |
| 41 | } |
| 42 | |
| 43 | record() { |
| 44 | entry=$1 |
| 45 | |
| 46 | # Whitespace-only copies are almost always an accident of a drag select. |
| 47 | case $entry in |
| 48 | *[![:space:]]*) ;; |
| 49 | *) return 0 ;; |
| 50 | esac |
| 51 | |
| 52 | hash=$(hash_of "$entry") |
| 53 | printf '%s' "$entry" > "$STORE/$hash" |
| 54 | |
| 55 | tmp="$INDEX.$$" |
| 56 | printf '%s\t%s\n' "$hash" "$(preview_of "$entry")" > "$tmp" |
| 57 | if [ -f "$INDEX" ]; then |
| 58 | grep -v "^$hash " "$INDEX" >> "$tmp" |
| 59 | fi |
| 60 | head -n "$LIMIT" "$tmp" > "$INDEX" |
| 61 | rm -f "$tmp" |
| 62 | |
| 63 | # Everything past the limit fell off the index above; drop its payload |
| 64 | # too, or the store grows without bound. |
| 65 | for f in "$STORE"/*; do |
| 66 | [ -e "$f" ] || continue |
| 67 | grep -q "^${f##*/} " "$INDEX" || rm -f "$f" |
| 68 | done |
| 69 | } |
| 70 | |
| 71 | # UTF8_STRING keeps this to text: asking for whatever target an image or a |
| 72 | # file-manager copy offers would store a blob nothing can paste back. |
| 73 | # |
| 74 | # The timeout is not optional. An X selection is served by the process that |
| 75 | # owns it, so reading one is a round trip to another program — and if that |
| 76 | # program stops answering (it died mid-transfer, or it is wedged), xclip waits |
| 77 | # on the reply forever. With no timeout that hangs this loop too, and history |
| 78 | # quietly stops recording until the daemon is restarted by hand. |
| 79 | current() { timeout "$READ_TIMEOUT" xclip -o -selection clipboard -t UTF8_STRING 2>/dev/null; } |
| 80 | |
| 81 | case "${1:-}" in |
| 82 | --once) |
| 83 | clip=$(current) || exit 0 |
| 84 | [ -n "$clip" ] && record "$clip" |
| 85 | exit 0 |
| 86 | ;; |
| 87 | --clear) |
| 88 | rm -rf "$STORE" "$INDEX" |
| 89 | mkdir -p "$STORE" |
| 90 | exit 0 |
| 91 | ;; |
| 92 | "") ;; |
| 93 | *) echo "usage: rofi-clipd [--once|--clear]" >&2; exit 2 ;; |
| 94 | esac |
| 95 | |
| 96 | # A second daemon would double every entry, so check for a live one first. |
| 97 | # The trap below clears the pidfile on a clean exit, but a kill -9 or a reboot |
| 98 | # leaves it behind — and that stale pid is eventually handed to some unrelated |
| 99 | # process, which would make this refuse to start at every login from then on. |
| 100 | # So the pid has to still be this script, not merely alive. |
| 101 | running() { |
| 102 | [ -f "$PIDFILE" ] || return 1 |
| 103 | pid=$(cat "$PIDFILE" 2>/dev/null) || return 1 |
| 104 | [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null || return 1 |
| 105 | tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null | grep -q "${0##*/}" |
| 106 | } |
| 107 | |
| 108 | if running; then |
| 109 | echo "rofi-clipd: already running (pid $(cat "$PIDFILE"))" >&2 |
| 110 | exit 1 |
| 111 | fi |
| 112 | echo $$ > "$PIDFILE" |
| 113 | # EXIT clears the pidfile on every way out. INT and TERM need handlers that |
| 114 | # actually exit: a handler that just cleans up returns into the loop, leaving a |
| 115 | # daemon that ignores kill *and* has already deleted the pidfile guarding |
| 116 | # against a second one — so the next login starts a duplicate that records |
| 117 | # every copy twice. |
| 118 | trap 'rm -f "$PIDFILE"' EXIT |
| 119 | trap 'exit 130' INT |
| 120 | trap 'exit 143' TERM |
| 121 | |
| 122 | last="" |
| 123 | while :; do |
| 124 | clip=$(current) || clip="" |
| 125 | if [ -n "$clip" ] && [ "$clip" != "$last" ]; then |
| 126 | last=$clip |
| 127 | record "$clip" |
| 128 | fi |
| 129 | sleep "$INTERVAL" |
| 130 | done |