diff --git a/shell/plugins/clipboard/Clipboard.qml b/shell/plugins/clipboard/Clipboard.qml index 5b049695..6fc216f4 100644 --- a/shell/plugins/clipboard/Clipboard.qml +++ b/shell/plugins/clipboard/Clipboard.qml @@ -19,8 +19,6 @@ Item { property string historyPath: Quickshell.env("HOME") + "/.local/state/omarchy/clipboard-history.json" property string captureScript: root.omarchyPath + "/shell/plugins/clipboard/capture.sh" - property string initScript: root.omarchyPath + "/shell/plugins/clipboard/init.sh" - property string watchScript: root.omarchyPath + "/shell/plugins/clipboard/watch.sh" // Shares the [menu] surface tokens — themes that style the menu also // style the clipboard. Selected-row colors composed in the // singleton so consumers drop them straight into Rectangle bindings. @@ -260,19 +258,22 @@ Item { onFileChanged: reload() } + // Reap watchers left behind by a previous shell instance, then start our + // own. The pdeathsig on the watchers makes the kernel kill them whenever + // the shell exits, however it exits, so no further lifecycle management. Process { id: initProc - command: [root.initScript, root.captureScript] + command: ["pkill", "-f", "wl-paste .*--watch .*/shell/plugins/clipboard/capture\\.sh"] onExited: { - currentProc.command = [root.captureScript] currentProc.running = true - watchProc.command = [root.watchScript, root.captureScript] - watchProc.running = true + textWatchProc.running = true + imageWatchProc.running = true } } Process { id: currentProc + command: [root.captureScript] stdout: StdioCollector { waitForEnd: true onStreamFinished: root.addClipboardJson(text) @@ -280,7 +281,16 @@ Item { } Process { - id: watchProc + id: textWatchProc + command: ["setpriv", "--pdeathsig", "TERM", "wl-paste", "--type", "text", "--watch", root.captureScript, "text"] + stdout: SplitParser { + onRead: function(data) { root.addClipboardJson(data) } + } + } + + Process { + id: imageWatchProc + command: ["setpriv", "--pdeathsig", "TERM", "wl-paste", "--type", "image/png", "--watch", root.captureScript, "image/png"] stdout: SplitParser { onRead: function(data) { root.addClipboardJson(data) } } diff --git a/shell/plugins/clipboard/capture.sh b/shell/plugins/clipboard/capture.sh index 105d616f..8af54863 100755 --- a/shell/plugins/clipboard/capture.sh +++ b/shell/plugins/clipboard/capture.sh @@ -1,5 +1,9 @@ #!/bin/bash +# Captures the current clipboard as a JSON entry on stdout. In watch mode, +# wl-paste invokes this with the payload on stdin and the mime as $1. Without +# arguments, it snapshots the current selection itself. + set -o pipefail STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/omarchy" @@ -14,35 +18,10 @@ fi emit_image() { local mime="$1" - local ext="$2" - local tmp="" - local hash="" - local file="" + local ext tmp hash file - tmp=$(mktemp --tmpdir="$IMAGE_DIR" clipboard.XXXXXX) || return 0 - if ! timeout 2s wl-paste --type "$mime" > "$tmp" 2>/dev/null || [[ ! -s $tmp ]]; then - rm -f "$tmp" - return 0 - fi - - hash=$(sha256sum "$tmp" | awk '{print $1}') - file="$IMAGE_DIR/$hash.$ext" - if [[ -e $file ]]; then - rm -f "$tmp" - else - mv "$tmp" "$file" - fi - - jq -cn --arg mime "$mime" --arg path "$file" --arg captured_at "$(date +'%A %H:%M')" \ - '{type:"image", mime:$mime, path:$path, capturedAt:$captured_at}' -} - -emit_image_stream() { - local mime="$1" - local ext="$2" - local tmp="" - local hash="" - local file="" + ext=${mime#image/} + [[ $ext == jpeg ]] && ext=jpg tmp=$(mktemp --tmpdir="$IMAGE_DIR" clipboard.XXXXXX) || return 0 cat >"$tmp" @@ -63,32 +42,22 @@ emit_image_stream() { '{type:"image", mime:$mime, path:$path, capturedAt:$captured_at}' } -emit_text_stream() { +emit_text() { jq -cRs 'select(length > 0) | {type:"text", text:.}' } -case "${OMARCHY_CLIPBOARD_WATCH_MIME:-}" in -text) emit_text_stream; exit 0 ;; -image/png) emit_image_stream 'image/png' 'png'; exit 0 ;; -image/jpeg) emit_image_stream 'image/jpeg' 'jpg'; exit 0 ;; -image/webp) emit_image_stream 'image/webp' 'webp'; exit 0 ;; -image/gif) emit_image_stream 'image/gif' 'gif'; exit 0 ;; -image/bmp) emit_image_stream 'image/bmp' 'bmp'; exit 0 ;; -image/tiff) emit_image_stream 'image/tiff' 'tiff'; exit 0 ;; +case "${1:-}" in +text) emit_text; exit 0 ;; +image/*) emit_image "$1"; exit 0 ;; esac -if grep -qx 'image/png' <<<"$types"; then - emit_image 'image/png' 'png' -elif grep -qx 'image/jpeg' <<<"$types"; then - emit_image 'image/jpeg' 'jpg' -elif grep -qx 'image/webp' <<<"$types"; then - emit_image 'image/webp' 'webp' -elif grep -qx 'image/gif' <<<"$types"; then - emit_image 'image/gif' 'gif' -elif grep -qx 'image/bmp' <<<"$types"; then - emit_image 'image/bmp' 'bmp' -elif grep -qx 'image/tiff' <<<"$types"; then - emit_image 'image/tiff' 'tiff' -elif grep -q '^text/' <<<"$types" || grep -qx 'UTF8_STRING' <<<"$types" || grep -qx 'STRING' <<<"$types"; then - wl-paste --type text --no-newline 2>/dev/null | jq -cRs 'select(length > 0) | {type:"text", text:.}' +for mime in image/png image/jpeg image/webp image/gif image/bmp image/tiff; do + if grep -qx "$mime" <<<"$types"; then + timeout 2s wl-paste --type "$mime" 2>/dev/null | emit_image "$mime" + exit 0 + fi +done + +if grep -q '^text/' <<<"$types" || grep -qx 'UTF8_STRING' <<<"$types" || grep -qx 'STRING' <<<"$types"; then + wl-paste --type text --no-newline 2>/dev/null | emit_text fi diff --git a/shell/plugins/clipboard/init.sh b/shell/plugins/clipboard/init.sh deleted file mode 100755 index 20c617b8..00000000 --- a/shell/plugins/clipboard/init.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/bin/bash - -current_script=${1:-} -[[ -n $current_script ]] || exit 0 - -is_clipboard_capture() { - local path=$1 - - [[ $path == */shell/plugins/clipboard/capture.sh ]] -} - -watched_script_for_pid() { - local pid=$1 - local -a args=() - local i - - [[ -r /proc/$pid/cmdline ]] || return 1 - mapfile -d '' -t args <"/proc/$pid/cmdline" || return 1 - ((${#args[@]} > 0)) || return 1 - - for ((i = 0; i < ${#args[@]}; i++)); do - if [[ ${args[i]} == "--watch" ]]; then - printf '%s\n' "${args[i + 1]:-}" - return 0 - fi - done - - return 1 -} - -for pid in $(pgrep -x wl-paste 2>/dev/null || true); do - watched_script=$(watched_script_for_pid "$pid" || true) - [[ -n $watched_script ]] || continue - is_clipboard_capture "$watched_script" || continue - - if [[ $watched_script == $current_script || ! -e $watched_script ]]; then - kill "$pid" 2>/dev/null || true - fi -done diff --git a/shell/plugins/clipboard/watch.sh b/shell/plugins/clipboard/watch.sh deleted file mode 100755 index 786d9488..00000000 --- a/shell/plugins/clipboard/watch.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/bin/bash - -set -o pipefail - -capture_script=${1:-} -[[ -n $capture_script && -x $capture_script ]] || exit 1 - -owner_pid=$PPID -watchdog_pid="" - -cleanup() { - local pid - - if [[ -n $watchdog_pid ]]; then - kill "$watchdog_pid" 2>/dev/null || true - fi - - for pid in $(jobs -p); do - [[ $pid == $watchdog_pid ]] && continue - kill "$pid" 2>/dev/null || true - done - - wait 2>/dev/null || true -} - -stop() { - cleanup - exit 0 -} - -trap cleanup EXIT -trap stop HUP INT TERM - -( - while kill -0 "$owner_pid" 2>/dev/null; do - sleep 1 - done - - kill -TERM "$$" 2>/dev/null || true -) & -watchdog_pid=$! - -OMARCHY_CLIPBOARD_WATCH_MIME=text wl-paste --type text --watch "$capture_script" 2>/dev/null & -OMARCHY_CLIPBOARD_WATCH_MIME=image/png wl-paste --type image/png --watch "$capture_script" 2>/dev/null & - -wait diff --git a/test/shell.d/clipboard-test.sh b/test/shell.d/clipboard-test.sh index 7293112e..055c3e66 100644 --- a/test/shell.d/clipboard-test.sh +++ b/test/shell.d/clipboard-test.sh @@ -162,6 +162,18 @@ assert( !/onContainsMouseChanged:[\s\S]*root\.selectedIndex/.test(clipboardQml), 'clipboard does not select rows from containsMouse' ) +assert( + clipboardQml.includes('command: ["setpriv", "--pdeathsig", "TERM", "wl-paste", "--type", "text", "--watch", root.captureScript, "text"]'), + 'clipboard text watcher dies with the shell via pdeathsig' +) +assert( + clipboardQml.includes('command: ["setpriv", "--pdeathsig", "TERM", "wl-paste", "--type", "image/png", "--watch", root.captureScript, "image/png"]'), + 'clipboard image watcher dies with the shell via pdeathsig' +) +assert( + clipboardQml.includes('command: ["pkill", "-f", "wl-paste .*--watch .*/shell/plugins/clipboard/capture\\\\.sh"]'), + 'clipboard init reaps stale watchers before starting new ones' +) JS TMPDIR=$(mktemp -d) @@ -245,17 +257,22 @@ capture_output=$(XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH=" [[ $capture_output == '{"type":"text","text":"terminal copy"}' ]] || fail "clipboard capture records normal text events" pass "clipboard capture records normal text events" -capture_output=$(printf 'closing app copy' | OMARCHY_CLIPBOARD_WATCH_MIME=text WL_PASTE_TEXT="stale read" XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh") +capture_output=$(printf 'closing app copy' | WL_PASTE_TEXT="stale read" 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":"closing app copy"}' ]] || fail "clipboard capture records watched text from stdin" pass "clipboard capture records watched text from stdin" -capture_output=$(printf 'png-data' | OMARCHY_CLIPBOARD_WATCH_MIME=image/png XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh") +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" [[ -s $image_path && $(<"$image_path") == "png-data" ]] || fail "clipboard capture stores watched png image data" pass "clipboard capture records watched png images" -capture_output=$(printf 'secret' | CLIPBOARD_STATE=sensitive OMARCHY_CLIPBOARD_WATCH_MIME=text XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh") +capture_output=$(printf 'jpg-data' | XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" image/jpeg) +image_path=$(jq -r '.path' <<<"$capture_output") +jq -e '.mime == "image/jpeg"' <<<"$capture_output" >/dev/null && [[ $image_path == *.jpg ]] || fail "clipboard capture stores watched jpeg images with jpg extension" +pass "clipboard capture stores watched jpeg images with jpg extension" + +capture_output=$(printf 'secret' | CLIPBOARD_STATE=sensitive XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text) [[ -z $capture_output ]] || fail "clipboard capture ignores sensitive watched text" pass "clipboard capture ignores sensitive watched text" @@ -288,39 +305,24 @@ chmod +x "$TMPDIR/bin/wl-paste" clipboard_lifecycle_dir="$TMPDIR/clipboard-lifecycle" current_script="$clipboard_lifecycle_dir/current/shell/plugins/clipboard/capture.sh" -other_script="$clipboard_lifecycle_dir/other/shell/plugins/clipboard/capture.sh" -missing_script="$clipboard_lifecycle_dir/missing/shell/plugins/clipboard/capture.sh" -watch_log="$clipboard_lifecycle_dir/wl-paste.log" -mkdir -p "$(dirname "$current_script")" "$(dirname "$other_script")" +mkdir -p "$(dirname "$current_script")" cp "$ROOT/shell/plugins/clipboard/capture.sh" "$current_script" -cp "$ROOT/shell/plugins/clipboard/capture.sh" "$other_script" -chmod +x "$current_script" "$other_script" - -WL_PASTE_LOG="$watch_log" PATH="$TMPDIR/bin:$PATH" wl-paste --type text --watch "$current_script" & -current_pid=$! -PIDS_TO_KILL+=("$current_pid") -WL_PASTE_LOG="$watch_log" PATH="$TMPDIR/bin:$PATH" wl-paste --type text --watch "$other_script" & -other_pid=$! -PIDS_TO_KILL+=("$other_pid") -WL_PASTE_LOG="$watch_log" PATH="$TMPDIR/bin:$PATH" wl-paste --type text --watch "$missing_script" & -missing_pid=$! -PIDS_TO_KILL+=("$missing_pid") +chmod +x "$current_script" +PATH="$TMPDIR/bin:$PATH" wl-paste --type text --watch "$current_script" text & +stale_pid=$! +PIDS_TO_KILL+=("$stale_pid") sleep 0.2 -PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/init.sh" "$current_script" -process_gone "$current_pid" || fail "clipboard init kills watchers for the current capture script" -process_gone "$missing_pid" || fail "clipboard init kills stale temp-root watchers" -process_alive "$other_pid" || fail "clipboard init preserves watchers for another existing capture script" -kill "$other_pid" 2>/dev/null || true -wait "$other_pid" 2>/dev/null || true -pass "clipboard init cleans current and stale watchers without killing other live roots" +pgrep -f 'wl-paste .*--watch .*/shell/plugins/clipboard/capture\.sh' | grep -qx "$stale_pid" || fail "clipboard reaper pattern matches running watchers" +kill "$stale_pid" 2>/dev/null || true +wait "$stale_pid" 2>/dev/null || true +pass "clipboard reaper pattern matches running watchers" watch_owner="$clipboard_lifecycle_dir/watch-owner.sh" watch_pid_file="$clipboard_lifecycle_dir/watch.pid" -: >"$watch_log" cat >"$watch_owner" <"$watch_pid_file" wait SH @@ -331,21 +333,17 @@ owner_pid=$! PIDS_TO_KILL+=("$owner_pid") for _ in {1..40}; do - [[ -s $watch_pid_file && $(wc -l <"$watch_log") -ge 2 ]] && break + [[ -s $watch_pid_file ]] && break sleep 0.1 done watch_pid=$(<"$watch_pid_file") -mapfile -t watch_child_pids < <(awk '{print $1}' "$watch_log") -[[ -n $watch_pid && ${#watch_child_pids[@]} -ge 2 ]] || fail "clipboard watch helper starts text and image watchers" -PIDS_TO_KILL+=("$watch_pid" "${watch_child_pids[@]}") +[[ -n $watch_pid ]] && process_alive "$watch_pid" || fail "clipboard watcher starts under setpriv" +PIDS_TO_KILL+=("$watch_pid") kill "$owner_pid" 2>/dev/null || true -process_gone "$watch_pid" || fail "clipboard watch helper exits when its owner exits" -for child_pid in "${watch_child_pids[@]}"; do - process_gone "$child_pid" || fail "clipboard watch helper stops child wl-paste watchers" -done -pass "clipboard watch helper cleans up when its owner exits" +process_gone "$watch_pid" || fail "clipboard watcher dies with its owner via pdeathsig" +pass "clipboard watcher dies with its owner via pdeathsig" jq -n --arg text "$(printf 'large block line 1\nlarge block line 2\n')" '[{type:"text", text:"ignored"}, {type:"text", text:$text}]' >"$TMPDIR/home/.local/state/omarchy/clipboard-history.json"