Everything that was actually diagnosed and fixed in the screenshot workflow, across two long debugging sessions — the underlying concepts, the full incident log of what broke and how each root cause was actually found (including the dead ends), and the current, working implementation. The workflow is done and working as of 2026-07-05.
Two keybindings looked broken in ways that seemed unrelated: Print saved a file but never copied it, and SUPER + Print copied something but never saved. Both turned out to be small, specific misunderstandings of tools you haven't met yet — a shell/Lua variable mixup, and a screenshot annotator that treats "copy" and "save" as two different buttons. The fix touches five layers of the Linux desktop stack. This is a map of those layers, in the order they came up.
1 · keyPrint / SUPER+Print
2 · shellscreenshot.sh grim / slurp
3 · clipboardwl-copy (cliphist watches it)
4 · d-busNotify() call → wayle
5 · clickActionInvoked signal
6 · sattyopen-satty.sh
7 · hyprlandspecial workspace toggle
Module 1
Shell variables & quoting
The original config was Lua that built a shell command as a string, which the shell then parsed separately. Those are two different languages with two different variable scopes, and nothing connects them automatically — a Lua local named screenshotPath and a shell variable named $screenshotPath are unrelated namesakes unless you explicitly splice the Lua value into the string.
The shell saw the literal text $screenshotPath, found no such environment variable, and substituted empty string. The command didn't error loudly — it just quietly wrote to nowhere.
Where this bit ustee -a "$screenshotPath" expanded to tee -a "". That's not a no-op: tee still forwards its input to stdout even when the file argument fails. So the clipboard stage kept working while the save stage failed silently — the exact asymmetry reported ("copies but doesn't save").
Try the failure mode yourself, harmlessly:
echo "hello" | tee -a "" ; echo "exit code: $?"
You'll see tee: '': No such file or directory on stderr, a non-zero exit code — and "hello" still printed to your terminal. The fix in the plan sidesteps the whole Lua/shell boundary by moving the logic into a real .sh script, where every variable is unambiguously a shell variable.
explainshell.comPaste any shell one-liner (like the original tee pipeline) and get every flag and operator annotated.
ShellCheckStatic analyzer that would have flagged the unset-variable pipeline instantly — worth running on both new scripts.
Module 2
Wayland & the clipboard
Hyprland is a Wayland compositor, not an X11 window manager — a distinction that matters here because Wayland deliberately has no global "screenshot" or "clipboard" API that any app can call. Instead there's a small set of single-purpose command-line tools, each doing one job, that you compose yourself: grim captures pixels, slurp lets you drag-select a region and prints its geometry, wl-copy/wl-paste read and write the Wayland clipboard.
The Wayland clipboard is ephemeral by design: whoever "owns" the clipboard has to stay alive to serve paste requests, unlike X11 where the compositor could hold it forever. That's why wl-copy forks itself into the background by default instead of exiting immediately — and why a clipboard history (pasting something you copied five minutes ago) needs a separate always-running watcher. That watcher is cliphist, which was already subscribed to image copies in this config before any of the debugging started — it just had nothing valid to record until the copy itself was fixed.
Where this bit us
Nothing needed to change in cliphist at all. Once wl-copy actually received real PNG bytes, the existing wl-paste --type image --watch cliphist store watcher picked it up for free — a good sign the rest of the pipeline was already sound.
Go deeper
Arch Wiki — WaylandGrounds why Wayland splits responsibilities across small tools instead of one API.
wl-clipboard (GitHub)wl-copy/wl-paste source and README — read the "persistence" section for why it backgrounds itself.
grim and slurp (GitHub)The two capture tools; both READMEs are short enough to read end to end in a few minutes.
Module 3
D-Bus, from zero
D-Bus is a message bus: a background service that lets independent programs on your machine call each other's functions and emit events, without either program needing to know how the other is implemented. Think of it as an internal, machine-local API layer that any app can plug into.
Two buses exist side by side. The system bus carries machine-wide things like disk mounting or network state; the session bus carries per-login-session things — and that's the one notifications live on, because they belong to your desktop session, not the whole machine.
On the bus, a running program registers a service name (like org.freedesktop.Notifications). Inside that service there are objects at path-like addresses (/org/freedesktop/Notifications), and each object exposes an interface — a named bundle of methods you can call and signals it can broadcast. This is exactly the vocabulary the rest of the fix is built on: a method call (Notify) going one direction, a signal (ActionInvoked) coming back later, asynchronously, when the user clicks.
Where this bit us
Because D-Bus is a standard, we never had to touch wayle-specific code. busctl --user call org.freedesktop.Notifications … Notify … talks to whichever daemon currently owns that service name — dunst, mako, or wayle — identically.
Inspect it live, right now, with no risk of side effects:
busctl --user list # every service currently on the session bus
busctl --user introspect \
org.freedesktop.Notifications \
/org/freedesktop/Notifications # every method/signal that object exposes
Go deeper
Arch Wiki — D-BusThe best first read: plain-language overview plus the exact CLI tools (busctl, dbus-monitor, dbus-send) used during this debugging session.
freedesktop.org — D-Bus TutorialLonger, more formal walkthrough of the object/interface/method/signal model straight from the maintainers.
man busctl, man dbus-monitorAlready on your machine — these cover every flag actually used in the plan's scripts.
Module 4
The Notifications spec
Desktop notifications aren't a Hyprland or wayle feature — they're a cross-desktop specification that any daemon can implement, which is exactly why dunst, mako, and wayle are interchangeable from a caller's point of view. The interface is org.freedesktop.Notifications, and the one method that matters here is Notify, with signature susssasa{sv}i — a compact way of saying: string, uint, string, string, string, array-of-strings, dict(string→variant), int. In order: app name, replaces-id, icon, summary, body, actions, hints, expire-timeout.
The actions array is the whole trick behind "click to open in Satty": it's a flat list of [key, label, key, label, …] pairs. Passing ["default", "Open in Satty"] tells the daemon "this notification is clickable," where "default" is the conventional key most daemons treat as the notification body itself, rather than a separate labeled button.
When a user clicks, the daemon doesn't call you back directly — it emits a ActionInvoked(uint32 id, string action_key)signal on the bus, tagged with the same id that Notify returned. Anything listening for that signal (in the plan, a dbus-monitor filter) can match the id and react.
Where this bit us
This id-matching is why screenshot.sh captures Notify's return value first, then filters dbus-monitor output for a signal carrying that same id — otherwise a click on an unrelated notification (say, from your email client) could be mistaken for a click on the screenshot one.
A "notification daemon" is just whichever background program currently owns the org.freedesktop.Notifications service name on the session bus and draws the popup you see. Only one can own it at a time — that's why dunst and mako, both installed, weren't in play once wayle claimed the name first.
Because the contract is the spec from Module 4 and not any particular daemon's private API, switching daemons is a configuration/ownership question, not a code-rewrite question — the same busctl call … Notify … line works whether dunst, mako, or wayle answers it, and GetCapabilities lets you ask the current owner what it supports (this session confirmed wayle supports actions, body, body-markup, icon-static, and persistence).
Where this bit us
wayle has no send subcommand of its own — its CLI only does list/dismiss/dnd/status. Sending notifications still goes through the standard Notify D-Bus call directly, which is actually an advantage: the scripts don't depend on wayle at all and would keep working unchanged if the daemon ever changes again.
Go deeper
mako (GitHub)A minimal, well-documented reference daemon — reading its README is a fast way to see the spec implemented plainly, even though it's not the one in use here.
busctl --user status org.freedesktop.NotificationsShows you, right now, which process currently owns the name on your machine.
Module 6
Hyprland: workspaces & window rules
Hyprland's hyprctl is the compositor's own control socket — every keybind, window placement, and workspace switch in this config is ultimately a hyprctl dispatch … call underneath, whether written as raw config or through the Lua wrapper (hl.bind, hl.window_rule) used here.
Regular workspaces are numbered slots you switch between. A special workspace is different: it's a named overlay that sits on top of whatever's currently visible, toggled on and off by name — conceptually, togglespecialworkspace NAME is the dispatcher, in the classic Hyprland config syntax you'll see documented everywhere. Toggling it on shows the overlay; toggling the same name off again always restores exactly what was underneath — which is precisely the "open Satty somewhere out of the way, then land back where I was" behavior asked for, with zero bookkeeping of "what workspace was I on before."
A window rule is a standing instruction matched by window properties (here, Satty's window class) that fires automatically whenever a matching window opens — used to silently route every new Satty window straight into that special workspace without it ever flashing on the main view first.
Where this bit us
Special workspaces do not auto-hide when their last window closes — that's a manual toggle the script has to do itself after Satty exits. Skipping that step would leave the overlay stuck open (empty) after annotating. Also: on this machine's Hyprland build, calling that dispatcher from a script turned out not to be as simple as the classic syntax above suggests at all — see Bug 9 in the debugging log for what actually had to change and why.
Go deeper
Hyprland Wiki — Workspace RulesCovers special workspaces directly, including the exact toggle/silent semantics used in the plan.
Hyprland Wiki — Window RulesThe windowrulev2-equivalent matching syntax that hl.window_rule wraps, including matching by class.
Hyprland Wiki — DispatchersFull list of what you can tell the compositor to do, including togglespecialworkspace and exec.
Module 7
Satty's action model
Satty is a screenshot annotator: it opens an image, lets you draw arrows/boxes/blur regions over it, and then you tell it what to do with the result. The part that mattered here is that Satty treats copy and save as genuinely separate actions with separate flags — --copy-command only runs when you trigger Satty's own copy action (Ctrl+C or the toolbar button), and it has no effect on whatever you did with save.
Where this bit us
The original Print binding only ever saved from Satty, so --copy-command wl-copy just never fired — not a bug in Satty, a mismatch between which action the user was taking and which action the flag was attached to. The fix copies the raw screenshot to the clipboard immediately, before Satty even opens, and separately gives Satty --save-after-copy plus --early-exit all so its copy action re-saves and closes the window in one step, matching how it's actually used.
Go deeper
Satty (GitHub)README documents every flag referenced in the plan: --copy-command, --save-after-copy, --early-exit, -o/--output-filename.
satty --helpFastest way to see the exact flag set for the version actually installed.
Module 8
Reading it back yourself
Every fact in this document was checked against the running system rather than assumed — the same handful of commands work for verifying any of it again later, or for debugging the next weird D-Bus/Hyprland issue that isn't this one.
# who currently owns notifications, and what it can do
busctl --user call org.freedesktop.Notifications \
/org/freedesktop/Notifications org.freedesktop.Notifications \
GetCapabilities
# watch clicks arrive live, in a second terminal, before triggering one
dbus-monitor --session \
"type='signal',interface='org.freedesktop.Notifications',member='ActionInvoked'"
# find a window's class/pid to write a matching window rule
hyprctl clients -j | jq '.[] | {class, pid, title}'
# confirm what actually landed on the clipboard
wl-paste | file -
Three more tools came up while chasing the bugs in Part 2, all worth keeping in your back pocket for future Hyprland/D-Bus debugging:
Tap Hyprland's own live event stream. Hyprland writes a raw, undocumented-but-simple event feed to a Unix socket for every window/workspace state change — this is how the special-workspace and focus bugs below were actually diagnosed, rather than guessed at:
python3 -c "
import socket, time
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.connect('\$XDG_RUNTIME_DIR/hypr/\$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock')
while True:
data = s.recv(4096)
if not data: break
for line in data.decode(errors='replace').splitlines():
print(f'{time.time():.3f} {line}')
"
Every line is eventName>>arg1,arg2,... — openwindow, closewindow, activewindow, createworkspace, activespecial, and so on. Redirect it to a file, do the thing you're debugging, then read back exactly what happened and in what order — far more reliable than trying to watch the screen and a terminal at the same time.
Catch a process that shouldn't exist twice. A tight polling loop, sampling every 100ms, is enough to catch races that are too fast to see by eye:
while true; do
date +%s.%N
pgrep -af 'screenshot.sh|open-satty.sh|satty' 2>/dev/null
sleep 0.1
done
Check who's really holding a lock.fuser -v LOCKFILE and cat /proc/locks both show which process currently holds an advisory lock (like one taken with flock) — essential for telling "the lock is working as designed" apart from "something is stuck holding it forever."
Go deeper
Hyprland Wiki — Using hyprctlThe clients -j JSON output shape used to poll for Satty's window before revealing the special workspace.
man jqWorth ten minutes if you haven't used it — every JSON tool output from hyprctl goes through it in this setup.
man flock, man 2 fcntlThe flock man page covers the CLI tool; the fcntl(2) man page's "Open file description locks" section explains why a lock can outlive the process that took it, which is exactly what Bug 13 below turned out to be.
Part 2
The debugging log
The plan file at /home/nkini/.claude/plans/silly-snacking-sun.md covered the original two bugs (no clipboard copy, no save) and a design for the fix. Everything below is what actually happened once that design met a real, running system — eight more incidents, most of them not guessed at but pinned down by watching the system directly. Each one lists what was tried and ruled out, not just what worked, since the point of this section is that you could rerun the same kind of investigation yourself.
Bug 9
The special workspace never actually shows
Symptom: clicking the notification correctly opened Satty (confirmed — its window really did exist), but no overlay ever appeared on screen. Nothing visibly happened at all.
confirmedhyprctl dispatch togglespecialworkspace screenshot — the classic syntax from Module 6 — was silently failing every single time. Tapped Hyprland's own event socket (see the snippet added to Module 8) across a full click cycle and the activespecial event, which fires whenever a special workspace is shown or hidden, never appeared anywhere in the capture — not once. Ran the exact dispatch command directly in a terminal next, and it surfaced the real error immediately: [string "return hl.dispatch(togglespecialworkspace scr..."]:1: ')' expected near 'screenshot'.
This particular Hyprland build — the one whose entire config is written in Lua via hl.* — doesn't accept classic dispatcher strings through hyprctl dispatch at all. It evaluates whatever text you pass as a Lua expression, effectively running hl.dispatch(<your text>). The correct form was sitting in the framework's own shipped example config (/usr/share/hypr/hyprland.lua), which calls dispatchers like hyprctl dispatch 'hl.dsp.exit()' — full Lua function calls, not bare dispatcher names.
Fixopen-satty.sh now calls hyprctl dispatch 'hl.dsp.workspace.toggle_special("screenshot")' — the exact same function the config's own mainMod+S scratchpad binding already used elsewhere in hyprland.lua. See the comment block right above both toggle calls in that script.
Bug 10
A stray terminal, and lost focus
Symptom: once Bug 9 was fixed, two more things showed up: (a) sometimes a brand-new terminal window appeared inside the special workspace, entirely on its own, minutes after Satty had already closed; and (b) after closing Satty, no window had keyboard focus at all — the correct workspace was visible, but nothing responded to typing.
ruled out A bug in open-satty.sh itself causing the stray terminal. Tapped the event socket across a full cycle again: the stray terminal's openwindow event landed several seconds after Satty's own closewindow/destroyworkspace events, and a concurrent pgrep polling loop (see Module 8) confirmed zero screenshot-related processes were even running at that moment. Whatever caused it, it wasn't our script running code.
confirmed Hyprland's own internal bookkeeping for "which workspace do brand-new windows land on" wasn't fully reverting after the special workspace was hidden, and separately, closing a window that had focus inside a special workspace drops focus to nothing rather than falling back to whatever's on the workspace underneath. Reproduced the second part directly and in isolation: launched Satty manually, watched hyprctl activewindow before/during/after, and confirmed it goes completely null once Satty closes — even after explicitly switching back to the correct regular workspace.
Fixopen-satty.sh now records two separate things before ever opening Satty: the real workspace ID (hyprctl activeworkspace) and the exact focused window's address (hyprctl activewindow). After hiding the special workspace, it explicitly re-asserts both — a workspace switch and a specific window focus — rather than assuming either one falls back on its own. See the orig_workspace/orig_window comments near the top of the script.
Bug 11
The clipboard "doesn't paste" — mostly a false alarm
Symptom: picking an entry from the SUPER+SHIFT+V rofi picker appeared in the list fine, but pasting failed in both Google Docs and Thunar.
ruled out A broken cliphist decode / wl-copy hand-off. Rather than guessing, dumped the clipboard's actual bytes right after a real rofi selection (wl-paste --type image/png to a file, checked with file) — came back as a correct, byte-identical 1920×1080 PNG every time. Re-tested Google Docs directly afterward and it pasted fine.
Thunar simply doesn't support "create a file from clipboard image data" at all — pasting a file into a folder needs a text/uri-list clipboard entry (a reference to an existing file), which a raw image copy never provides. Not a bug in this workflow; a missing feature in Thunar.
Bug 12 · feature
Making a rofi pick paste itself
Ask: could picking an entry in rofi paste it immediately, instead of just loading the clipboard and leaving you to press Ctrl+V yourself?
Wayland clipboard tools only ever touch the clipboard — none of them can simulate a keystroke. That needs a separate tool. Chose wtype over the more common ydotool: it's built specifically for wlroots compositors like Hyprland, needs no background daemon and no special group permissions, unlike ydotool which needs both.
Fix
New script, clipboard-paste.sh, bound to SUPER+SHIFT+V: loads the clipboard exactly as before, then checks the focused window's class and sends Ctrl+Shift+V for terminal-like classes, Ctrl+V for everything else — most terminal emulators reserve plain Ctrl+V for something else. Known residual gap, documented directly at the top of that script: Claude Code runs inside a terminal but wants plain Ctrl+V for image pastes specifically, and this class-based heuristic can only see the terminal's own window class, not what's running inside it — so auto-paste currently sends the wrong shortcut into Claude Code. Manual Ctrl+V still works there.
Bug 13
Print silently fires twice
Symptom: found while re-testing Bug 10's fix — things felt subtly unreliable, "worked once, then got weird."
confirmed A single physical Print press was reproducibly spawning two concurrent screenshot.sh processes. Caught directly with the pgrep polling loop from Module 8 — both PIDs persisted for the entire run, each independently capturing, copying, and sending its own notification.
ruled out A bug in hl.dsp.exec_cmd or in the script itself causing a self-relaunch. Manually dispatched the exact same command directly (bypassing the physical key entirely) a dozen times — it only ever ran once. Whatever's duplicating the event, it's specific to how this particular key press arrives, possibly a firmware/driver quirk on this Print key — never conclusively identified, and not worth chasing further once it could be made harmless regardless of cause.
Fix
Added an flock-based mutex at the top of screenshot.sh: a second concurrent invocation now exits immediately, no matter what's causing it.
Bug 14
The lock fix breaks Print forever
Symptom: after Bug 13's fix, Print eventually stopped doing anything at all — every single press, no file, no notification, nothing.
confirmedfuser -v on the lock file (see Module 8) showed it held open by a wl-copy process that had been running for over nine minutes, reparented to PID 1 (its original parent, an earlier screenshot.sh, was long gone). /proc/locks confirmed an active advisory lock still tied to that same file.
wl-copy deliberately never exits on its own — it stays alive indefinitely to keep serving paste requests (see Module 2) — and it had inherited a duplicate of the script's own lock file descriptor, simply by being launched from a shell that had one open (ordinary fork/exec fd inheritance, nothing exotic). Per Linux flock semantics, a lock tied to an open file description stays held as long as any process still holds a copy of that descriptor open, even after the process that originally created the lock has exited. A long-lived child can pin a lock forever.
Fix
Added 9<&- to the wl-copy line in screenshot.sh — closes that one file descriptor for wl-copy specifically before it runs, so it can never inherit the lock in the first place. Cleared the already-stuck lock by deleting the lock file itself (empty, purely internal, safe to remove any time).
Bug 15
Releasing the lock "early" reopens the race
Symptom: an earlier version of Bug 13's fix released the lock right after sending the notification, reasoning that only the capture/copy/notify steps needed protecting — the long click-wait and Satty session shouldn't block a later, legitimate Print press. Re-testing showed Print could still sometimes produce two overlapping notifications.
confirmed Measured the actual gap between the two spurious screenshot.sh starts from Bug 13, using the same pgrep polling technique, and found it wasn't always sub-millisecond — sometimes 100+ ms apart. That's longer than it takes the first instance to reach the early-release point, so the second invocation could arrive after the first had already unlocked, acquire its own fresh lock, and run a complete parallel cycle anyway.
Fix
The lock in screenshot.sh is now held for the script's entire run — capture, copy, notify, the click-wait, and any Satty session — released only when the process actually exits. Safe now that Bug 14's wl-copy fix stops it from being pinned open beyond that. The trade-off (a genuinely-intentional rapid second Print press would also be ignored) is explained directly in the script's comments.
Bug 16
The multi-second click delay
Symptom: the longest chase of the session. Anywhere from ~1 to ~6 seconds between clicking the notification and Satty's window actually appearing — inconsistent, and initially looked unfixable.
ruled outThe daemon only fires ActionInvoked on dismiss, not on click (this was your hypothesis). Tested with an isolated raw notification and a live dbus-monitor tap, clicking immediately — the signal arrived in well under a second. The daemon fires it right away; this wasn't it.
ruled outdbus-monitor's own stdout is block-buffered once piped elsewhere (a classic Unix gotcha — many programs switch from line-buffered to fully-buffered output once stdout isn't a terminal). Tested stdbuf -oL dbus-monitor ..., which forces line-buffering via an LD_PRELOAD shim — no change at all. The delay stayed at ~93-95% of whatever timeout value was configured, regardless of exactly when the click happened. dbus-monitor apparently doesn't use the kind of glibc stdio stdbuf can intercept.
ruled outSwitch to busctl monitor (systemd's bus-monitoring tool — a completely different implementation from dbus-monitor), in case the issue was specific to one binary. Identical near-timeout delay.
ruled outdbus-monitor only flushes once a second message follows closely behind the first (a click usually fires both ActionInvoked and NotificationClosed almost simultaneously). Widened the match filter to catch both signals instead of just ActionInvoked — same delay again.
confirmedgawk itself was buffering its own reads from the incoming pipe, independent of anything dbus-monitor was doing. Timed the exact same parsing logic two ways side by side: through gawk, and reimplemented in plain bash (a while read -r line; do ... done < <(dbus-monitor ...) loop, using parameter expansion instead of awk's split/match). The gawk version consistently only produced a result at ~93-95% of whatever timeout was set; the plain-bash version consistently detected the exact same click in ~1-1.5 seconds, matching ordinary human reaction time.
Fixscreenshot.sh's click-listener no longer uses gawk at all — replaced with a plain bash read loop that parses the same dbus-monitor output using parameter expansion. See the long comment right above it for exactly how the parsing works line by line.
Methodology
The toolkit behind this log
Module 8 (Reading it back yourself) already covers the Hyprland event-socket tap and the pgrep polling loop, which caught Bugs 10 and 13-15. Bug 16 needed one more instrument: a disposable, throwaway notification used purely as a stopwatch, since it lets you measure exactly how long detection takes without ever touching the real screenshot workflow.
The click-latency test rig. Start a listener in the background, print the exact moment you send a real (but harmless) notification, then print the exact moment the listener detects the click. The gap between those two timestamps is the number that mattered — not "does it work," but "how long does it take":
# start listening FIRST, in the background
(
action=$(timeout 10 dbus-monitor --session \
"type='signal',interface='org.freedesktop.Notifications',member='ActionInvoked'" 2>/dev/null | \
gawk -v target="ID_FROM_BELOW" '
/member=ActionInvoked/ {
getline; split($0, a, " "); id = a[2]
getline; match($0, /"([^"]*)"/, m); act = m[1]
if (id == target) { print act; exit }
}
')
echo "got action='$action' at: $(date +%s.%N)"
) &
# then fire a real, disposable test notification and note when
echo "notify sent at: $(date +%s.%N)"
busctl --user call org.freedesktop.Notifications /org/freedesktop/Notifications \
org.freedesktop.Notifications Notify susssasa{sv}i \
"TimingTest" 0 "" "Click me now" "" \
2 "default" "Click" 0 10000 # <- note the returned id, use it as target above
wait # click the notification now
This rig is what falsified three hypotheses in a row cheaply: swap only the target id (a fresh one each run, since it increments per call), rerun, click promptly, and read off the delta. It's also what made the real cause obvious once tried: rerunning the exact same rig with only the consumer swapped — the gawk block above versus a plain while read -r line; do ... done < <(dbus-monitor ...) loop — produced two starkly different deltas (~93-95% of the timeout vs. ~1-1.5 seconds) from otherwise identical input. When two consumers of the identical data stream disagree that badly on timing, the consumer is the suspect, not the data source.
Combining two observers around one real action. Some bugs only make sense relative to timing across two different systems — "did Hyprland report this window opening before or after that process existed?" Running two independent capture loops in the background at once, against the same real button-press, then merging their timestamps afterward, is what pinned down both Bug 10 and Bug 13:
# observer 1 — Hyprland's own event stream, timestamped
python3 -c "
import socket, time
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.connect('\$XDG_RUNTIME_DIR/hypr/\$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock')
with open('events.log', 'w') as f:
while True:
data = s.recv(4096)
if not data: break
t = time.time()
for line in data.decode(errors='replace').splitlines():
f.write(f'{t:.3f} {line}\n')
f.flush()
" &
# observer 2 — process snapshots, timestamped, every 100ms
(
end=$((SECONDS+30))
while [ $SECONDS -lt $end ]; do
ts=$(date +%s.%N)
procs=$(pgrep -af 'satty|open-satty|screenshot.sh')
[ -n "$procs" ] && printf '%s %s\n' "$ts" "$procs" >> procs.log
sleep 0.1
done
) &
wait # do the real thing now: press Print, click, close Satty
Both logs end up on the same wall-clock timeline, so afterward you can line up "this window opened" against "this process existed" to within 100ms. That's exactly how Bug 10's stray terminal was proven to land in the special workspace at a moment when zero screenshot-related processes were even running, and how Bug 13's double invocation was proven to be two complete, independent processes rather than one process forking.
Reproducing a bug without a human in the loop. Once Bug 10's focus-loss symptom was described, the fastest way to confirm the mechanism wasn't to keep asking for real Print-and-click cycles — it was to reproduce the exact sequence directly from a terminal:
This reproduced activewindow going completely null in isolation, in a few seconds, with a disposable test image — no physical keypress needed, no risk to a real screenshot, and repeatable as many times as it took to confirm the fix.
Testing the primitive before blaming your own script. Twice this session, the fastest path to a root cause was to stop reading screenshot.sh/open-satty.sh entirely and test the underlying command by hand, outside any script:
# is our own exec_cmd wrapper double-firing, or is it something else? (Bug 13)
hyprctl dispatch 'hl.dsp.exec_cmd("date +%s%N >> /tmp/exec_count.log")'
wc -l /tmp/exec_count.log # exactly 1 -> rules out exec_cmd itself
# does the classic dispatcher syntax even work on this system at all? (Bug 9)
hyprctl dispatch togglespecialworkspace screenshot
# -> immediate Lua parse error, printed straight to the terminal
Both answers came back in under a second, and each one ruled out an entire category of explanation before a single line of either script needed to change.
Bug 17 · open, unresolved
SUPER+Print occasionally creates a workspace with no click, and focus is lost
Symptom: reported once, not yet reproduced on demand despite repeated attempts. Pressing SUPER+Print, a workspace gets created even without ever clicking the resulting notification, and afterward no window can gain keyboard focus at all — the only recovery is closing the current window and switching to a different workspace.
open Most likely related to the same still-unexplained mechanism behind Bug 13 (Print reproducibly double-firing at the key-event level, root cause never identified) — but broader here, since Bug 13 only ever duplicated a whole script run, never corrupted focus or created a workspace without the click-gated path in open-satty.sh ever running. A stuck modifier key (SUPER not being released) or a duplicate/malformed scancode at the input-driver level would explain both an unexpected SUPER+<something> binding firing and a subsequent focus/input malfunction in one shot — but this is a hypothesis, not yet confirmed.
Current status
Not reproducible on demand, so not yet debugged the way Bugs 9-16 were. A standalone logger, independent of any particular debugging session, is now in place to catch it the next time it happens — see below.
Standing tool
An always-on logger for bugs that won't reproduce
Bug 17 above doesn't reproduce on demand, which makes every technique earlier in this log (which all assume you can trigger the bug while something's watching) useless on its own. ~/.config/hypr/scripts/debug-logger.sh is the answer to that: a standalone script, independent of any Claude Code session, meant to be started in its own terminal and left running during ordinary use.
It runs two of the same taps used throughout this log — Hyprland's own timestamped event stream (the exact technique from Bugs 9-10 and the toolkit above), and a live kernel-log follow (journalctl -k -f), in case the root cause turns out to be at the input-driver level rather than in Hyprland at all — writing both continuously to ~/.cache/hypr-debug/ with simple size-based rotation so it's safe to leave running for hours or days. When Bug 17 happens again: no need to stop it, just note roughly when, and hand over the relevant window of both log files.
Part 3
What's actually running today
This section supersedes the design in /home/nkini/.claude/plans/silly-snacking-sun.md — that file reflects the original intent going in, not what actually ended up built after sixteen bugs' worth of contact with reality. If you're touching this workflow again, start here, not there.
Script
Bound to
Responsibility
screenshot.sh
Print (full) SUPER+Print (region)
Captures via grim/slurp, copies to clipboard immediately, saves to ~/Pictures/Screenshots, sends a notification with two actions — the body opens Satty, a separate "Show in Files" button opens the containing folder in Thunar with the file pre-selected — and listens (plain bash, not gawk — Bug 16) for whichever was clicked. Guarded end-to-end by an flock mutex (Bugs 13-15).
open-satty.sh
called by screenshot.sh on click
Opens Satty on the special:screenshot workspace, reveals it via the correct Lua-expression dispatch syntax (Bug 9), waits for Satty to exit, then explicitly restores both the original workspace and the original focused window (Bug 10) before hiding the special workspace again.
clipboard-paste.sh
SUPER+SHIFT+V
Rofi picker over cliphist history; loads the selection onto the clipboard and immediately simulates the paste keystroke via wtype, choosing Ctrl+Shift+V or Ctrl+V based on the focused window's class (Bug 12).
debug-logger.sh
run manually, standalone
Not part of the screenshot pipeline — a diagnostic aid for Bug 17, the one still-open, non-reproducible bug. Not bound to any key; start it yourself in a terminal when you want a capture running in the background.
All three live in ~/.config/hypr/scripts/, are executable, and are wired up in hyprland.lua around line 285 (the bindings) and line 386 (the satty-scratch-workspace window rule). Every script is heavily commented in place — read the actual file for the line-by-line mechanics; this page covers the concepts and the history, not a duplicate of that reference.
Part 4
Ideas explored, not yet decided
Not bugs, not built — design questions that came up, were actually investigated against wayle's real source rather than guessed at, and then deliberately set aside for a future decision.
Idea · deferred, not implemented
Click launches a program directly, with nothing of ours listening at all
The question: right now, screenshot.sh spawns a small process that watches for a click for a bounded window, then gives up. Could a notification click instead directly invoke Thunar/Satty, with literally no process of ours running to catch it?
What "invoke" actually does. Read wayle's own source (it's open source — confirmed real via pacman -Qi wayle-bin, then found on GitHub). Every path that handles a click — the notification body, and each action button, in both the transient popup and the persisted dropdown item — ends up calling the same function:
That's the entire behavior: re-broadcast the exact (id, action_key) pair as the standard org.freedesktop.Notifications.ActionInvoked signal — the same signal screenshot.sh has been catching this whole time. Wayle never interprets what "default" or "show-folder" mean; it doesn't know or care. That interpretation only happens on whichever side is listening.
Why this rules out zero-listener, structurally. The freedesktop Notifications spec is a fire-and-forget broadcast model by design — the daemon's contract is "show this, and shout if it's clicked," nothing more. Checked for a config-level escape hatch (some notification tools let you bind a shell command directly to an action) and there isn't one here: docs/config/modules/notifications.md has no such option, and busctl --user introspect org.freedesktop.Notifications /org/freedesktop/Notifications confirms wayle exposes only the four standard spec methods (Notify, CloseNotification, GetCapabilities, GetServerInformation) — nothing custom. Every app with clickable notifications (Slack, Discord, Firefox) handles this the same way: they can react because they're already running continuously anyway, so it doesn't read as "a listener," it's just one more thing their existing process does. A one-shot script like ours doesn't have that luxury for free.
So the real choice is between two designs, not three:
Option A — current design
One bounded listener per screenshot, self-cleaning, no persistent state anywhere.
Simple: nothing to supervise, nothing that can be "the wrong version" after a crash.
Doesn't work for a click after the listener gives up — which matters more now that persistence means the notification itself could still be sitting in the dropdown far longer than that.
Option B — one always-on daemon
Exactly one process, ever — not one per unclicked screenshot piling up. Same category of thing as the cliphist watchers already running continuously via hyprland.start, not a new pattern for this setup.
Needs its own {id → file path} table, kept only in its own memory — confirmed via busctl introspect above that wayle has no way to answer "what was notification #N about?" after the fact, so there's no shortcut around keeping this ourselves.
That table dies with the process: a crash or restart silently orphans any not-yet-clicked notifications. Would want real supervision (a systemd --user service with Restart=on-failure), not just a bare backgrounded process.
Single point of failure — a bug in this one daemon breaks click-handling for every pending screenshot at once, instead of affecting just one.
Current status
Deferred, nothing implemented. Worth revisiting together with the expire_timeout=-1 persistence change from Bug 16's discussion — there's no point building always-on click handling for notifications that vanish from the dropdown after 6 seconds anyway.
wayle-services: wayle-notification/src/persistence.rsThe SQLite-backed NotificationStore (~/.local/share/wayle/notifications.db) and its expiry filter — an unset expire_timeout is never treated as expired, an explicit one is, once it elapses.