pi-ssh-image-clipboard
v1.0.0
Published
Pi extension: Ctrl+V image paste into remote pi sessions over ssh/Eternal Terminal reverse tunnels, multi-client-box aware via tmux.
Downloads
18
Maintainers
Readme
pi-ssh-image-clipboard
Ctrl+V image paste into remote pi sessions. Locally, pi's Ctrl+V grabs an image from your clipboard, writes a temp file and inserts its path. On a headless box over ssh/mosh/et it silently does nothing — this package makes it work, by fetching the clipboard image from the machine you are typing on through a reverse tunnel riding your ssh or Eternal Terminal connection.
Works with any terminal (ghostty, iTerm2, ...) since the terminal is not
involved. Multi-client aware: if several boxes are attached to the same tmux
session, the image comes from the box that pressed Ctrl+V (resolved via tmux
client_activity).
How it works
Mac: launchd (inetd-style) -> pngpaste [nothing runs when idle]
| unix socket / TCP 127.0.0.1:7779
| reverse tunnel (et env-var socket, or ssh RemoteForward)
v
remote: per-connection socket (+ ~/.pi-clip/by-tty/<tty>.sock symlink
maintained by a login hook)
v
pi extension: Ctrl+V -> pick socket of the most-recently-active tmux
client -> fetch bytes -> /tmp/pi-clipboard-<uuid>.png -> insert pathThe extension registers Ctrl+V only on headless Linux (no
DISPLAY/WAYLAND_DISPLAY), so it never shadows pi's built-in image paste
on desktop machines. With no live tunnel (e.g. connected via mosh, which
cannot forward anything) it is a silent no-op, matching stock behavior.
Install (remote box running pi)
pi install git:github.com/pasky/pi-ssh-image-clipboardAdd the login hook to ~/.bashrc (maps each login tty to that connection's
clipboard socket):
# pi-ssh-image-clipboard: map this login's tty to the client box's clipboard
# socket, so Ctrl+V in pi grabs the clipboard of the box you're typing from.
# ssh: LC_PI_CLIP names the box socket (~/.pi-clip/<box>.sock, RemoteForward).
# et: PI_CLIP_SOCK is set by `et -r PI_CLIP_SOCK:/client/sock/path`.
if [ -z "$TMUX" ]; then
_pi_clip_target=""
if [ -n "$PI_CLIP_SOCK" ]; then
_pi_clip_target="$PI_CLIP_SOCK"
elif [ -n "$SSH_TTY" ] && [ -n "$LC_PI_CLIP" ]; then
_pi_clip_target="$HOME/.pi-clip/$LC_PI_CLIP.sock"
fi
if [ -n "$_pi_clip_target" ] && _pi_clip_tty=$(tty 2>/dev/null); then
case "$_pi_clip_tty" in /dev/pts/*)
mkdir -p ~/.pi-clip/by-tty
ln -sf "$_pi_clip_target" ~/.pi-clip/by-tty/"$(basename "$_pi_clip_tty")".sock
esac
fi
unset _pi_clip_target _pi_clip_tty
fiOne-time: mkdir -p ~/.pi-clip/by-tty && chmod 700 ~/.pi-clip
Recommended if you use the ssh (RemoteForward) transport (needs root): let
sshd replace stale socket files after unclean disconnects, otherwise the
re-established forward fails until the stale .sock file is removed:
echo 'StreamLocalBindUnlink yes' | sudo tee /etc/ssh/sshd_config.d/60-pi-clip.conf
sudo sshd -t && sudo systemctl reload sshClient box setup (Mac)
Any OS works if it serves its clipboard as an image on a local socket; instructions below are for macOS.
Replace pasky below with your username, of course.
brew install pngpasteCreate ~/Library/LaunchAgents/org.pasky.clipserve.plist — launchd itself
listens and spawns pngpaste - per connection (inetd-style, no daemon
running when idle). Adjust the two /Users/pasky paths; Intel Macs have
pngpaste at /usr/local/bin/pngpaste:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>org.pasky.clipserve</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/pngpaste</string>
<string>-</string>
</array>
<key>inetdCompatibility</key>
<dict><key>Wait</key><false/></dict>
<!-- keep pngpaste's stderr (e.g. "no image" error text) off the socket -->
<key>StandardErrorPath</key><string>/tmp/pngpaste-clipserve.err</string>
<key>Sockets</key>
<dict>
<!-- TCP listener: used by ssh RemoteForward -->
<key>ListenerTCP</key>
<dict>
<key>SockNodeName</key><string>127.0.0.1</string>
<key>SockServiceName</key><string>7779</string>
</dict>
<!-- Unix socket listener: used by et's env-var reverse tunnel -->
<key>ListenerUnix</key>
<dict>
<key>SockPathName</key><string>/Users/pasky/.clipserve.sock</string>
</dict>
</dict>
</dict>
</plist>Activate and test locally (screenshot to clipboard first, cmd-ctrl-shift-4):
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/org.pasky.clipserve.plist
nc -U ~/.clipserve.sock | file - # expect: PNG image data ...
nc 127.0.0.1 7779 | file - # ditto(After later plist edits: launchctl bootout gui/$(id -u)/org.pasky.clipserve,
rm -f ~/.clipserve.sock, then bootstrap again — launchd does not re-read
plists on its own.)
Connecting
Eternal Terminal — tested with et 6.2.11
alias et='et -k 1 -r PI_CLIP_SOCK:/Users/pasky/.clipserve.sock'
et thatboxet creates a private per-connection socket on the remote
(/tmp/et_forward_sock_XXXXXX/sock, mode 0700), exports its path as
$PI_CLIP_SOCK in the session, and connects it to the client-side UNIX
socket named after the colon (absolute client-side path, no ~). The
~/.bashrc hook does the rest; no box name needed.
Do NOT try -r /remote/path.sock:7779 — released et (6.2.x) has no
socket-path tunnel syntax (it fails with a bogus "port range" error);
that syntax only exists in unreleased master. Plain TCP -r 7779:7779
works but supports only a single client box (last/first bind wins).
ssh
Pick a short unique name per client box (e.g. mbp). In the box's
~/.ssh/config:
Host thatbox
RemoteForward /home/pasky/.pi-clip/mbp.sock localhost:7779
SetEnv LC_PI_CLIP=mbpLC_* is used because stock Debian sshd has AcceptEnv LANG LC_* — no
server-side sshd change needed for the env var.
mosh
Cannot work — mosh forwards nothing. Ctrl+V degrades to a silent no-op.
Ctrl+V resolution order
~/.pi-clip/by-tty/<tty>.sockof the tmux client with the newestclient_activity(the keypress itself bumps it).- Every
~/.pi-clip/*.sockand~/.pi-clip/by-tty/*.sock, newest first. - Legacy
~/.pi-clip.sock, then TCP127.0.0.1:7779.
Candidates are probed with a 300 ms connect timeout; dead/stale sockets are skipped. Once a socket accepts, it is authoritative: an empty/imageless response shows "No image on client clipboard" rather than falling through to a different box's clipboard.
Env overrides: PI_REMOTE_CLIP_DIR, PI_REMOTE_CLIP_SOCK (legacy single
socket), PI_REMOTE_CLIP_PORT.
Notes
- Non-PNG clipboard images (e.g. JPEG copied from a browser) are fine: pngpaste converts to PNG on output. The extension sniffs magic bytes (png/jpg/gif/webp) and rejects anything else.
- Empty clipboard: pngpaste exits non-zero writing an error to stderr; the
plist's
StandardErrorPathkeeps that text off the socket so the extension sees a clean empty read. - Multi-user remote boxes: unix sockets are used precisely so other local
users cannot read your client clipboard (et's socket dir is 0700; keep
~/.pi-clipat 0700).
Tests
node test/ssh-image-clipboard.test.mjs