diff --git a/CHANGELOG.md b/CHANGELOG.md index 817e9c064..16ef6f49b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Hyprland: dropped the legacy hyprlang dot, the files it deployed no longer exist ### Fixed +- Hyprland: a session started without `XDG_CONFIG_HOME`, such as one launched from a TTY, no longer dies with a Lua error before the first window; the unset variable is treated as unset instead of being pasted into a path - Waybar: the theme module and the HyDE menu call `theme.switch` again; `themeswitch` was removed and the calls failed outright - Dolphin: the "Set As Wallpaper" service menu switches the theme again - Fish: `HYPRLAND_CONFIG` points at the deployed `hypr/hyde.lua` instead of a config the Lua release deleted, so a session started outside uwsm no longer comes up without a config diff --git a/Configs/.local/share/hypr/lua/dynamic.lua b/Configs/.local/share/hypr/lua/dynamic.lua index 712f106f1..af18c36dc 100644 --- a/Configs/.local/share/hypr/lua/dynamic.lua +++ b/Configs/.local/share/hypr/lua/dynamic.lua @@ -58,10 +58,18 @@ end local state_ui = check_require("lua_state.ui") or {} hyde.config({ui = state_ui.ui or state_ui, wallbash = state_ui.wallbash or {}}, {skip_empty = true}) -- Merge the config safely and ignore blank strings from state UI --- Loads user config if exists -local user_config_path = os.getenv("XDG_CONFIG_HOME") .. "/hyde/config.toml" -if io.open(user_config_path) then - hyde.config.load_toml(user_config_path) +-- Loads the user config if there is one. The directory comes from hyde.path, +-- which leaves it nil rather than building a path out of an unset variable, so +-- a session without XDG_CONFIG_HOME skips the optional file instead of dying +-- on a nil concatenation. The probe closes the handle it opens: this runs on +-- every reload, and a leaked one accumulates for the life of the session. +local user_config_path = hyde.path.config and (hyde.path.config .. "/hyde/config.toml") +if user_config_path then + local user_config = io.open(user_config_path) + if user_config then + user_config:close() + hyde.config.load_toml(user_config_path) + end end check_require("lua_state.animations") diff --git a/Configs/.local/share/hypr/lua/hyde/path.lua b/Configs/.local/share/hypr/lua/hyde/path.lua index b45e7a74c..c8ea175b0 100644 --- a/Configs/.local/share/hypr/lua/hyde/path.lua +++ b/Configs/.local/share/hypr/lua/hyde/path.lua @@ -1,46 +1,101 @@ --- hyde/path.lua --- Provides global access to important XDG and HyDE paths +--- @module hyde.path +--- Resolves the XDG and HyDE directories the Hyprland Lua configuration reads +--- from, and exposes them globally as `hyde.path`. +--- +--- This module is loaded before any other part of the configuration, so it has +--- to survive an environment it does not like. A session started outside uwsm +--- — from a TTY, or through a display manager that exports little — can reach +--- it with `HOME` absent and the XDG variables unset or empty. A field that +--- cannot be resolved is left `nil` for the caller to skip; raising here would +--- take the whole configuration down before Hyprland has a window on screen. +--- +--- Usage: +--- local config_file = hyde.path.config and (hyde.path.config .. "/hyde/config.toml") local P = {} -P.config = os.getenv("XDG_CONFIG_HOME") or (os.getenv("HOME") .. "/.config") -P.cache = os.getenv("XDG_CACHE_HOME") or (os.getenv("HOME") .. "/.cache") -P.state = (os.getenv("XDG_STATE_HOME") or (os.getenv("HOME") .. "/.local/state")) +local HOME = os.getenv("HOME") --- XDG runtime directory +--- Reads an XDG base directory from the environment. +--- +--- Per the specification a variable that is set but empty counts as unset, and +--- the fallback is derived from `HOME`. +--- +--- @param name string Environment variable holding the directory. +--- @param fallback string Path appended to `HOME` when the variable is unset. +--- @return string|nil path The directory, or nil when `HOME` is absent too. +--- +--- Example: +--- env("XDG_CONFIG_HOME", "/.config") --> "/home/user/.config" +local function env(name, fallback) + local value = os.getenv(name) + if value == nil or value == "" then + return HOME and HOME .. fallback + end + + return value +end + +--- @field config string|nil User configuration directory. +P.config = env("XDG_CONFIG_HOME", "/.config") + +--- @field cache string|nil User cache directory. +P.cache = env("XDG_CACHE_HOME", "/.cache") + +--- @field state string|nil User state directory. +P.state = env("XDG_STATE_HOME", "/.local/state") + +--- @field data string|nil User data directory. +P.data = env("XDG_DATA_HOME", "/.local/share") + +--- @field runtime string|nil Runtime directory. It has no fallback: the spec +--- requires the session to provide one, and inventing a path would put runtime +--- state somewhere that outlives the session. P.runtime = os.getenv("XDG_RUNTIME_DIR") --- HyDE library directory resolution -local lib_paths = { - os.getenv("HOME") .. "/.local/lib", - "/usr/local/lib", - "/usr/lib", -} -for _, p in ipairs(lib_paths) do - local test = io.popen("[ -d '" .. p .. "' ] && echo 1 || echo 0"):read("*l") - if test == "1" then - P.lib = p - break +--- Reports whether a path is a directory. +--- +--- Plain Lua cannot stat, so this asks the shell. The pipe is closed on every +--- outcome — leaving it open leaks a handle per probe, and this module runs on +--- every configuration reload. +--- +--- @param path string Absolute path to test. +--- @return boolean exists True when the path is a directory. +local function is_directory(path) + local pipe = io.popen("[ -d '" .. path .. "' ] && echo 1 || echo 0") + if not pipe then + return false end + + local answer = pipe:read("*l") + pipe:close() + + return answer == "1" end --- HyDE share directory: check user, then system -local share_paths = { - (os.getenv("XDG_DATA_HOME") or (os.getenv("HOME") .. "/.local/share")) .. "", - "/usr/local/share", - "/usr/share", -} -for _, p in ipairs(share_paths) do - local test = io.popen("[ -d '" .. p .. "' ] && echo 1 || echo 0"):read("*l") - if test == "1" then - P.share = p - break +--- Returns the first candidate that exists, preferring the user's own. +--- +--- @param user_path string|nil User candidate, nil when it cannot be resolved. +--- @param system_paths table Ordered system candidates to fall back on. +--- @return string|nil path The first existing directory, or nil when none do. +local function first_directory(user_path, system_paths) + if user_path and is_directory(user_path) then + return user_path + end + + for _, path in ipairs(system_paths) do + if is_directory(path) then + return path + end end end +--- @field lib string|nil Directory holding the HyDE Lua and shell libraries. +P.lib = first_directory(HOME and HOME .. "/.local/lib", {"/usr/local/lib", "/usr/lib"}) +--- @field share string|nil Directory holding the shipped HyDE data files. +P.share = first_directory(P.data, {"/usr/local/share", "/usr/share"}) --- Make globally accessible _G.hyde = _G.hyde or {} _G.hyde.path = P diff --git a/tests/test_paths.sh b/tests/test_paths.sh new file mode 100755 index 000000000..b037227e1 --- /dev/null +++ b/tests/test_paths.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env sh +# The Hyprland config resolves its paths through hyde.path, which runs before +# anything else in the session. An environment it cannot cope with takes the +# whole config down, so the resolver has to survive a missing or empty variable +# rather than error on a nil concatenation. + +# shellcheck source=tests/lib/common.sh +. "$(dirname -- "$0")/lib/common.sh" + +if ! command -v lua >/dev/null 2>&1; then + skip "lua is not installed" + finish +fi + +path_module="$REPO_ROOT/Configs/.local/share/hypr/lua/hyde/path.lua" +[ -f "$path_module" ] || { + fail "hyde/path.lua is missing" + finish +} + +work_dir=$(mktemp -d) +trap 'rm -rf "$work_dir"' EXIT +mkdir -p "$work_dir/home/.config" + +# Prints the resolved value of one field, or "error" when loading blew up. +resolve() { + lua -e " + local ok, err = pcall(dofile, [[$path_module]]) + if not ok then + io.write('error: ', tostring(err)) + else + io.write(tostring(hyde.path.$1)) + end + " 2>&1 +} + +config=$(HOME="$work_dir/home" XDG_CONFIG_HOME='' resolve config) +[ "$config" = "$work_dir/home/.config" ] || + fail "an empty XDG_CONFIG_HOME did not fall back to HOME: $config" + +config=$(HOME="$work_dir/home" XDG_CONFIG_HOME="$work_dir/elsewhere" resolve config) +[ "$config" = "$work_dir/elsewhere" ] || + fail "a set XDG_CONFIG_HOME was not honoured: $config" + +# A session without HOME cannot resolve the fallback, but it must not error: +# an unresolved path is skippable, a raised error is not. +config=$(env -u HOME -u XDG_CONFIG_HOME lua -e " + local ok, err = pcall(dofile, [[$path_module]]) + if not ok then + io.write('error: ', tostring(err)) + else + io.write(tostring(hyde.path.config)) + end +" 2>&1) +case $config in +error*) fail "an absent HOME raised instead of leaving the path unresolved: $config" ;; +nil) ;; +*) fail "an absent HOME resolved a path out of nowhere: $config" ;; +esac + +# The consumer has to be prepared for that, otherwise the guard above buys +# nothing: dynamic.lua must not concatenate the config path unconditionally. +dynamic="$REPO_ROOT/Configs/.local/share/hypr/lua/dynamic.lua" +grep -q 'os.getenv("XDG_CONFIG_HOME") \.\.' "$dynamic" && + fail "dynamic.lua still builds the config path by concatenating os.getenv" + +# Every probe of an optional file has to close what it opened; a bare +# `if io.open(path) then` leaks a handle on every config reload. +grep -qE '^[[:space:]]*if io\.open\(' "$dynamic" && + fail "dynamic.lua tests a file with io.open without closing the handle" + +printf ' %d path field(s) checked\n' 3 + +finish