Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion bin/omarchy-launch-screensaver
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

# omarchy:summary=Launch the Omarchy screensaver in the default terminal on the system with the correct font configuration.

# Keep concurrent idle/manual launches from saving each other's screensaver.
exec 9>"$XDG_RUNTIME_DIR/omarchy-screensaver-$HYPRLAND_INSTANCE_SIGNATURE.lock"
flock -n 9 || exit 0

# Exit early if screensaver is already running
pgrep -f '[o]rg.omarchy.screensaver' && exit 0

Expand Down Expand Up @@ -34,9 +38,17 @@ hypr_exec() {

SOCKET="$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock"

# The screensaver's fullscreen rule displaces the previous fullscreen window
# before openwindow is emitted. Save both compositor and client state now.
clients=$(hyprctl clients -j) || exit 1
fullscreen_windows=$(jq -c '[.[] | select(.mapped and (.fullscreen != 0 or .fullscreenClient != 0))
| {address, pid, stableId, fullscreen, fullscreenClient, fullscreenHandler}]' <<<"$clients") || exit 1

# Open Hyprland's event stream before spawning anything, so a terminal that maps
# quickly can't emit its openwindow event before we are listening for it.
exec {events}< <(socat -U - "UNIX-CONNECT:$SOCKET")
exec {events}< <(socat -U - "UNIX-CONNECT:$SOCKET" 9>&-)
events_pid=$!
trap 'kill "$events_pid" 2>/dev/null; wait "$events_pid" 2>/dev/null' EXIT

# hypr_exec is async and a new window maps on whatever monitor is focused at that
# moment. Block until this monitor's screensaver actually opens before moving
Expand Down Expand Up @@ -71,3 +83,41 @@ for m in $(hyprctl monitors -j | jq -r '.[] | .name'); do
done

hypr_focus_monitor "$focused"

if [[ $fullscreen_windows != "[]" ]]; then
# All monitors must be clear before restoring: another screensaver closing
# can otherwise displace the restored window again. Reconcile on close events
# and timeouts, so an already-closed window cannot leave us waiting forever.
unanswered=0
while true; do
if ! clients=$(hyprctl clients -j); then
(( ++unanswered >= 3 )) && exit 1
sleep 1
continue
fi
unanswered=0
# Keep the class out of jq's argv: the screensaver exits with pkill -f.
# A failed query is not evidence that every screensaver has closed.
screensaver_running=$(SCREENSAVER_CLASS=org.omarchy.screensaver \
jq 'any(.[]; .mapped and .class == env.SCREENSAVER_CLASS)' <<<"$clients") || exit 1
if [[ $screensaver_running == "false" ]]; then
break
fi
while IFS= read -r -t 1 -u "$events" event; do
[[ $event == closewindow\>\>* ]] && break
done
# A disconnected event stream returns immediately instead of timing out.
kill -0 "$events_pid" 2>/dev/null || exit 1
done

# Address alone can be reused after a window closes. Restore only surviving,
# changed windows, without focusing them or changing the current workspace.
while IFS= read -r command; do
hyprctl dispatch "$command" >/dev/null
done < <(jq -r --argjson saved "$fullscreen_windows" '
$saved[] as $before | .[]
| select(.mapped and .address == $before.address and .pid == $before.pid and .stableId == $before.stableId)
| select(.fullscreen != $before.fullscreen or .fullscreenClient != $before.fullscreenClient)
| "hl.dsp.window.fullscreen_state({ window = \"address:\(.address)\", action = \"set\", internal = \($before.fullscreen), client = \($before.fullscreenClient), layout_aware = \($before.fullscreenHandler != "default") })"
' <<<"$clients")
fi
226 changes: 226 additions & 0 deletions test/shell.d/screensaver-fullscreen-test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
#!/bin/bash

set -euo pipefail

source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
require_command jq
require_command pgrep
real_jq=$(command -v jq)
real_pgrep=$(command -v pgrep)

test_tmp=$(mktemp -d)
launch_pid=""
cleanup() {
if [[ -n $launch_pid ]]; then
kill "$launch_pid" 2>/dev/null || true
wait "$launch_pid" 2>/dev/null || true
fi
rm -rf "$test_tmp"
}
trap cleanup EXIT

mkdir -p "$test_tmp/bin" "$test_tmp/home"
mkfifo "$test_tmp/events"
exec {events_fd}<>"$test_tmp/events"

cat >"$test_tmp/bin/jq" <<'STUB'
#!/bin/bash
if [[ $* == *'any(.[];'* ]]; then
# Check the actual process argv, without killing any desktop processes.
if "$OMARCHY_TEST_PGREP" -f '[o]rg.omarchy.screensaver' | grep -qx "$$"; then
touch "$OMARCHY_TEST_DIR/pkill-collision"
kill -TERM "$$"
fi
[[ ! -f $OMARCHY_TEST_DIR/jq-failed ]] || exit 2
fi
exec "$OMARCHY_TEST_JQ" "$@"
STUB
cat >"$test_tmp/bin/pgrep" <<'STUB'
#!/bin/bash
exit 1
STUB
cat >"$test_tmp/bin/omarchy-toggle-enabled" <<'STUB'
#!/bin/bash
exit "${OMARCHY_TEST_DISABLED:-1}"
STUB
cat >"$test_tmp/bin/omarchy-hyprland-monitor-focused" <<'STUB'
#!/bin/bash
printf 'DP-1\n'
STUB
cat >"$test_tmp/bin/xdg-terminal-exec" <<'STUB'
#!/bin/bash
printf 'foot.desktop\n'
STUB
cat >"$test_tmp/bin/socat" <<'STUB'
#!/bin/bash
exec cat "$OMARCHY_TEST_DIR/events"
STUB
cat >"$test_tmp/bin/hyprctl" <<'STUB'
#!/bin/bash
set -euo pipefail
state="$OMARCHY_TEST_DIR/clients.json"
printf '%s\n' "$*" >>"$OMARCHY_TEST_DIR/calls"
case "$*" in
'clients -j')
[[ ! -f $OMARCHY_TEST_DIR/query-failed ]] || exit 1
if [[ -f $OMARCHY_TEST_DIR/query-misses ]]; then
misses=$(cat "$OMARCHY_TEST_DIR/query-misses")
if (( misses > 0 )); then
printf '%s\n' "$((misses - 1))" >"$OMARCHY_TEST_DIR/query-misses"
exit 1
fi
fi
cat "$state"
;;
'monitors -j')
printf '[{"name":"DP-1"},{"name":"DP-2"}]\n'
;;
*'hl.dsp.exec_cmd('* )
count=$(cat "$OMARCHY_TEST_DIR/spawned")
(( ++count ))
# Mapping a screensaver clears the previous internal fullscreen state on
# this monitor. Client-only fullscreen and hidden workspaces are untouched.
jq --argjson monitor "$((count - 1))" --arg address "0xf$count" '
map(if .monitor == $monitor and .fullscreen != 0 then
.fullscreen = 0 | .fullscreenClient = 0 else . end)
+ [{address: $address, class: "org.omarchy.screensaver", mapped: true}]
' "$state" >"$state.tmp"
mv "$state.tmp" "$state"
printf '%s\n' "$count" >"$OMARCHY_TEST_DIR/spawned"
printf 'openwindow>>f%s,1,org.omarchy.screensaver,Screensaver\n' "$count" >"$OMARCHY_TEST_DIR/events"
;;
*'hl.dsp.window.fullscreen_state('* )
# A restore while any screensaver is still mapped is a failure.
jq -e 'all(.[]; .class != "org.omarchy.screensaver" or .mapped == false)' "$state" >/dev/null
printf '%s\n' "$2" >>"$OMARCHY_TEST_DIR/restored"
;;
esac
STUB
chmod +x "$test_tmp/bin/"*

