Clipboard integration between tmux, nvim, zsh, x11, across SSH sessions

A long-time thorn in my side has been that several different applications in my typical workflow have independent concepts of clipboards that just don’t work well together; plus I often work with multiply nested SSH sessions, which adds another layer of difficulty. A quick web search shows known solutions for integrating some combinations of the elements in my stack, but I wasn’t able to find an existing post with instructions on how to integrate everything seamlessly. Finally I decided to sit down and get all my clipboards working how I’d like, and figured I’d share.

The Components

tmux

tmux is a core part of my workflow and provides a flexible multi-buffer clipboard to use. It’s relatively easy to hook into its clipboard and it provides a set of command-line tools to manipulate its clipboard buffers. For these reasons, most of my synchronization methods are based around tmux as a core, and will not function without a running tmux session.

tmux maintains a configurably-sized list of copy buffers. By default, the copy buffer on top (the most recent one) is used for pasting, but any can be selected. Buffers are named with an incrementing integer.

nvim

vim maintains a set of 10 copy registers that (sometimes) rotate when new text is yanked. Register 0 corresponds to the most recent yanked text, and is shifted to register 1, 2, etc as more text is yanked. This lines up well with the way that tmux handles buffers.

I use neovim for my workflow, and the vimscript here is made to work with neovim rather than vim. I haven’t tested it with vim, but am aware of at least one relevant difference here (how SIGUSR1 is handled). It’s likely the same script could be easily made to work with vim as well.

It’s worth noting that vim already has built-in integration with the X clipboard in the form of special registers, but this doesn’t handle the other cases here.

zsh

zsh’s line editor provides copy/paste functionality. I use oh-my-zsh with its vim plugin which just calls the shell functions clipcopy and clippaste. These functions can be easily overridden for integration.

xclip

The xclip utility provides easy access to the X11 clipboard. However, there is no easy way to be notified when the clipboard changes, so a small bash daemon is used here to poll for changes.

ssh

I often have multiple levels of nested ssh+tmux sessions and it would be nice to have clipboard synchronized between them. This will be discussed in more detail later.

A word about security

Clipboard synchronization in this manner is a great convenience, but one shouldn’t ignore possible security concerns. Clipboards may often be used to store sensitive information, and it’s not ideal to have that pushed around to a bunch of different machines over ssh and stored in a potentially long-lived (in memory) buffer somewhere.

Additionally, there are attacks where an attacker can unexpectedly set a clipboard to malicious data containing escape sequences or malicious commands to run, with unexpected effects on paste.

One should always be wary of these concerns; but there are a few ways to mitigate them. Firstly, do not synchronize clipboard with untrusted systems. This method allows independently configuring which ssh sessions share clipboards. Additionally, a ‘purge’ mechanism is included that wipes all historical clipboard buffers throughout the stack.

For additionally security, the automatic synchronization hooks can be left out of each step here. The relevant commands can be triggered manually by keybinds to “move around” the clipboard to different applications and hosts (ie. by adding keybinds to move a clipboard “up” and “down” the SSH connection stack). This is less seamless, but may be important if connecting clipboards to less trustworthy systems and using the clipboard for sensitive data.

Starting out: Connecting tmux and nvim

I started out by connecting in one component at a time. The first two I chose were tmux and vim. This integration consists of two parts: When a vim yank is executed, push a new tmux buffer; and when a tmux copy is executed, shift in a new vim copy register. This way, buffer history is preserved, and the latest clipboard buffer is always synchronized.

Syncing from vim to tmux

Sending register contents from vim to tmux requires triggering a hook on yank inside vim. It would be possible to do this via keybinds, but neovim provides an autocommand TextYankPost that is triggered after each yank. This can be used to run a shell command and pipe in the yanked text.

The vimscript here can be added to your vim configuration, or in a separate file.

A small difficulty is in the way that vim handles storing registers. Registers are either stored in character mode (which is not null-safe) or in line mode (which does not preserve whether or not there is a trailing newline). So first, we need a function to return register contents that preserves as much as possible. It returns a series of lines with a trailing newline marked with an empty entry.

