diff --git a/docs/configuration/ml4w.md b/docs/configuration/ml4w.md index cf27a2e3..9b8f1cc0 100644 --- a/docs/configuration/ml4w.md +++ b/docs/configuration/ml4w.md @@ -151,6 +151,16 @@ one of those files silently disappears on the next upgrade. The rule: (it keeps the body it parsed at start). A listener without a Matugen call is left untouched with a warning; a missing listener is skipped. Override the paths with `ML4W_GTK_LISTENER` and `ML4W_LISTENERS_SCRIPT`. +- **Matugen no longer writes Dreamcoder's files.** Restoring colours after Matugen is a + race: Matugen rewrites `hypr/colors.conf`, `hypr/colors.lua`, `waybar/colors.css`, + `rofi/colors.rasi` and, through a symlink into Waybar, `swaync/colors.css` on every + wallpaper or mode change, and a late write beats the restore. The hook comments out those + five `[templates.*]` sections in `~/.config/matugen/config.toml` with a `#dreamcoder-off# ` + prefix (idempotent; the result is validated as TOML before it is written). Every other + template (kitty, btop, GTK, quickshell, ...) keeps running. An ML4W upgrade restores the + stock file, so re-run `./scripts/apply-ml4w-hooks.sh` (or `dreamcoder repair`). To undo by + hand: `sed -i 's/^#dreamcoder-off# //' ~/.config/matugen/config.toml`. Override the path + with `MATUGEN_CONFIG`. - **waypaper is optional.** ML4W 2.16 no longer installs it; its `post_command` is hooked only when `~/.config/waypaper/config.ini` exists. - **Colour files may be regular files.** ML4W 2.16 ships diff --git a/scripts/apply-ml4w-hooks.sh b/scripts/apply-ml4w-hooks.sh index 268b451a..76e160ec 100755 --- a/scripts/apply-ml4w-hooks.sh +++ b/scripts/apply-ml4w-hooks.sh @@ -29,11 +29,22 @@ # ============================================================================ set -euo pipefail -source "${DREAMCODER_DOTS_DIR:-$(cd "$(dirname "$0")/.." && pwd)}/lib/env.sh" +ENV_LIB="${DREAMCODER_DOTS_DIR:-$(cd "$(dirname "$0")/.." && pwd)}/lib/env.sh" +if [[ ! -f "${ENV_LIB}" ]]; then + printf '✗ Required library not found: %s\n' "${ENV_LIB}" >&2 + exit 1 +fi +# shellcheck source=../lib/env.sh +source "${ENV_LIB}" ensure_dots_dir WAYPAPER_CONFIG="${WAYPAPER_CONFIG:-${HOME}/.config/waypaper/config.ini}" ML4W_WALLPAPER_SCRIPT="${ML4W_WALLPAPER_SCRIPT:-${HOME}/.config/ml4w/scripts/ml4w-wallpaper}" ML4W_WALLPAPER_VAR="${ML4W_WALLPAPER_VAR:-IMAGE_PATH}" +# It is embedded in the generated runner block as a variable name. +if [[ ! "${ML4W_WALLPAPER_VAR}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then + printf '✗ ML4W_WALLPAPER_VAR is not a valid shell identifier: %s\n' "${ML4W_WALLPAPER_VAR}" >&2 + exit 1 +fi HOOK_SCRIPT="${DREAMCODER_DOTS_DIR}/scripts/wallpaper-hook.sh" BEGIN_MARK='# >>> Dreamcoder wallpaper hook >>>' END_MARK='# <<< Dreamcoder wallpaper hook <<<' @@ -42,6 +53,11 @@ ML4W_GTK_LISTENER="${ML4W_GTK_LISTENER:-${HOME}/.config/ml4w/listeners/gtk-theme ML4W_LISTENERS_SCRIPT="${ML4W_LISTENERS_SCRIPT:-${HOME}/.config/ml4w/listeners.sh}" LISTENER_BEGIN_MARK='# >>> Dreamcoder listener hook >>>' LISTENER_END_MARK='# <<< Dreamcoder listener hook <<<' +MATUGEN_CONFIG="${MATUGEN_CONFIG:-${HOME}/.config/matugen/config.toml}" +MATUGEN_OFF_PREFIX='#dreamcoder-off# ' +# Matugen templates whose output is a file Dreamcoder owns. swaync/colors.css is a symlink +# to waybar/colors.css, so its template lands on the same file. +DREAMCODER_MATUGEN_TEMPLATES="hyprland hyprland-lua waybar rofi swaync" # Print the runner without any Dreamcoder block (marked or legacy) and without # trailing blank lines, so re-appending always yields the same bytes. @@ -65,14 +81,17 @@ hook_ml4w_runner() { printf '⚠ ML4W wallpaper runner not found, skipped: %s\n' "${ML4W_WALLPAPER_SCRIPT}" >&2 return 0 fi - local current desired + # %q escapes every shell metacharacter: the path lands in generated shell source, where + # a quote, $ or backtick inside double quotes would still expand or break out. + local current desired hook_q + hook_q="$(printf '%q' "${HOOK_SCRIPT}")" current="$(cat "${ML4W_WALLPAPER_SCRIPT}")" desired="$(strip_runner_hook "${ML4W_WALLPAPER_SCRIPT}") ${BEGIN_MARK} # Managed by dreamcoder-dots scripts/apply-ml4w-hooks.sh; re-run it after ML4W upgrades. -if [[ -x \"${HOOK_SCRIPT}\" ]]; then - \"${HOOK_SCRIPT}\" \"\$${ML4W_WALLPAPER_VAR}\" +if [[ -x ${hook_q} ]]; then + ${hook_q} \"\$${ML4W_WALLPAPER_VAR}\" fi ${END_MARK}" if [[ "${current}" == "${desired}" ]]; then @@ -92,9 +111,10 @@ ${END_MARK}" # because `dreamcoder sync` renders Dark when DREAMCODER_THEME_MODE is unset. # Exits 3 when no Matugen call is found, leaving the decision to the caller. render_listener_hook() { - awk -v begin="${LISTENER_BEGIN_MARK}" -v end="${LISTENER_END_MARK}" \ - -v dispatcher="${DREAMCODER_DOTS_DIR}/scripts/dreamcoder" ' + DREAMCODER_DISPATCHER_Q="$(printf '%q' "${DREAMCODER_DOTS_DIR}/scripts/dreamcoder")" \ + awk -v begin="${LISTENER_BEGIN_MARK}" -v end="${LISTENER_END_MARK}" ' function trim(s) { sub(/^[[:space:]]+/, "", s); sub(/[[:space:]]+$/, "", s); return s } + BEGIN { dispatcher = ENVIRON["DREAMCODER_DISPATCHER_Q"] } trim($0) == begin { skip = 1; next } skip && trim($0) == end { skip = 0; next } skip { next } @@ -108,7 +128,7 @@ render_listener_hook() { print pad "# Managed by dreamcoder-dots scripts/apply-ml4w-hooks.sh; re-run it after ML4W upgrades." print pad "# Matugen just rewrote the colour files from the wallpaper: restore Dreamcoder" print pad "# colours before the reloads below. The sync never writes gtk settings.ini." - print pad "if [[ -x \"" dispatcher "\" ]]; then" + print pad "if [[ -x " dispatcher " ]]; then" if (match($0, /-m[[:space:]]+"?(dark|light)"?/)) { # The branch passes its mode to Matugen: reuse it verbatim. mode = substr($0, RSTART, RLENGTH) @@ -120,7 +140,7 @@ render_listener_hook() { print pad " grep -Eq \"^gtk-application-prefer-dark-theme=(1|true)$\" \"${SETTINGS_FILE:-$HOME/.config/gtk-3.0/settings.ini}\" && _dreamcoder_mode=dark" } print pad " mkdir -p \"$HOME/.cache/dreamcoder\"" - print pad " DREAMCODER_THEME_MODE=\"$_dreamcoder_mode\" DREAMCODER_WRITE_REPO=0 timeout 120 \"" dispatcher "\" sync \"$HOME/.cache/dreamcoder/ml4w-listener-sync.log\" 2>&1 || true" + print pad " DREAMCODER_THEME_MODE=\"$_dreamcoder_mode\" DREAMCODER_WRITE_REPO=0 timeout 120 " dispatcher " sync \"$HOME/.cache/dreamcoder/ml4w-listener-sync.log\" 2>&1 || true" print pad "fi" print pad end } @@ -130,6 +150,12 @@ render_listener_hook() { } hook_gtk_listener() { + # The block bounds `dreamcoder sync` with timeout(1); without it a hung sync would + # stall ML4W's listener, so leave the listener alone rather than hook it unbounded. + if ! command -v timeout >/dev/null 2>&1; then + printf '⚠ timeout(1) not found, GTK theme listener hook skipped\n' >&2 + return 0 + fi if [[ ! -f "${ML4W_GTK_LISTENER}" ]]; then printf '⚠ ML4W GTK theme listener not found, skipped: %s\n' "${ML4W_GTK_LISTENER}" >&2 return 0 @@ -158,6 +184,10 @@ hook_gtk_listener() { # ~1s) so the theme apply below never races a half-restarted listener; every # stream is detached so the relaunched listener cannot hold the caller's pipes. restart_gtk_listener() { + if ! command -v timeout >/dev/null 2>&1; then + printf '⚠ timeout(1) not found, restart the GTK theme listener manually: %s --restart gtk-theme-switcher\n' "${ML4W_LISTENERS_SCRIPT}" >&2 + return 0 + fi if [[ ! -x "${ML4W_LISTENERS_SCRIPT}" ]]; then printf '⚠ ML4W listeners.sh not found, restart the GTK theme listener manually: %s\n' "${ML4W_LISTENERS_SCRIPT}" >&2 return 0 @@ -169,6 +199,47 @@ restart_gtk_listener() { fi } +# Print the Matugen config with every Dreamcoder-owned template section commented out. +# Sections that are already disabled start with '#', so re-running changes nothing. +# To undo by hand: sed -i 's/^#dreamcoder-off# //' ~/.config/matugen/config.toml +disable_owned_templates() { + awk -v owned="${DREAMCODER_MATUGEN_TEMPLATES}" -v prefix="${MATUGEN_OFF_PREFIX}" ' + BEGIN { n = split(owned, names, " "); for (i = 1; i <= n; i++) want["[templates." names[i] "]"] = 1 } + /^\[/ { header = $0; sub(/[ \t]+$/, "", header); off = (header in want) } + off && $0 != "" && $0 !~ /^#/ { print prefix $0; next } + { print } + ' "$1" +} + +# Matugen rewrites the colour files Dreamcoder owns on every wallpaper or mode change, +# and restoring them afterwards is a race that can be lost. Disable those templates at +# the source. ML4W upgrades restore the stock file, so re-run this script afterwards. +hook_matugen_config() { + if [[ ! -f "${MATUGEN_CONFIG}" ]]; then + printf '⚠ Matugen config not found, skipped: %s\n' "${MATUGEN_CONFIG}" >&2 + return 0 + fi + local current desired + current="$(cat "${MATUGEN_CONFIG}")" + desired="$(disable_owned_templates "${MATUGEN_CONFIG}")" + if [[ "${current}" == "${desired}" ]]; then + printf '✓ Matugen no longer writes Dreamcoder-owned colour files (already current)\n' + return 0 + fi + # Never leave Matugen with a config it cannot parse: without a validator, leave it alone. + if ! python3 -c 'import tomllib' 2>/dev/null; then + printf '⚠ python3 with tomllib is not available to validate the Matugen config, left untouched: %s\n' "${MATUGEN_CONFIG}" >&2 + return 0 + fi + if ! printf '%s\n' "${desired}" | python3 -c 'import sys, tomllib; tomllib.loads(sys.stdin.read())' 2>/dev/null; then + printf '⚠ patched Matugen config is not valid TOML, left untouched: %s\n' "${MATUGEN_CONFIG}" >&2 + return 0 + fi + # Write through the path so a symlinked config stays a symlink. + printf '%s\n' "${desired}" >"${MATUGEN_CONFIG}" + printf '✓ Matugen templates for Dreamcoder-owned files disabled: %s\n' "${MATUGEN_CONFIG}" +} + hook_waypaper() { if [[ ! -f "${WAYPAPER_CONFIG}" ]]; then printf '✓ waypaper config absent (not used by ML4W 2.16), skipped\n' @@ -178,16 +249,22 @@ hook_waypaper() { printf '✓ waypaper post_command hook already present\n' return 0 fi - local hook="${HOOK_SCRIPT} \"\$wallpaper\" > /dev/null 2>&1" - # `&` in a sed replacement expands to the whole match, and the hook text - # contains `2>&1`; unescaped it corrupts the line by re-inserting the match. - local hook_sed="${hook//&/\\&}" - sed -i "s|^post_command = \(.*\)|post_command = \1; ${hook_sed}|" "${WAYPAPER_CONFIG}" + # awk with ENVIRON instead of sed: the hook text carries the script path and `2>&1`, and a + # sed replacement would mangle `&`, the delimiter and backslashes. + local hook updated + hook="$(printf '%q' "${HOOK_SCRIPT}") \"\$wallpaper\" > /dev/null 2>&1" + updated="$(DREAMCODER_WAYPAPER_HOOK="${hook}" awk ' + /^post_command = / { print $0 "; " ENVIRON["DREAMCODER_WAYPAPER_HOOK"]; next } + { print } + ' "${WAYPAPER_CONFIG}")" + # Write through the path so a symlinked config stays a symlink. + printf '%s\n' "${updated}" >"${WAYPAPER_CONFIG}" printf '✓ waypaper post_command hooked: %s\n' "${WAYPAPER_CONFIG}" } hook_ml4w_runner hook_gtk_listener +hook_matugen_config hook_waypaper "${DREAMCODER_DOTS_DIR}/scripts/theme-auto.sh" diff --git a/tests/fixtures/ml4w/matugen-config-2.16.toml b/tests/fixtures/ml4w/matugen-config-2.16.toml new file mode 100644 index 00000000..35f43eca --- /dev/null +++ b/tests/fixtures/ml4w/matugen-config-2.16.toml @@ -0,0 +1,89 @@ +[config] + +[templates.colorsjson] +input_path = '~/.config/matugen/templates/colors.json' +output_path = '~/.config/ml4w/colors/colors.json' + +[templates.kitty] +input_path = '~/.config/matugen/templates/kitty-colors.conf' +output_path = '~/.config/kitty/colors-matugen.conf' +post_hook = 'pkill -SIGUSR1 kitty' + +[templates.btop] +input_path = '~/.config/matugen/templates/btop.theme' +output_path = '~/.config/btop/themes/matugen.theme' + +[templates.hyprland] +input_path = '~/.config/matugen/templates/hyprland-colors.conf' +output_path = '~/.config/hypr/colors.conf' +post_hook = 'hyprctl reload' + +[templates.hyprland-lua] +input_path = '~/.config/matugen/templates/hyprland-colors.lua' +output_path = '~/.config/hypr/colors.lua' +post_hook = 'hyprctl reload' + +[templates.waybar] +input_path = '~/.config/matugen/templates/colors.css' +output_path = '~/.config/waybar/colors.css' + +[templates.rofi] +input_path = '~/.config/matugen/templates/rofi-colors.rasi' +output_path = '~/.config/rofi/colors.rasi' + +[templates.gtk3] +input_path = '~/.config/matugen/templates/gtk-colors.css' +output_path = '~/.config/gtk-3.0/colors.css' + +[templates.gtk4] +input_path = '~/.config/matugen/templates/gtk-colors.css' +output_path = '~/.config/gtk-4.0/colors.css' +post_hook = '~/.config/matugen/post-hook-scripts/gtk-themes-reload.sh' + +[templates.pywalfox] +input_path = '~/.config/matugen/templates/pywalfox-colors.json' +output_path = '~/.cache/wal/colors.json' + +[templates.swaync] +input_path = '~/.config/matugen/templates/colors.css' +output_path = '~/.config/swaync/colors.css' + +[templates.sequences] +input_path = '~/.config/matugen/templates/sequences' +output_path = '~/.cache/wal/sequences' + +[templates.primary] +input_path = '~/.config/matugen/templates/primary' +output_path = '~/.config/ml4w/colors/primary' + +[templates.secondary] +input_path = '~/.config/matugen/templates/secondary' +output_path = '~/.config/ml4w/colors/secondary' + +[templates.on_surface] +input_path = '~/.config/matugen/templates/onsurface' +output_path = '~/.config/ml4w/colors/onsurface' + +[templates.on_primary] +input_path = '~/.config/matugen/templates/onprimary' +output_path = '~/.config/ml4w/colors/onprimary' + +[templates.ml4w_dotfiles_settings] +input_path = "./templates/colors.json" +output_path = "~/.local/share/ml4w-dotfiles-settings/colors/colors.json" + +# Colors of the ML4W Dock (github.com/mylinuxforwork/ml4w-dock), which reads +# its own copy so it can also run without the rest of the ML4W shell. It watches the file, so no +# post_hook is needed. The path is theme.colorsFile in ~/.config/ml4w-dock/config.json. +[templates.ml4w_dock] +input_path = "./templates/colors.json" +output_path = "~/.config/ml4w-dock/colors.json" + +[templates.quickshell_overview] +input_path = "./templates/quickshell-overview.qml" +output_path = "~/.local/share/quickshell-overview/common/Appearance.colors.qml" + +[templates.ohmyposh] +input_path = '~/.config/matugen/templates/ohmyposh-colors.json' +output_path = '~/.config/ohmyposh/colors.json' +post_hook = "jq --slurpfile palette ~/.config/ohmyposh/colors.json '. + $palette[0]' ~/.config/ohmyposh/EDM115-newline.omp.json > /tmp/new_theme.json && mv /tmp/new_theme.json ~/.config/ohmyposh/EDM115-newline.omp.json" diff --git a/tests/ml4w/apply_ml4w_hooks.bats b/tests/ml4w/apply_ml4w_hooks.bats index 2137843d..d887ca7b 100644 --- a/tests/ml4w/apply_ml4w_hooks.bats +++ b/tests/ml4w/apply_ml4w_hooks.bats @@ -18,6 +18,7 @@ RUNNER_FIXTURE="${BATS_TEST_DIRNAME}/../fixtures/ml4w/ml4w-wallpaper-2.16" LISTENER_FIXTURE="${BATS_TEST_DIRNAME}/../fixtures/ml4w/gtk-theme-switcher-2.16" +MATUGEN_FIXTURE="${BATS_TEST_DIRNAME}/../fixtures/ml4w/matugen-config-2.16.toml" setup() { TEST_DIR="$(mktemp -d)" @@ -27,8 +28,10 @@ setup() { printf '#!/usr/bin/env bash\nexit 0\n' >"${TEST_DIR}/scripts/theme-auto.sh" printf '#!/usr/bin/env bash\nexit 0\n' >"${TEST_DIR}/scripts/wallpaper-hook.sh" chmod +x "${TEST_DIR}/scripts/"*.sh - WAYPAPER_CONFIG="${TEST_DIR}/config.ini" - ML4W_WALLPAPER_SCRIPT="${TEST_DIR}/ml4w-wallpaper" + # Exported: every invocation of the script under test, including ones that do not go + # through run_hooks, must be unable to reach the real ~/.config files. + export WAYPAPER_CONFIG="${TEST_DIR}/config.ini" + export ML4W_WALLPAPER_SCRIPT="${TEST_DIR}/ml4w-wallpaper" printf '[Settings]\npost_command = ~/.config/ml4w/scripts/ml4w-wallpaper "$wallpaper" --skip > /dev/null 2>&1\n' >"${WAYPAPER_CONFIG}" cp "${RUNNER_FIXTURE}" "${ML4W_WALLPAPER_SCRIPT}" chmod +x "${ML4W_WALLPAPER_SCRIPT}" @@ -39,6 +42,10 @@ setup() { chmod +x "${ML4W_GTK_LISTENER}" printf '#!/usr/bin/env bash\necho "$*" >>"%s/restarts"\n' "${TEST_DIR}" >"${ML4W_LISTENERS_SCRIPT}" chmod +x "${ML4W_LISTENERS_SCRIPT}" + # This suite runs with the real HOME: without an override the hooks would edit the + # live ~/.config/matugen/config.toml. + export MATUGEN_CONFIG="${TEST_DIR}/matugen.toml" + cp "${MATUGEN_FIXTURE}" "${MATUGEN_CONFIG}" } teardown() { @@ -74,7 +81,7 @@ count_restarts() { run run_hooks [ "$status" -eq 0 ] [ "$(count_blocks)" -eq 1 ] - grep -q 'wallpaper-hook.sh" "\$IMAGE_PATH"' "${ML4W_WALLPAPER_SCRIPT}" + grep -q 'wallpaper-hook.sh "\$IMAGE_PATH"' "${ML4W_WALLPAPER_SCRIPT}" } @test "apply-ml4w-hooks: the hook runs after the runner's own work" { @@ -129,7 +136,7 @@ LEGACY [ "$(count_blocks)" -eq 1 ] run grep -q 'Dreamcoder final wallpaper/theme sync' "${ML4W_WALLPAPER_SCRIPT}" [ "$status" -ne 0 ] - [ "$(grep -c 'wallpaper-hook.sh" "\$IMAGE_PATH"' "${ML4W_WALLPAPER_SCRIPT}")" -eq 1 ] + [ "$(grep -c 'wallpaper-hook.sh "\$IMAGE_PATH"' "${ML4W_WALLPAPER_SCRIPT}")" -eq 1 ] } @test "apply-ml4w-hooks: a symlinked runner stays a symlink" { @@ -153,7 +160,7 @@ LEGACY ML4W_WALLPAPER_VAR="used_wallpaper" \ DREAMCODER_DOTS_DIR="${TEST_DIR}" \ bash "${DREAMCODER_DOTS_DIR}/scripts/apply-ml4w-hooks.sh" - grep -q 'wallpaper-hook.sh" "\$used_wallpaper"' "${ML4W_WALLPAPER_SCRIPT}" + grep -q 'wallpaper-hook.sh "\$used_wallpaper"' "${ML4W_WALLPAPER_SCRIPT}" } @test "apply-ml4w-hooks: the default target is the current ML4W runner" { @@ -314,3 +321,161 @@ STUB HOME="${TEST_DIR}/home" SETTINGS_FILE="${TEST_DIR}/settings.ini" bash "${TEST_DIR}/block.sh" [ "$(tr '\n' ' ' <"${TEST_DIR}/dispatched")" = "dark light " ] } + +# ── Matugen templates that write Dreamcoder-owned files ───────────────────── +# Matugen rewrites hypr/colors.{conf,lua}, waybar/colors.css, rofi/colors.rasi and (through +# a symlink into waybar) swaync/colors.css from the wallpaper. Restoring Dreamcoder colours +# after Matugen is a race that can be lost, so the templates themselves are disabled. + +template_ids() { + python3 - "$1" <<'PY' +import sys +import tomllib + +with open(sys.argv[1], "rb") as f: + print(" ".join(sorted(tomllib.load(f).get("templates", {})))) +PY +} + +@test "matugen: templates that write Dreamcoder-owned files are disabled, the rest kept" { + run run_hooks + [ "$status" -eq 0 ] + before="$(template_ids "${MATUGEN_FIXTURE}")" + after="$(template_ids "${MATUGEN_CONFIG}")" + for owned in hyprland hyprland-lua waybar rofi swaync; do + [[ " ${before} " == *" ${owned} "* ]] + [[ " ${after} " != *" ${owned} "* ]] + done + for kept in colorsjson kitty btop gtk3 gtk4 quickshell_overview; do + [[ " ${after} " == *" ${kept} "* ]] + done +} + +@test "matugen: the patched config is still valid TOML" { + run run_hooks + [ "$status" -eq 0 ] + run template_ids "${MATUGEN_CONFIG}" + [ "$status" -eq 0 ] +} + +@test "matugen: re-running leaves the config byte-identical" { + run run_hooks + first="$(cat "${MATUGEN_CONFIG}")" + run run_hooks + [ "$status" -eq 0 ] + [ "$(cat "${MATUGEN_CONFIG}")" = "${first}" ] + [[ "$output" == *"Matugen"*"already current"* ]] +} + +@test "matugen: an ML4W upgrade that restores the stock config is disabled again" { + run run_hooks + cp "${MATUGEN_FIXTURE}" "${MATUGEN_CONFIG}" + run run_hooks + [ "$status" -eq 0 ] + [[ " $(template_ids "${MATUGEN_CONFIG}") " != *" waybar "* ]] +} + +@test "matugen: a missing config is skipped without failing" { + rm -f "${MATUGEN_CONFIG}" + run run_hooks + [ "$status" -eq 0 ] + [[ "$output" == *"Matugen config not found"* ]] +} + +# ── robustness (paths with spaces, missing tools) ─────────────────────────── + +@test "harness: every file the hooks write is isolated under the temp dir" { + # A test that reaches the live ~/.config once already left the real wallpaper runner + # pointing at a deleted temp path. + for var in WAYPAPER_CONFIG ML4W_WALLPAPER_SCRIPT ML4W_GTK_LISTENER ML4W_LISTENERS_SCRIPT MATUGEN_CONFIG; do + [[ "${!var}" == "${TEST_DIR}/"* ]] + done +} + +@test "waypaper: the hook executable is quoted so a dots path with spaces still works" { + SPACED="${TEST_DIR}/dots with space" + mkdir -p "${SPACED}/lib" "${SPACED}/scripts" + cp "${TEST_DIR}/lib/"*.sh "${SPACED}/lib/" + cp "${TEST_DIR}/scripts/"*.sh "${SPACED}/scripts/" + DREAMCODER_DOTS_DIR="${SPACED}" run bash "${BATS_TEST_DIRNAME}/../../scripts/apply-ml4w-hooks.sh" + [ "$status" -eq 0 ] + grep -qF 'dots\ with\ space/scripts/wallpaper-hook.sh' "${WAYPAPER_CONFIG}" +} + +@test "hooks: a hostile dots path is escaped in the generated runner and waypaper commands" { + # The path goes into generated shell source. Quotes, $(...), backticks, |, & and a + # backslash must reach the hook as literal characters and must not execute anything. + HOSTILE="${TEST_DIR}"'/we$(touch INJECTED)`touch INJECTED2`"q|a&b\c' + mkdir -p "${HOSTILE}/lib" "${HOSTILE}/scripts" "${TEST_DIR}/work" + cp "${TEST_DIR}/lib/"*.sh "${HOSTILE}/lib/" + cp "${TEST_DIR}/scripts/theme-auto.sh" "${HOSTILE}/scripts/" + printf '#!/usr/bin/env bash\nprintf "%%s" "$1" >"%s/called"\n' "${TEST_DIR}/work" >"${HOSTILE}/scripts/wallpaper-hook.sh" + chmod +x "${HOSTILE}/scripts/"*.sh + DREAMCODER_DOTS_DIR="${HOSTILE}" run bash "${BATS_TEST_DIRNAME}/../../scripts/apply-ml4w-hooks.sh" + [ "$status" -eq 0 ] + + # Runner block: run it as the ML4W runner would. + sed -n '/^# >>> Dreamcoder wallpaper hook >>>$/,/^# <<< Dreamcoder wallpaper hook <<<$/p' \ + "${ML4W_WALLPAPER_SCRIPT}" >"${TEST_DIR}/block.sh" + (cd "${TEST_DIR}/work" && IMAGE_PATH=runner-image bash "${TEST_DIR}/block.sh") + [ "$(cat "${TEST_DIR}/work/called")" = "runner-image" ] + + # waypaper post_command: run the appended command with $wallpaper set. + rm -f "${TEST_DIR}/work/called" + cmd="$(grep '^post_command' "${WAYPAPER_CONFIG}" | sed 's/^[^;]*; //')" + (cd "${TEST_DIR}/work" && wallpaper=waypaper-image bash -c "${cmd}") + [ "$(cat "${TEST_DIR}/work/called")" = "waypaper-image" ] + + [ ! -e "${TEST_DIR}/work/INJECTED" ] + [ ! -e "${TEST_DIR}/work/INJECTED2" ] + [ ! -e "${TEST_DIR}/INJECTED" ] +} + +@test "hooks: a wallpaper variable that is not a shell identifier is rejected" { + ML4W_WALLPAPER_VAR='x; touch INJECTED' run run_hooks + [ "$status" -ne 0 ] + [[ "$output" == *"not a valid shell identifier"* ]] +} + +# A PATH holding only the tools the script needs, so a specific one can be left out. +restricted_path() { + local skip="$1" bin="${TEST_DIR}/restricted-bin" tool src + mkdir -p "${bin}" + for tool in bash env dirname awk sed grep cat tr cut head tail sort uniq date git readlink \ + chmod mkdir cp rm mktemp python3 timeout printf hostname; do + [[ "${tool}" == "${skip}" ]] && continue + src="$(command -v "${tool}" 2>/dev/null)" && ln -sf "${src}" "${bin}/${tool}" + done + printf '%s' "${bin}" +} + +# Set PATH only around the hooks, not around bats' own `run` bookkeeping. +run_hooks_with_path() { + PATH="$1" run_hooks +} + +@test "listener: without timeout(1) the hook is skipped with a warning, not applied unbounded" { + before="$(cat "${ML4W_GTK_LISTENER}")" + run run_hooks_with_path "$(restricted_path timeout)" + [[ "$output" == *"timeout(1) not found"* ]] + [ "$(cat "${ML4W_GTK_LISTENER}")" = "${before}" ] +} + +@test "matugen: without a TOML validator the config is left untouched with a warning" { + before="$(cat "${MATUGEN_CONFIG}")" + bin="${TEST_DIR}/nopy" + mkdir -p "${bin}" + printf '#!/bin/sh\nexit 1\n' >"${bin}/python3" + chmod +x "${bin}/python3" + run run_hooks_with_path "${bin}:${PATH}" + [[ "$output" == *"to validate the Matugen config, left untouched"* ]] + [ "$(cat "${MATUGEN_CONFIG}")" = "${before}" ] +} + +@test "hooks: a missing required library fails with a clear message" { + empty="${TEST_DIR}/no-lib" + mkdir -p "${empty}" + DREAMCODER_DOTS_DIR="${empty}" run bash "${BATS_TEST_DIRNAME}/../../scripts/apply-ml4w-hooks.sh" + [ "$status" -ne 0 ] + [[ "$output" == *"Required library not found"* ]] +}