launch() {
env PATH="$test_tmp/bin:$PATH" HOME="$test_tmp/home" OMARCHY_PATH="$ROOT" \
XDG_RUNTIME_DIR="$test_tmp" HYPRLAND_INSTANCE_SIGNATURE=test \
OMARCHY_TEST_DIR="$test_tmp" OMARCHY_TEST_JQ="$real_jq" OMARCHY_TEST_PGREP="$real_pgrep" \
timeout --kill-after=1 15 "$ROOT/bin/omarchy-launch-screensaver" "$@"
}

wait_for_launch() {
for (( attempt = 0; attempt < 100; attempt++ )); do
[[ $(<"$test_tmp/spawned") == "2" ]] && return
sleep 0.02
done
fail "screensaver maps on both monitors" "$(<"$test_tmp/calls")"
}

reset_state() {
rm -f "$test_tmp/query-failed" "$test_tmp/jq-failed" "$test_tmp/query-misses"
: >"$test_tmp/calls"
: >"$test_tmp/restored"
printf '0\n' >"$test_tmp/spawned"
# Extra windows exercise stale addresses, unchanged state, both fullscreen
# handlers, and a different process reusing an otherwise matching identity.
jq -n '[
{address:"0xa", pid:1, stableId:"a", monitor:0, fullscreen:2, fullscreenClient:2, fullscreenHandler:"default"},
{address:"0xb", pid:2, stableId:"b", monitor:1, fullscreen:1, fullscreenClient:1, fullscreenHandler:"scrolling"},
{address:"0xc", pid:3, stableId:"c", monitor:0, fullscreen:0, fullscreenClient:0},
{address:"0xd", pid:4, stableId:"d", monitor:0, fullscreen:0, fullscreenClient:2},
{address:"0xe", pid:5, stableId:"e", monitor:0, fullscreen:2, fullscreenClient:0},
{address:"0x10", pid:6, stableId:"10", monitor:0, fullscreen:2, fullscreenClient:2},
{address:"0x11", pid:7, stableId:"11", monitor:0, fullscreen:2, fullscreenClient:2},
{address:"0x12", pid:8, stableId:"12", monitor:0, fullscreen:2, fullscreenClient:2},
{address:"0x13", pid:9, stableId:"13", monitor:0, fullscreen:2, fullscreenClient:2},
{address:"0x14", pid:10, stableId:"14", monitor:2, fullscreen:2, fullscreenClient:2}
] | map(. + {mapped:true, class:"test"})' >"$test_tmp/clients.json"
}

