Skip to content

Commit bd4fdca

Browse files
NagyViktclaude
andcommitted
v1.33: bridge browser Copy Image into the daemon's fast path
Closes the long-standing ROADMAP item where right-click → "Copy Image" in Firefox / Chrome put image bytes on the Wayland clipboard with no file write, so the flashpasted daemon (which only stages from inotify on ~/Pictures/Screenshots/) never saw them. On GNOME mutter, Claude Code's subsequent `wl-paste -t image/png` then read 0 bytes — mutter blocks surfaceless reads of the browser's Wayland selection, and Firefox under Wayland often doesn't mirror image bytes to the X11 selection either. End result: image-paste from a browser silently delivered nothing. `bin/flashpaste-capture-clip` is the new shim: probe both clipboards for image MIME, read the bytes via the real `wl-paste` first (so xclip's silent text-fallback can't lie about MIME), validate the PNG / JPEG / WebP magic, then atomically `mv` into ~/Pictures/Screenshots/flashpaste-clip-latest.png. Stable filename, so the screenshots dir doesn't accumulate captured-clip files over time — the daemon's inotify path is idempotent and happily re-stages on every overwrite. `bin/paste_image.sh`'s image branch calls the helper before sending \026 to kitty, then sleeps 60 ms for the daemon's X11 owner to re-claim from the freshly staged bytes (verified against the daemon journal: `staged screenshot from inotify ... path=…/flashpaste-clip-latest.png`). `rs/flashpaste-common/src/compress.rs` tightens the attachment defaults in the same release: `DEFAULT_MAX_BYTES` 4 MB → 3.5 MB and `DEFAULT_MAX_DIM` 2400 px → 1568 px. Claude's API caps single attachments at 5 MB base64 (~3.75 MB raw) and multi-image conversations at 2000×2000 px; the new defaults sit under both with headroom for HTTP framing and the JSON-RPC wrapper, so an oversized browser image downscales cleanly before it hits the agent surface instead of being rejected at upload time. `install.sh` learns to symlink the new helper into `~/.local/bin/`. Packaging version bumps in `flake.nix`, `packaging/aur/PKGBUILD{,-git}`, `packaging/build-{deb,rpm}.sh`, `packaging/homebrew/README.md`, and the workspace `rs/Cargo.toml` track 1.32 → 1.33. User-visible side effect: right-click → Copy Image in Firefox now attaches the same image when you Ctrl+V in Claude Code, with one extra wl-paste + xclip probe per image paste (~30–80 ms). The existing screenshot path is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1f91d35 commit bd4fdca

13 files changed

Lines changed: 231 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,17 @@ Release-tag policy: every `vX.Y` commit on `main` must be tagged and have a matc
66

77
## [Unreleased]
88

9+
## [1.33] - 2026-05-21
10+
11+
### Added
12+
13+
- `bin/flashpaste-capture-clip` — new helper that reads an image from the system clipboard and writes it to `~/Pictures/Screenshots/flashpaste-clip-latest.png` so the daemon's inotify watcher can stage it. Bridges the long-standing "browser Copy Image" gap (`ROADMAP.md:32`, now ticked) where Firefox / Chrome put image bytes on the Wayland clipboard without writing a file, so the daemon's fast path never saw them and Claude Code's `wl-paste -t image/png` came back with 0 bytes (mutter blocks surfaceless reads, and Firefox under Wayland often doesn't mirror images to the X11 selection either). Stable filename — the file is overwritten on every capture, no clutter accumulates in the screenshots dir.
14+
15+
### Changed
16+
17+
- `bin/paste_image.sh` image branch now calls `flashpaste-capture-clip` before sending `\026` to kitty, then waits 60 ms for the daemon to claim the staged bytes. Cost is one extra wl-paste + xclip probe per image paste (~30–80 ms); the helper validates the PNG / JPEG / WebP magic so a malformed read can't clobber the daemon's stage with text bytes (the same xclip-lies-about-MIME trap the wl-paste shim already defends against at `bin/wl-paste:99-104`).
18+
- `rs/flashpaste-common/src/compress.rs` tightens the attachment defaults: `DEFAULT_MAX_BYTES` 4 MB → 3.5 MB and `DEFAULT_MAX_DIM` 2400 px → 1568 px. Claude's API caps single attachments at 5 MB base64 (~3.75 MB raw) and multi-image conversations at 2000×2000 px; the new defaults sit under both with headroom for HTTP framing and the JSON-RPC wrapper. Browser-copied images that come in larger now downscale cleanly before they hit the agent surface.
19+
920
## [1.32] - 2026-05-20
1021

1122
### Added

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ Target: tighten the existing bash, reduce surprises, make non-Ubuntu installs fr
2929
- [ ] **Add shellcheck + bats-core to a GitHub Actions CI** so regressions don't ship.
3030
- [ ] **Doctor: add machine-readable `--json` output** so other tools can consume the diagnosis.
3131
- [ ] **Wedge-cache eviction on `SIGUSR1`** — if user runs `flashpaste reset` (or after mutter recovers), drop the cache immediately instead of waiting 30s.
32-
- [ ] **Handle copy-image-from-browser** explicitly (Firefox / Chrome put image bytes on the clipboard without writing a file). Today the auto-pickup only triggers on screenshot *files*; browser images work via the regular probe path but the FAST PATH is bypassed.
32+
- [x] **Handle copy-image-from-browser** explicitly (Firefox / Chrome put image bytes on the clipboard without writing a file). Today the auto-pickup only triggers on screenshot *files*; browser images work via the regular probe path but the FAST PATH is bypassed. *Shipped via `bin/flashpaste-capture-clip`: the image branch of `bin/paste_image.sh` reads the bytes from the kitty-subprocess context, writes them to `~/Pictures/Screenshots/flashpaste-clip-latest.png`, and the daemon's inotify watcher stages them.*
3333

