diff --git a/bin/omarchy-hyprland-session-locked b/bin/omarchy-hyprland-session-locked new file mode 100755 index 00000000..7ff5248d --- /dev/null +++ b/bin/omarchy-hyprland-session-locked @@ -0,0 +1,29 @@ +#!/bin/bash + +# omarchy:summary=Returns true when the compositor holds a session lock +# omarchy:hidden=true + +# Hyprland reports no lock state directly, but an active ext-session-lock is one +# of the reasons a monitor cannot go solitary: LOCK in solitaryBlockedBy. It +# stays set once the lock's client dies, which is the case worth detecting. +# +# Exits 0 locked, 1 unlocked, 2 undetermined. Hyprland stops at the first reason +# on a monitor with no workspace yet, before it ever reaches the lock, so a +# missing LOCK there means nothing was asked. Callers branching only on success +# treat 2 as unlocked. +monitors=$(hyprctl -j monitors 2>/dev/null) || exit 2 + +state=$(jq ' + def blockers: .solitaryBlockedBy // []; + def readable: blockers | index("WORKSPACE") | not; + + if any(.[]; blockers | index("LOCK")) then 0 + elif any(.[]; readable) then 1 + else 2 + end +' <<<"$monitors" 2>/dev/null) + +case $state in + 0 | 1) exit "$state" ;; + *) exit 2 ;; +esac diff --git a/bin/omarchy-launch-shell b/bin/omarchy-launch-shell index 3a6fb74a..e3e13d05 100755 --- a/bin/omarchy-launch-shell +++ b/bin/omarchy-launch-shell @@ -6,4 +6,80 @@ # Quickshell only logs to its instance runtime dir (tmpfs), so when the shell # dies the idle/lock event trail is gone after a reboot. The journal keeps it # across sessions, bounded and timestamped, under the omarchy-shell tag. -exec systemd-cat -t omarchy-shell -- quickshell -n -p "$OMARCHY_PATH/shell" +# +# Backgrounded because bash defers a trap until a foreground command returns but +# interrupts wait. systemd-cat execs, so the job is Quickshell itself. +run_shell() { + systemd-cat -t omarchy-shell -- quickshell -n -p "$OMARCHY_PATH/shell" & + shell_pid=$! + + local status + while true; do + wait "$shell_pid" + status=$? + + # An interrupted wait and a shell killed by that signal report alike. + kill -0 "$shell_pid" 2>/dev/null || break + done + + shell_pid="" + return $status +} + +# A compositor busy reconfiguring outputs can miss a query without being gone, +# and that is when the shell dies. +compositor_alive() { + local attempt + + for attempt in 1 2 3; do + hyprctl -j monitors >/dev/null 2>&1 && return 0 + (( attempt < 3 )) && sleep 0.5 + done + + return 1 +} + +# Quickshell relaunches itself from its signal handlers, but Qt leaves through +# _exit() when the Wayland connection fails, raising no signal: no crash report, +# no relaunch, no bar. Supervise those deaths. A clean exit is a deliberate stop +# (omarchy-restart-shell starts its own replacement); a signal here means the +# session is going, and has to reach the shell the launcher used to exec. +terminating=0 +shell_pid="" + +stop() { + terminating=1 + [[ -n $shell_pid ]] && kill -TERM "$shell_pid" 2>/dev/null + return 0 +} +trap stop HUP INT TERM + +attempts=0 +window_started=$SECONDS + +while true; do + # A signal during the backoff only reaches the trap once the sleep is over. + (( terminating )) && exit 0 + + run_shell + status=$? + + (( terminating )) && exit 0 + (( status == 0 )) && exit 0 + + # Relaunching into a session already tearing down burns the attempt budget. + compositor_alive || exit 0 + + if (( SECONDS - window_started > 60 )); then + attempts=0 + window_started=$SECONDS + fi + + if (( ++attempts > 5 )); then + logger -t omarchy-shell "Giving up on the Omarchy shell after $attempts relaunches in under a minute." + exit 1 + fi + + logger -t omarchy-shell "Omarchy shell exited with status $status; relaunching." + sleep 1 +done diff --git a/bin/omarchy-restart-shell b/bin/omarchy-restart-shell index 09b20d23..8211a1e6 100755 --- a/bin/omarchy-restart-shell +++ b/bin/omarchy-restart-shell @@ -26,7 +26,7 @@ fi # ask the lock service rather than merely pinging the shell: only a locker # that reports the lock secure or in progress is worth preserving. relock=0 -if [[ $(hyprctl -j monitors 2>/dev/null) == *'"LOCK"'* ]]; then +if omarchy-hyprland-session-locked; then locking=$(OMARCHY_PATH="$session_omarchy_path" OMARCHY_SHELL_IPC_TIMEOUT=0.5s omarchy-shell lock status 2>/dev/null | jq -r '.secure or .requested' 2>/dev/null) if [[ $locking == "true" ]]; then diff --git a/shell/plugins/lock/Service.qml b/shell/plugins/lock/Service.qml index b15a0821..84203a8f 100644 --- a/shell/plugins/lock/Service.qml +++ b/shell/plugins/lock/Service.qml @@ -31,6 +31,8 @@ Item { property int backgroundVersion: 0 property string lastEvent: "init" property string lastEventAt: "" + property bool strandedLock: false + property bool strandedLockResolved: false readonly property bool locked: lockRequested || sessionLock.locked || sessionLock.secure readonly property bool authenticating: authenticatingPassword || fingerprintAuthenticating @@ -74,6 +76,29 @@ Item { sessionLock.locked = true } + // ext-session-lock outlives its client, and a restart carries no lock over, so + // a session locked this early is an orphan behind Hyprland's failsafe. Outputs + // are often still absent here, so ask until the answer means something. + function checkStrandedLock() { + if (strandedLockResolved || strandedLockCheckProc.running) return + + // A lock this shell took is nobody's orphan. + if (locked || lockRequested) { + strandedLockResolved = true + return + } + + strandedLockCheckProc.running = true + } + + function recoverStrandedLock() { + if (!strandedLock || locked || !passwordPamConfigured) return + + strandedLock = false + logEvent("lock-stranded: recovering") + beginLock() + } + function refreshBackground() { if (!readlinkProc.running) readlinkProc.running = true } @@ -361,6 +386,21 @@ Item { } } + Process { + id: strandedLockCheckProc + command: ["bash", "-c", "omarchy-hyprland-session-locked"] + onExited: function(exitCode) { + // No output to read the lock off yet. + if (exitCode === 2) return + + root.strandedLockResolved = true + + // A lock taken while this was in flight is this shell's own. + root.strandedLock = exitCode === 0 && !root.locked && !root.lockRequested + root.recoverStrandedLock() + } + } + Process { id: wakeProcess command: ["bash", "-c", "omarchy-system-wake"] @@ -402,9 +442,34 @@ Item { onTriggered: root.requestSessionLock() } + Timer { + id: strandedLockRetryTimer + interval: 500 + repeat: true + // Covers the compositor settling; screens coming back re-arm it. + readonly property int budget: 20 + property int remaining: 20 + running: !root.strandedLockResolved && remaining > 0 + + function rearm() { + if (!root.strandedLockResolved) remaining = budget + } + + onTriggered: { + remaining -= 1 + root.checkStrandedLock() + } + } + Connections { target: Quickshell - function onScreensChanged() { root.requestSessionLock() } + function onScreensChanged() { + root.requestSessionLock() + + // A monitor still coming up has no workspace, so cannot answer yet. + strandedLockRetryTimer.rearm() + root.checkStrandedLock() + } } onAuthenticatingChanged: { @@ -422,9 +487,21 @@ Item { onFileChanged: reload() } + // No lock before PAM is known good. An answer from before then may be stale -- + // the failsafe can be cleared from a TTY -- so re-ask rather than act on it. + onPasswordPamConfiguredChanged: { + if (!passwordPamConfigured) return + + strandedLock = false + strandedLockResolved = false + strandedLockRetryTimer.rearm() + checkStrandedLock() + } + Component.onCompleted: { refreshBackground() refreshFingerprintStatus() + checkStrandedLock() } IpcHandler { diff --git a/test/shell.d/hyprland-session-locked-test.sh b/test/shell.d/hyprland-session-locked-test.sh new file mode 100755 index 00000000..b541f984 --- /dev/null +++ b/test/shell.d/hyprland-session-locked-test.sh @@ -0,0 +1,84 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +require_command jq + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +fake_bin="$test_tmp/bin" +mkdir -p "$fake_bin" + +cat >"$fake_bin/hyprctl" <<'SH' +#!/bin/bash + +[[ ${1:-} == "-j" && ${2:-} == "monitors" ]] || exit 1 +[[ ${OMARCHY_TEST_HYPRCTL_FAILS:-0} == 1 ]] && exit 4 +printf '%s\n' "$OMARCHY_TEST_MONITORS" +SH +chmod +x "$fake_bin/hyprctl" + +LOCKED=0 +UNLOCKED=1 +UNDETERMINED=2 + +assert_status() { + local expected="$1" monitors="$2" description="$3" hyprctl_fails="${4:-0}" actual=0 + + PATH="$fake_bin:$PATH" \ + OMARCHY_TEST_MONITORS="$monitors" \ + OMARCHY_TEST_HYPRCTL_FAILS="$hyprctl_fails" \ + "$ROOT/bin/omarchy-hyprland-session-locked" || actual=$? + + (( actual == expected )) || fail "$description" "expected exit $expected, got $actual" + pass "$description" +} + +# LOCK in solitaryBlockedBy is how an active ext-session-lock shows up. +assert_status $LOCKED \ + '[{"name":"HDMI-A-1","solitaryBlockedBy":["WINDOWED","LOCK","CANDIDATE"]}]' \ + "a locked session is detected" + +assert_status $LOCKED \ + '[{"name":"eDP-1","solitaryBlockedBy":["WINDOWED"]},{"name":"DP-2","solitaryBlockedBy":["LOCK"]}]' \ + "a lock on any monitor counts as a locked session" + +assert_status $UNLOCKED \ + '[{"name":"HDMI-A-1","solitaryBlockedBy":["WINDOWED","CANDIDATE"]}]' \ + "an unlocked session is reported as unlocked" + +# A monitor showing a solitary client has no blockers at all. +assert_status $UNLOCKED \ + '[{"name":"HDMI-A-1","solitaryBlockedBy":null}]' \ + "a monitor with no solitary blockers is reported as unlocked" + +# LOCK only carries this meaning inside the reason list. +assert_status $UNLOCKED \ + '[{"name":"LOCK-1","description":"LOCK display","activeWorkspace":{"name":"LOCK"},"solitaryBlockedBy":["WINDOWED"]}]' \ + "LOCK elsewhere in the monitor payload is not a locked session" + +# Guessing "unlocked" with nothing to read strands the session. +assert_status $UNDETERMINED '[]' \ + "a session with no monitors to read cannot say" + +assert_status $UNDETERMINED '[]' \ + "an unreachable compositor cannot say" 1 + +assert_status $UNDETERMINED 'not json at all' \ + "an unreadable monitor payload cannot say" + +# Hyprland stops at the first reason and never reaches the lock check. +assert_status $UNDETERMINED \ + '[{"name":"HDMI-A-1","solitaryBlockedBy":["WORKSPACE"]}]' \ + "a monitor with no workspace yet cannot say" + +assert_status $UNLOCKED \ + '[{"name":"HDMI-A-1","solitaryBlockedBy":["WORKSPACE"]},{"name":"DP-2","solitaryBlockedBy":["WINDOWED"]}]' \ + "one readable monitor is enough to answer" + +assert_status $LOCKED \ + '[{"name":"HDMI-A-1","solitaryBlockedBy":["WORKSPACE"]},{"name":"DP-2","solitaryBlockedBy":["LOCK"]}]' \ + "a lock is still found alongside a monitor that cannot say" diff --git a/test/shell.d/launch-shell-test.sh b/test/shell.d/launch-shell-test.sh new file mode 100755 index 00000000..695000db --- /dev/null +++ b/test/shell.d/launch-shell-test.sh @@ -0,0 +1,189 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +test_tmp=$(mktemp -d) +launch_pid="" + +# A supervisor that fails to stop would hang the run instead of failing it. +cleanup() { + if [[ -n $launch_pid ]]; then + pkill -TERM -P "$launch_pid" 2>/dev/null || true + kill -KILL "$launch_pid" 2>/dev/null || true + wait "$launch_pid" 2>/dev/null || true + fi + rm -rf "$test_tmp" +} +trap cleanup EXIT + +fake_bin="$test_tmp/bin" +shell_root="$test_tmp/root" +mkdir -p "$fake_bin" "$shell_root/shell" + +# Each launch consumes the next status from OMARCHY_TEST_QS_STATUSES; "run" +# stands in for a healthy shell that keeps going until stopped. +cat >"$fake_bin/quickshell" <<'SH' +#!/bin/bash + +printf '%s\n' "$*" >>"$OMARCHY_TEST_QS_LOG" + +launches=$(wc -l <"$OMARCHY_TEST_QS_LOG") +status=$(awk -v n="$launches" 'NR == n { print; found = 1 } END { if (!found) print "0" }' <<<"$OMARCHY_TEST_QS_STATUSES") + +if [[ $status == "run" ]]; then + trap 'touch "$OMARCHY_TEST_QS_TERMINATED"; exit 143' TERM + while true; do sleep 0.05; done +fi + +exit "${status:-0}" +SH + +cat >"$fake_bin/systemd-cat" <<'SH' +#!/bin/bash + +while (( $# > 0 )); do + [[ $1 == "--" ]] && { shift; break; } + shift +done +exec "$@" +SH + +cat >"$fake_bin/hyprctl" <<'SH' +#!/bin/bash + +[[ ${OMARCHY_TEST_COMPOSITOR_GONE:-0} == 1 ]] && exit 4 + +# Refuse the first OMARCHY_TEST_HYPRCTL_MISSES queries, then answer. +if (( ${OMARCHY_TEST_HYPRCTL_MISSES:-0} > 0 )); then + misses=$(cat "$OMARCHY_TEST_HYPRCTL_MISS_COUNT" 2>/dev/null || printf '0') + if (( misses < OMARCHY_TEST_HYPRCTL_MISSES )); then + printf '%s\n' "$(( misses + 1 ))" >"$OMARCHY_TEST_HYPRCTL_MISS_COUNT" + exit 4 + fi +fi + +printf '[]\n' +SH + +cat >"$fake_bin/logger" <<'SH' +#!/bin/bash + +shift 2 +printf '%s\n' "$*" >>"$OMARCHY_TEST_LOGGER_LOG" +SH + +chmod +x "$fake_bin/quickshell" "$fake_bin/systemd-cat" "$fake_bin/hyprctl" "$fake_bin/logger" + +qs_log="$test_tmp/quickshell.log" +logger_log="$test_tmp/logger.log" +qs_terminated="$test_tmp/quickshell-terminated" +hyprctl_misses="$test_tmp/hyprctl-misses" + +launch_shell() { + : >"$qs_log" + : >"$logger_log" + + PATH="$fake_bin:$PATH" \ + OMARCHY_PATH="$shell_root" \ + OMARCHY_TEST_QS_LOG="$qs_log" \ + OMARCHY_TEST_QS_STATUSES="$1" \ + OMARCHY_TEST_COMPOSITOR_GONE="${2:-0}" \ + OMARCHY_TEST_LOGGER_LOG="$logger_log" \ + OMARCHY_TEST_QS_TERMINATED="$qs_terminated" \ + OMARCHY_TEST_HYPRCTL_MISSES="${3:-0}" \ + OMARCHY_TEST_HYPRCTL_MISS_COUNT="$hyprctl_misses" \ + timeout 30 "$ROOT/bin/omarchy-launch-shell" +} + +launches() { + wc -l <"$qs_log" | tr -d ' ' +} + +launch_shell '0' || fail "a clean launch succeeds" +[[ $(launches) == 1 ]] || fail "a shell that exits cleanly is not relaunched" "$(<"$qs_log")" +grep -F -- "-n -p $shell_root/shell" "$qs_log" >/dev/null || fail "the shell launches from OMARCHY_PATH" +pass "a shell that exits cleanly is left alone" + +# Qt leaves through _exit(), so Quickshell's crash handler never relaunches it. +launch_shell $'255\n0' || fail "a shell that died on a Wayland error is relaunched" +[[ $(launches) == 2 ]] || fail "the dead shell is relaunched exactly once" "$(<"$qs_log")" +grep -F 'exited with status 255' "$logger_log" >/dev/null || fail "the relaunch is recorded in the journal" +pass "a shell that dies without a signal is relaunched" + +launch_shell $'255\n255\n255\n255\n255\n255\n255\n255' && fail "a shell that keeps dying is given up on" +[[ $(launches) == 6 ]] || fail "relaunches stop after the attempt budget" "$(<"$qs_log")" +grep -F 'Giving up' "$logger_log" >/dev/null || fail "giving up is recorded in the journal" +pass "a shell that keeps dying is not relaunched forever" + +# The compositor takes the shell with it, and the session is already going. +launch_shell $'255\n0' 1 || fail "a shell outliving the compositor exits cleanly" +[[ $(launches) == 1 ]] || fail "the shell is not relaunched into a dead session" "$(<"$qs_log")" +pass "the shell is not relaunched once the compositor is gone" + +# A compositor mid-modeset can miss a query without being gone. +rm -f "$hyprctl_misses" +launch_shell $'255\n0' 0 2 || fail "a shell survives a compositor that misses a query" +[[ $(launches) == 2 ]] || fail "a missed compositor query does not end supervision" "$(<"$qs_log")" +pass "a compositor too busy to answer is not mistaken for one that is gone" + +# A signal mid-backoff only reaches the trap once the sleep is over. +: >"$qs_log" +: >"$logger_log" + +PATH="$fake_bin:$PATH" \ +OMARCHY_PATH="$shell_root" \ +OMARCHY_TEST_QS_LOG="$qs_log" \ +OMARCHY_TEST_QS_STATUSES=$'255\n0' \ +OMARCHY_TEST_COMPOSITOR_GONE=0 \ +OMARCHY_TEST_LOGGER_LOG="$logger_log" \ +OMARCHY_TEST_QS_TERMINATED="$qs_terminated" \ + "$ROOT/bin/omarchy-launch-shell" & +launch_pid=$! + +for (( waited = 0; waited < 100; waited++ )); do + [[ $(launches) == 1 ]] && break + sleep 0.05 +done +[[ $(launches) == 1 ]] || fail "the supervised shell launched before the signal" "$(<"$qs_log")" + +kill -TERM "$launch_pid" +wait "$launch_pid" || fail "a signalled supervisor exits cleanly" +launch_pid="" +[[ $(launches) == 1 ]] || fail "the shell is not relaunched after the session asked to stop" "$(<"$qs_log")" +pass "a signal during backoff stops the supervisor before it relaunches" + +# Stopping the launcher used to stop the shell, back when it exec'd Quickshell. +: >"$qs_log" +: >"$logger_log" +rm -f "$qs_terminated" + +PATH="$fake_bin:$PATH" \ +OMARCHY_PATH="$shell_root" \ +OMARCHY_TEST_QS_LOG="$qs_log" \ +OMARCHY_TEST_QS_STATUSES='run' \ +OMARCHY_TEST_COMPOSITOR_GONE=0 \ +OMARCHY_TEST_LOGGER_LOG="$logger_log" \ +OMARCHY_TEST_QS_TERMINATED="$qs_terminated" \ + "$ROOT/bin/omarchy-launch-shell" & +launch_pid=$! + +for (( waited = 0; waited < 100; waited++ )); do + [[ $(launches) == 1 ]] && break + sleep 0.05 +done +[[ $(launches) == 1 ]] || fail "the healthy shell launched before the signal" "$(<"$qs_log")" + +kill -TERM "$launch_pid" +for (( waited = 0; waited < 100; waited++ )); do + kill -0 "$launch_pid" 2>/dev/null || break + sleep 0.05 +done +kill -0 "$launch_pid" 2>/dev/null && fail "a signalled supervisor stops instead of waiting on a live shell" +wait "$launch_pid" 2>/dev/null || true +launch_pid="" + +[[ -f $qs_terminated ]] || fail "the running shell is signalled when the supervisor is" +[[ $(launches) == 1 ]] || fail "the signalled shell is not relaunched" "$(<"$qs_log")" +pass "stopping the supervisor stops the shell it is watching" diff --git a/test/shell.d/lock-stranded-recovery-test.sh b/test/shell.d/lock-stranded-recovery-test.sh new file mode 100755 index 00000000..dec67038 --- /dev/null +++ b/test/shell.d/lock-stranded-recovery-test.sh @@ -0,0 +1,89 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +run_node_test <<'JS' +const fs = require('fs') +const serviceQml = fs.readFileSync(path.join(root, 'shell/plugins/lock/Service.qml'), 'utf8') + +// The compositor holds the lock past its client, so a fresh shell must retake it. +assert( + /Component\.onCompleted:[\s\S]*checkStrandedLock\(\)/.test(serviceQml), + 'the lock service asks the compositor whether the session is locked at startup' +) + +assert( + /id: strandedLockCheckProc[\s\S]*omarchy-hyprland-session-locked/.test(serviceQml), + 'the startup check goes through the shared session lock helper' +) + +// "No output to read" taken for "unlocked" leaves the failsafe up for good. +assert( + /onExited: function\(exitCode\) \{[\s\S]*if \(exitCode === 2\) return/.test(serviceQml), + 'an undetermined answer never resolves the check' +) + +assert( + /if \(exitCode === 2\) return\s*\n\s*root\.strandedLockResolved = true/.test(serviceQml), + 'only a compositor that reports a lock counts as a stranded lock' +) + +// omarchy-restart-shell re-locks a fresh shell, possibly mid-question. +assert( + /root\.strandedLock = exitCode === 0 && !root\.locked && !root\.lockRequested/.test(serviceQml), + 'a lock this shell took while the check was in flight is not stranded' +) + +assert( + /id: strandedLockRetryTimer[\s\S]*running: !root\.strandedLockResolved && remaining > 0/.test(serviceQml), + 'the check retries while the compositor cannot answer, and stops once it has' +) + +// A display asleep for hours outlasts any retry budget. +assert( + /function onScreensChanged\(\) \{[\s\S]*root\.checkStrandedLock\(\)/.test(serviceQml), + 'a screen coming back re-asks whether a lock is stranded' +) + +// One probe is not enough: a monitor still coming up cannot answer. +assert( + /function onScreensChanged\(\) \{[\s\S]*strandedLockRetryTimer\.rearm\(\)[\s\S]*root\.checkStrandedLock\(\)/.test(serviceQml), + 'a screen coming back gives the check its settling time again' +) + +assert( + /function rearm\(\) \{\s*if \(!root\.strandedLockResolved\) remaining = budget/.test(serviceQml), + 're-arming never restarts a check that already has its answer' +) + +// A lock this shell owns ends the search. +assert( + /function checkStrandedLock\(\) \{\s*if \(strandedLockResolved \|\| strandedLockCheckProc\.running\) return[\s\S]*if \(locked \|\| lockRequested\) \{\s*strandedLockResolved = true/.test(serviceQml), + 'a lock this shell took is not treated as stranded' +) + +assert( + /function recoverStrandedLock\(\) \{\s*if \(!strandedLock \|\| locked \|\| !passwordPamConfigured\) return/.test(serviceQml), + 'recovery is skipped unless a stranded lock is waiting and PAM can authenticate it' +) + +// The compositor answer and the PAM config land asynchronously, in either +// order, so whichever arrives last has to drive the recovery. +assert( + /onPasswordPamConfiguredChanged: \{[\s\S]*checkStrandedLock\(\)/.test(serviceQml), + 'recovery retries once the PAM config has loaded' +) + +// The failsafe can be cleared from a TTY while PAM is still loading. +assert( + /onPasswordPamConfiguredChanged: \{\s*if \(!passwordPamConfigured\) return\s*\n\s*strandedLock = false\s*\n\s*strandedLockResolved = false/.test(serviceQml), + 'a late PAM config re-asks the compositor instead of trusting a stale answer' +) + +assert( + /strandedLock = false\s*\n\s*logEvent\("lock-stranded: recovering"\)\s*\n\s*beginLock\(\)/.test(serviceQml), + 'recovery takes the lock once and records it in the journal' +) +JS diff --git a/test/shell.d/monitor-recovery-test.sh b/test/shell.d/monitor-recovery-test.sh index ec5c1ae5..024fb2b4 100755 --- a/test/shell.d/monitor-recovery-test.sh +++ b/test/shell.d/monitor-recovery-test.sh @@ -82,6 +82,6 @@ pass "system wake resyncs clamshell display state" grep -F 'lock-pending: no-real-screen' "$lock_service" >/dev/null grep -F 'lock-pending: screen-stabilizing' "$lock_service" >/dev/null grep -F 'id: sessionLockStabilizeTimer' "$lock_service" >/dev/null -grep -F 'function onScreensChanged() { root.requestSessionLock() }' "$lock_service" >/dev/null +grep -Pzo 'function onScreensChanged\(\) \{\n(.*\n)*?\s*root\.requestSessionLock\(\)\n' "$lock_service" >/dev/null grep -F 'realScreens: root.realScreenCount()' "$lock_service" >/dev/null pass "lock service waits for stable real screens before session lock" diff --git a/test/shell.d/restart-shell-test.sh b/test/shell.d/restart-shell-test.sh index ab9f0c54..094c74f6 100755 --- a/test/shell.d/restart-shell-test.sh +++ b/test/shell.d/restart-shell-test.sh @@ -80,6 +80,7 @@ touch "$restart_root/shell/shell.qml" ln -s "$ROOT/bin/omarchy-shell" "$restart_bin/omarchy-shell" ln -s "$ROOT/bin/omarchy-launch-shell" "$restart_bin/omarchy-launch-shell" ln -s "$ROOT/bin/omarchy-cmd-missing" "$restart_bin/omarchy-cmd-missing" +ln -s "$ROOT/bin/omarchy-hyprland-session-locked" "$restart_bin/omarchy-hyprland-session-locked" cat >"$restart_bin/qs" <<'SH' #!/bin/bash @@ -131,10 +132,12 @@ cat >"$restart_bin/hyprctl" <<'SH' #!/bin/bash if [[ ${1:-} == "-j" && ${2:-} == "monitors" ]]; then + # Hyprland reports an active session lock as a reason the monitor cannot hand + # a client the whole screen, not as a workspace. if [[ ${OMARCHY_TEST_SESSION_LOCKED:-0} == 1 ]]; then - printf '[{"activeWorkspace":{"name":"LOCK"}}]\n' + printf '[{"name":"eDP-1","solitaryBlockedBy":["WINDOWED","LOCK","CANDIDATE"]}]\n' else - printf '[]\n' + printf '[{"name":"eDP-1","solitaryBlockedBy":["WINDOWED","CANDIDATE"]}]\n' fi elif [[ ${1:-} == "dispatch" && ${2:-} == hl.dsp.exec_cmd* ]]; then printf '%s\n' "${2:-}" >>"$OMARCHY_TEST_DISPATCH_LOG"