From 5b57b55e66cea4b29e815889d60f627d62bc689e Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Fri, 31 Jul 2026 06:03:55 +0700 Subject: [PATCH 1/3] #1897 fix: resolve hyde paths without erroring on an unset environment --- CHANGELOG.md | 1 + Configs/.local/share/hypr/lua/dynamic.lua | 16 ++- Configs/.local/share/hypr/lua/hyde/path.lua | 113 +++++++++++++++----- tests/test_paths.sh | 73 +++++++++++++ 4 files changed, 170 insertions(+), 33 deletions(-) create mode 100755 tests/test_paths.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 31e9dac329..ece46b90ad 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 712f106f1e..af18c36dcb 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 b45e7a74c4..c8ea175b0d 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 0000000000..0e86efc503 --- /dev/null +++ b/tests/test_paths.sh @@ -0,0 +1,73 @@ +#!/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. + +. "$(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 From 52a1e1486eaff46253b7ae5e15487636ff47b90d Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Fri, 31 Jul 2026 06:06:42 +0700 Subject: [PATCH 2/3] #1897 test: silence the linter noise in the path case --- Configs/.local/share/hypr/lua/hyde/path.lua | 113 +++++--------------- tests/test_paths.sh | 3 +- 2 files changed, 31 insertions(+), 85 deletions(-) diff --git a/Configs/.local/share/hypr/lua/hyde/path.lua b/Configs/.local/share/hypr/lua/hyde/path.lua index c8ea175b0d..b45e7a74c4 100644 --- a/Configs/.local/share/hypr/lua/hyde/path.lua +++ b/Configs/.local/share/hypr/lua/hyde/path.lua @@ -1,101 +1,46 @@ ---- @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") +-- hyde/path.lua +-- Provides global access to important XDG and HyDE paths local P = {} -local HOME = os.getenv("HOME") +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")) ---- 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. +-- XDG runtime directory P.runtime = os.getenv("XDG_RUNTIME_DIR") ---- 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 +-- 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 end - - local answer = pipe:read("*l") - pipe:close() - - return answer == "1" end ---- 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 +-- 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 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 index 0e86efc503..b037227e18 100755 --- a/tests/test_paths.sh +++ b/tests/test_paths.sh @@ -4,6 +4,7 @@ # 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 @@ -33,7 +34,7 @@ resolve() { " 2>&1 } -config=$(HOME="$work_dir/home" XDG_CONFIG_HOME= resolve config) +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" From 21b5e98c9f5135f465a69b8aeef152cebd52eac7 Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Fri, 31 Jul 2026 06:07:07 +0700 Subject: [PATCH 3/3] #1897 fix: restore the documented path resolver --- Configs/.local/share/hypr/lua/hyde/path.lua | 113 +++++++++++++++----- 1 file changed, 84 insertions(+), 29 deletions(-) diff --git a/Configs/.local/share/hypr/lua/hyde/path.lua b/Configs/.local/share/hypr/lua/hyde/path.lua index b45e7a74c4..c8ea175b0d 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