3434
## Phase 2 — Performance (Rust where it pays)
3535

bin/flashpaste-capture-clip

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
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"

bin/paste_image.sh

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,44 @@ if [ "$mode" = "auto" ]; then
9696
fi
9797

9898
if [ "$mode" = "image" ]; then
99+
# Bridge the "browser Copy Image" case (ROADMAP.md:32). The flashpasted
100+
# daemon's fast path is wired to inotify on ~/Pictures/Screenshots/, so
101+
# an image copied from a browser (no file written) bypasses staging.
102+
# Without staging there's nothing for Claude Code's wl-paste shim to
103+
# fall back to — mutter blocks surfaceless reads of Firefox's Wayland
104+
# selection, and Firefox under Wayland often doesn't mirror image
105+
# bytes to the X11 selection either, so Claude gets 0 bytes.
106+
#
107+
# The capture helper reads the bytes from THIS context (kitty
108+
# subprocess, often closer to the focused surface than the daemon's
109+
# surfaceless connection), validates the magic, and writes them to
110+
# ~/Pictures/Screenshots/flashpaste-clip-latest.png. The daemon's
111+
# inotify watcher picks the file up and stages it in RAM, after which
112+
# the existing X11/Wayland ownership path serves Claude correctly.
113+
#
114+
# Cost: one wl-paste + one xclip probe + one mv on every image paste
115+
# (~30–80ms total). If the helper returns non-zero we proceed anyway
116+
# — the existing send-text path remains the source of truth and any
117+
# regression manifests as the old "no image attached" behaviour, not
118+
# corruption.
119+
capture_helper=$(command -v flashpaste-capture-clip 2>/dev/null)
120+
if [ -z "$capture_helper" ] && [ -x "$(dirname "$0")/flashpaste-capture-clip" ]; then
121+
capture_helper="$(dirname "$0")/flashpaste-capture-clip"
122+
fi
123+
if [ -n "$capture_helper" ]; then
124+
capture_out=$("$capture_helper" 2>/dev/null)
125+
capture_rc=$?
126+
clog "paste-image" "event=capture" "rc=$capture_rc" "dest='$capture_out'"
127+
if [ "$capture_rc" = "0" ]; then
128+
# Give the daemon's inotify watcher one tick to fire IN_MOVED_TO,
129+
# read the bytes (~5–20ms for a typical browser image), and notify
130+
# the X11 owner to refresh. 60ms covers the 95th percentile on
131+
# this stack; longer would add user-visible latency, shorter
132+
# races the X11 SetSelectionOwner call.
133+
sleep 0.06
134+
fi
135+
fi
136+
99137
win="${KITTY_WINDOW_ID:-}"
100138
match=()
101139
[ -n "$win" ] && match=(--match "id:$win")

flake.nix

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828

2929
flashpaste = pkgs.rustPlatform.buildRustPackage {
3030
pname = "flashpaste";
31-
version = "1.32";
31+
version = "1.33";
3232
src = ./.;
3333

3434
# Cargo workspace lives under rs/.

install.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ mkdir -p "$BIN_DST"
2828
for script in tmux-paste-dispatch.sh clipboard-set.sh clipboard-janitor.sh \
2929
get-clipboard-text.sh clip-pipeline-log.sh screenshot-to-clipboard \
3030
flashpaste-screenshot-preload.sh flashpaste-doctor.sh \
31-
flashpaste-trace.sh flashpaste-logs.sh; do
31+
flashpaste-trace.sh flashpaste-logs.sh \
32+
flashpaste-capture-clip; do
3233
src="$BIN_SRC/$script"
3334
# Drop the .sh suffix on the destination for the user-facing log viewer
3435
# so `flashpaste-logs` is what shows up on $PATH (matches the muscle

packaging/aur/PKGBUILD

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
# Source: GitHub release tarball (stable).
88

99
pkgname=flashpaste
10-
pkgver=1.32
10+
pkgver=1.33
1111
pkgrel=1
1212
pkgdesc="Sub-15ms image-paste glue for terminal AI agents on GNOME Wayland"
1313
arch=('x86_64')

packaging/aur/PKGBUILD-git

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
pkgname=flashpaste-git
99
_pkgname=flashpaste
10-
pkgver=1.32.r0.g0000000
10+
pkgver=1.33.r0.g0000000
1111
pkgrel=1
1212
pkgdesc="Sub-15ms image-paste glue for terminal AI agents on GNOME Wayland (git)"
1313
arch=('x86_64')

packaging/build-deb.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
set -euo pipefail
1515

1616
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
17-
VERSION="${VERSION:-$(git -C "$REPO_DIR" describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || echo "1.32")}"
17+
VERSION="${VERSION:-$(git -C "$REPO_DIR" describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || echo "1.33")}"
1818
ARCH="${ARCH:-all}" # all = noarch (pure bash)
1919
STAGE="${STAGE:-$REPO_DIR/dist/staging}"
2020
OUT_DIR="$REPO_DIR/dist"

packaging/build-rpm.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
set -euo pipefail
1010

1111
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
12-
VERSION="${VERSION:-$(git -C "$REPO_DIR" describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || echo "1.32")}"
12+
VERSION="${VERSION:-$(git -C "$REPO_DIR" describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || echo "1.33")}"
1313
RPM_TOP="${RPM_TOP:-$REPO_DIR/dist/rpmbuild}"
1414
OUT_DIR="$REPO_DIR/dist"
1515
SPEC="$REPO_DIR/packaging/rpm/flashpaste.spec"

0 commit comments

Comments
 (0)