close_screensaver() {
local address="$1"
# Closed clients can remain listed while their closing animation plays.
jq --arg address "$address" 'map(if .address == $address then .mapped = false else . end)' \
"$test_tmp/clients.json" >"$test_tmp/clients.tmp"
mv "$test_tmp/clients.tmp" "$test_tmp/clients.json"
printf 'closewindow>>%s\n' "${address#0x}" >&"$events_fd"
}

reset_state
launch force &
launch_pid=$!
wait_for_launch
[[ ! -s $test_tmp/restored ]] || fail "nothing restores while screensavers are mapped"
pass "fullscreen state is saved before either screensaver maps"

# pgrep deliberately always misses: the lock must protect the snapshot itself.
launch force
[[ $(<"$test_tmp/spawned") == "2" ]] || fail "a concurrent launch is ignored"
pass "concurrent launches cannot replace the saved state"

close_screensaver 0xf1
sleep 0.1
[[ ! -s $test_tmp/restored ]] || fail "closing one monitor must not restore fullscreen yet"
pass "restoration waits for the last screensaver window"

jq 'map(select(.address != "0x10"))
| map(if .address == "0x11" then .stableId = "reused"
elif .address == "0x12" then .pid = 999
elif .address == "0x13" then .mapped = false else . end)' \
"$test_tmp/clients.json" >"$test_tmp/clients.tmp"
mv "$test_tmp/clients.tmp" "$test_tmp/clients.json"
close_screensaver 0xf2
wait "$launch_pid" || fail "the launcher exits after restoring fullscreen"
launch_pid=""

[[ $(wc -l <"$test_tmp/restored") == "3" ]] || fail "only three surviving changed windows are restored" "$(<"$test_tmp/restored")"
grep -F 'window = "address:0xa", action = "set", internal = 2, client = 2, layout_aware = false' "$test_tmp/restored" >/dev/null || fail "fullscreen state restores with the original handler"
grep -F 'window = "address:0xb", action = "set", internal = 1, client = 1, layout_aware = true' "$test_tmp/restored" >/dev/null || fail "maximized state restores with the layout handler"
grep -F 'window = "address:0xe", action = "set", internal = 2, client = 0' "$test_tmp/restored" >/dev/null || fail "independent client fullscreen state is preserved"
pass "fullscreen, maximized and independent client state restore by window address"
pass "normal, client-only, unchanged, closed, unmapped and reused windows are left alone"
[[ $(grep -c 'hl.dsp.focus(' "$test_tmp/calls") == "3" ]] || fail "restoration must not focus windows or switch workspaces"
pass "restoration does not steal focus"
[[ ! -f $test_tmp/pkill-collision ]] || fail "the polling process matched the screensaver kill pattern"
pass "the polling process cannot be killed by the screensaver's pkill pattern"
pass "unmapped screensavers do not delay restoration during their closing animation"

# A new cycle must acquire the lock even if no further compositor event arrives.
reset_state
OMARCHY_TEST_DISABLED=0 launch && fail "a disabled screensaver must not launch"
[[ $(<"$test_tmp/spawned") == "0" ]] || fail "disabled screensaver spawned a window"
pass "the event reader releases its lock and screensaver disabling still works"

# A busy compositor can miss a response without losing the session.
reset_state
launch force &
launch_pid=$!
wait_for_launch
printf '1\n' >"$test_tmp/query-misses"
close_screensaver 0xf1
close_screensaver 0xf2
wait "$launch_pid" || fail "a transient query failure loses the saved state"
launch_pid=""
[[ -s $test_tmp/restored ]] || fail "fullscreen was not restored after the query recovered"
pass "a transient compositor query failure retains the saved state"

# An interrupted or invalid jq result must not mean every screensaver closed.
reset_state
launch force &
launch_pid=$!
wait_for_launch
touch "$test_tmp/jq-failed"
printf 'closewindow>>unrelated\n' >&"$events_fd"
wait "$launch_pid" && fail "a failed jq query must not mean the screensaver closed"
launch_pid=""
[[ ! -s $test_tmp/restored ]] || fail "restoration followed a failed jq query"
pass "a failed jq query never restores while the screensaver is still mapped"

# The compositor failing to answer is not evidence the screensaver has closed.
reset_state
launch force &
launch_pid=$!
wait_for_launch
touch "$test_tmp/query-failed"
printf 'closewindow>>unrelated\n' >&"$events_fd"
wait "$launch_pid" && fail "a failed compositor query must not restore from stale state"
launch_pid=""
[[ ! -s $test_tmp/restored ]] || fail "restoration followed a failed compositor query"
pass "a failed compositor query never restores from stale state"