Fix clipboard watcher lifecycle

This commit is contained in:
David Heinemeier Hansson
2026-07-02 11:09:42 -07:00
parent 76e0b718b2
commit 738821c3ab
4 changed files with 216 additions and 9 deletions
+4 -3
View File
@@ -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
}
}
+39
View File
@@ -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
+46
View File
@@ -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
+127 -6
View File
@@ -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" <<SH
#!/bin/bash
WL_PASTE_LOG="$watch_log" PATH="$TMPDIR/bin:\$PATH" "$ROOT/shell/plugins/clipboard/watch.sh" "$current_script" &
printf '%s\n' "\$!" >"$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" \