diff --git a/shell/plugins/clipboard/Clipboard.qml b/shell/plugins/clipboard/Clipboard.qml index 0b53c4c4..5b049695 100644 --- a/shell/plugins/clipboard/Clipboard.qml +++ b/shell/plugins/clipboard/Clipboard.qml @@ -19,7 +19,8 @@ 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 watchCommand: "script=$1\ntrap 'kill $(jobs -p) 2>/dev/null' EXIT\nOMARCHY_CLIPBOARD_WATCH_MIME=text wl-paste --type text --watch \"$script\" 2>/dev/null &\nOMARCHY_CLIPBOARD_WATCH_MIME=image/png wl-paste --type image/png --watch \"$script\" 2>/dev/null &\nwait" + 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. @@ -261,11 +262,11 @@ Item { Process { id: initProc - command: ["bash", "-c", "mkdir -p ~/.local/state/omarchy\nscript=$1\nfor pid in $(pgrep -x wl-paste || true); do\n cmdline=$(tr '\\0' ' ' <\"/proc/$pid/cmdline\" 2>/dev/null || true)\n if [[ $cmdline == *\"wl-paste --type text --watch $script \"* || $cmdline == *\"wl-paste --type \"*\" --watch $script \"* || $cmdline == *\"/shell/plugins/clipboard/capture.sh\"* ]]; then\n kill \"$pid\" 2>/dev/null || true\n fi\ndone", "clipboard-init", root.captureScript] + command: [root.initScript, root.captureScript] onExited: { currentProc.command = [root.captureScript] currentProc.running = true - watchProc.command = ["bash", "-c", root.watchCommand, "clipboard-watch", root.captureScript] + watchProc.command = [root.watchScript, root.captureScript] watchProc.running = true } } diff --git a/shell/plugins/clipboard/init.sh b/shell/plugins/clipboard/init.sh new file mode 100755 index 00000000..20c617b8 --- /dev/null +++ b/shell/plugins/clipboard/init.sh @@ -0,0 +1,39 @@ +#!/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 new file mode 100755 index 00000000..786d9488 --- /dev/null +++ b/shell/plugins/clipboard/watch.sh @@ -0,0 +1,46 @@ +#!/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 edcb45c4..7293112e 100644 --- a/test/shell.d/clipboard-test.sh +++ b/test/shell.d/clipboard-test.sh @@ -165,7 +165,42 @@ assert( JS TMPDIR=$(mktemp -d) -trap 'rm -rf "$TMPDIR"' EXIT +PIDS_TO_KILL=() + +cleanup() { + local pid + + for pid in "${PIDS_TO_KILL[@]}"; do + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + done + + rm -rf "$TMPDIR" +} +trap cleanup EXIT + +process_gone() { + local pid=$1 + local stat + + for _ in {1..40}; do + stat=$(ps -o stat= -p "$pid" 2>/dev/null || true) + if [[ -z $stat || $stat == Z* ]]; then + return 0 + fi + sleep 0.1 + done + + return 1 +} + +process_alive() { + local pid=$1 + local stat + + stat=$(ps -o stat= -p "$pid" 2>/dev/null || true) + [[ -n $stat && $stat != Z* ]] +} mkdir -p "$TMPDIR/bin" "$TMPDIR/home/.local/state/omarchy" @@ -206,26 +241,112 @@ SH chmod +x "$TMPDIR/bin/wl-copy" "$TMPDIR/bin/wl-paste" "$TMPDIR/bin/wtype" "$TMPDIR/bin/omarchy-launch-browser" "$TMPDIR/bin/omarchy-launch-editor" "$TMPDIR/bin/tensaku-edit" -capture_output=$(XDG_RUNTIME_DIR="$TMPDIR" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh") +capture_output=$(XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh") [[ $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" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh") +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 == '{"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 'secret' | CLIPBOARD_STATE=sensitive OMARCHY_CLIPBOARD_WATCH_MIME=text XDG_RUNTIME_DIR="$TMPDIR" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh") +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") +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") [[ -z $capture_output ]] || fail "clipboard capture ignores sensitive watched text" pass "clipboard capture ignores sensitive watched text" -capture_output=$(CLIPBOARD_STATE=sensitive XDG_RUNTIME_DIR="$TMPDIR" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh") +capture_output=$(CLIPBOARD_STATE=sensitive XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh") [[ -z $capture_output ]] || fail "clipboard capture ignores sensitive clipboard events" pass "clipboard capture ignores sensitive clipboard events" -capture_output=$(WL_PASTE_TYPES="text/plain\nx-kde-passwordManagerHint\n" XDG_RUNTIME_DIR="$TMPDIR" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh") +capture_output=$(WL_PASTE_TYPES="text/plain\nx-kde-passwordManagerHint\n" XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh") [[ -z $capture_output ]] || fail "clipboard capture ignores password manager hint" pass "clipboard capture ignores password manager hint" +cat >"$TMPDIR/bin/wl-paste" <<'SH' +#!/bin/bash +printf '%s\t%s\n' "$BASHPID" "$*" >>"${WL_PASTE_LOG:-/dev/null}" +sleep_pid="" + +cleanup() { + [[ -n $sleep_pid ]] && kill "$sleep_pid" 2>/dev/null || true + exit 0 +} +trap cleanup HUP INT TERM + +while true; do + sleep 10 & + sleep_pid=$! + wait "$sleep_pid" +done +SH +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")" +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") + +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" + +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 +chmod +x "$watch_owner" + +"$watch_owner" & +owner_pid=$! +PIDS_TO_KILL+=("$owner_pid") + +for _ in {1..40}; do + [[ -s $watch_pid_file && $(wc -l <"$watch_log") -ge 2 ]] && 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[@]}") + +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" + 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" WL_COPY_OUT="$TMPDIR/copied" WTYPE_OUT="$TMPDIR/wtype" HOME="$TMPDIR/home" PATH="$TMPDIR/bin:$PATH" \