From 006460ad57fedb93f131b2a1201416b8f7b1ad15 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 17 Aug 2026 05:09:51 -0500 Subject: [PATCH] Decode UTF-16 clipboard text (#7249) --- shell/plugins/clipboard/capture.sh | 20 ++++++++++++++- test/shell.d/clipboard-test.sh | 41 ++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/shell/plugins/clipboard/capture.sh b/shell/plugins/clipboard/capture.sh index 8af54863..cf4b980d 100755 --- a/shell/plugins/clipboard/capture.sh +++ b/shell/plugins/clipboard/capture.sh @@ -43,7 +43,25 @@ emit_image() { } emit_text() { - jq -cRs 'select(length > 0) | {type:"text", text:.}' + perl -MEncode=decode,FB_CROAK -MJSON::PP=encode_json -0777 -e ' + my $raw = ; + exit unless length $raw; + + my $encoding; + if ($raw =~ /^(?:\xFF\xFE|\xFE\xFF)/) { + $encoding = "UTF-16"; + } elsif ($raw =~ /^(?:[^\0]\0)+\z/s) { + # BOM-less UTF-16 is indistinguishable from NUL-separated bytes, so only + # decode the consistent whole-payload pattern seen from affected apps. + $encoding = "UTF-16LE"; + } elsif ($raw =~ /^(?:\0[^\0])+\z/s) { + $encoding = "UTF-16BE"; + } + + my $text = $encoding ? eval { decode($encoding, $raw, FB_CROAK) } : undef; + $text = decode("UTF-8", $raw) unless defined $text; + print "{\"type\":\"text\",\"text\":", encode_json($text), "}\n"; + ' } case "${1:-}" in diff --git a/test/shell.d/clipboard-test.sh b/test/shell.d/clipboard-test.sh index f83c6322..aafcd75d 100644 --- a/test/shell.d/clipboard-test.sh +++ b/test/shell.d/clipboard-test.sh @@ -292,6 +292,47 @@ capture_output=$(printf 'closing app copy' | WL_PASTE_TEXT="stale read" XDG_RUNT [[ $capture_output == '{"type":"text","text":"closing app copy"}' ]] || fail "clipboard capture records watched text from stdin" pass "clipboard capture records watched text from stdin" +capture_output=$(printf '%s' 'UTF-16 clipboard - fixed' | iconv -f UTF-8 -t UTF-16LE | XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text) +[[ $capture_output == '{"type":"text","text":"UTF-16 clipboard - fixed"}' ]] || fail "clipboard capture decodes UTF-16LE text" +pass "clipboard capture decodes UTF-16LE text" + +# BOM-less UTF-16LE "A" is byte-identical to UTF-8 "A\0". The strict +# whole-payload pattern intentionally resolves that ambiguity as UTF-16. +capture_output=$(printf 'A' | iconv -f UTF-8 -t UTF-16LE | XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text) +[[ $capture_output == '{"type":"text","text":"A"}' ]] || fail "clipboard capture decodes exact NUL-padded UTF-16LE text" +pass "clipboard capture decodes exact NUL-padded UTF-16LE text" + +capture_output=$(printf 'BE text' | iconv -f UTF-8 -t UTF-16BE | XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text) +[[ $capture_output == '{"type":"text","text":"BE text"}' ]] || fail "clipboard capture decodes exact NUL-padded UTF-16BE text" +pass "clipboard capture decodes exact NUL-padded UTF-16BE text" + +capture_output=$({ printf '\377\376'; printf '%s' 'Little endian 日本 😀' | iconv -f UTF-8 -t UTF-16LE; } | XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text) +[[ $capture_output == '{"type":"text","text":"Little endian 日本 😀"}' ]] || fail "clipboard capture decodes BOM-tagged UTF-16LE text" +pass "clipboard capture decodes BOM-tagged UTF-16LE text" + +capture_output=$({ printf '\376\377'; printf '%s' 'Big endian 日本 😀' | iconv -f UTF-8 -t UTF-16BE; } | XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text) +[[ $capture_output == '{"type":"text","text":"Big endian 日本 😀"}' ]] || fail "clipboard capture decodes BOM-tagged UTF-16BE text" +pass "clipboard capture decodes BOM-tagged UTF-16BE text" + +assert_ambiguous_utf16_falls_back() { + local description="$1" value="$2" expected capture_output + printf '%s' "$value" | iconv -f UTF-8 -t UTF-16LE >"$TMPDIR/ambiguous-utf16" + expected=$(jq -cRs '{type:"text", text:.}' <"$TMPDIR/ambiguous-utf16") + capture_output=$(XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text <"$TMPDIR/ambiguous-utf16") + [[ $capture_output == "$expected" ]] || fail "$description" "expected: $expected\nactual: $capture_output" + pass "$description" +} + +assert_ambiguous_utf16_falls_back "clipboard capture leaves BOM-less UTF-16 punctuation undecoded" '—' +assert_ambiguous_utf16_falls_back "clipboard capture leaves BOM-less UTF-16 CJK undecoded" '日本' +assert_ambiguous_utf16_falls_back "clipboard capture leaves BOM-less UTF-16 surrogate pairs undecoded" '😀' + +printf '\377\376\075\330' >"$TMPDIR/malformed-utf16" +expected=$(jq -cRs '{type:"text", text:.}' <"$TMPDIR/malformed-utf16") +capture_output=$(XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text <"$TMPDIR/malformed-utf16") +[[ $capture_output == "$expected" ]] || fail "clipboard capture falls back from malformed UTF-16" "expected: $expected\nactual: $capture_output" +pass "clipboard capture falls back from malformed UTF-16" + capture_output=$(printf 'png-data' | XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" image/png) image_path=$(jq -r '.path' <<<"$capture_output") jq -e '.type == "image" and .mime == "image/png" and (.capturedAt | type == "string")' <<<"$capture_output" >/dev/null || fail "clipboard capture records watched png images"