Verify wallpaper policy application
This commit is contained in:
@@ -1,43 +1,42 @@
|
|||||||
pragma Singleton
|
pragma Singleton
|
||||||
|
|
||||||
// The desktop background.
|
// Verified wallpaper policy application. hyprpaper 0.8 applies one output per
|
||||||
//
|
// IPC call, so Panama queues every connected output and persists a policy only
|
||||||
// hyprpaper owns the actual painting; this owns choosing. Two things are worth
|
// after listactive confirms the complete map.
|
||||||
// knowing about hyprpaper 0.8:
|
|
||||||
//
|
|
||||||
// * Its IPC is much smaller than the documentation for older versions
|
|
||||||
// suggests. `wallpaper <output>,<path>` 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 "<output>,<path>", 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.
|
|
||||||
|
|
||||||
import Quickshell
|
import Quickshell
|
||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
import QtQuick
|
import QtQuick
|
||||||
|
|
||||||
|
import "WallpaperPolicy.js" as WallpaperPolicy
|
||||||
import qs.config
|
import qs.config
|
||||||
|
|
||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
// Absolute paths of candidate images, newest first.
|
|
||||||
property var available: []
|
property var available: []
|
||||||
property string active: ""
|
property var activeByOutput: ({})
|
||||||
property string lastError: ""
|
property string lastError: ""
|
||||||
property bool scanning: false
|
property bool scanning: false
|
||||||
|
property var transaction: null
|
||||||
|
property bool startupRestoreEnabled: true
|
||||||
|
|
||||||
readonly property string configured: DesktopPreferences.get("wallpaperPath")
|
readonly property string configured: DesktopPreferences.get("wallpaperPath")
|
||||||
readonly property string shippedPath: `${Quickshell.env("HOME")}/Pictures/Wallpapers/faroe_islands.jpg`
|
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: [
|
readonly property var searchRoots: [
|
||||||
`${Quickshell.env("HOME")}/Pictures/Wallpapers`,
|
`${Quickshell.env("HOME")}/Pictures/Wallpapers`,
|
||||||
`${Quickshell.env("HOME")}/Pictures/Backgrounds`,
|
`${Quickshell.env("HOME")}/Pictures/Backgrounds`,
|
||||||
@@ -47,20 +46,14 @@ Singleton {
|
|||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: scan
|
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",
|
command: ["bash", "-lc",
|
||||||
"find " + root.searchRoots.map(dir => `'${dir}'`).join(" ")
|
"find " + root.searchRoots.map(dir => `'${dir}'`).join(" ")
|
||||||
+ " -maxdepth 2 -type f \\( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' \\)"
|
+ " -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"]
|
+ " -printf '%T@ %p\\n' 2>/dev/null | sort -rn | cut -d' ' -f2- | head -60"]
|
||||||
|
|
||||||
stdout: StdioCollector {
|
stdout: StdioCollector {
|
||||||
onStreamFinished: {
|
onStreamFinished: {
|
||||||
const paths = this.text.split("\n").map(line => line.trim()).filter(line => line.length > 0);
|
root.available = this.text.split("\n")
|
||||||
root.available = paths;
|
.map(line => line.trim()).filter(line => line.length > 0);
|
||||||
root.scanning = false;
|
root.scanning = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,40 +64,35 @@ Singleton {
|
|||||||
command: ["hyprctl", "hyprpaper", "listactive"]
|
command: ["hyprctl", "hyprpaper", "listactive"]
|
||||||
stdout: StdioCollector {
|
stdout: StdioCollector {
|
||||||
onStreamFinished: {
|
onStreamFinished: {
|
||||||
// "DP-2: /path/to/image.jpg", one line per output.
|
const parsed = root.parseActive(this.text);
|
||||||
const first = this.text.split("\n").find(line => line.indexOf(":") > 0);
|
if (parsed !== null)
|
||||||
root.active = first ? first.slice(first.indexOf(":") + 1).trim() : "";
|
root.activeByOutput = parsed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// hyprpaper requires an explicit output name: the "<empty>,<path>" 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 {
|
Process {
|
||||||
id: apply
|
id: applyProcess
|
||||||
|
|
||||||
property string requested: ""
|
|
||||||
property string storedValue: ""
|
|
||||||
property var remaining: []
|
|
||||||
|
|
||||||
onExited: (exitCode, exitStatus) => {
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
if (root.transaction === null)
|
||||||
|
return;
|
||||||
if (exitCode !== 0) {
|
if (exitCode !== 0) {
|
||||||
root.lastError = "hyprpaper could not load that image.";
|
root.lastError = "Hyprpaper did not apply that background.";
|
||||||
apply.remaining = [];
|
root.transaction = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (apply.remaining.length > 0) {
|
root.drainTransaction();
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: verifyProcess
|
||||||
|
property string outputText: ""
|
||||||
|
onStarted: outputText = ""
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: verifyProcess.outputText = this.text
|
||||||
|
}
|
||||||
|
onExited: (exitCode, exitStatus) => root.finishVerification(exitCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
@@ -113,17 +101,12 @@ Singleton {
|
|||||||
restore.restart();
|
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 {
|
Timer {
|
||||||
id: restore
|
id: restore
|
||||||
interval: 1500
|
interval: 1500
|
||||||
onTriggered: {
|
onTriggered: {
|
||||||
const stored = root.configured;
|
if (root.startupRestoreEnabled)
|
||||||
if (stored !== "" && stored !== root.active)
|
root.applyCurrentPolicy(false);
|
||||||
root.set(stored);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,37 +118,171 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function refreshActive(): void {
|
function refreshActive(): void {
|
||||||
if (!activeQuery.running)
|
if (!activeQuery.running && !root.busy)
|
||||||
activeQuery.running = true;
|
activeQuery.running = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Applies to every connected output. Returns false when the path is not one
|
function candidates(extra: var): var {
|
||||||
// the schema will accept, so a caller can report the refusal.
|
const result = [];
|
||||||
function set(path: string): bool {
|
for (const path of root.available.concat([root.shippedPath, root.configured]).concat(extra || [])) {
|
||||||
const effectivePath = path === "" ? root.shippedPath : path;
|
if (typeof path === "string" && /^\/[^,\n]+$/.test(path) && !result.includes(path))
|
||||||
if (PreferenceSchema.coerce("wallpaperPath", effectivePath) === undefined) {
|
result.push(path);
|
||||||
root.lastError = "That file path cannot be used as a wallpaper.";
|
}
|
||||||
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
if (apply.running)
|
const outputs = root.outputNames();
|
||||||
return false;
|
|
||||||
|
|
||||||
apply.requested = effectivePath;
|
|
||||||
apply.storedValue = path;
|
|
||||||
|
|
||||||
const outputs = Quickshell.screens.map(screen => screen.name).filter(name => !!name);
|
|
||||||
if (outputs.length === 0) {
|
if (outputs.length === 0) {
|
||||||
root.lastError = "No display to set a wallpaper on.";
|
root.lastError = "No display to set a wallpaper on.";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const expected = WallpaperPolicy.effectiveMap(
|
||||||
apply.remaining = outputs.slice(1);
|
normalised.mode, normalised.globalPath, normalised.slideshowPath,
|
||||||
apply.exec(["hyprctl", "hyprpaper", "wallpaper", `${outputs[0]},${effectivePath}`]);
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The display name for a path: the file's own name, without extension,
|
function drainTransaction(): void {
|
||||||
// with separators turned into spaces.
|
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 {
|
function titleFor(path: string): string {
|
||||||
const file = String(path).split("/").pop();
|
const file = String(path).split("/").pop();
|
||||||
return file.replace(/\.[^.]+$/, "").replace(/[_-]+/g, " ");
|
return file.replace(/\.[^.]+$/, "").replace(/[_-]+/g, " ");
|
||||||
|
|||||||
@@ -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")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+161
@@ -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'
|
||||||
Reference in New Issue
Block a user