From a845534110c03912ee11157cbf9667b4f024093b Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 14:06:53 -0400 Subject: [PATCH] Generate managed lock screen configuration --- .../quickshell/config/PreferenceSchema.qml | 34 ++ config/dot/quickshell/scripts/panama-lock | 336 ++++++++++++++++++ .../quickshell/lock-screen-helper-contract.sh | 152 ++++++++ 3 files changed, 522 insertions(+) create mode 100755 config/dot/quickshell/scripts/panama-lock create mode 100755 tests/quickshell/lock-screen-helper-contract.sh diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index 8861a9d..ac0dbd2 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -748,6 +748,40 @@ Singleton { detail: "Shown on every output" }, + // ── Lock-screen appearance ───────────────────────────────────────── + // scripts/panama-lock validates these again before generating a state + // config. The tracked hyprlock.conf remains the safe fallback. + { + key: "lockBackgroundMode", type: "enum", def: "screenshot", group: "lockAppearance", + label: "Background", detail: "What appears behind the lock screen", + options: [ + { value: "screenshot", label: "Blurred desktop" }, + { value: "wallpaper", label: "Current wallpaper" }, + { value: "solid", label: "Solid color" } + ] + }, + { + key: "lockBlurLevel", type: "int", def: 3, min: 0, max: 5, step: 1, + group: "lockAppearance", label: "Background blur", + detail: "Softens what is behind the password field" + }, + { + key: "lockShowClock", type: "bool", def: true, group: "lockAppearance", + label: "Show clock", detail: "Use the desktop's 12 or 24-hour format" + }, + { + key: "lockShowDate", type: "bool", def: true, group: "lockAppearance", + label: "Show date", detail: "Show the weekday and full date" + }, + { + key: "lockShowUser", type: "bool", def: true, group: "lockAppearance", + label: "Show user name", detail: "Identify the signed-in account" + }, + { + key: "lockFadeOnEmpty", type: "bool", def: false, group: "lockAppearance", + label: "Hide password field until typing", detail: "Keep the empty field out of the way" + }, + // ── Idle, lock, and sleep ─────────────────────────────────────────── // Written into a generated hypridle config; see scripts/panama-idle. // Zero means never for all three. diff --git a/config/dot/quickshell/scripts/panama-lock b/config/dot/quickshell/scripts/panama-lock new file mode 100755 index 0000000..f38ff66 --- /dev/null +++ b/config/dot/quickshell/scripts/panama-lock @@ -0,0 +1,336 @@ +#!/usr/bin/env bash + +# Generates Panama's hyprlock configuration into the state directory. The +# tracked config is never rewritten and remains the fallback if generation +# fails, so a malformed preference can never leave the session without a +# working locker. + +set -euo pipefail + +config_home="${XDG_CONFIG_HOME:-$HOME/.config}" +state_home="${XDG_STATE_HOME:-$HOME/.local/state}" +settings="$config_home/panama/settings.json" +state_dir="$state_home/panama" +generated="$state_dir/hyprlock.conf" +status_file="$state_dir/hyprlock-status.json" +fallback="$config_home/hypr/hyprlock.conf" +temporary="$generated.tmp.$$" +status_temporary="$status_file.tmp.$$" + +settings_valid=false + +cleanup() { + rm -f "$temporary" "$status_temporary" 2>/dev/null || true +} +trap cleanup EXIT + +read_string() { + local key="$1" default="$2" value + if [[ "$settings_valid" != true ]]; then + printf '%s' "$default" + return + fi + value="$(jq -er --arg key "$key" \ + 'if has($key) and (.[$key] | type) == "string" then .[$key] else empty end' \ + "$settings" 2>/dev/null)" || value="$default" + printf '%s' "$value" +} + +read_bool() { + local key="$1" default="$2" value + if [[ "$settings_valid" != true ]]; then + printf '%s' "$default" + return + fi + value="$(jq -er --arg key "$key" \ + 'if has($key) and (.[$key] | type) == "boolean" then (.[$key] | tostring) else empty end' \ + "$settings" 2>/dev/null)" || value="$default" + printf '%s' "$value" +} + +read_int() { + local key="$1" default="$2" low="$3" high="$4" value + if [[ "$settings_valid" != true ]]; then + printf '%s' "$default" + return + fi + value="$(jq -er --arg key "$key" \ + 'if has($key) and (.[$key] | type) == "number" and (.[$key] | floor) == .[$key] + then (.[$key] | tostring) else empty end' "$settings" 2>/dev/null)" || value="$default" + if [[ ! "$value" =~ ^-?[0-9]+$ ]] || (( value < low || value > high )); then + value="$default" + fi + printf '%s' "$value" +} + +read_object() { + local key="$1" + if [[ "$settings_valid" != true ]]; then + printf '{}' + return + fi + jq -c --arg key "$key" \ + 'if has($key) and (.[$key] | type) == "object" then .[$key] else {} end' \ + "$settings" 2>/dev/null || printf '{}' +} + +valid_path() { + [[ "$1" == /* && "$1" != *","* && "$1" != *$'\n'* ]] +} + +load_preferences() { + if [[ -r "$settings" ]] && jq -e 'type == "object"' "$settings" >/dev/null 2>&1; then + settings_valid=true + else + settings_valid=false + fi + + background_mode="$(read_string lockBackgroundMode screenshot)" + [[ "$background_mode" == screenshot || "$background_mode" == wallpaper || "$background_mode" == solid ]] \ + || background_mode=screenshot + + blur_level="$(read_int lockBlurLevel 3 0 5)" + show_clock="$(read_bool lockShowClock true)" + show_date="$(read_bool lockShowDate true)" + show_user="$(read_bool lockShowUser true)" + fade_on_empty="$(read_bool lockFadeOnEmpty false)" + use_24_hour="$(read_bool use24Hour false)" + + color_scheme="$(read_string colorScheme dark)" + [[ "$color_scheme" == dark || "$color_scheme" == light ]] || color_scheme=dark + + wallpaper_mode="$(read_string wallpaperMode single)" + [[ "$wallpaper_mode" == single || "$wallpaper_mode" == slideshow || "$wallpaper_mode" == per-monitor ]] \ + || wallpaper_mode=single + wallpaper_path="$(read_string wallpaperPath '')" + if ! valid_path "$wallpaper_path"; then + wallpaper_path="$HOME/Pictures/Wallpapers/faroe_islands.jpg" + fi + wallpaper_assignments="$(read_object wallpaperPerMonitor)" + + case "$blur_level" in + 0) blur_passes=0; blur_size=1 ;; + 1) blur_passes=1; blur_size=3 ;; + 2) blur_passes=2; blur_size=5 ;; + 3) blur_passes=3; blur_size=8 ;; + 4) blur_passes=4; blur_size=10 ;; + 5) blur_passes=5; blur_size=12 ;; + esac + + if [[ "$color_scheme" == light ]]; then + background_color='rgba(245, 246, 250, 1.0)' + foreground_color='rgba(55, 63, 87, 1.0)' + dim_color='rgba(111, 119, 151, 1.0)' + field_color='rgba(220, 223, 232, 0.88)' + else + background_color='rgba(34, 36, 54, 1.0)' + foreground_color='rgba(200, 211, 245, 1.0)' + dim_color='rgba(130, 139, 184, 1.0)' + field_color='rgba(46, 47, 61, 0.85)' + fi +} + +monitor_names() { + hyprctl -j monitors 2>/dev/null \ + | jq -r '.[]? | .name | select(type == "string") | select(test("^[A-Za-z0-9_.-]+$"))' \ + 2>/dev/null || true +} + +emit_background() { + local monitor="$1" path="$2" + printf 'background {\n' + printf ' monitor = %s\n' "$monitor" + if [[ -n "$path" ]]; then + printf ' path = %s\n' "$path" + fi + printf ' blur_passes = %s\n' "$blur_passes" + printf ' blur_size = %s\n' "$blur_size" + printf ' noise = 0.0117\n' + printf ' contrast = 0.9\n' + printf ' brightness = 0.8\n' + printf ' vibrancy = 0.17\n' + printf ' vibrancy_darkness = 0.05\n' + printf ' color = %s\n' "$background_color" + printf ' zindex = -1\n' + printf '}\n\n' +} + +emit_backgrounds() { + local output assigned saw_output=false + case "$background_mode" in + screenshot) + emit_background '' screenshot + ;; + solid) + emit_background '' '' + ;; + wallpaper) + while IFS= read -r output; do + [[ -n "$output" ]] || continue + saw_output=true + assigned="$wallpaper_path" + if [[ "$wallpaper_mode" == per-monitor ]]; then + candidate="$(jq -r --arg output "$output" \ + 'if has($output) and (.[$output] | type) == "string" then .[$output] else "" end' \ + <<<"$wallpaper_assignments" 2>/dev/null || true)" + if valid_path "$candidate"; then + assigned="$candidate" + fi + fi + emit_background "$output" "$assigned" + done < <(monitor_names) + if [[ "$saw_output" != true ]]; then + emit_background '' "$wallpaper_path" + fi + ;; + esac +} + +emit_config() { + printf '# Generated by Panama. Do not edit.\n\n' + printf 'general {\n' + printf ' hide_cursor = true\n' + printf ' fractional_scaling = 2\n' + printf ' screencopy_mode = 0\n' + printf ' fail_timeout = 2000\n' + printf '}\n\n' + printf 'auth {\n' + printf ' pam:enabled = true\n' + printf ' pam:module = hyprlock\n' + printf '}\n\n' + + emit_backgrounds + + if [[ "$show_clock" == true ]]; then + printf 'label {\n' + printf ' monitor =\n' + if [[ "$use_24_hour" == true ]]; then + printf ' text = cmd[update:1000] date +"%%H:%%M"\n' + else + printf ' text = cmd[update:1000] date +"%%-I:%%M"\n' + fi + printf ' color = %s\n' "$foreground_color" + printf ' font_size = 120\n' + printf ' font_family = Adwaita Sans Light\n' + printf ' position = 0, 260\n' + printf ' halign = center\n' + printf ' valign = center\n' + printf '}\n\n' + fi + + if [[ "$show_date" == true ]]; then + printf 'label {\n' + printf ' monitor =\n' + printf ' text = cmd[update:60000] date +"%%A, %%B %%-d"\n' + printf ' color = %s\n' "$dim_color" + printf ' font_size = 24\n' + printf ' font_family = Adwaita Sans\n' + printf ' position = 0, 160\n' + printf ' halign = center\n' + printf ' valign = center\n' + printf '}\n\n' + fi + + printf 'input-field {\n' + printf ' monitor =\n' + printf ' size = 340, 52\n' + printf ' position = 0, -40\n' + printf ' halign = center\n' + printf ' valign = center\n' + printf ' outline_thickness = 2\n' + printf ' rounding = 26\n' + printf ' outer_color = rgba(130, 170, 255, 0.9)\n' + printf ' inner_color = %s\n' "$field_color" + printf ' font_color = %s\n' "$foreground_color" + printf ' check_color = rgba(130, 170, 255, 1.0)\n' + printf ' fail_color = rgba(255, 117, 127, 1.0)\n' + printf ' dots_size = 0.25\n' + printf ' dots_spacing = 0.3\n' + printf ' dots_center = true\n' + printf ' placeholder_text = Password\n' + printf ' fail_text = $FAIL ($ATTEMPTS)\n' + printf ' fade_on_empty = %s\n' "$fade_on_empty" + printf ' hide_input = false\n' + printf '}\n\n' + + if [[ "$show_user" == true ]]; then + printf 'label {\n' + printf ' monitor =\n' + printf ' text = $USER\n' + printf ' color = %s\n' "$foreground_color" + printf ' font_size = 16\n' + printf ' font_family = Adwaita Sans\n' + printf ' position = 0, -110\n' + printf ' halign = center\n' + printf ' valign = center\n' + printf '}\n' + fi +} + +write_status() { + local generated_value="$1" path_value="$2" fallback_value="$3" error_value="$4" + mkdir -p "$state_dir" 2>/dev/null || return 0 + [[ -w "$state_dir" ]] || return 0 + jq -nc --argjson generated "$generated_value" --arg path "$path_value" \ + --argjson fallback "$fallback_value" --arg error "$error_value" \ + '{generated:$generated,path:$path,fallback:$fallback,error:$error}' \ + >"$status_temporary" 2>/dev/null || return 0 + mv "$status_temporary" "$status_file" 2>/dev/null || true +} + +generate() { + load_preferences + mkdir -p "$state_dir" || return 1 + if ! emit_config >"$temporary"; then + rm -f "$temporary" 2>/dev/null || true + write_status false "$fallback" true "The lock-screen configuration could not be generated." + return 1 + fi + if [[ ! -s "$temporary" ]] \ + || ! rg -q '^auth \{' "$temporary" \ + || ! rg -q '^background \{' "$temporary" \ + || ! rg -q '^input-field \{' "$temporary"; then + rm -f "$temporary" + write_status false "$fallback" true "The lock-screen configuration could not be generated." + return 1 + fi + if ! mv "$temporary" "$generated"; then + rm -f "$temporary" 2>/dev/null || true + write_status false "$fallback" true "The lock-screen configuration could not be generated." + return 1 + fi + write_status true "$generated" false "" +} + +status() { + if [[ -r "$status_file" ]] \ + && jq -e '.generated | type == "boolean"' "$status_file" >/dev/null 2>&1 \ + && jq -e '.path | type == "string"' "$status_file" >/dev/null 2>&1; then + jq -c '{generated,path,fallback:(.fallback == true),error:(.error // "")}' "$status_file" + elif [[ -s "$generated" ]]; then + jq -nc --arg path "$generated" '{generated:true,path:$path,fallback:false,error:""}' + else + jq -nc --arg path "$fallback" \ + '{generated:false,path:$path,fallback:true,error:"The generated lock-screen configuration is unavailable."}' + fi +} + +case "${1:-status}" in + generate) + generate + ;; + status) + status + ;; + run) + if generate >/dev/null 2>&1; then + exec hyprlock -c "$generated" + fi + write_status false "$fallback" true "The lock-screen configuration could not be generated." + exec hyprlock -c "$fallback" + ;; + *) + printf 'usage: panama-lock [generate|status|run]\n' >&2 + exit 2 + ;; +esac diff --git a/tests/quickshell/lock-screen-helper-contract.sh b/tests/quickshell/lock-screen-helper-contract.sh new file mode 100755 index 0000000..b3ec062 --- /dev/null +++ b/tests/quickshell/lock-screen-helper-contract.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +helper="$repo_dir/config/dot/quickshell/scripts/panama-lock" +schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml" +fixture="$(mktemp -d /tmp/panama-lock-helper.XXXXXX)" +fixture_home="$fixture/home" +config_home="$fixture/config" +state_home="$fixture/state" +test_bin="$fixture/bin" +settings="$config_home/panama/settings.json" +generated="$state_home/panama/hyprlock.conf" +hyprlock_log="$fixture/hyprlock.log" + +fail() { + printf 'lock screen helper contract: %s\n' "$1" >&2 + exit 1 +} + +cleanup() { + chmod 700 "$state_home/panama" 2>/dev/null || true + rm -rf "$fixture" +} +trap cleanup EXIT + +mkdir -p "$fixture_home/Pictures/Wallpapers" "$config_home/panama" \ + "$config_home/hypr" "$state_home" "$test_bin" +cp "$repo_dir/config/dot/hypr/hyprlock.conf" "$config_home/hypr/hyprlock.conf" + +cat >"$test_bin/hyprctl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +if [[ "${1:-}" == "-j" && "${2:-}" == "monitors" ]]; then + printf '%s\n' '[{"name":"DP-2"},{"name":"HDMI-A-1"}]' + exit 0 +fi +exit 91 +EOF +chmod +x "$test_bin/hyprctl" + +cat >"$test_bin/hyprlock" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >>"$PANAMA_TEST_HYPRLOCK_LOG" +EOF +chmod +x "$test_bin/hyprlock" + +run_helper() { + HOME="$fixture_home" XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" \ + PATH="$test_bin:$PATH" PANAMA_TEST_HYPRLOCK_LOG="$hyprlock_log" "$helper" "$@" +} + +write_settings() { + printf '%s\n' "$1" >"$settings" +} + +assert_schema_entry() { + local key="$1" + rg -Fq "key: \"$key\"" "$schema" || fail "schema is missing $key" +} + +[[ -x "$helper" ]] || fail 'panama-lock helper is missing or not executable' +for key in lockBackgroundMode lockBlurLevel lockShowClock lockShowDate lockShowUser lockFadeOnEmpty; do + assert_schema_entry "$key" +done + +write_settings '{}' +run_helper generate +[[ -s "$generated" ]] || fail 'default generation did not create a config' +rg -Fq 'path = screenshot' "$generated" || fail 'default background is not a screenshot' +rg -Fq 'blur_passes = 3' "$generated" || fail 'default blur pass count is not 3' +rg -Fq 'blur_size = 8' "$generated" || fail 'default blur size is not 8' +rg -Fq 'text = cmd[update:1000] date +"%-I:%M"' "$generated" || fail 'default clock is not 12-hour' +rg -Fq 'text = cmd[update:60000] date +"%A, %B %-d"' "$generated" || fail 'default date is missing' +rg -Fq 'text = $USER' "$generated" || fail 'default user label is missing' +rg -Fq 'fade_on_empty = false' "$generated" || fail 'default password field fades when empty' +jq -e '.generated == true and .fallback == false and .error == "" and (.path | endswith("/panama/hyprlock.conf"))' \ + <<<"$(run_helper status)" >/dev/null || fail 'status did not report the generated config' + +write_settings '{ + "lockBackgroundMode":"solid", + "lockBlurLevel":0, + "lockShowClock":false, + "lockShowDate":false, + "lockShowUser":false, + "lockFadeOnEmpty":true, + "use24Hour":true, + "colorScheme":"light" +}' +run_helper generate +rg -Fq 'color = rgba(245, 246, 250, 1.0)' "$generated" || fail 'light solid mode has the wrong colour' +rg -Fq 'blur_passes = 0' "$generated" || fail 'blur level zero did not disable passes' +rg -Fq 'blur_size = 1' "$generated" || fail 'blur level zero did not use the safe size' +rg -Fq 'fade_on_empty = true' "$generated" || fail 'fade-on-empty setting was ignored' +if rg -q '^label \{' "$generated"; then + fail 'hidden clock, date, and user labels were still generated' +fi + +write_settings '{ + "lockBackgroundMode":"wallpaper", + "wallpaperMode":"per-monitor", + "wallpaperPath":"/images/global.jpg", + "wallpaperPerMonitor":{"DP-2":"/images/portrait.jpg"} +}' +run_helper generate +[[ "$(rg -c '^background \{' "$generated")" -eq 2 ]] || fail 'wallpaper mode did not create one block per output' +awk '/^background \{/{block++} block==1 && /monitor = DP-2/{monitor=1} block==1 && /path = \/images\/portrait.jpg/{path=1} END{exit !(monitor && path)}' "$generated" \ + || fail 'per-monitor wallpaper was not used for DP-2' +awk '/^background \{/{block++} block==2 && /monitor = HDMI-A-1/{monitor=1} block==2 && /path = \/images\/global.jpg/{path=1} END{exit !(monitor && path)}' "$generated" \ + || fail 'missing monitor assignment did not fall back to the global wallpaper' + +write_settings '{"lockBackgroundMode":"screenshot","lockShowClock":true,"lockShowDate":false,"lockShowUser":false,"use24Hour":true}' +run_helper generate +rg -Fq 'text = cmd[update:1000] date +"%H:%M"' "$generated" || fail '24-hour clock setting was ignored' + +write_settings '{"lockBackgroundMode":"nope","lockBlurLevel":99,"lockShowClock":"yes","lockShowDate":0,"lockShowUser":null,"lockFadeOnEmpty":[]}' +run_helper generate +rg -Fq 'path = screenshot' "$generated" || fail 'invalid background mode did not use the default' +rg -Fq 'blur_passes = 3' "$generated" || fail 'invalid blur did not use the default' +rg -Fq 'text = cmd[update:1000] date +"%-I:%M"' "$generated" || fail 'invalid clock boolean did not use the default' +rg -Fq 'text = cmd[update:60000] date +"%A, %B %-d"' "$generated" || fail 'invalid date boolean did not use the default' +rg -Fq 'text = $USER' "$generated" || fail 'invalid user boolean did not use the default' +rg -Fq 'fade_on_empty = false' "$generated" || fail 'invalid fade boolean did not use the default' + +write_settings 'not json' +run_helper generate +rg -Fq 'path = screenshot' "$generated" || fail 'malformed JSON did not use shipped defaults' + +before_checksum="$(sha256sum "$generated" | cut -d' ' -f1)" +chmod 500 "$state_home/panama" +if run_helper generate >/dev/null 2>&1; then + fail 'generation unexpectedly succeeded with an unwritable state directory' +fi +after_checksum="$(sha256sum "$generated" | cut -d' ' -f1)" +[[ "$before_checksum" == "$after_checksum" ]] || fail 'failed generation replaced the last valid config' +chmod 700 "$state_home/panama" + +: >"$hyprlock_log" +run_helper run +[[ "$(tail -1 "$hyprlock_log")" == "-c $generated" ]] || fail 'run did not use the generated config after success' + +chmod 500 "$state_home/panama" +run_helper run 2>"$fixture/fallback.stderr" +chmod 700 "$state_home/panama" +[[ "$(tail -1 "$hyprlock_log")" == "-c $config_home/hypr/hyprlock.conf" ]] \ + || fail 'run did not use the tracked fallback after generation failure' +[[ ! -s "$fixture/fallback.stderr" ]] || fail 'safe fallback leaked an internal filesystem error' + +printf 'lock screen helper contract: PASS\n'