|
| 1 | +#!/usr/bin/env bash |
| 2 | +# Capture an external image from the system clipboard into the flashpaste |
| 3 | +# screenshot cache so the daemon's inotify watcher stages it for paste. |
| 4 | +# |
| 5 | +# Use case: right-click → "Copy Image" in Firefox/Chrome puts image bytes |
| 6 | +# on the Wayland clipboard with no file write. The flashpasted daemon |
| 7 | +# normally only stages from inotify on ~/Pictures/Screenshots/, so a |
| 8 | +# browser-copied image bypasses the fast path entirely (ROADMAP.md:32). |
| 9 | +# On GNOME mutter, Claude Code's subsequent `wl-paste -t image/png` also |
| 10 | +# returns 0 bytes — mutter blocks surfaceless reads, and Firefox under |
| 11 | +# Wayland often doesn't mirror image bytes to the X11 selection either — |
| 12 | +# so nothing attaches. |
| 13 | +# |
| 14 | +# Strategy: read the bytes via real wl-paste then xclip fallback, validate |
| 15 | +# the magic, and write to ~/Pictures/Screenshots/flashpaste-clip-latest.png. |
| 16 | +# The daemon's inotify watcher fires `IN_CLOSE_WRITE` on the move, stages |
| 17 | +# the bytes in RAM, and owns both X11 and Wayland clipboards from the |
| 18 | +# in-memory copy. By the time the caller sends \026 to kitty, the daemon |
| 19 | +# serves Claude's wl-paste shim's xclip fallback with the captured bytes. |
| 20 | +# |
| 21 | +# Stable filename is intentional: a single overwriting file means no |
| 22 | +# accumulating clutter in the screenshots dir, and the daemon happily |
| 23 | +# re-stages on every overwrite (the inotify path is idempotent). |
| 24 | +# |
| 25 | +# Exit codes: |
| 26 | +# 0 — captured a fresh external image, file written to dest dir |
| 27 | +# 1 — no image MIME advertised on clipboard |
| 28 | +# 2 — image MIME advertised but byte read returned 0 (mutter surfaceless |
| 29 | +# block + no X11 mirror) — caller should fall through to the normal |
| 30 | +# send-text path; this helper has nothing to add |
| 31 | + |
| 32 | +set -u |
| 33 | + |
| 34 | +. "$HOME/.local/bin/clip-pipeline-log.sh" 2>/dev/null \ |
| 35 | + || . "$(dirname "$0")/clip-pipeline-log.sh" 2>/dev/null \ |
| 36 | + || true |
| 37 | +type clog >/dev/null 2>&1 || clog() { :; } |
| 38 | + |
| 39 | +# Use the REAL wl-paste, not our shim. The shim falls back to xclip when |
| 40 | +# real wl-paste returns empty — but xclip on a Wayland-only Firefox copy |
| 41 | +# would either return empty OR silently return whatever other X11 owner |
| 42 | +# is holding the selection (often the daemon's stale staged screenshot, |
| 43 | +# which is exactly the bug we're trying to fix). We want the truth: if |
| 44 | +# real wl-paste can't read the browser image, we explicitly fall back to |
| 45 | +# xclip with a magic-byte check downstream. |
| 46 | +REAL_WL_PASTE="${FLASHPASTE_REAL_WL_PASTE:-/usr/bin/wl-paste}" |
| 47 | +[ -x "$REAL_WL_PASTE" ] || REAL_WL_PASTE=$(command -v wl-paste 2>/dev/null) |
| 48 | + |
| 49 | +DEST_DIR="${FLASHPASTE_CAPTURE_DIR:-$HOME/Pictures/Screenshots}" |
| 50 | +DEST_NAME="${FLASHPASTE_CAPTURE_NAME:-flashpaste-clip-latest.png}" |
| 51 | + |
| 52 | +# Probe MIME types on both clipboard sides. Bail early if neither side |
| 53 | +# advertises an image — nothing to capture. |
| 54 | +wl_types=$(timeout 0.3 "$REAL_WL_PASTE" --list-types 2>/dev/null | tr '\n' ',') |
| 55 | +x_types=$(timeout 0.3 xclip -selection clipboard -t TARGETS -o 2>/dev/null | tr '\n' ',') |
| 56 | +case "$wl_types,$x_types" in |
| 57 | + *image/*) ;; |
| 58 | + *) |
| 59 | + clog "capture-clip" "event=no-image-mime" "wl='$wl_types'" "x='$x_types'" |
| 60 | + exit 1 |
| 61 | + ;; |
| 62 | +esac |
| 63 | + |
| 64 | +# Pick the best advertised MIME. Prefer PNG (lossless, universally accepted |
| 65 | +# by Claude Code), then JPEG, then WebP. Anything else falls back to the |
| 66 | +# first image/* we can find. |
| 67 | +mime= |
| 68 | +for want in image/png image/jpeg image/webp; do |
| 69 | + case "$wl_types,$x_types" in |
| 70 | + *"$want"*) mime="$want"; break ;; |
| 71 | + esac |
| 72 | +done |
| 73 | +if [ -z "$mime" ]; then |
| 74 | + mime=$(printf '%s,%s' "$wl_types" "$x_types" | tr ',' '\n' | grep -m1 '^image/' || true) |
| 75 | +fi |
| 76 | +if [ -z "$mime" ]; then |
| 77 | + clog "capture-clip" "event=no-image-mime-after-pick" |
| 78 | + exit 1 |
| 79 | +fi |
| 80 | + |
| 81 | +mkdir -p "$DEST_DIR" 2>/dev/null || { |
| 82 | + clog "capture-clip" "event=mkdir-failed" "dir='$DEST_DIR'" |
| 83 | + exit 2 |
| 84 | +} |
| 85 | +tmp=$(mktemp -t flashpaste-capture.XXXXXX) || exit 2 |
| 86 | +trap 'rm -f "$tmp"' EXIT |
| 87 | + |
| 88 | +# Try real wl-paste first. On mutter from a surfaceless context this often |
| 89 | +# returns 0 bytes, but from a focused kitty subprocess it sometimes works |
| 90 | +# — and when it does, the bytes are exactly what the browser put there. |
| 91 | +src= |
| 92 | +if timeout 0.5 "$REAL_WL_PASTE" -t "$mime" >"$tmp" 2>/dev/null && [ -s "$tmp" ]; then |
| 93 | + src=wl-paste |
| 94 | +fi |
| 95 | + |
| 96 | +# Fall back to xclip if wl-paste returned empty. |
| 97 | +if [ -z "$src" ]; then |
| 98 | + if timeout 0.5 xclip -selection clipboard -t "$mime" -o >"$tmp" 2>/dev/null && [ -s "$tmp" ]; then |
| 99 | + src=xclip |
| 100 | + fi |
| 101 | +fi |
| 102 | + |
| 103 | +if [ -z "$src" ]; then |
| 104 | + clog "capture-clip" "event=read-empty" "mime='$mime'" |
| 105 | + exit 2 |
| 106 | +fi |
| 107 | + |
| 108 | +# Magic-byte check. xclip silently returns text bytes when the requested |
| 109 | +# MIME isn't actually advertised (the wl-paste shim has the same defence — |
| 110 | +# see bin/wl-paste:99-104). Without this check we'd happily write a |
| 111 | +# UTF-8 URL to flashpaste-clip-latest.png and the daemon would dutifully |
| 112 | +# stage it as an "image", clobbering whatever real image was staged. |
| 113 | +magic=$(head -c 12 "$tmp" | od -An -tx1 | tr -d ' \n') |
| 114 | +ok=0 |
| 115 | +case "$mime" in |
| 116 | + image/png) |
| 117 | + case "$magic" in 89504e470d0a1a0a*) ok=1 ;; esac |
| 118 | + ;; |
| 119 | + image/jpeg) |
| 120 | + case "$magic" in ffd8ff*) ok=1 ;; esac |
| 121 | + ;; |
| 122 | + image/webp) |
| 123 | + # RIFF<4-byte len>WEBP — match RIFF prefix and WEBP at offset 8. |
| 124 | + case "$magic" in 52494646????????57454250*) ok=1 ;; esac |
| 125 | + ;; |
| 126 | + *) |
| 127 | + # Unknown image MIME: trust it if there's *any* binary content. Worst |
| 128 | + # case the daemon stages bytes that won't decode in Claude; not worse |
| 129 | + # than the current behaviour where nothing attaches at all. |
| 130 | + [ -s "$tmp" ] && ok=1 |
| 131 | + ;; |
| 132 | +esac |
| 133 | +if [ "$ok" = "0" ]; then |
| 134 | + clog "capture-clip" "event=bad-magic" "mime='$mime'" "magic='$magic'" "src='$src'" |
| 135 | + exit 2 |
| 136 | +fi |
| 137 | + |
| 138 | +# Decide the final extension from the MIME (the dest filename is fixed, |
| 139 | +# but if the user has overridden FLASHPASTE_CAPTURE_NAME we honour any |
| 140 | +# extension they chose; otherwise we coerce PNG/JPEG/WebP into the right |
| 141 | +# suffix so the daemon's mime_for() inference matches what we captured). |
| 142 | +dest="$DEST_DIR/$DEST_NAME" |
| 143 | +case "$mime,$DEST_NAME" in |
| 144 | + image/jpeg,flashpaste-clip-latest.png) dest="$DEST_DIR/flashpaste-clip-latest.jpg" ;; |
| 145 | + image/webp,flashpaste-clip-latest.png) dest="$DEST_DIR/flashpaste-clip-latest.webp" ;; |
| 146 | +esac |
| 147 | + |
| 148 | +# Atomic rename so the daemon's inotify watcher sees one IN_MOVED_TO |
| 149 | +# instead of an IN_CLOSE_WRITE on a partially-written file. mktemp lives |
| 150 | +# on the same filesystem as $HOME on a default setup; if mv fails across |
| 151 | +# filesystems we copy then delete (less atomic but still complete-bytes). |
| 152 | +if ! mv -f "$tmp" "$dest" 2>/dev/null; then |
| 153 | + if ! cp -f "$tmp" "$dest" 2>/dev/null; then |
| 154 | + clog "capture-clip" "event=write-failed" "dest='$dest'" |
| 155 | + exit 2 |
| 156 | + fi |
| 157 | +fi |
| 158 | +# Clear the trap so we don't try to rm $tmp after a successful mv. |
| 159 | +trap - EXIT |
| 160 | + |
| 161 | +size=$(stat -c %s "$dest" 2>/dev/null || echo 0) |
| 162 | +clog "capture-clip" "event=captured" "dest='$dest'" "bytes=$size" "src='$src'" "mime='$mime'" |
| 163 | +printf '%s\n' "$dest" |
0 commit comments