function! YankSyncGetRegLines(regname)
	return getreg(a:regname, 1, 1) + (getregtype(a:regname) ==# 'v' ? [] : [''])
endfunction

Now we just need to call a shell command whenever something is yanked and pipe it to stdin:

" Look for script pushclip.sh in same directory as vim script
let s:sdir = fnamemodify(resolve(expand('<sfile>:p')), ':h')
let s:ysshpush = s:sdir . '/pushclip.sh'
function! YankSyncPush(regname)
	if empty(a:regname)
		let contents = YankSyncGetRegLines(a:regname)
		call system(s:ysshpush, contents)
	endif
endfunction
augroup clipmgmt
	autocmd!
	autocmd TextYankPost * call YankSyncPush(v:event['regname'])
augroup END

The associated shell script (pushclip.sh) to load from stdin into a tmux buffer is pretty simple:

#!/bin/bash
# Find a filename to use as a temp file
TEMPFILE="`tempfile 2>/dev/null`"
if [ $? -ne 0 ]; then
	TEMPFILE="/tmp/_clip_temp_yssh$USER"
fi
# Save stdin to file
cat > "$TEMPFILE"
# Load tmux buffer
tmux load-buffer "$TEMPFILE"
# Remove file
rm -f "$TEMPFILE"

A word about temp files: Several components here use temporary files for brief periods of time to store clipboard data. These may not always be necessary. In fact, the above can become a one-liner: tmux load-buffer - . But there will be more added to this script as more components are integrated, and sometimes temporary files make things easier to work with.

After setting this up, text yanked in vim will automatically be added as a tmux buffer.

Syncing from tmux to vim

Hooking into tmux copy mode can be done using keybinds in tmux.conf. Very new versions of tmux include an option copy-command that can be used to do the same thing in a nicer way. These keykinds are for vi mode.

unbind-key -T copy-mode-vi y
bind-key -T copy-mode-vi y send-keys -X copy-selection-and-cancel \; run-shell -b ~/.userenv/clipboard/tmuxcopypush.sh
unbind-key -T copy-mode-vi Enter
bind-key -T copy-mode-vi Enter send-keys -X copy-selection-and-cancel \; run-shell -b ~/.userenv/clipboard/tmuxcopypush.sh
unbind-key -T copy-mode-vi A
bind-key -T copy-mode-vi A send-keys -X append-selection-and-cancel \; run-shell -b ~/.userenv/clipboard/tmuxcopypush.sh
unbind-key -T copy-mode-vi D
bind-key -T copy-mode-vi D send-keys -X copy-end-of-line \; run-shell -b ~/.userenv/clipboard/tmuxcopypush.sh
unbind-key -T copy-mode C-w
bind-key -T copy-mode C-w send-keys -X copy-selection-and-cancel \; run-shell -b ~/.userenv/clipboard/tmuxcopypush.sh

Note that these keybinds reference tmuxcopypush.sh in ~/.userenv/clipboard. This is where I store the associated files for clipboard management; alter the paths to your desired location.

Next, vim needs to be notified that there is an updated clipboard. neovim provides the Signal autocommand which can be used to hook into SIGUSR1 and can be triggered externally by sending the signal. This is not supported by vim or older versions of neovim (although I believe that vim may have an alternative).

The signal can be sent to running nvims with the following (saved as updatevims.sh):

# If nvim version is too old, it may crash on USR1, so don't try it
NVIM_VER="`nvim -v | grep '^NVIM' | head -n1 | cut -d ' ' -f 2`"
if [[ "$NVIM_VER" < "v0.4" ]]; then exit 0; fi

killall -USR1 -u `whoami` nvim &>/dev/null
exit 0

Back in the vim config, when the signal is detected, call out to a shell command to get the latest clipboard buffer from tmux, then shift it into the register stack. First check if the clipboard data has actually changed, and do nothing if it has not. This can be important in several places to prevent clipboard-setting feedback loops.

let s:ysshpull = s:sdir . '/vimyanksyncpull.sh'

" Shifts register 0->1, 1->2, etc. then sets reg 0 to the given value
" Expects a linewise list for newcontents
function! YankSyncShiftRegs(newcontents)
	for i in [9, 8, 7, 6, 5, 4, 3, 2, 1]
		let rtype = getregtype(i - 1)
		let rcontents = getreg(i - 1, 1, rtype ==# 'V')
		call setreg(i, rcontents, rtype)
	endfor
	" If the new contents ends with a NL (empty list entry), remove the
	" empty entry and setreg linewise.  Otherwise, setreg characterwise.
	if len(a:newcontents) == 0
		call setreg(0, [], 'cu')
	elseif empty(a:newcontents[-1])
		call setreg(0, a:newcontents[0:-2], 'lu')
	else
		call setreg(0, a:newcontents, 'cu')
	endif
endfunction

function! YankSyncPull()
	let newbuf = systemlist(s:ysshpull, '', 1)
	if v:shell_error == 0
		let curcontents = YankSyncGetRegLines(0)
		if curcontents != newbuf
			call YankSyncShiftRegs(newbuf)
		endif
	endif
endfunction

augroup clipmgmt
	autocmd!
	autocmd TextYankPost * call YankSyncPush(v:event['regname'])
	silent! autocmd Signal SIGUSR1 call YankSyncPull()
augroup END

The script vimyanksyncpull.sh fundamentally just has to output the current clipboard contents. A bit of extra logic is needed to handle edge cases. Since SIGUSR1 is shared as the notification mechanism between different things, we want to avoid getting nvim bogged down with spurious clipboard fetch requests, so only return a buffer if it was created within the last few seconds. Also check to make sure it’s below a specified maximum size. Return a nonzero exit status if no clipboard is available.

# buffer must have been created within this number of seconds
MAX_TIME_DIFF=3
# don't output if buffer too large
MAX_SIZE=100000000

csv="`tmux list-buffers -F '#{buffer_created},#{buffer_size},#{buffer_name}' | grep ',buffer[0-9]'`"
if [ $? -ne 0 ]; then exit 1; fi
csv="`echo "$csv" | head -n1`"
if [ -z "$csv" ]; then exit 1; fi

ctime="`echo "$csv" | cut -d , -f 1`"
bsize="`echo "$csv" | cut -d , -f 2`"
bname="`echo "$csv" | cut -d , -f 3-`"
now="`date +%s`"

if [ `expr $now - $ctime` -gt $MAX_TIME_DIFF ]; then exit 1; fi
if [ $bsize -gt $MAX_SIZE ]; then exit 1; fi

tmux show-buffer -b "$bname"

The last remaining part is the script that tmux triggers on copy, tmuxcopypush.sh, referenced above in tmux.conf. This currently just calls updatevims.sh but more will be added later.

export MYDIR="$(realpath "$(dirname "$0")")" # This line won't be included in further script clippings
"$MYDIR/updatevims.sh" &>/dev/null

After reloading vim and tmux configuration, the integration between the two should be working.

Loading historical buffers on vim startup

The integration so far will push new tmux buffers to vim, but does not work for newly started vim sessions. This hook in the vim config can take care of that:

function! YankSyncPullAll()
	for i in [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
		let newbuf = systemlist(s:ysshgetbuf . ' ' . string(i), '', 1)
		if v:shell_error == 0
			call YankSyncShiftRegs(newbuf)
		endif
	endfor
endfunction

augroup clipmgmt
	autocmd VimEnter * call YankSyncPullAll()
augroup END

Adding more sync targets: zsh and xclip

Integrating zsh

With oh-my-zsh, connecting in an external clipboard involves overriding two builtin functions in zshrc:

function clipcopy() {
	~/.userenv/clipboard/pushclip.sh
}
function clippaste() {
	~/.userenv/clipboard/getcopybuffer.sh
}

There are 2 scripts here. We already know about pushclip.sh, and we can derive getcopybuffer.sh from vimyanksyncpull.sh. getcopybuffer.sh also supports returning older copy buffers with a nonzero argument.

if [ $# -ne 1 ]; then
	N=0
else
	N=$1
fi

MAX_SIZE=100000000

# Find the name of the latest copy buffer, excluding named buffers
csv="`tmux list-buffers -F '#{buffer_created},#{buffer_size},#{buffer_name}' | grep ',buffer[0-9]'`"
if [ $? -ne 0 ]; then exit 1; fi

# Get the request entry
headcount=`expr $N + 1`
csv="`echo "$csv" | head -n$headcount | tail -n1`"
if [ -z "$csv" ]; then exit 1; fi

# Parse out components
ctime="`echo "$csv" | cut -d , -f 1`"
bsize="`echo "$csv" | cut -d , -f 2`"
bname="`echo "$csv" | cut -d , -f 3-`"
if [ $bsize -gt $MAX_SIZE ]; then exit 2; fi

tmux show-buffer -b "$bname"
exit 0

Integrating xclip

X maintains an independent clipboard buffer, so integration is a bit more complicated than with zsh. Pushing to X is easy – we just need to add a call to xclip to pushclip.sh and tmuxcopypush.sh.

To facilitate this, here’s a script pushtogui.sh that pulls the latest tmux buffer and sends it to xclip:

# make sure xclip exists
if ! command -v xclip &>/dev/null; then
	echo no xclip 1>&2
	exit 0
fi

# make sure $DISPLAY is set
if [ -z "$DISPLAY" ]; then
	echo no DISPLAY 1>&2
	exit 0
fi

MAXSIZE=10000000
MYDIR="$(realpath "$(dirname "$0")")"

TEMPFILE="`tempfile 2>/dev/null`"
if [ $? -ne 0 ]; then
	TEMPFILE="/tmp/_clip_temppshgui_yssh$USER"
fi

"$MYDIR/getcopybuffer.sh" 0 > "$TEMPFILE"
if [ $? -ne 0 ]; then rm -f "$TEMPFILE"; exit 0; fi
if [ -z "`cat "$TEMPFILE"`" ]; then rm -f "$TEMPFILE"; exit 0; fi
if [ `cat "$TEMPFILE" | wc -c` -gt $MAXSIZE ]; then rm -f "$TEMPFILE"; exit 0; fi

xclip -i "$TEMPFILE" -selection clipboard

rm -f "$TEMPFILE"

Calls to this now need to be appended to pushclip.sh and tmuxcopypush.sh:

"$MYDIR/pushtogui.sh"

After that, pushing to X should work.

Pulling from X is a little more complicated and requires polling for a clipboard update. This small daemon script xclipwatchd_main.sh should be running once in the background (a script to start it is in the repo).

LOOP_DELAY=0.5
LOOP_DELAY_BIG=5
BIG_THRESHOLD=50000

TEMPFILE="/tmp/_clip_xcwatchd1_yssh$USER"
TEMPFILE2="/tmp/_clip_xcwatchd2_yssh$USER"
MYDIR="$(realpath "$(dirname "$0")")"

update_clip() {
	fn="$1"

	# make sure new contents do not equal current contents
	"$MYDIR/getcopybuffer.sh" 0 > "$TEMPFILE2"
	cmp "$fn" "$TEMPFILE2" &>/dev/null
	if [ $? -ne 1 ]; then rm -f "$TEMPFILE2"; return; fi
	rm -f "$TEMPFILE2"

	# load new buffer into tmux and notify vims
	tmux load-buffer "$fn"
	"$MYDIR/updatevims.sh"
}

# make sure xclip exists
if ! command -v xclip &>/dev/null; then
	echo no xclip 1>&2
	exit 0
fi

# make sure $DISPLAY is set
if [ -z "$DISPLAY" ]; then
	echo no DISPLAY 1>&2
	exit 0
fi

xclip -o -selection clipboard >"$TEMPFILE" 2>/dev/null
if [ $? -ne 0 ]; then
	echo -n '' > "$TEMPFILE"
fi
LSIZE=`cat "$TEMPFILE" | wc -c`
LHASH="`cat "$TEMPFILE" | md5sum | cut -d ' ' -f 1`"
rm -f "$TEMPFILE"

# keep fetching clipboard until a change is found
# wait longer if last clip was longer
while [ 1 ]; do
	xclip -o -selection clipboard >"$TEMPFILE" 2>/dev/null
	if [ $? -ne 0 ]; then
		rm -f "$TEMPFILE"
		sleep $LOOP_DELAY_BIG
		continue
	fi
	CHASH="`cat "$TEMPFILE" | md5sum | cut -d ' ' -f 1`"
	if [ "$CHASH" != "$LHASH" ]; then
		# clipboard changed
		LHASH="$CHASH"
		LSIZE=`cat "$TEMPFILE" | wc -c`
		update_clip "$TEMPFILE"
	fi
	rm -f "$TEMPFILE"
	d=$LOOP_DELAY
	if [ $LSIZE -gt $BIG_THRESHOLD ]; then d=$LOOP_DELAY_BIG; fi
	sleep $d
done

This script continuously loops and checks for a change in X clipboard contents. When one is detected, after verifying that the clipboard is different from tmux’s buffer, it is pushed to tmux, and vims are notified.

Integration across SSH

This is the trickiest component, but also potentially the most useful. I had the following requirements in mind:

  • Integration should be practically seamless, such that anything copied into tmux, vim, etc. on one machine should be transparently moved to the other machines.
  • Integration should be out-of-band of the main tty to avoid lagging the interactive session. The OSC 52 escape code allows for unidirectional clipboard transfer over a tty, which, in addition to being unidirectional, will lag the session for large clipboards.
  • Clipboards should be copied throughout the stack through both incoming and outgoing ssh connections. This should work even with complex networks of SSH connections, where clipboard pushes on any machine should be propagated to the others.
  • Clipboards should NOT by synchronized over SSH by default, for security reasons. Instead, they should require a specific ssh command to activate.
  • Transmitting data when unnecessary should be avoided where practical.
  • Setup of clipboard sync over ssh should be transparent and should work after configuration of my dotfiles on involved machines.
  • The communication mechanism should be secure and should not allow external access to the clipboard.

Approach

With the above requirements, the initial apparent choice is tunneling over SSH. Typically this is done with TCP sockets; but those would be difficult to use here for a few reasons. However, SSH additionally supports UNIX file socket forwarding, which are great for this use case, and also makes it easier to secure.

A small shell-script daemon runs on each machine listening for clipboard updates on a UNIX socket in ~/.clipsync/. When an update is received, it is propagated both to local tmux etc and to other connected SSH sessions.

When propagating an update to other SSH sessions, it is necessary (to limit unnecessary data transfer) to not send data back to the host it originally came from, and also not send the same data multiple times to the same host in the case of multiple SSH connections between the hosts. In lieu of a suitably reliable host identifier across hosts, a randomly generated one is used and is stored in ~/.clipsync/hostid.

When a SSH connection is established, two tunnels are added on in opposite directions. One allows the remote host to push clipboard updates to the local clipsyncd, and the other allows the local host to push clipboard updates to the remote clipsyncd.

The clipsync daemon

The main code for the daemon is stored in clipsyncd_main.sh:

BASEDIR="$HOME/.clipsync"
mkdir -p "$BASEDIR"
MYDIR="$(realpath "$(dirname "$0")")"

TEMPFILE="/tmp/_clipsyncd_temp_yssh$USER"
TEMPFILE2="/tmp/_clipsyncd_temp2_yssh$USER"

handle_buf() {
	fn="$1"
	srchost="$2"

	# make sure new contents do not equal current contents
	"$MYDIR/getcopybuffer.sh" 0 > "$TEMPFILE"
	S=$?
	if [ $S -ne 0 ] && [ $S -ne 1 ]; then echo 'getcopybuffer.sh error'; return; fi
	cmp "$fn" "$TEMPFILE" &>/dev/null
	if [ $? -ne 1 ]; then return; fi

	# load new buffer into tmux and notify vims
	tmux load-buffer "$fn"
	"$MYDIR/updatevims.sh" &
	# push to gui
	"$MYDIR/pushtogui.sh" &>/dev/null &

	# propagate tmux clipboard to other connected machines, excluding the source
	"$MYDIR/clipsyncd_propagate.sh" "$srchost" &

}

while [ 1 ]; do
	rm -f "$BASEDIR/clipsync.sock"
	nc -l -U "$BASEDIR/clipsync.sock" > "$TEMPFILE"
	if [ $? -eq 0 ]; then
		srchost="`head -n1 "$TEMPFILE"`"
		bskip="`echo "$srchost" | wc -c`"
		tail -c +`expr $bskip + 1` "$TEMPFILE" > "$TEMPFILE2"
		handle_buf "$TEMPFILE2" "$srchost"
	fi
	rm -f "$TEMPFILE" "$TEMPFILE2"
done

This script uses netcat in a loop to listen for connections and handle them one at a time. It’s worth noting that this can fail with rapid-fire clipboard updates.

After a client connects to the socket, it sends its own hostid, a newline, then the clipboard data. On the receiving end, this is parsed out. The clipboard data is synchronized with tmux, vim, and X, like we’ve already seen – and then a new script is called, clipsyncd_propagate.sh.

But before looking at that, here’s the client script clipsyncdpush.sh:

localid="`cat ~/.clipsync/hostid`"
NCOPT=""
if [ `uname` != 'Darwin' ]; then
	NCOPT='-N'
fi
(echo "$localid" && cat) | nc -U $NCOPT "$1"
exit $?

This script expects the socket path as an argument. It fetches the local hostid from file, and also includes support for Mac’s default netcat.

The SSH tunnels

To connect the clipsyncd sockets between machines, SSH tunnels are needed. To do this, I’m using a wrapper around ssh clipssh.sh.

localbasedir="$HOME/.clipsync"

localid="`cat ~/.clipsync/hostid`"
if [ $? -ne 0 ]; then
	echo 'clipssh: Initialization error - could not get local host id' 1>&2
	exit 1
fi

echo 'clipssh: getting remote host info' 1>&2
remoteinfo="`ssh "$@" 'cat "$HOME/.clipsync/hostid" && echo "$HOME/.clipsync"'`"
if [ $? -ne 0 ]; then
	echo 'clipssh: Initialization error - could not get remote host info' 1>&2
	exit 1
fi

remoteid="`echo "$remoteinfo" | head -n1`"
remotebasedir="`echo "$remoteinfo" | head -n2 | tail -n1`"
echo "clipssh: remoteid=$remoteid remotebasedir=$remotebasedir" 1>&2

sessid="`date +%s`${RANDOM}"

tun_r="${remotebasedir}/sock_in/${localid}+${sessid}:${localbasedir}/clipsync.sock"
tun_l="${localbasedir}/sock_out/${remoteid}+${sessid}:${remotebasedir}/clipsync.sock"

echo "clipssh: ssh -R $tun_r -L $tun_l $@" 1>&2

ssh -R "$tun_r" -L "$tun_l" "$@"

This script first fetches both the local and remote (over ssh) host ids, as they are needed for naming the tunneled socket filenames. Then the actual SSH connection is established with the tunnels for the UNIX sockets. Naming of the sockets is specific and important for the next step.

Propagating clipboard updates

The goal of update propagation is to send the clipboard data once to each connected host, even if there are multiple tunnels to that host. It also needs to be able to handle and clean up after dead sockets. The solution to this heavily leverages the filename of the tunneled sockets.

The filename of each tunneled socket consists of the remote hostid followed by an incrementing timestamp and then a randomized number. This allows easily sorting to find unique hostids, and also allows sorting within each hostid to process old tunnel sockets first to make sure they get cleaned up.

The script clipsyncd_propagate.sh first finds a list of unique connected remote host ids, then processes each individually. For each, sockets are processed from old to new and get removed if nonfunctional until it successfully connects and sends the clipboard data.

# argument is optional: host id to exclude
# fetches clipboard data from tmux

BASEDIR="$HOME/.clipsync"
MYDIR="$(realpath "$(dirname "$0")")"
localid="`cat ~/.clipsync/hostid`"
excludeid="$1"
MAXSIZE=10000000

listallsocks() {
	find "$BASEDIR/sock_in" "$BASEDIR/sock_out" -type s
}

listallhosts() {
	listallsocks | while read -r line; do
		bn="`basename "$line"`"
		echo "$bn" | cut -d '+' -f 1
	done | sort | uniq
}

sendtohost() {
	host="$1"
	#echo "sendtohost $host"
	if [ "$host" = "$localid" ]; then return; fi
	if [ ! -z "$excludeid" ] && [ "$host" = "$excludeid" ]; then return; fi
	file="$2"
	for sock in `listallsocks | grep -F "/${host}+" | sort`; do
		"$MYDIR/clipsyncdpush.sh" "$sock" < "$file" &>/dev/null
		if [ $? -eq 0 ]; then
			break
		else
			# clean up old sockets we cant connect to
			rm -f "$sock"
		fi
	done
}

TEMPFILE="`tempfile 2>/dev/null`"
if [ $? -ne 0 ]; then
	TEMPFILE="/tmp/_clip_tempcsdp_yssh$USER"
fi

"$MYDIR/getcopybuffer.sh" 0 > "$TEMPFILE"
if [ $? -ne 0 ]; then rm -f "$TEMPFILE"; exit 0; fi
if [ -z "`cat "$TEMPFILE"`" ]; then rm -f "$TEMPFILE"; exit 0; fi
if [ `cat "$TEMPFILE" | wc -c` -gt $MAXSIZE ]; then rm -f "$TEMPFILE"; exit 0; fi

for h in `listallhosts`; do
	sendtohost "$h" "$TEMPFILE"
done

rm -f "$TEMPFILE"
exit 0

Tieing it in

After the above is set up and the clipsync daemons running, each machine should be receiving any clipboard data sent to its clipsync socket; and the (tmux) clipboard on each machine should be able to be pushed through the stack using clipsyncd_propagate.sh, or to an individual socket with clipsyncdpush.sh.

Next, calls need to be added to clipsyncd_propagate.sh from each place where the local tmux clipboard can be updated. Calls should be added just after the data is pushed into tmux. So far, those files are: pushclip.sh, tmuxcopypush.sh, and xclipwatchd_main.sh.

Having to manually start clipsyncd_main.sh on each machine is annoying, so I also wanted to set it up to start automatically. The daemon can only run once for each user, which is a bit tricky to ensure. This quick script start_clipsyncd.sh checks if the process is already running (in a pretty non-ideal way) and starts it if not.

pcount=`ps ux | grep -v grep | grep clipsyncd_main.sh | wc -l`
if [ $pcount -gt 2 ]; then exit; fi
MYDIR="$(realpath "$(dirname "$0")")"
nohup "$MYDIR/clipsyncd_main.sh" </dev/null >/dev/null 2>&1 &
disown

And I have tmux set up to start it when a new session is created (tmux.conf):

set-hook -g session-created[0] 'run-shell -b ~/.userenv/clipboard/start_clipsyncd.sh'

And at this point, full clipboard synchronization should be working.

Adding purge function

As noted above, clipboards can be used to store sensitive data; and in these cases, it’s not good to leave such data in various clipboard history buffers for long periods of time. To mitigate this, a “purge” function can be added to the clipboard synchronization stack to remove all current and historical clipboard buffers from local and connected machines (recursively).

This purging is triggered by pushing a sentinel value (of “!!!___PURGED___!!!”) through the clipboard stack. The two applications that maintain historical buffers (tmux and vim) also need to detect this to wipe out their historical data.

First, the purge_local.sh script handles purging historical tmux buffers and pushing it to vim and X:

# Remove tmux buffers (only automatically named ones)
for bufname in `tmux list-buffers -F '#{buffer_name}' | grep '^buffer[0-9]'`; do
	tmux delete-buffer -b "$bufname"
done

# Add a tmux purge sentinel buffer to indicate a purge
echo -n '!!!___PURGED___!!!' | tmux load-buffer -

# Notify vims and gui
"$MYDIR/updatevims.sh"
"$MYDIR/pushtogui.sh" &>/dev/null

Next, the vim script needs to be altered to detect this and handle it accordingly:

function! YankSyncPurgeRegs(newcontents)
	for i in [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, '']
		call setreg(i, a:newcontents, 'c')
	endfor
endfunction

function! YankSyncPull()
	let newbuf = systemlist(s:ysshpull, '', 1)
	if v:shell_error == 0
		if newbuf == ['!!!___PURGED___!!!']
			call YankSyncPurgeRegs(newbuf)
		else
			let curcontents = YankSyncGetRegLines(0)
			if curcontents != newbuf
				call YankSyncShiftRegs(newbuf)
			endif
		endif
	endif
endfunction

Here we’ve added a conditional to detect the sentinel value to YankSyncPull() and a new function YankSyncPurgeRegs() to clear out all numbered registers.

Now add a conditional to clipsyncd_main.shto run a local purge when the sentinel value is received:

if [ "`cat "$fn"`" = '!!!___PURGED___!!!' ]; then
	"$MYDIR/purge_local.sh"
else
	tmux load-buffer "$fn"
	"$MYDIR/updatevims.sh"
	"$MYDIR/pushtogui.sh" &>/dev/null
fi

And, finally, a script to kick off the purge process, purge.sh:

"$MYDIR/purge_local.sh"
"$MYDIR/clipsyncd_propagate.sh"

At this point, when purge.sh is run, it should clear all clipboards locally and remotely. A keybind can be set up to easily access this.

Conclusion

Integrating together all of these components turned out to be a bit more involved than most of the clipboard integration configurations I’ve seen. It seems to work pretty well, but there are quite a few areas that could be improved. If integrating many more components, it would likely be ideal to refactor this into a central process to manage clipboard synchronization; and, perhaps, implement some components in a different language like python (although one of my goals for this was to not depend on external languages).

There are a few parts of these files not included here for brevity, and a few alterations have been made (such as to execute some operations asynchronously for a snappier experience). The full set of relevant scripts and configuration can be found in my dotfiles repo here.

Leave a comment

Your email address will not be published. Required fields are marked *