From 52818b7290268230e4408b7865dfb8b5f934253e Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 14:22:23 -0400 Subject: [PATCH] Verify wallpaper policy application --- config/dot/quickshell/services/Wallpaper.qml | 277 +++++++++++++----- .../quickshell/wallpaper-service-harness.qml | 46 +++ .../quickshell/wallpaper-service-contract.sh | 161 ++++++++++ 3 files changed, 404 insertions(+), 80 deletions(-) create mode 100644 config/dot/quickshell/wallpaper-service-harness.qml create mode 100755 tests/quickshell/wallpaper-service-contract.sh diff --git a/config/dot/quickshell/services/Wallpaper.qml b/config/dot/quickshell/services/Wallpaper.qml index 1e6d43e..545d233 100644 --- a/config/dot/quickshell/services/Wallpaper.qml +++ b/config/dot/quickshell/services/Wallpaper.qml @@ -1,43 +1,42 @@ pragma Singleton -// The desktop background. -// -// hyprpaper owns the actual painting; this owns choosing. Two things are worth -// knowing about hyprpaper 0.8: -// -// * Its IPC is much smaller than the documentation for older versions -// suggests. `wallpaper ,` and `listactive` work; `preload`, -// `listloaded`, `unload`, and `reload` all answer "invalid hyprpaper -// request". So there is no preload step -- setting is a single call. -// * hyprpaper.conf lives in the Panama repo via the ~/.config/hypr symlink, -// so it cannot be rewritten at runtime without dirtying a tracked file. -// The chosen wallpaper therefore lives in the shared settings store like -// every other preference, and is re-applied when the shell starts. -// -// The argument is ",", so a path containing a comma would be -// parsed as a different request. The schema's pattern rejects those, and the -// value is passed as a single argv element rather than through a shell. +// Verified wallpaper policy application. hyprpaper 0.8 applies one output per +// IPC call, so Panama queues every connected output and persists a policy only +// after listactive confirms the complete map. import Quickshell import Quickshell.Io import QtQuick + +import "WallpaperPolicy.js" as WallpaperPolicy import qs.config Singleton { id: root - // Absolute paths of candidate images, newest first. property var available: [] - property string active: "" + property var activeByOutput: ({}) property string lastError: "" property bool scanning: false + property var transaction: null + property bool startupRestoreEnabled: true readonly property string configured: DesktopPreferences.get("wallpaperPath") readonly property string shippedPath: `${Quickshell.env("HOME")}/Pictures/Wallpapers/faroe_islands.jpg` + readonly property string active: { + const outputs = root.outputNames(); + if (outputs.length > 0 && root.activeByOutput[outputs[0]]) + return root.activeByOutput[outputs[0]]; + const paths = Object.values(root.activeByOutput); + return paths.length > 0 ? paths[0] : ""; + } + readonly property bool busy: root.transaction !== null + || applyProcess.running || verifyProcess.running + + property var outputNames: function() { + return Quickshell.screens.map(screen => screen.name).filter(name => !!name); + } - // Directories searched for wallpapers, in order. Screenshots are - // deliberately excluded: a folder of 300 screenshots is not a wallpaper - // picker, and including it made the grid useless on this machine. readonly property var searchRoots: [ `${Quickshell.env("HOME")}/Pictures/Wallpapers`, `${Quickshell.env("HOME")}/Pictures/Backgrounds`, @@ -47,20 +46,14 @@ Singleton { Process { id: scan - - // -print0 would be safer against odd filenames, but the schema already - // rejects paths containing commas or newlines, and this list is only - // ever offered as candidates -- the value that gets stored is validated - // again on the way in. command: ["bash", "-lc", "find " + root.searchRoots.map(dir => `'${dir}'`).join(" ") + " -maxdepth 2 -type f \\( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' \\)" + " -printf '%T@ %p\\n' 2>/dev/null | sort -rn | cut -d' ' -f2- | head -60"] - stdout: StdioCollector { onStreamFinished: { - const paths = this.text.split("\n").map(line => line.trim()).filter(line => line.length > 0); - root.available = paths; + root.available = this.text.split("\n") + .map(line => line.trim()).filter(line => line.length > 0); root.scanning = false; } } @@ -71,59 +64,49 @@ Singleton { command: ["hyprctl", "hyprpaper", "listactive"] stdout: StdioCollector { onStreamFinished: { - // "DP-2: /path/to/image.jpg", one line per output. - const first = this.text.split("\n").find(line => line.indexOf(":") > 0); - root.active = first ? first.slice(first.indexOf(":") + 1).trim() : ""; + const parsed = root.parseActive(this.text); + if (parsed !== null) + root.activeByOutput = parsed; } } } - // hyprpaper requires an explicit output name: the "," form that - // older versions accepted as "all outputs" is silently ignored by 0.8, so a - // wallpaper set that way appears to succeed and never changes. Outputs are - // therefore walked one at a time. Process { - id: apply - - property string requested: "" - property string storedValue: "" - property var remaining: [] - + id: applyProcess onExited: (exitCode, exitStatus) => { + if (root.transaction === null) + return; if (exitCode !== 0) { - root.lastError = "hyprpaper could not load that image."; - apply.remaining = []; + root.lastError = "Hyprpaper did not apply that background."; + root.transaction = null; return; } - if (apply.remaining.length > 0) { - const next = apply.remaining[0]; - apply.remaining = apply.remaining.slice(1); - apply.exec(["hyprctl", "hyprpaper", "wallpaper", `${next},${apply.requested}`]); - return; - } - root.lastError = ""; - DesktopPreferences.set("wallpaperPath", apply.storedValue); - root.refreshActive(); + root.drainTransaction(); } } + Process { + id: verifyProcess + property string outputText: "" + onStarted: outputText = "" + stdout: StdioCollector { + onStreamFinished: verifyProcess.outputText = this.text + } + onExited: (exitCode, exitStatus) => root.finishVerification(exitCode) + } + Component.onCompleted: { root.rescan(); root.refreshActive(); restore.restart(); } - // hyprpaper is started by the compositor's autostart, so it may not be - // listening yet when the shell comes up. Re-applying the stored choice - // after a short delay makes the wallpaper survive a reboot without needing - // hyprpaper.conf to know about it. Timer { id: restore interval: 1500 onTriggered: { - const stored = root.configured; - if (stored !== "" && stored !== root.active) - root.set(stored); + if (root.startupRestoreEnabled) + root.applyCurrentPolicy(false); } } @@ -135,37 +118,171 @@ Singleton { } function refreshActive(): void { - if (!activeQuery.running) + if (!activeQuery.running && !root.busy) activeQuery.running = true; } - // Applies to every connected output. Returns false when the path is not one - // the schema will accept, so a caller can report the refusal. - function set(path: string): bool { - const effectivePath = path === "" ? root.shippedPath : path; - if (PreferenceSchema.coerce("wallpaperPath", effectivePath) === undefined) { - root.lastError = "That file path cannot be used as a wallpaper."; + function candidates(extra: var): var { + const result = []; + for (const path of root.available.concat([root.shippedPath, root.configured]).concat(extra || [])) { + if (typeof path === "string" && /^\/[^,\n]+$/.test(path) && !result.includes(path)) + result.push(path); + } + return result; + } + + function currentPolicy(): var { + return { + mode: DesktopPreferences.get("wallpaperMode") ?? "single", + globalPath: root.configured === "" ? root.shippedPath : root.configured, + collection: DesktopPreferences.get("wallpaperSlideshowPaths") ?? [], + intervalMinutes: DesktopPreferences.get("wallpaperIntervalMinutes") ?? 30, + shuffle: DesktopPreferences.get("wallpaperShuffle") !== false, + assignments: DesktopPreferences.get("wallpaperPerMonitor") ?? ({}), + slideshowPath: root.active + }; + } + + function normalisePolicy(policy: var): var { + if (!policy || typeof policy !== "object") + return null; + const mode = ["single", "slideshow", "per-monitor"].includes(policy.mode) + ? policy.mode : "single"; + const rawGlobal = policy.globalPath === "" ? root.shippedPath : policy.globalPath; + const candidatePaths = root.candidates([rawGlobal] + .concat(policy.collection || []) + .concat(Object.values(policy.assignments || {})) + .concat([policy.slideshowPath || ""])); + if (!WallpaperPolicy.validPath(rawGlobal, candidatePaths)) + return null; + return { + mode, + globalPath: rawGlobal, + storedPath: rawGlobal === root.shippedPath ? "" : rawGlobal, + collection: WallpaperPolicy.validCollection(policy.collection || [], candidatePaths), + intervalMinutes: Math.max(5, Math.min(1440, Number(policy.intervalMinutes) || 30)), + shuffle: policy.shuffle !== false, + assignments: WallpaperPolicy.validAssignments(policy.assignments || {}, candidatePaths), + slideshowPath: WallpaperPolicy.validPath(policy.slideshowPath, candidatePaths) + ? policy.slideshowPath : rawGlobal, + candidates: candidatePaths + }; + } + + function applyPolicy(policy: var, persist: bool, automatic: bool): bool { + if (root.busy) + return false; + const normalised = root.normalisePolicy(policy); + if (normalised === null) { + root.lastError = "That wallpaper policy is not valid."; return false; } - if (apply.running) - return false; - - apply.requested = effectivePath; - apply.storedValue = path; - - const outputs = Quickshell.screens.map(screen => screen.name).filter(name => !!name); + const outputs = root.outputNames(); if (outputs.length === 0) { root.lastError = "No display to set a wallpaper on."; return false; } - - apply.remaining = outputs.slice(1); - apply.exec(["hyprctl", "hyprpaper", "wallpaper", `${outputs[0]},${effectivePath}`]); + const expected = WallpaperPolicy.effectiveMap( + normalised.mode, normalised.globalPath, normalised.slideshowPath, + normalised.assignments, outputs, normalised.candidates); + if (Object.keys(expected).length !== outputs.length + || Object.values(expected).some(path => path === "")) { + root.lastError = "That wallpaper policy is not valid."; + return false; + } + root.lastError = ""; + root.transaction = { + expected, + remaining: outputs.slice(), + policy: normalised, + persist: persist === true, + automatic: automatic === true + }; + root.drainTransaction(); return true; } - // The display name for a path: the file's own name, without extension, - // with separators turned into spaces. + function drainTransaction(): void { + if (root.transaction === null || applyProcess.running || verifyProcess.running) + return; + if (root.transaction.remaining.length === 0) { + verifyProcess.exec(["hyprctl", "hyprpaper", "listactive"]); + return; + } + const output = root.transaction.remaining[0]; + root.transaction.remaining = root.transaction.remaining.slice(1); + applyProcess.exec([ + "hyprctl", "hyprpaper", "wallpaper", + `${output},${root.transaction.expected[output]}` + ]); + } + + function parseActive(text: string): var { + const result = {}; + const lines = String(text).split("\n").map(line => line.trim()).filter(line => line !== ""); + for (const line of lines) { + const match = line.match(/^([A-Za-z0-9_.-]+): (\/[^,\n]+)$/); + if (!match || result[match[1]] !== undefined) + return null; + result[match[1]] = match[2]; + } + return result; + } + + function finishVerification(exitCode: int): void { + if (root.transaction === null) + return; + const observed = exitCode === 0 ? root.parseActive(verifyProcess.outputText) : null; + const expected = root.transaction.expected; + const matches = observed !== null + && Object.keys(observed).length === Object.keys(expected).length + && Object.keys(expected).every(output => observed[output] === expected[output]); + if (!matches) { + root.lastError = "Hyprpaper did not confirm that background."; + root.transaction = null; + return; + } + + const completed = root.transaction; + root.activeByOutput = observed; + root.transaction = null; + root.lastError = ""; + if (completed.persist) + root.persistPolicy(completed.policy); + } + + function persistPolicy(policy: var): void { + DesktopPreferences.set("wallpaperMode", policy.mode); + DesktopPreferences.set("wallpaperPath", policy.storedPath); + DesktopPreferences.set("wallpaperSlideshowPaths", policy.collection); + DesktopPreferences.set("wallpaperIntervalMinutes", policy.intervalMinutes); + DesktopPreferences.set("wallpaperShuffle", policy.shuffle); + DesktopPreferences.set("wallpaperPerMonitor", policy.assignments); + } + + function applyCurrentPolicy(persist: bool): bool { + return root.applyPolicy(root.currentPolicy(), persist === true, false); + } + + function setSingle(path: string): bool { + const effectivePath = path === "" ? root.shippedPath : path; + const allowed = root.candidates([]); + if (!WallpaperPolicy.validPath(effectivePath, allowed)) { + root.lastError = "That file path cannot be used as a wallpaper."; + return false; + } + const policy = root.currentPolicy(); + policy.mode = "single"; + policy.globalPath = effectivePath; + policy.slideshowPath = effectivePath; + return root.applyPolicy(policy, true, false); + } + + // Compatibility boundary used by backup/reset and the existing picker. + function set(path: string): bool { + return root.setSingle(path); + } + function titleFor(path: string): string { const file = String(path).split("/").pop(); return file.replace(/\.[^.]+$/, "").replace(/[_-]+/g, " "); diff --git a/config/dot/quickshell/wallpaper-service-harness.qml b/config/dot/quickshell/wallpaper-service-harness.qml new file mode 100644 index 0000000..a96b15c --- /dev/null +++ b/config/dot/quickshell/wallpaper-service-harness.qml @@ -0,0 +1,46 @@ +import Quickshell +import Quickshell.Io +import QtQuick + +import qs.config +import qs.services + +ShellRoot { + Component.onCompleted: { + Wallpaper.outputNames = function() { return ["DP-2", "HDMI-A-1"]; }; + Wallpaper.startupRestoreEnabled = false; + Wallpaper.available = ["/images/a.jpg", "/images/b.jpg", "/images/c.jpg"]; + } + + IpcHandler { + target: "wallpaper-service-test" + + function applyPerMonitor(): bool { + return Wallpaper.applyPolicy({ + mode: "per-monitor", + globalPath: "/images/a.jpg", + collection: [], + intervalMinutes: 30, + shuffle: true, + assignments: { "HDMI-A-1": "/images/b.jpg" }, + slideshowPath: "" + }, true); + } + + function applySingle(path: string): bool { + return Wallpaper.set(path); + } + + function status(): string { + return JSON.stringify({ + busy: Wallpaper.busy, + lastError: Wallpaper.lastError, + activeByOutput: Wallpaper.activeByOutput, + active: Wallpaper.active, + mode: DesktopPreferences.get("wallpaperMode"), + path: DesktopPreferences.get("wallpaperPath"), + assignments: DesktopPreferences.get("wallpaperPerMonitor") + }); + } + } +} diff --git a/tests/quickshell/wallpaper-service-contract.sh b/tests/quickshell/wallpaper-service-contract.sh new file mode 100755 index 0000000..23f7fde --- /dev/null +++ b/tests/quickshell/wallpaper-service-contract.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +service="$repo_dir/config/dot/quickshell/services/Wallpaper.qml" +fixture="$(mktemp -d /tmp/panama-wallpaper-service.XXXXXX)" +config_home="$fixture/config" +state_home="$fixture/state" +config_path="$config_home/quickshell" +harness="$config_path/wallpaper-service-harness.qml" +test_bin="$fixture/bin" +command_log="$fixture/commands.log" +active_state="$fixture/active.json" +control="$fixture/control" +shell_log="$fixture/quickshell.log" +harness_pid="" + +fail() { + printf 'wallpaper service contract: %s\n' "$1" >&2 + [[ -s "$shell_log" ]] && sed -n '1,180p' "$shell_log" >&2 + exit 1 +} + +for needle in 'property var activeByOutput' 'function applyPolicy' 'function setSingle'; do + rg -Fq "$needle" "$service" || fail "Wallpaper service is missing $needle" +done + +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 + +mkdir -p "$config_home/panama" "$state_home" "$test_bin" +cp -a "$repo_dir/config/dot/quickshell" "$config_path" +printf '%s\n' '{}' >"$config_home/panama/settings.json" +printf '%s\n' '{}' >"$active_state" +: >"$command_log" +: >"$control" + +cat >"$test_bin/hyprctl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +printf '%s' 'hyprctl' >>"$PANAMA_WALLPAPER_COMMAND_LOG" +printf ' %s' "$@" >>"$PANAMA_WALLPAPER_COMMAND_LOG" +printf '\n' >>"$PANAMA_WALLPAPER_COMMAND_LOG" + +if [[ "${1:-}" == "hyprpaper" && "${2:-}" == "wallpaper" ]]; then + request="${3:-}" + output="${request%%,*}" + path="${request#*,}" + if [[ "$(<"$PANAMA_WALLPAPER_CONTROL")" == fail-second && "$output" == HDMI-A-1 ]]; then + exit 7 + fi + next="$(jq -c --arg output "$output" --arg path "$path" '. + {($output):$path}' \ + "$PANAMA_WALLPAPER_ACTIVE_STATE")" + printf '%s\n' "$next" >"$PANAMA_WALLPAPER_ACTIVE_STATE" + exit 0 +fi + +if [[ "${1:-}" == "hyprpaper" && "${2:-}" == "listactive" ]]; then + if [[ "$(<"$PANAMA_WALLPAPER_CONTROL")" == wrong ]]; then + printf 'DP-2: /images/wrong.jpg\nHDMI-A-1: /images/b.jpg\n' + exit 0 + fi + jq -r 'to_entries[] | "\(.key): \(.value)"' "$PANAMA_WALLPAPER_ACTIVE_STATE" + exit 0 +fi + +exit 91 +EOF +chmod +x "$test_bin/hyprctl" + +qs_for_harness() { + env_args=( + XDG_CONFIG_HOME="$config_home" + XDG_STATE_HOME="$state_home" + PATH="$test_bin:$PATH" + PANAMA_WALLPAPER_COMMAND_LOG="$command_log" + PANAMA_WALLPAPER_ACTIVE_STATE="$active_state" + PANAMA_WALLPAPER_CONTROL="$control" + ) + if [[ "$harness_pid" =~ ^[0-9]+$ && "${1:-}" == "ipc" ]]; then + env "${env_args[@]}" qs -p "$harness" ipc --pid "$harness_pid" "${@:2}" + else + env "${env_args[@]}" qs -p "$harness" "$@" + fi +} + +wait_idle() { + local status="" + for _ in $(seq 1 80); do + status="$(qs_for_harness ipc call wallpaper-service-test status)" + [[ "$(jq -r '.busy' <<<"$status")" == false ]] && { printf '%s' "$status"; return; } + sleep 0.1 + done + fail "wallpaper transaction did not settle: $status" +} + +qs_for_harness --daemonize >"$shell_log" 2>&1 || fail 'isolated service 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 wallpaper-service-test$'; then + break + fi + sleep 0.1 +done +[[ "$harness_pid" =~ ^[0-9]+$ ]] || fail 'isolated service harness process did not start' +wait_idle >/dev/null + +: >"$command_log" +printf '%s\n' '{}' >"$active_state" +qs_for_harness ipc call wallpaper-service-test applyPerMonitor >/dev/null +success="$(wait_idle)" +expected_calls=$'hyprctl hyprpaper wallpaper DP-2,/images/a.jpg\nhyprctl hyprpaper wallpaper HDMI-A-1,/images/b.jpg\nhyprctl hyprpaper listactive' +[[ "$(<"$command_log")" == "$expected_calls" ]] || fail "transaction argv/order was wrong: $(<"$command_log")" +jq -e '.lastError == "" and .mode == "per-monitor" and .path == "/images/a.jpg" + and .assignments == {"HDMI-A-1":"/images/b.jpg"} + and .activeByOutput == {"DP-2":"/images/a.jpg","HDMI-A-1":"/images/b.jpg"}' \ + <<<"$success" >/dev/null || fail "verified policy was not persisted: $success" + +printf 'wrong\n' >"$control" +: >"$command_log" +qs_for_harness ipc call wallpaper-service-test applySingle /images/c.jpg >/dev/null +wrong="$(wait_idle)" +jq -e '.path == "/images/a.jpg" and .mode == "per-monitor" + and .lastError == "Hyprpaper did not confirm that background."' \ + <<<"$wrong" >/dev/null || fail "wrong readback persisted an unverified policy: $wrong" + +printf 'fail-second\n' >"$control" +: >"$command_log" +qs_for_harness ipc call wallpaper-service-test applyPerMonitor >/dev/null +failed="$(wait_idle)" +jq -e '.path == "/images/a.jpg" and .mode == "per-monitor" + and .lastError == "Hyprpaper did not apply that background."' \ + <<<"$failed" >/dev/null || fail "partial output failure changed policy: $failed" +[[ "$(tail -1 "$command_log")" == 'hyprctl hyprpaper wallpaper HDMI-A-1,/images/b.jpg' ]] \ + || fail 'transaction continued after the failed output' + +if rg -n 'ReferenceError|TypeError|Binding loop|Unable to assign|Cannot assign' "$shell_log"; then + fail 'service harness emitted a QML runtime warning' +fi + +printf 'wallpaper service contract: PASS\n'