diff --git a/config/dot/hypr/hypridle.conf b/config/dot/hypr/hypridle.conf index c191099..2832c06 100644 --- a/config/dot/hypr/hypridle.conf +++ b/config/dot/hypr/hypridle.conf @@ -15,7 +15,7 @@ general { # `pidof` guard prevents stacking lockers if this fires twice. - lock_cmd = pidof hyprlock || hyprlock + lock_cmd = pidof hyprlock || ~/.config/quickshell/scripts/panama-lock run before_sleep_cmd = loginctl lock-session after_sleep_cmd = hyprctl dispatch 'hl.dsp.dpms({ action = "on" })' diff --git a/config/dot/quickshell/lock-screen-harness.qml b/config/dot/quickshell/lock-screen-harness.qml new file mode 100644 index 0000000..d38e989 --- /dev/null +++ b/config/dot/quickshell/lock-screen-harness.qml @@ -0,0 +1,32 @@ +import Quickshell +import Quickshell.Io +import QtQuick + +import qs.config +import qs.services + +ShellRoot { + IpcHandler { + target: "lock-screen-test" + + function status(): string { + return JSON.stringify({ + generated: LockScreen.generated, + path: LockScreen.path, + fallback: LockScreen.fallback, + lastError: LockScreen.lastError, + busy: LockScreen.busy + }); + } + + function burst(): void { + DesktopPreferences.set("lockBlurLevel", 1); + DesktopPreferences.set("lockBlurLevel", 2); + DesktopPreferences.set("lockBlurLevel", 4); + } + + function refresh(): void { + LockScreen.refresh(); + } + } +} diff --git a/config/dot/quickshell/scripts/panama-idle b/config/dot/quickshell/scripts/panama-idle index f5cb8db..1f7bf8a 100755 --- a/config/dot/quickshell/scripts/panama-idle +++ b/config/dot/quickshell/scripts/panama-idle @@ -59,7 +59,7 @@ generate() { printf '# The shipped defaults live in the Panama repo at config/dot/hypr/hypridle.conf.\n\n' printf 'general {\n' - printf ' lock_cmd = pidof hyprlock || hyprlock\n' + printf ' lock_cmd = pidof hyprlock || ~/.config/quickshell/scripts/panama-lock run\n' if [[ "$lock_on_sleep" == "true" ]]; then printf ' before_sleep_cmd = loginctl lock-session\n' fi diff --git a/config/dot/quickshell/services/LockScreen.qml b/config/dot/quickshell/services/LockScreen.qml new file mode 100644 index 0000000..044743d --- /dev/null +++ b/config/dot/quickshell/services/LockScreen.qml @@ -0,0 +1,132 @@ +pragma Singleton + +// Generated lock-screen state. Visual preferences are coalesced into one +// helper invocation, while the last valid status remains visible if a helper +// response is malformed. + +import Quickshell +import Quickshell.Io +import QtQuick + +import qs.config + +Singleton { + id: root + + readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-lock" + + property bool generated: false + property string path: "" + property bool fallback: true + property string lastError: "" + property string watchedSignature: "" + property bool regenerateAfterCurrent: false + + readonly property bool busy: generateProcess.running || statusProcess.running + + function preferenceSignature(): string { + DesktopPreferences.revision; + return JSON.stringify([ + DesktopPreferences.get("lockBackgroundMode"), + DesktopPreferences.get("lockBlurLevel"), + DesktopPreferences.get("lockShowClock"), + DesktopPreferences.get("lockShowDate"), + DesktopPreferences.get("lockShowUser"), + DesktopPreferences.get("lockFadeOnEmpty"), + DesktopPreferences.get("use24Hour"), + DesktopPreferences.get("colorScheme"), + DesktopPreferences.get("wallpaperPath"), + DesktopPreferences.get("wallpaperMode"), + DesktopPreferences.get("wallpaperPerMonitor") + ]); + } + + function applyStatus(text: string): void { + try { + const state = JSON.parse(text); + if (!state || typeof state.generated !== "boolean" + || typeof state.path !== "string" || state.path.length === 0) + throw new Error("invalid lock status"); + root.generated = state.generated; + root.path = state.path; + root.fallback = state.fallback === true; + root.lastError = typeof state.error === "string" ? state.error : ""; + } catch (error) { + root.lastError = "The lock-screen configuration could not be read."; + } + } + + function refresh(): void { + if (!statusProcess.running) + statusProcess.exec([root.helperPath, "status"]); + } + + function regenerate(): void { + if (generateProcess.running) { + root.regenerateAfterCurrent = true; + return; + } + generateProcess.exec([root.helperPath, "generate"]); + } + + Process { + id: statusProcess + + property bool parsed: false + + onRunningChanged: { + if (running) + parsed = false; + } + + stdout: StdioCollector { + onStreamFinished: { + statusProcess.parsed = true; + root.applyStatus(this.text); + } + } + + onExited: (exitCode, exitStatus) => { + if (exitCode !== 0 && !statusProcess.parsed) + root.lastError = "The lock-screen configuration could not be read."; + } + } + + Process { + id: generateProcess + + onExited: (exitCode, exitStatus) => { + if (exitCode !== 0) + root.lastError = "The lock-screen configuration could not be generated."; + root.refresh(); + if (root.regenerateAfterCurrent) { + root.regenerateAfterCurrent = false; + regenerateTimer.restart(); + } + } + } + + Connections { + target: DesktopPreferences + + function onRevisionChanged(): void { + const signature = root.preferenceSignature(); + if (signature === root.watchedSignature) + return; + root.watchedSignature = signature; + regenerateTimer.restart(); + } + } + + Timer { + id: regenerateTimer + interval: 250 + onTriggered: root.regenerate() + } + + Component.onCompleted: { + root.watchedSignature = root.preferenceSignature(); + root.refresh(); + regenerateTimer.restart(); + } +} diff --git a/tests/quickshell/lock-screen-service-contract.sh b/tests/quickshell/lock-screen-service-contract.sh new file mode 100755 index 0000000..baf914b --- /dev/null +++ b/tests/quickshell/lock-screen-service-contract.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +shipped_idle="$repo_dir/config/dot/hypr/hypridle.conf" +idle_helper="$repo_dir/config/dot/quickshell/scripts/panama-idle" +fixture="$(mktemp -d /tmp/panama-lock-service.XXXXXX)" +config_home="$fixture/config" +state_home="$fixture/state" +config_path="$config_home/quickshell" +harness="$config_path/lock-screen-harness.qml" +test_bin="$fixture/bin" +generate_log="$fixture/generate.log" +status_response="$fixture/status.json" +shell_log="$fixture/quickshell.log" +harness_pid="" + +fail() { + printf 'lock screen service contract: %s\n' "$1" >&2 + [[ -s "$shell_log" ]] && sed -n '1,160p' "$shell_log" >&2 + exit 1 +} + +instances_for_harness() { + qs list --all 2>/dev/null | awk -v expected="$harness" ' + /^Instance / { pid = "" } + /^[[:space:]]*Process ID:/ { pid = $3 } + /^[[:space:]]*Config path:/ { + path = $0 + sub(/^[[:space:]]*Config path: /, "", path) + if (path == expected && pid ~ /^[0-9]+$/) print pid + } + ' +} + +cleanup() { + if [[ "$harness_pid" =~ ^[0-9]+$ ]] && kill -0 "$harness_pid" 2>/dev/null; then + kill "$harness_pid" 2>/dev/null || true + fi + rm -rf "$fixture" +} +trap cleanup EXIT + +expected_lock='lock_cmd = pidof hyprlock || ~/.config/quickshell/scripts/panama-lock run' +rg -Fxq " $expected_lock" "$shipped_idle" || fail 'shipped hypridle does not use panama-lock' + +mkdir -p "$config_home/panama" "$state_home" "$test_bin" +printf '%s\n' '{}' >"$config_home/panama/settings.json" +cat >"$test_bin/systemctl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +if [[ "$*" == *"is-active"* ]]; then printf 'active\n'; fi +EOF +chmod +x "$test_bin/systemctl" +HOME="$fixture/home" XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" \ + PATH="$test_bin:$PATH" "$idle_helper" apply +rg -Fxq " $expected_lock" "$state_home/panama/hypridle.conf" \ + || fail 'generated hypridle does not use panama-lock' + +cp -a "$repo_dir/config/dot/quickshell" "$config_path" +cat >"$config_path/scripts/panama-lock" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +case "${1:-}" in + generate) + printf 'generate\n' >>"$PANAMA_LOCK_TEST_GENERATE_LOG" + ;; + status) + cat "$PANAMA_LOCK_TEST_STATUS_RESPONSE" + ;; + *) exit 92 ;; +esac +EOF +chmod +x "$config_path/scripts/panama-lock" + +printf '%s\n' '{"generated":true,"path":"/fixture/generated-hyprlock.conf","fallback":false,"error":""}' \ + >"$status_response" + +qs_for_harness() { + if [[ "$harness_pid" =~ ^[0-9]+$ && "${1:-}" == "ipc" ]]; then + XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" \ + PANAMA_LOCK_TEST_GENERATE_LOG="$generate_log" \ + PANAMA_LOCK_TEST_STATUS_RESPONSE="$status_response" \ + qs -p "$harness" ipc --pid "$harness_pid" "${@:2}" + else + XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" \ + PANAMA_LOCK_TEST_GENERATE_LOG="$generate_log" \ + PANAMA_LOCK_TEST_STATUS_RESPONSE="$status_response" \ + qs -p "$harness" "$@" + fi +} + +qs_for_harness --daemonize >"$shell_log" 2>&1 || fail 'isolated harness did not launch' +for _ in $(seq 1 60); do + harness_pid="$(instances_for_harness | head -1)" + if [[ "$harness_pid" =~ ^[0-9]+$ ]] \ + && qs_for_harness ipc show 2>/dev/null | rg -q '^target lock-screen-test$'; then + break + fi + sleep 0.1 +done +[[ "$harness_pid" =~ ^[0-9]+$ ]] || fail 'isolated harness process did not start' +qs_for_harness ipc show 2>/dev/null | rg -q '^target lock-screen-test$' \ + || fail 'lock-screen-test IPC target did not register' + +for _ in $(seq 1 60); do + initial="$(qs_for_harness ipc call lock-screen-test status)" + [[ "$(jq -r '.busy' <<<"$initial")" == false ]] \ + && [[ "$(jq -r '.path' <<<"$initial")" == /fixture/generated-hyprlock.conf ]] && break + sleep 0.1 +done +jq -e '.generated == true and .fallback == false and .path == "/fixture/generated-hyprlock.conf" and .lastError == ""' \ + <<<"$initial" >/dev/null || fail "valid helper status was not accepted: $initial" + +baseline_generations="$(wc -l <"$generate_log" 2>/dev/null || printf 0)" +qs_for_harness ipc call lock-screen-test burst >/dev/null +for _ in $(seq 1 60); do + current_generations="$(wc -l <"$generate_log" 2>/dev/null || printf 0)" + (( current_generations >= baseline_generations + 1 )) && break + sleep 0.1 +done +sleep 0.35 +current_generations="$(wc -l <"$generate_log" 2>/dev/null || printf 0)" +[[ "$current_generations" -eq $(( baseline_generations + 1 )) ]] \ + || fail "three rapid preferences started $(( current_generations - baseline_generations )) generations" + +printf '%s\n' 'not json' >"$status_response" +qs_for_harness ipc call lock-screen-test refresh >/dev/null +for _ in $(seq 1 60); do + malformed="$(qs_for_harness ipc call lock-screen-test status)" + [[ "$(jq -r '.busy' <<<"$malformed")" == false ]] \ + && [[ "$(jq -r '.lastError' <<<"$malformed")" != "" ]] && break + sleep 0.1 +done +jq -e '.path == "/fixture/generated-hyprlock.conf" + and .lastError == "The lock-screen configuration could not be read."' \ + <<<"$malformed" >/dev/null || fail "malformed status replaced valid state: $malformed" + +if rg -n 'ReferenceError|TypeError|Binding loop|Unable to assign|Cannot assign' "$shell_log"; then + fail 'isolated harness emitted a QML runtime warning' +fi + +printf 'lock screen service contract: PASS\n'