rofi/scripts/rofi-clipd 4.5 K raw
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