diff --git a/config/dot/quickshell/scripts/panama-doctor b/config/dot/quickshell/scripts/panama-doctor index 74850e7..d3b7e79 100755 --- a/config/dot/quickshell/scripts/panama-doctor +++ b/config/dot/quickshell/scripts/panama-doctor @@ -102,7 +102,7 @@ class RepairResult: CHECK_ORDER = ( "desktop.hyprland", "desktop.quickshell", "desktop.notifications", "desktop.portals", - "desktop.hyprpaper", "desktop.hypridle", "desktop.vicinae", "input.pipewire", + "desktop.hyprpaper", "desktop.hypridle", "desktop.hyprlock", "desktop.vicinae", "input.pipewire", "input.clipboard", "input.wallpaper", "input.capture", "input.ocr", "input.brightness", "integration.nextcloud", "integration.rustdesk", "integration.kdeconnect", "integration.bluebubbles", "integration.home-assistant", "integration.calendar", "panama.runtime-links", "panama.vicinae-commands", @@ -159,6 +159,7 @@ CHECK_TITLES = { "desktop.portals": "Desktop portals", "desktop.hyprpaper": "Hyprpaper", "desktop.hypridle": "Hypridle", + "desktop.hyprlock": "Lock screen", "desktop.vicinae": "Vicinae", "input.pipewire": "PipeWire", "input.clipboard": "Clipboard", @@ -320,6 +321,41 @@ def check_portals(config: DoctorConfig) -> Check: return Check("desktop.portals", "desktop-foundation", "Desktop portals", "warning", detail) +def check_hyprlock(config: DoctorConfig) -> Check: + helper = config.root / "config/dot/quickshell/scripts/panama-lock" + tracked_fallback = config.config_home / "hypr/hyprlock.conf" + generated_config = config.state_home / "panama/hyprlock.conf" + # The doctor seals PATH for every probe. Invoke the authored helper with a + # fixed system-only PATH so its bash shebang and jq dependency remain + # available without inheriting arbitrary parent executables. + result = run_command(("/usr/bin/env", "PATH=/usr/bin:/bin", str(helper), "status"), config) + + if result.state != "ok": + if tracked_fallback.is_file(): + return Check("desktop.hyprlock", "desktop-foundation", "Lock screen", "warning", "Managed lock-screen status is unavailable; the tracked fallback remains available.") + return Check("desktop.hyprlock", "desktop-foundation", "Lock screen", "error", "No usable lock-screen configuration is available.") + + try: + state = json.loads(result.stdout) + generated = state["generated"] + path = state["path"] + fallback = state["fallback"] + error = state["error"] + if not isinstance(generated, bool) or not isinstance(path, str) \ + or not isinstance(fallback, bool) or not isinstance(error, str): + raise ValueError + except (json.JSONDecodeError, KeyError, TypeError, ValueError): + if tracked_fallback.is_file(): + return Check("desktop.hyprlock", "desktop-foundation", "Lock screen", "warning", "Managed lock-screen status is invalid; the tracked fallback remains available.") + return Check("desktop.hyprlock", "desktop-foundation", "Lock screen", "error", "No usable lock-screen configuration is available.") + + if generated and not fallback and generated_config.is_file(): + return Check("desktop.hyprlock", "desktop-foundation", "Lock screen", "ok", "Managed lock-screen configuration is available.") + if tracked_fallback.is_file(): + return Check("desktop.hyprlock", "desktop-foundation", "Lock screen", "warning", "The tracked lock-screen fallback is in use.") + return Check("desktop.hyprlock", "desktop-foundation", "Lock screen", "error", "No usable lock-screen configuration is available.") + + def check_brightness(config: DoctorConfig) -> Check: result = run_command(("panama-brightness", "list"), config) instructions = Action("instructions", "View setup instructions", target="ddc-permissions") @@ -514,7 +550,7 @@ def unavailable_versions() -> list[dict[str, str]]: def collect_checks(config: DoctorConfig) -> list[Check]: probes: dict[str, Callable[[], Check]] = { "desktop.hyprland": lambda: check_hyprland(config), "desktop.quickshell": lambda: check_quickshell(config), "desktop.notifications": lambda: check_notifications(config), "desktop.portals": lambda: check_portals(config), - "desktop.hyprpaper": lambda: service_check("desktop.hyprpaper", "Hyprpaper", "hyprpaper", config, Action("repair", "Restart Hyprpaper")), "desktop.hypridle": lambda: service_check("desktop.hypridle", "Hypridle", "hypridle", config, Action("repair", "Restart Hypridle")), "desktop.vicinae": lambda: service_check("desktop.vicinae", "Vicinae", "vicinae", config, Action("repair", "Restart Vicinae")), "input.pipewire": lambda: service_check("input.pipewire", "PipeWire", "pipewire", config), + "desktop.hyprpaper": lambda: service_check("desktop.hyprpaper", "Hyprpaper", "hyprpaper", config, Action("repair", "Restart Hyprpaper")), "desktop.hypridle": lambda: service_check("desktop.hypridle", "Hypridle", "hypridle", config, Action("repair", "Restart Hypridle")), "desktop.hyprlock": lambda: check_hyprlock(config), "desktop.vicinae": lambda: service_check("desktop.vicinae", "Vicinae", "vicinae", config, Action("repair", "Restart Vicinae")), "input.pipewire": lambda: service_check("input.pipewire", "PipeWire", "pipewire", config), "input.clipboard": lambda: simple_ipc_check("input.clipboard", "Clipboard", "clipboard", config), "input.wallpaper": lambda: simple_ipc_check("input.wallpaper", "Wallpaper", "wallpaper", config), "input.capture": lambda: simple_ipc_check("input.capture", "Capture", "capture", config), "input.ocr": lambda: executable_check("input.ocr", "OCR", "tesseract", config), "input.brightness": lambda: check_brightness(config), "integration.nextcloud": lambda: check_nextcloud(config), "integration.rustdesk": lambda: check_rustdesk(config), "integration.kdeconnect": lambda: check_kdeconnect(config), "integration.bluebubbles": lambda: check_bluebubbles(config), "integration.home-assistant": lambda: check_home_assistant(config), "integration.calendar": lambda: check_calendar(config), "panama.runtime-links": lambda: check_runtime_links(config), "panama.vicinae-commands": lambda: check_vicinae_commands(config), "panama.selected-terminal": lambda: executable_check("panama.selected-terminal", "Selected terminal", "kitty", config), "panama.selected-launcher": lambda: executable_check("panama.selected-launcher", "Selected launcher", "vicinae", config), "panama.processes": lambda: check_processes(config), "panama.caffeine": lambda: check_caffeine(config), diff --git a/config/dot/quickshell/services/SettingsBackup.qml b/config/dot/quickshell/services/SettingsBackup.qml index b5ed82e..58a2868 100644 --- a/config/dot/quickshell/services/SettingsBackup.qml +++ b/config/dot/quickshell/services/SettingsBackup.qml @@ -50,6 +50,8 @@ Singleton { return String(DesktopPreferences.get("wallpaperPath") ?? ""); } property var applyWallpaper: function(path) { Wallpaper.set(path); } + property var regenerateLock: function() { LockScreen.regenerate(); } + property var lockBusy: function() { return LockScreen.busy; } property var reloadShell: function() { Quickshell.reload(false); } property var protectedDisplays: ({}) @@ -119,6 +121,7 @@ Singleton { root.applyCompositor(); root.reloadKeybinds(); root.applyWallpaper(root.currentWallpaper()); + root.regenerateLock(); settleReload.attempts = 0; settleReload.restart(); @@ -135,7 +138,7 @@ Singleton { // Let the current instances finish their external writes before a // soft reload replaces them. The cap keeps a failed external tool // from leaving restored Home state stale indefinitely. - if ((!root.keybindsReloading() && !root.systemBusy()) || attempts >= 30) { + if ((!root.keybindsReloading() && !root.systemBusy() && !root.lockBusy()) || attempts >= 30) { stop(); root.setDisplayBlocked(false); root.protectedDisplays = ({}); diff --git a/config/dot/quickshell/services/SystemSettings.qml b/config/dot/quickshell/services/SystemSettings.qml index 54beb3b..4520c40 100644 --- a/config/dot/quickshell/services/SystemSettings.qml +++ b/config/dot/quickshell/services/SystemSettings.qml @@ -42,6 +42,8 @@ Singleton { property var reloadKeybinds: function() { Keybinds.applyReload(); } property var keybindsReloading: function() { return Keybinds.reloading; } property var applyWallpaper: function(path) { Wallpaper.set(path); } + property var regenerateLock: function() { LockScreen.regenerate(); } + property var lockBusy: function() { return LockScreen.busy; } readonly property bool busy: monitorQuery.running || serviceQuery.running || versionQuery.running || configWrite.running || configVerify.running || bluebubblesQuery.running @@ -537,6 +539,7 @@ Singleton { root.applyPersistedDisplayPolicy(); root.reloadKeybinds(); root.applyWallpaper(String(DesktopPreferences.get("wallpaperPath") ?? "")); + root.regenerateLock(); resetRelease.attempts = 0; resetRelease.restart(); } @@ -549,7 +552,7 @@ Singleton { repeat: true onTriggered: { attempts++; - if ((!root.keybindsReloading() && !root.busy) || attempts >= 50) { + if ((!root.keybindsReloading() && !root.busy && !root.lockBusy()) || attempts >= 50) { stop(); root.setDisplayBlocked(false); } diff --git a/config/dot/quickshell/settings-backup-harness.qml b/config/dot/quickshell/settings-backup-harness.qml index 37cb0de..8f06a4b 100644 --- a/config/dot/quickshell/settings-backup-harness.qml +++ b/config/dot/quickshell/settings-backup-harness.qml @@ -63,6 +63,8 @@ ShellRoot { SettingsBackup.systemBusy = function() { return false; }; SettingsBackup.currentWallpaper = function() { return "/tmp/restored-wallpaper.jpg"; }; SettingsBackup.applyWallpaper = function(path) { root.record("wallpaper.set:" + path); }; + SettingsBackup.regenerateLock = function() { root.record("lock.regenerate"); }; + SettingsBackup.lockBusy = function() { return false; }; SettingsBackup.reloadShell = function() { root.record("shell.reload"); }; } diff --git a/config/dot/quickshell/settings-system-harness.qml b/config/dot/quickshell/settings-system-harness.qml index 6c563ae..a500fc6 100644 --- a/config/dot/quickshell/settings-system-harness.qml +++ b/config/dot/quickshell/settings-system-harness.qml @@ -46,6 +46,8 @@ ShellRoot { SystemSettings.reloadKeybinds = function() { root.recordReset("keybinds.reload"); }; SystemSettings.keybindsReloading = function() { return false; }; SystemSettings.applyWallpaper = function(path) { root.recordReset("wallpaper.set:" + path); }; + SystemSettings.regenerateLock = function() { root.recordReset("lock.regenerate"); }; + SystemSettings.lockBusy = function() { return false; }; } IpcHandler { diff --git a/tests/quickshell/lock-screen-service-contract.sh b/tests/quickshell/lock-screen-service-contract.sh index baf914b..e586a29 100755 --- a/tests/quickshell/lock-screen-service-contract.sh +++ b/tests/quickshell/lock-screen-service-contract.sh @@ -76,6 +76,7 @@ chmod +x "$config_path/scripts/panama-lock" printf '%s\n' '{"generated":true,"path":"/fixture/generated-hyprlock.conf","fallback":false,"error":""}' \ >"$status_response" +: >"$generate_log" qs_for_harness() { if [[ "$harness_pid" =~ ^[0-9]+$ && "${1:-}" == "ipc" ]]; then @@ -113,7 +114,15 @@ 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)" +# Component startup intentionally schedules one generation. Let that and any +# preference-file load revision settle before measuring only the burst below. +sleep 0.6 +for _ in $(seq 1 30); do + startup="$(qs_for_harness ipc call lock-screen-test status)" + [[ "$(jq -r '.busy' <<<"$startup")" == false ]] && break + sleep 0.1 +done +baseline_generations="$(wc -l <"$generate_log")" 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)" diff --git a/tests/quickshell/panama-doctor-contract.sh b/tests/quickshell/panama-doctor-contract.sh index b0dfccd..eb6761a 100755 --- a/tests/quickshell/panama-doctor-contract.sh +++ b/tests/quickshell/panama-doctor-contract.sh @@ -90,6 +90,11 @@ for name in hypr quickshell uwsm vicinae; do ln -s "$repo_dir/config/dot/$name" "$config_home/$name" done ln -s "$repo_dir/config/local/share/vicinae/scripts" "$data_home/vicinae/scripts/panama" +mkdir -p "$state_home/panama" +printf '%s\n' 'background { path = /fixture-secret-wallpaper.jpg }' \ + >"$state_home/panama/hyprlock.conf" +printf '%s\n' '{"generated":true,"path":"/fixture-secret-wallpaper.jpg","fallback":false,"error":""}' \ + >"$state_home/panama/hyprlock-status.json" run_doctor() { HOME="$home" \ @@ -107,7 +112,7 @@ run_doctor() { /usr/bin/python3 "$doctor" "$@" } -expected_order=$'desktop.hyprland\ndesktop.quickshell\ndesktop.notifications\ndesktop.portals\ndesktop.hyprpaper\ndesktop.hypridle\ndesktop.vicinae\ninput.pipewire\ninput.clipboard\ninput.wallpaper\ninput.capture\ninput.ocr\ninput.brightness\nintegration.nextcloud\nintegration.rustdesk\nintegration.kdeconnect\nintegration.bluebubbles\nintegration.home-assistant\nintegration.calendar\npanama.runtime-links\npanama.vicinae-commands\npanama.selected-terminal\npanama.selected-launcher\npanama.processes\npanama.caffeine' +expected_order=$'desktop.hyprland\ndesktop.quickshell\ndesktop.notifications\ndesktop.portals\ndesktop.hyprpaper\ndesktop.hypridle\ndesktop.hyprlock\ndesktop.vicinae\ninput.pipewire\ninput.clipboard\ninput.wallpaper\ninput.capture\ninput.ocr\ninput.brightness\nintegration.nextcloud\nintegration.rustdesk\nintegration.kdeconnect\nintegration.bluebubbles\nintegration.home-assistant\nintegration.calendar\npanama.runtime-links\npanama.vicinae-commands\npanama.selected-terminal\npanama.selected-launcher\npanama.processes\npanama.caffeine' assert_schema_and_redaction() { local snapshot="$1" @@ -116,8 +121,8 @@ assert_schema_and_redaction() { and (.summary.status | IN("healthy", "warning", "error")) and (.context.session | IN("hyprland", "other")) and (.context.versions | type == "array") - and ([.checks[].id] | length == 25) - and ([.checks[].id] | unique | length == 25) + and ([.checks[].id] | length == 26) + and ([.checks[].id] | unique | length == 26) and ([.checks[].status] | all(IN("ok", "warning", "error", "unconfigured")))' \ >/dev/null <<<"$snapshot" || fail "invalid schema: $snapshot" [[ "$(jq -r '.checks[].id' <<<"$snapshot")" == "$expected_order" ]] \ @@ -139,6 +144,21 @@ check_status() { snapshot="$(run_doctor --json)" assert_schema_and_redaction "$snapshot" check_status "$snapshot" panama.vicinae-commands ok +check_status "$snapshot" desktop.hyprlock ok + +printf '%s\n' '{"generated":false,"path":"/fixture-secret-wallpaper.jpg","fallback":true,"error":"fixture-secret-token"}' \ + >"$state_home/panama/hyprlock-status.json" +fallback_lock="$(run_doctor --json)" +check_status "$fallback_lock" desktop.hyprlock warning +assert_schema_and_redaction "$fallback_lock" + +rm "$config_home/hypr" +missing_lock="$(run_doctor --json)" +check_status "$missing_lock" desktop.hyprlock error +assert_schema_and_redaction "$missing_lock" +ln -s "$repo_dir/config/dot/hypr" "$config_home/hypr" +printf '%s\n' '{"generated":true,"path":"/fixture-secret-wallpaper.jpg","fallback":false,"error":""}' \ + >"$state_home/panama/hyprlock-status.json" # The diagnostic follows the actual installer contract: the scripts parent is # a directory and only its Panama child is an authored link. diff --git a/tests/quickshell/settings-backup-live-contract.sh b/tests/quickshell/settings-backup-live-contract.sh index b6d2322..975d28d 100755 --- a/tests/quickshell/settings-backup-live-contract.sh +++ b/tests/quickshell/settings-backup-live-contract.sh @@ -48,6 +48,7 @@ for mapping in \ 'SystemSettings.applyPersistedDisplayPolicy();' \ 'Keybinds.applyReload();' \ 'Wallpaper.set(path);' \ + 'LockScreen.regenerate();' \ 'Quickshell.reload(false);'; do rg -Fq "$mapping" "$service" || fail "production restore seam is missing: $mapping" done @@ -88,6 +89,7 @@ jq -e ' "system.apply", "keybinds.reload", "wallpaper.set:/tmp/restored-wallpaper.jpg", + "lock.regenerate", "display.block:false", "shell.reload" ] @@ -126,6 +128,7 @@ jq -e ' "system.apply", "keybinds.reload", "wallpaper.set:/tmp/restored-wallpaper.jpg", + "lock.regenerate", "display.block:false", "shell.reload" ] diff --git a/tests/quickshell/settings-commit-reset-contract.sh b/tests/quickshell/settings-commit-reset-contract.sh index 21bf264..fcb9cea 100755 --- a/tests/quickshell/settings-commit-reset-contract.sh +++ b/tests/quickshell/settings-commit-reset-contract.sh @@ -45,6 +45,8 @@ rg -Fq 'Keybinds.applyReload();' "$system_settings" \ || fail 'restoreDefaults does not replay shipped keybindings' rg -Fq 'root.applyWallpaper(String(DesktopPreferences.get("wallpaperPath") ?? ""));' "$system_settings" \ || fail 'restoreDefaults does not visibly reapply the shipped wallpaper' +rg -Fq 'LockScreen.regenerate();' "$system_settings" \ + || fail 'restoreDefaults does not regenerate the shipped lock screen' rg -Fq 'const effectivePath = path === "" ? root.shippedPath : path;' "$wallpaper_service" \ || fail 'clearing wallpaper preference leaves the old image visible' rg -Fq 'property string storedValue:' "$wallpaper_service" \ @@ -139,12 +141,24 @@ jq -e '.calls == [ "display.protect", "keybinds.reload", "wallpaper.set:", + "lock.regenerate", "display.block:false" ] and .displayBlocked == false' <<<"$reset_state" >/dev/null \ || fail "reset did not safely replay non-reactive state: $reset_state" [[ "$(qs_for_harness ipc call settings-system-test stored dockHideDelayMs)" == "250" ]] \ || fail 'reset did not restore a schema default' +for default_case in \ + 'lockBackgroundMode|"screenshot"' \ + 'lockBlurLevel|3' \ + 'lockShowClock|true' \ + 'lockShowDate|true' \ + 'lockShowUser|true' \ + 'lockFadeOnEmpty|false'; do + IFS='|' read -r key expected <<<"$default_case" + [[ "$(qs_for_harness ipc call settings-system-test stored "$key")" == "$expected" ]] \ + || fail "reset did not restore $key to $expected" +done [[ "$(qs_for_harness ipc call settings-system-test stored displays | jq -cS .)" == "$(jq -cS . <<<"$display_fixture")" ]] \ || fail 'reset replaced confirmed display geometry without confirmation'