diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9b1921a75c..fc5f5781f8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,7 +24,7 @@ jobs: - name: Install test dependencies run: | sudo apt-get update - sudo apt-get install -y --no-install-recommends lua5.4 shellcheck + sudo apt-get install -y --no-install-recommends lua5.4 shellcheck fish sudo ln -sf /usr/bin/lua5.4 /usr/local/bin/lua sudo ln -sf /usr/bin/luac5.4 /usr/local/bin/luac @@ -32,6 +32,7 @@ jobs: run: | lua -v shellcheck --version + fish --version python3 --version - name: Run the test suite diff --git a/CHANGELOG.md b/CHANGELOG.md index 416e049bd2..e01f14f6c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,12 @@ 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 +- Fish: `~/.local/bin` reaches `PATH` again, so `hyde-shell` and everything the keybinds call resolve in a fish session; the directory was handed to `fish_add_path` joined to the rest of `PATH` by colons, which is one path that exists nowhere and is dropped without a word +- Weather Applet: Avoid crashes from unknown weather codes or unavailable wttr.in responses. +- Core: an install no longer ends at the Lua step on a machine that cannot build `lgi`; the introspection headers it compiles against are declared as a dependency, and an optional rock that still fails is reported and skipped instead of taking the run down before any dotfile is deployed +- Hyprland: a session on a machine with a discrete NVIDIA GPU no longer comes up with a timed-out configuration; driver detection reads `/proc` and `/sys`, and the library directories are found by opening the candidate paths, so the budget Hyprland allows the whole configuration is no longer spent waiting on a subprocess +- Core: an install deploys the cursor dots again instead of aborting on an existing theme file; the locked `deez-dots` revision predated the extraction fix, so every install kept running the defect the fix had already closed +- Core: Pyprland commands keep their arguments when `nc`, `socat`, and `ncat` are unavailable, so commands such as `hyde-shell pypr toggle console` work through the CLI fallback again - 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 - Hyprland: a home directory containing an apostrophe no longer makes the session load its libraries from the system directory instead of the user's own, and an empty `XDG_RUNTIME_DIR` reads as unset rather than resolving against the working directory - Waybar: the theme module and the HyDE menu call `theme.switch` again; `themeswitch` was removed and the calls failed outright diff --git a/Configs/.config/fish/conf.d/hyde.fish b/Configs/.config/fish/conf.d/hyde.fish index 7bb2c98e14..a8ce696136 100644 --- a/Configs/.config/fish/conf.d/hyde.fish +++ b/Configs/.config/fish/conf.d/hyde.fish @@ -82,7 +82,10 @@ end end end -fish_add_path $HOME/.local/bin:$PATH +# One directory per argument: fish_add_path does not split on ":", so a +# colon-joined string is a single path that exists nowhere, and a path that +# does not exist is dropped without a word. +fish_add_path $HOME/.local/bin if type -q starship diff --git a/Configs/.config/fish/functions/_hyde_editor.fish b/Configs/.config/fish/functions/_hyde_editor.fish new file mode 100644 index 0000000000..ff24d24859 --- /dev/null +++ b/Configs/.config/fish/functions/_hyde_editor.fish @@ -0,0 +1,13 @@ +# The editor a selection opens in, discovered rather than chosen. Mirrors +# Configs/.config/zsh/functions/fzf.zsh: the user's own answer first, then an +# order of discovery over what this machine already has. HyDE installs none of +# them. +function _hyde_editor -d "Print the first usable editor on this machine" + for candidate in $EDITOR $VISUAL nvim vim helix hx nano micro emacs + if test -n "$candidate"; and type -q -- $candidate + echo $candidate + return 0 + end + end + return 1 +end diff --git a/Configs/.config/fish/functions/fzf/ffe.fish b/Configs/.config/fish/functions/fzf/ffe.fish index ba9b8bab22..337657e2cc 100644 --- a/Configs/.config/fish/functions/fzf/ffe.fish +++ b/Configs/.config/fish/functions/fzf/ffe.fish @@ -18,8 +18,13 @@ function ffe -d "Find file with fzf and open in Editor" set selected_file (find . -maxdepth $max_depth -type f 2>/dev/null | fzf $fzf_options) if test -n "$selected_file"; and test -f "$selected_file" + set editor (_hyde_editor) + if test -z "$editor" + echo "No editor found. Install one, or set EDITOR in ~/.config/fish/user.fish." + return 1 + end cd (dirname $selected_file) - nvim (basename $selected_file) + $editor (basename $selected_file) else return 1 end diff --git a/Configs/.config/fish/functions/fzf/ffec.fish b/Configs/.config/fish/functions/fzf/ffec.fish index 043f0c0f0c..a7aea7da33 100644 --- a/Configs/.config/fish/functions/fzf/ffec.fish +++ b/Configs/.config/fish/functions/fzf/ffec.fish @@ -20,8 +20,13 @@ function ffec -d "Fuzzy search by file content and open in Editor" set selected_file (grep -irl -- "$grep_pattern" ./ 2>/dev/null | fzf $fzf_options) if test -n "$selected_file" + set editor (_hyde_editor) + if test -z "$editor" + echo "No editor found. Install one, or set EDITOR in ~/.config/fish/user.fish." + return 1 + end cd (dirname $selected_file) - nvim (basename $selected_file) + $editor (basename $selected_file) else echo "No file selected or search returned no results." end diff --git a/Configs/.config/fish/user.fish b/Configs/.config/fish/user.fish index caf105a908..b32a145341 100644 --- a/Configs/.config/fish/user.fish +++ b/Configs/.config/fish/user.fish @@ -35,7 +35,7 @@ # Add your configurations here # set EDITOR nvim -set EDITOR code +# set EDITOR code # set aurhelper yay set aurhelper yay diff --git a/Configs/.config/hypr/hyprland.lua b/Configs/.config/hypr/hyprland.lua index d110b85860..0e03779d07 100644 --- a/Configs/.config/hypr/hyprland.lua +++ b/Configs/.config/hypr/hyprland.lua @@ -1,3 +1,19 @@ +-- Hyprland loads this file when it is started without a config, and it prefers +-- it over hyprland.conf. HyDE loads it too, last, as the override layer below. +-- The block keeps the two apart: hyde.lua sets `hyde` on its first line, so it +-- runs only when this file is the entry point and HyDE has not been loaded. +-- Removing it leaves a session with a cursor and nothing else. +if not hyde then + local share = os.getenv("XDG_DATA_HOME") or (os.getenv("HOME") .. "/.local/share") + local entry = share .. "/hypr/hyde.lua" + local handle = io.open(entry, "r") + if not handle then + error("HyDE is not installed at " .. entry .. ". Run install.sh -r, or point Hyprland at your own config.") + end + handle:close() + dofile(entry) +end + -- Your Hyprland configuration. HyDE never overwrites this file. -- -- It loads after HyDE's own binds, so settings here take precedence. Replacing diff --git a/Configs/.config/zsh/.zshrc b/Configs/.config/zsh/.zshrc index 9e81274ad2..5b2fe31254 100644 --- a/Configs/.config/zsh/.zshrc +++ b/Configs/.config/zsh/.zshrc @@ -38,6 +38,6 @@ #  This is your file  # Add your configurations here # export EDITOR=nvim -export EDITOR=code +# export EDITOR=code # unset -f command_not_found_handler # Uncomment to prevent searching for commands not found in package manager diff --git a/Configs/.config/zsh/functions/fzf.zsh b/Configs/.config/zsh/functions/fzf.zsh index ff0474560e..65bb571137 100644 --- a/Configs/.config/zsh/functions/fzf.zsh +++ b/Configs/.config/zsh/functions/fzf.zsh @@ -1,4 +1,21 @@ # best fzf aliases ever + +# The editor a selection opens in, discovered rather than chosen. The user's own +# answer comes first; the rest is an order of discovery over what this machine +# already has. HyDE installs none of them, so a machine with no editor gets a +# message naming the fix instead of a guess. +_hyde_editor() { + local candidate + for candidate in "$EDITOR" "$VISUAL" nvim vim helix hx nano micro emacs; do + [[ -n "$candidate" ]] || continue + if command -v "$candidate" &>/dev/null; then + printf '%s' "$candidate" + return 0 + fi + done + return 1 +} + _fuzzy_change_directory() { local initial_query="$1" local selected_dir @@ -34,11 +51,12 @@ _fuzzy_edit_search_file_content() { selected_file=$(grep -irl "${1:-}" ./ | fzf "${fzf_options[@]}") if [[ -n "$selected_file" ]]; then - if command -v "$EDITOR" &>/dev/null; then - "$EDITOR" "$selected_file" + local editor + if editor=$(_hyde_editor); then + "$editor" "$selected_file" else - echo "EDITOR is not specified. using vim. (you can export EDITOR in ~/.zshrc)" - vim "$selected_file" + echo "No editor found. Install one, or export EDITOR from \$ZDOTDIR/user.zsh or \$HOME/.user.zsh." + return 1 fi else @@ -61,11 +79,12 @@ _fuzzy_edit_search_file() { selected_file=$(find . -maxdepth $max_depth -type f 2>/dev/null | fzf "${fzf_options[@]}") if [[ -n "$selected_file" && -f "$selected_file" ]]; then - if command -v "$EDITOR" &>/dev/null; then - "$EDITOR" "$selected_file" + local editor + if editor=$(_hyde_editor); then + "$editor" "$selected_file" else - echo "EDITOR is not specified. using vim. (you can export EDITOR in ~/.zshrc)" - vim "$selected_file" + echo "No editor found. Install one, or export EDITOR from \$ZDOTDIR/user.zsh or \$HOME/.user.zsh." + return 1 fi else return 1 diff --git a/Configs/.local/bin/hyde-shell b/Configs/.local/bin/hyde-shell index 5c7cb32530..d27bdd819f 100755 --- a/Configs/.local/bin/hyde-shell +++ b/Configs/.local/bin/hyde-shell @@ -377,11 +377,12 @@ run_pypr() { fi if [[ -S "$socket_path" ]] && pgrep -u "$USER" pypr >/dev/null 2>&1; then - message="${*:-"help"}" - if ! printf "%s" "${message[@]}" | nc -N -U "$socket_path" 2>/dev/null; then - if ! printf "%s" "${message[@]}" | socat - UNIX-CONNECT:"$socket_path" 2>/dev/null; then - if ! printf "%s" "${message[@]}" | ncat -U "$socket_path" 2>/dev/null; then - if ! pypr "${message[@]}"; then + local -a msg=("$@") + ((${#msg[@]} == 0)) && msg=("help") + if ! printf '%s\n' "${msg[*]}" | nc -N -U "$socket_path" 2>/dev/null; then + if ! printf '%s\n' "${msg[*]}" | socat - UNIX-CONNECT:"$socket_path" 2>/dev/null; then + if ! printf '%s\n' "${msg[*]}" | ncat -U "$socket_path" 2>/dev/null; then + if ! pypr "${msg[@]}"; then print_log -sec "pypr" "Error communicating with socket: $socket_path" exit 1 fi diff --git a/Configs/.local/lib/hyde/pyutils/lua_env.json b/Configs/.local/lib/hyde/pyutils/lua_env.json index 1f5d042aea..b33ba159d3 100644 --- a/Configs/.local/lib/hyde/pyutils/lua_env.json +++ b/Configs/.local/lib/hyde/pyutils/lua_env.json @@ -5,7 +5,7 @@ {"name": "dkjson", "version": "2.11-1"}, {"name": "luasocket", "version": "3.1.0-1"}, {"name": "luafilesystem", "version": "1.9.0-1"}, - "https://raw.githubusercontent.com/lgi-devs/lgi/master/lgi-scm-1.rockspec" + {"name": "https://raw.githubusercontent.com/lgi-devs/lgi/master/lgi-scm-1.rockspec", "optional": true} ], "snapshot_exclude": [ {"name": "luasec", "version": "1.3.2-1"}, diff --git a/Configs/.local/lib/hyde/pyutils/lua_env.py b/Configs/.local/lib/hyde/pyutils/lua_env.py index 881483ea06..8202209cf3 100644 --- a/Configs/.local/lib/hyde/pyutils/lua_env.py +++ b/Configs/.local/lib/hyde/pyutils/lua_env.py @@ -64,10 +64,16 @@ def load_bootstrap_config(): return install, snapshot_exclude def _parse_pkg_entry(entry): + """Reads one package entry as (name, version, optional). + + An entry may mark itself optional, which decides what happens when the + rock refuses to build: a required one stops the caller, an optional one + is reported and skipped. + """ if isinstance(entry, dict) and "name" in entry: - return (entry["name"], entry.get("version")) + return (entry["name"], entry.get("version"), bool(entry.get("optional"))) elif isinstance(entry, str): - return (entry, None) + return (entry, None, False) return None if isinstance(config, dict): @@ -79,7 +85,7 @@ def _parse_pkg_entry(entry): if exclude_cfg is None: exclude_cfg = config.get("exclude") - # install: list of (name, version) tuples + # install: list of (name, version, optional) tuples if isinstance(install_cfg, list): install = [] for item in install_cfg: @@ -97,7 +103,7 @@ def _parse_pkg_entry(entry): else: snapshot_exclude = set() # Always exclude bootstrap_install names - snapshot_exclude = set([name for name, _ in install]) | snapshot_exclude + snapshot_exclude = set([entry[0] for entry in install]) | snapshot_exclude return install, snapshot_exclude @@ -201,6 +207,33 @@ def install_rock(name, version=None, force=False): run(args) +def install_bootstrap(bootstrap_install, force=False): + """Installs the bootstrap rocks, skipping the optional ones that fail. + + A rock that refuses to build stops the caller only when the bootstrap + config declares it required. lgi is the reason this distinction exists: it + is compiled against the GObject introspection headers, and a system without + them used to take the whole installation down at the Lua step, before a + single dotfile had been deployed. + """ + skipped = [] + + for name, version, optional in bootstrap_install: + try: + install_rock(name, version, force=force) + except subprocess.CalledProcessError: + if not optional: + raise + skipped.append(name) + print(f"[lua_env] {name} did not build; it is optional, continuing without it") + + if skipped: + print(f"[lua_env] {len(skipped)} optional package(s) skipped: {', '.join(skipped)}") + print("[lua_env] install their build dependencies and run 'hyde-shell luainit' to retry") + + return skipped + + def create_env(force=False): ensure_system_tools() print(f"[lua_env] Using system Lua: {LUA_BIN}") @@ -209,8 +242,7 @@ def create_env(force=False): bootstrap_install, _ = load_bootstrap_config() ensure_state_dir() - for name, version in bootstrap_install: - install_rock(name, version, force=force) + install_bootstrap(bootstrap_install, force=force) restore_saved_rocks(force=force) @@ -236,8 +268,7 @@ def sync_env(): ensure_state_dir() # Reinstall all bootstrap_install packages to latest or pinned version bootstrap_install, _ = load_bootstrap_config() - for name, version in bootstrap_install: - install_rock(name, version, force=True) + install_bootstrap(bootstrap_install, force=True) # Now snapshot only user-installed rocks snapshot_installed_rocks() diff --git a/Configs/.local/lib/hyde/uv.lock b/Configs/.local/lib/hyde/uv.lock index 3be71c4a14..5892dbe29b 100644 --- a/Configs/.local/lib/hyde/uv.lock +++ b/Configs/.local/lib/hyde/uv.lock @@ -340,7 +340,7 @@ wheels = [ [[package]] name = "deez-dots" version = "0.1.0" -source = { git = "https://github.com/HyDE-Project/deez-dots.git#406d5602cd60600bd05e9d1139bc5ec0dd5eaa44" } +source = { git = "https://github.com/HyDE-Project/deez-dots.git#e514a3215d4d0a691ac532accfa12e982d41160e" } [[package]] name = "frozenlist" diff --git a/Configs/.local/lib/hyde/weather.py b/Configs/.local/lib/hyde/weather.py index 88927edc72..e6d68dd071 100755 --- a/Configs/.local/lib/hyde/weather.py +++ b/Configs/.local/lib/hyde/weather.py @@ -82,7 +82,7 @@ class WttrResponse(TypedDict): WEATHER_CODES = { **dict.fromkeys(["113"], "☀️ "), **dict.fromkeys(["116"], "⛅ "), - **dict.fromkeys(["119", "122", "143", "248", "260"], "☁️ "), + **dict.fromkeys(["119", "122", "143", "149", "248", "260"], "☁️ "), **dict.fromkeys( [ "176", @@ -136,7 +136,7 @@ def load_env_file(filepath: Path) -> None: def get_weather_icon(weatherinstance: CurrentCondition | HourlyPoint) -> str: """Returns the appropriate weather icon based on the weather code.""" - return WEATHER_CODES[weatherinstance["weatherCode"]] + return WEATHER_CODES.get(weatherinstance["weatherCode"], "☁️ ") def get_description(weatherinstance: CurrentCondition | HourlyPoint) -> str: @@ -325,6 +325,37 @@ def get_default_locale() -> tuple[str, TempUnit, TimeFormat, WindUnit]: windspeed_unit: WindUnit = "km/h" +def get_weather_data(url: str, headers: dict[str, str]) -> WttrResponse | None: + try: + response = requests.get(url, timeout=10, headers=headers) + response.raise_for_status() + weather = response.json() + except (requests.exceptions.RequestException, json.decoder.JSONDecodeError): + return None + + if not isinstance(weather, dict): + return None + + current_condition = weather.get("current_condition") + forecast = weather.get("weather") + nearest_area = weather.get("nearest_area") + if not ( + isinstance(current_condition, list) + and len(current_condition) > 0 + and isinstance(forecast, list) + and len(forecast) > 0 + and isinstance(nearest_area, list) + and len(nearest_area) > 0 + ): + return None + + return cast(WttrResponse, weather) + + +def print_weather_unavailable() -> None: + print(json.dumps({"text": "Weather --", "tooltip": "Weather unavailable: wttr.in did not return usable data", "class": "error"})) + + def main() -> None: global weather_lang, temp_unit, time_format, windspeed_unit @@ -391,12 +422,15 @@ def main() -> None: # Get the weather data headers = {"User-Agent": "Mozilla/5.0"} - response = requests.get(url, timeout=10, headers=headers) - try: - weather = cast(WttrResponse, response.json()) - except json.decoder.JSONDecodeError: - sys.exit(1) - current_weather = weather["current_condition"][0] + weather = get_weather_data(url, headers) + if weather is None: + print_weather_unavailable() + sys.exit(0) + current_conditions = weather.get("current_condition") + if not current_conditions: + print_weather_unavailable() + sys.exit(0) + current_weather = current_conditions[0] # Get the data to display # waybar text diff --git a/Configs/.local/share/hypr/lua/env.lua b/Configs/.local/share/hypr/lua/env.lua index b91f292925..9e965cf633 100644 --- a/Configs/.local/share/hypr/lua/env.lua +++ b/Configs/.local/share/hypr/lua/env.lua @@ -9,14 +9,30 @@ hl.env("DCONF_PROFILE", ((os.getenv("XDG_CONFIG_HOME") ~= "" and os.getenv("XDG -- This is only a bare minimum to get NVIDIA working. -- User may need to add specific variables for their -- setup in ~/.config/hypr/hyprland.lua +-- The probe reads two files and never leaves the process. It used to run +-- nvidia-smi, which is what took whole sessions down: Hyprland gives the +-- entire configuration 1500 ms and its watchdog counts VM instructions, so it +-- cannot interrupt a blocked C call. A laptop that has to wake a sleeping +-- discrete GPU to answer spends the budget here, and every module loaded after +-- this one dies with a timeout reported somewhere unrelated. local function has_nvidia_working() - local f = io.open("/proc/driver/nvidia/version", "r") - if not f then + local version = io.open("/proc/driver/nvidia/version", "r") + if not version then return false end - f:close() - local ok, _, code = os.execute("nvidia-smi >/dev/null 2>&1") - return ok == true or code == 0 + version:close() + + -- The module registers /proc as it comes up, so a driver that is still + -- loading or on its way out is ruled out by its recorded state. + local initstate = io.open("/sys/module/nvidia/initstate", "r") + if not initstate then + return true + end + + local state = initstate:read("*l") + initstate:close() + + return state == "live" end if has_nvidia_working() then hl.env("LIBVA_DRIVER_NAME", "nvidia") diff --git a/Configs/.local/share/hypr/lua/hyde/path.lua b/Configs/.local/share/hypr/lua/hyde/path.lua index fe88e2e8bd..7aca9d0747 100644 --- a/Configs/.local/share/hypr/lua/hyde/path.lua +++ b/Configs/.local/share/hypr/lua/hyde/path.lua @@ -57,39 +57,40 @@ P.data = env("XDG_DATA_HOME", "/.local/share") --- so it reads as nil rather than as the working directory. P.runtime = env("XDG_RUNTIME_DIR") ---- Wraps a value so the shell reads it as one literal word. ---- ---- Single quotes protect everything except a single quote itself, which has to ---- leave the quoted run, contribute an escaped quote and open a new run. Values ---- here are derived from HOME, and a home directory is free to contain one. ---- ---- @param value string Value to pass to the shell. ---- @return string quoted The value as a single quoted shell word. ---- ---- Example: ---- shell_quote("/home/o'brien") --> "'/home/o'\\''brien'" -local function shell_quote(value) - return "'" .. value:gsub("'", "'\\''") .. "'" -end +--- Errno the open reports when the path exists but may not be read. +local EACCES = 13 --- 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. +--- Plain Lua cannot stat, so this asks the path itself, and asks for the entry +--- inside it that only a directory has: `dir/.` opens, while `file/.` and +--- `fifo/.` fail with ENOTDIR before anything is opened. Probing the bare path +--- instead would hand the resolver a way to hang — opening a FIFO with no +--- writer blocks until one arrives, and this runs before Hyprland has a window +--- on screen. The handle is closed on every outcome; leaving it open leaks one +--- per probe, and this module runs on every configuration reload. +--- +--- Asking the shell instead, as this used to, costs a fork per probe. Hyprland +--- gives the whole configuration a single 1500 ms budget and its watchdog +--- counts VM instructions, so time spent waiting on a subprocess is time no +--- part of the configuration can account for. +--- +--- A refusal on permission grounds counts as a directory. A directory that +--- grants search but not read cannot be listed and cannot be opened this way, +--- yet a file inside it still loads by name — which is all `package.path` ever +--- does with it, and what `test -d` reported here before. --- --- @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 " .. shell_quote(path) .. " ] && echo 1 || echo 0") - if not pipe then - return false + local handle, _, code = io.open(path .. "/.", "r") + if not handle then + return code == EACCES end - local answer = pipe:read("*l") - pipe:close() + handle:close() - return answer == "1" + return true end --- Returns the first candidate that exists, preferring the user's own. diff --git a/MIGRATION-LUA.md b/MIGRATION-LUA.md index ee6e6a4767..1a3b5bb780 100644 --- a/MIGRATION-LUA.md +++ b/MIGRATION-LUA.md @@ -36,8 +36,9 @@ Two things worth knowing before you run it: - `git pull` on its own changes nothing on your machine. The deployment and the migration step both live in `install.sh -r`. -- If `install.sh -r` stops with `deez-dots not found in Python environment`, run - `./install.sh -p` once to rebuild the Python environment, then retry `-r`. +- `install.sh -r` refreshes the Python environment before it deploys anything, + so the dot deployment runs the dependency revisions this checkout pins rather + than whatever was installed last time. ## The failures are silent diff --git a/Scripts/dots-groups/core.toml b/Scripts/dots-groups/core.toml index e025a3fe73..a19365aaa6 100644 --- a/Scripts/dots-groups/core.toml +++ b/Scripts/dots-groups/core.toml @@ -2,20 +2,24 @@ [global] action = "preserve" # git = "https://github.com/HyDE-Project/HyDE.git" - git_branch = "lua" owner = "The HyDE Project" # pre_command = "echo 'starting deployment'" version = "0.1.0" # dots = ["kitty", "hyprland"] # List of dots to be used in the configuration file # We can call it core packages! + # Order matters: dots are deployed in the order they are listed here. The + # archives download over the network and write into /usr/local under sudo, + # which is the most likely thing in this group to fail and the least + # important thing in it to have. Everything that makes up the desktop is + # deployed before them. include = [ "../dots/deps.toml", - "../dots/archives.toml", "../dots/hyde.toml", "../dots/hyprland.toml", "../dots/wallbash.toml", "../dots/waybar.toml", "../dots/rofi.toml", + "../dots/archives.toml", ] # Loads an array of package manager commands diff --git a/Scripts/dots/deps.toml b/Scripts/dots/deps.toml index b5f76d5fad..dd682fa10c 100644 --- a/Scripts/dots/deps.toml +++ b/Scripts/dots/deps.toml @@ -4,6 +4,7 @@ pacman = [ # System "lua", # for lua runtime "luarocks", # Core scripts lib + "libgirepository", # headers and pkg-config data the lgi rock is built against "uwsm", # A standalone Wayland session manager "pipewire", # audio/video server "pipewire-alsa", # pipewire alsa client @@ -62,7 +63,8 @@ pacman = [ "firefox", # browser "ark", # kde file archiver "unzip", # extracting zip files - "vim", # terminal text editor + # No text editor: HyDE runs none of its own, and the one it configures + # (Scripts/dots/wallbash-vim.toml) brings its own vim when it is opted into. # * "code", # ide text editor "nwg-displays", # monitor management utility "fzf", # Command-line fuzzy finder diff --git a/Scripts/dots/hyde.toml b/Scripts/dots/hyde.toml index 33db2c2bb0..e0e02ab9a8 100644 --- a/Scripts/dots/hyde.toml +++ b/Scripts/dots/hyde.toml @@ -1,7 +1,7 @@ [hyde] description = "Hyde core configuration" pre_command = "echo 'HyDE Is the Best!'" -version = "v26.7.4" +version = "v26.8.1" #? Core configuration [[hyde.files]] diff --git a/Scripts/global_fn.sh b/Scripts/global_fn.sh index a51e3e7c93..9c0e3fe216 100755 --- a/Scripts/global_fn.sh +++ b/Scripts/global_fn.sh @@ -181,6 +181,37 @@ print_log() { fi } +# Creates the Python environment and syncs it against this checkout's lock. +# +# The dot deployment, the dependency checks and hyde-shell all run out of that +# environment, and the revisions they run are the ones this checkout pins. A +# run that skips this works with whatever was installed the last time it did +# not, so a corrected pin never reaches the machine that needs it. It lives +# here rather than in a script of its own so the pre-install path and the +# installer cannot drift apart. +setup_python_env() { + local pyutils="${cloneDir}/Configs/.local/lib/hyde/pyutils/python_env.py" + local python_env_dir="${HOME}/.local/state/hyde/python_env" + + if [ "${flg_DryRun:-0}" -eq 1 ]; then + print_log -y "[PYTHON] " -b "dry-run :: " "Would setup Python environment" + return 0 + fi + + if ! python3 "${pyutils}" create; then + print_log -err "[PYTHON] " -crit "ERROR" "Failed to create the Python environment; the error above says why" + print_log -err "[PYTHON] " -crit "HINT" "A missing python3 or base-devel is the usual cause" + return 1 + fi + + if ! "${python_env_dir}/bin/python" "${pyutils}" sync; then + print_log -err "[PYTHON] " -crit "ERROR" "Failed to install dependencies" + return 1 + fi + + print_log -g "[PYTHON] " -b "complete :: " "Environment setup complete" +} + # Runs each migration in "$1" missing from the record in "$2", in version order, # and records the ones that exit zero. Migrations must therefore be safe to run # on a machine that has no record yet, which replays all of them once. diff --git a/Scripts/install.sh b/Scripts/install.sh index f6400104d2..d3c84b09d8 100755 --- a/Scripts/install.sh +++ b/Scripts/install.sh @@ -159,8 +159,18 @@ EOF exit 0 fi +# Both branches below run out of the Python environment, and the revisions they +# run are the ones this checkout's lock pins. Without this step a restore +# deploys with whatever was installed last time, so a corrected pin never +# arrives. It has to happen here, above the first use of deez: the dependency +# checks reach it before the deployment does. The pre-install script covers it +# for a combined run; on its own each operation gets the environment alone, +# since the rest of that script rewrites the bootloader and pacman +# configuration and has no business running on a restore. if has_operation "install" && has_operation "restore"; then - "${scrDir}/install_pre.sh" + "${scrDir}/install_pre.sh" || exit 1 +elif has_operation "install" || has_operation "restore"; then + setup_python_env || exit 1 fi #------------# @@ -303,6 +313,7 @@ if has_operation "restore"; then EOF deez_exe="${HOME}/.local/state/hyde/python_env/bin/deez" + deploy_failed=0 #------------------------------------------# # rebuild transient TOML: core deps+nvidia # @@ -401,13 +412,26 @@ EOF exit 1 } + # A failed deployment used to end the run here, which cost the user + # every step below it — the theme, the wallpaper cache, the migrations, + # the services. Those are what bring a partly deployed tree back into + # shape, so they are exactly what should still run. The failure is + # carried to the end of the restore and reported there. print_log -g "[DEEZ-DOTS] " -b "deploy :: " "Installing core dotfiles..." - "${deez_exe}" --source "${cloneDir}" --config "${scrDir}/dots-groups/core.toml" dots --skip-git --deploy all || exit 1 + "${deez_exe}" --source "${cloneDir}" --config "${scrDir}/dots-groups/core.toml" dots --skip-git --deploy all || { + print_log -err "[DEEZ-DOTS] " -crit "ERROR" "Core dotfiles deployed with failures" + deploy_failed=1 + } print_log -g "[DEEZ-DOTS] " -b "deploy :: " "Installing extra dotfiles..." - "${deez_exe}" --source "${cloneDir}" --config "${scrDir}/dots-groups/extra.toml" dots --skip-git --deploy || exit 1 + "${deez_exe}" --source "${cloneDir}" --config "${scrDir}/dots-groups/extra.toml" dots --skip-git --deploy || { + print_log -err "[DEEZ-DOTS] " -crit "ERROR" "Extra dotfiles deployed with failures" + deploy_failed=1 + } - print_log -g "[DEEZ-DOTS] " -b "complete :: " "Dotfiles deployed" + if [ "${deploy_failed}" -eq 0 ]; then + print_log -g "[DEEZ-DOTS] " -b "complete :: " "Dotfiles deployed" + fi fi "${scrDir}/restore_thm.sh" @@ -475,6 +499,14 @@ EOF "${scrDir}/restore_svc.sh" fi +# Reported here rather than where it happened, so the theme, the migrations and +# the services above still run against the dots that did land. +if [ "${deploy_failed:-0}" -ne 0 ]; then + print_log -err "[DEEZ-DOTS] " -crit "ERROR" "Some dots were not deployed. Deal with the failures reported above and run the restore again." + print_log -b "Log" " :: " -y "View logs at ${cacheDir}/logs/${HYDE_LOG}" + exit 1 +fi + if has_operation "install"; then echo "" print_log -g "Installation" " :: " "COMPLETED!" diff --git a/Scripts/install_pre.sh b/Scripts/install_pre.sh index 1fae176448..8585c3f20d 100755 --- a/Scripts/install_pre.sh +++ b/Scripts/install_pre.sh @@ -15,28 +15,10 @@ flg_DryRun=${flg_DryRun:-0} flg_Grub=${flg_Grub:-1} flg_Nvidia=${flg_Nvidia:-1} -# Python environment setup -if [ "${flg_DryRun}" -eq 1 ]; then - print_log -y "[PYTHON] " -b "dry-run :: " "Would setup Python environment" -else - python_env_dir="${HOME}/.local/state/hyde/python_env" - - # Create venv using system python3 - if ! python3 "${cloneDir}/Configs/.local/lib/hyde/pyutils/python_env.py" create; then - print_log -err "[PYTHON] " -crit "ERROR" "Failed to create Python environment" - print_log -err "[PYTHON] " -crit "ERROR" "Did you you forgot to install base-devel?" - exit 1 - fi - - # Sync dependencies using venv python - python_exe="${python_env_dir}/bin/python" - if ! "${python_exe}" "${cloneDir}/Configs/.local/lib/hyde/pyutils/python_env.py" sync; then - print_log -err "[PYTHON] " -crit "ERROR" "Failed to install dependencies" - exit 1 - fi - - print_log -g "[PYTHON] " -b "complete :: " "Environment setup complete" -fi +# Python environment setup. The installer runs the same function on its own for +# a restore, which needs the environment without the bootloader and pacman +# changes that follow here. +setup_python_env || exit 1 # grub if [ "${flg_Grub}" -eq 1 ] && pkg_installed grub && [ -f /boot/grub/grub.cfg ]; then diff --git a/Scripts/migrations/v26.8.1.sh b/Scripts/migrations/v26.8.1.sh new file mode 100755 index 0000000000..09d45b2c64 --- /dev/null +++ b/Scripts/migrations/v26.8.1.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env sh + +# Hyprland resolves "$XDG_CONFIG_HOME/hypr/hyprland.lua" before hyprland.conf +# and ignores the latter once the former exists. HyDE deploys that path as the +# user's override layer, loaded last by hyde.lua, and its entry point lives in +# the data directory where Hyprland never looks. A session started without +# HYPRLAND_CONFIG therefore loads the override layer on its own: a valid, +# empty configuration, so no error, and a compositor with a cursor and nothing +# else. +# +# The file is deployed with the preserve action, so a corrected template only +# reaches new installs. This puts the loader at the top of the copy already on +# disk. Nothing else in the file is touched, and a file that already has the +# loader is left alone. + +config_home="${XDG_CONFIG_HOME:-${HOME}/.config}" +target="${config_home}/hypr/hyprland.lua" +backup_dir="${XDG_STATE_HOME:-${HOME}/.local/state}/hyde/migration/v26.8.1" + +# A dangling link is checked before absence: exiting zero on one would have the +# runner record this migration as applied, and repairing the link afterwards +# would leave the loader missing for good. +if [ -L "${target}" ]; then + # A config kept under a dotfile manager is a symlink into that tree. + # Replacing the link with a regular file would take the machine off the + # managed copy, so the rewrite follows it and edits what it points at. + resolved=$(readlink -f "${target}") + if [ -z "${resolved}" ] || [ ! -f "${resolved}" ]; then + echo " ${target} is a symlink that leads nowhere, leaving it alone" >&2 + exit 1 + fi + echo " ${target} is a symlink, editing ${resolved}" + target="${resolved}" +fi + +[ -f "${target}" ] || exit 0 + +if grep -q '^if not hyde then$' "${target}"; then + echo " ${target} already loads HyDE, nothing to do" + exit 0 +fi + +if ! mkdir -p "${backup_dir}"; then + echo " cannot create ${backup_dir}, leaving ${target} alone" >&2 + exit 1 +fi + +# The copy is taken before the rewrite: a failure past this point leaves the +# original recoverable rather than half written. +if ! cp -p "${target}" "${backup_dir}/hyprland.lua"; then + echo " cannot back up ${target}, leaving it alone" >&2 + exit 1 +fi + +rewritten="${target}.hyde-migration" + +# Seeding the new file from the old one carries its mode across; the +# redirection below truncates the copy rather than creating a file under the +# current umask. +if ! cp -p "${target}" "${rewritten}"; then + echo " cannot stage the rewrite of ${target}, leaving it alone" >&2 + rm -f "${rewritten}" + exit 1 +fi + +{ + cat <<'LOADER' +-- Hyprland loads this file when it is started without a config, and it prefers +-- it over hyprland.conf. HyDE loads it too, last, as the override layer below. +-- The block keeps the two apart: hyde.lua sets `hyde` on its first line, so it +-- runs only when this file is the entry point and HyDE has not been loaded. +-- Removing it leaves a session with a cursor and nothing else. +if not hyde then + local share = os.getenv("XDG_DATA_HOME") or (os.getenv("HOME") .. "/.local/share") + local entry = share .. "/hypr/hyde.lua" + local handle = io.open(entry, "r") + if not handle then + error("HyDE is not installed at " .. entry .. ". Run install.sh -r, or point Hyprland at your own config.") + end + handle:close() + dofile(entry) +end + +LOADER + cat "${target}" +} >"${rewritten}" || { + echo " cannot write ${rewritten}, leaving ${target} alone" >&2 + rm -f "${rewritten}" + exit 1 +} + +if ! mv "${rewritten}" "${target}"; then + echo " cannot replace ${target}, the original is unchanged" >&2 + rm -f "${rewritten}" + exit 1 +fi + +echo " ${target} now loads HyDE when Hyprland picks it" +echo " the copy from before this change is at ${backup_dir}/hyprland.lua" +exit 0 diff --git a/Scripts/migrations/v26.8.2.sh b/Scripts/migrations/v26.8.2.sh new file mode 100755 index 0000000000..31a5f14f75 --- /dev/null +++ b/Scripts/migrations/v26.8.2.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env sh + +# The Lua release deleted the hyprlang configuration from the repository, but +# deployment overwrites files and never removes the ones that disappeared +# upstream. The whole chain is therefore still on every upgraded machine, and +# it is not inert: Hyprland falls back to "$XDG_CONFIG_HOME/hypr/hyprland.conf" +# whenever no Lua config is found, and that file sources the rest. A session +# that lands on it runs the pre-Lua configuration — the battery notification +# still names a script that was moved aside, and the close-window binds still +# call one that no longer exists. +# +# Nothing is deleted here. Everything is moved into a backup directory, which +# for most of these files is the only copy of settings the user wrote. +# +# This runs only when the Lua entry point is in place. Moving hyprland.conf out +# of the way while nothing else can be found would leave Hyprland to generate a +# fresh default config, which is worse than the leftovers. + +config_home="${XDG_CONFIG_HOME:-${HOME}/.config}" +data_home="${XDG_DATA_HOME:-${HOME}/.local/share}" +backup_dir="${XDG_STATE_HOME:-${HOME}/.local/state}/hyde/migration/v26.8.2" + +entry_point="${data_home}/hypr/hyde.lua" +user_config="${config_home}/hypr/hyprland.lua" + +# Readable, not merely present: the loader opens this file, and an unreadable +# one would fail there with the fallback configuration already moved away. +if [ ! -r "${entry_point}" ]; then + echo " ${entry_point} is missing or unreadable, leaving the hyprlang configuration in place" + exit 0 +fi + +if [ ! -f "${user_config}" ] || ! grep -q '^if not hyde then$' "${user_config}"; then + echo " ${user_config} does not load HyDE yet, leaving the hyprlang configuration in place" + exit 0 +fi + +# Paths are relative to the two roots below. The compositor configuration only: +# hyprlock, hypridle and hyprsunset are still hyprlang and still shipped. +config_leftovers=" +animations.conf +hyprland.conf +keybindings.conf +monitors.conf +nvidia.conf +shaders.conf +userprefs.conf +windowrules.conf +workflows.conf +animations +workflows +" + +data_leftovers=" +hypr/defaults.conf +hypr/dynamic.conf +hypr/env.conf +hypr/finale.conf +hypr/hyprland.conf +hypr/migration.conf +hypr/startup.conf +hypr/variables.conf +hypr/windowrules.conf +hyde/hyprland.conf +hyde/keybindings.conf +hyde/templates/hypr +" + +moved=0 +failed=0 + +move_leftover() { + src="$1" + rel="$2" + + # A link that leads nowhere still occupies a retired path, and it is still + # a copy of what the user had. Both tests, or a dangling one is skipped and + # the path stays behind forever. + [ -e "${src}" ] || [ -L "${src}" ] || return 0 + + dst="${backup_dir}/${rel}" + + # A rerun after the file was restored would otherwise destroy the copy kept + # by the first run, so an occupied destination is reported and left alone. + if [ -e "${dst}" ] || [ -L "${dst}" ]; then + echo " skipped ${rel}, a backup already exists at ${dst}" >&2 + failed=$((failed + 1)) + return 0 + fi + + if ! mkdir -p "$(dirname "${dst}")"; then + echo " failed to create the backup directory for ${rel}" >&2 + failed=$((failed + 1)) + return 0 + fi + + if mv "${src}" "${dst}"; then + echo " moved ${rel}" + moved=$((moved + 1)) + else + echo " failed to move ${rel}" >&2 + failed=$((failed + 1)) + fi +} + +for rel in ${config_leftovers}; do + move_leftover "${config_home}/hypr/${rel}" "config/hypr/${rel}" +done + +for rel in ${data_leftovers}; do + move_leftover "${data_home}/${rel}" "data/${rel}" +done + +if [ "${moved}" -gt 0 ]; then + echo "Moved ${moved} hyprlang leftover(s) to ${backup_dir}" + echo "They are the only copy of what you had configured before the Lua release." +fi + +if [ "${failed}" -gt 0 ]; then + echo "Left ${failed} hyprlang leftover(s) in place" >&2 + exit 1 +fi + +exit 0 diff --git a/dots.toml b/dots.toml index 62d4266969..2948b5b69e 100644 --- a/dots.toml +++ b/dots.toml @@ -7,7 +7,6 @@ version = "0.1.0" # Version of the configuration owner = "The HyDE Project" # Owner of the configuration file action = "preserve" # Actions can either be sync or preserve git = "https://github.com/HyDE-Project/HyDE.git" -git_branch = "lua" pre_command = "./Scripts/install_aur.sh" post_command = "notify-send 'deez done'" # dots = ["kitty", "hyprland"] # List of dots to be used in the configuration file diff --git a/tests/test_config_reload.sh b/tests/test_config_reload.sh new file mode 100755 index 0000000000..5f7270edb8 --- /dev/null +++ b/tests/test_config_reload.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env sh +# Hyprland runs the whole Lua configuration under a single 1500 ms budget, and +# its watchdog is installed with LUA_MASKCOUNT: it counts VM instructions, so it +# cannot interrupt a blocked C call. A subprocess started while the +# configuration loads therefore spends the budget where nothing can attribute +# it, and the timeout surfaces in whichever module runs next — a parser, a bind +# table, anything with a tight Lua loop, none of which are the cause. +# +# Nothing the session loads at reload time may fork. The scan covers the whole +# shipped hypr tree, entry point included, since that is what Hyprland executes. + +# shellcheck source=tests/lib/common.sh +. "$(dirname -- "$0")/lib/common.sh" + +config_dir="$REPO_ROOT/Configs/.local/share/hypr" +[ -d "$config_dir" ] || { + fail "the shipped Hyprland directory is missing" + finish +} + +list=$(mktemp) +trap 'rm -f "$list"' EXIT +find "$config_dir" -name '*.lua' -type f | sort > "$list" + +# The TOML parser is the one library outside that tree the configuration pulls +# in — hyde/config.lua requires it, and it requires nothing further. The rest of +# luautils is shared with command line tools, where a subprocess is legitimate. +toml="$REPO_ROOT/Configs/.local/lib/hyde/luautils/toml.lua" +[ -f "$toml" ] && printf '%s\n' "$toml" >> "$list" + +count=0 +while IFS= read -r file; do + count=$((count + 1)) + + hits=$(grep -nE 'io\.popen|os\.execute' "$file") + status=$? + + # grep answers 1 for a file with no match and 2 or more for a failure to + # read one, which must not pass as "nothing found". + if [ "$status" -gt 1 ]; then + fail "${file#"$REPO_ROOT"/} could not be scanned" + continue + fi + + [ -z "$hits" ] && continue + + printf '%s\n' "$hits" | while IFS= read -r hit; do + printf ' %s:%s\n' "${file#"$REPO_ROOT"/}" "$hit" + done + fail "${file#"$REPO_ROOT"/} blocks the configuration on a subprocess" +done < "$list" + +# A guard that scans nothing passes for the wrong reason, and would keep doing +# so if the tree moved out from under it. +[ "$count" -gt 0 ] || + fail "no Lua file was scanned, the configuration tree is not where this expects it" + +printf ' %d file(s) checked\n' "$count" + +finish diff --git a/tests/test_dots.sh b/tests/test_dots.sh index 60afbee5b5..6173655160 100755 --- a/tests/test_dots.sh +++ b/tests/test_dots.sh @@ -20,4 +20,39 @@ if grep -q 'feature-grimblast-toplevel-handle' "$hyde_metafile"; then fail "grimblast still uses the double-selection experimental branch" fi +# Dots are deployed in the order the group lists them. The archives download +# over the network and write into /usr/local under sudo, so they are the most +# likely member of the core group to fail and the least important one to have. +# Everything the desktop is made of has to be deployed before them. +core_group="$REPO_ROOT/Scripts/dots-groups/core.toml" +core_order=$(sed -n '/include = \[/,/^ *\]/p' "$core_group" | + sed -n 's|.*"\.\./dots/\([A-Za-z0-9_-]*\)\.toml".*|\1|p') + +position_in_core() { + printf '%s\n' "$core_order" | grep -nxF "$1" | head -n 1 | cut -d: -f1 +} + +archives_at=$(position_in_core archives) +# The packages the rest of the group needs are installed by the first entry, +# so it is the one member whose position is fixed rather than merely early. +deps_at=$(position_in_core deps) +if [ -z "$deps_at" ]; then + fail "the core group no longer includes deps.toml" +elif [ "$deps_at" -ne 1 ]; then + fail "deps.toml is no longer the first entry in the core group" +fi + +if [ -z "$archives_at" ]; then + fail "the core group no longer includes archives.toml, or the include list could not be read" +else + for required in hyde hyprland; do + required_at=$(position_in_core "$required") + if [ -z "$required_at" ]; then + fail "the core group no longer includes $required.toml" + elif [ "$required_at" -gt "$archives_at" ]; then + fail "$required.toml is deployed after archives.toml in the core group" + fi + done +fi + finish diff --git a/tests/test_fish_env.sh b/tests/test_fish_env.sh new file mode 100755 index 0000000000..40625688ef --- /dev/null +++ b/tests/test_fish_env.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env sh +# Everything HyDE installs for the user lives in ~/.local/bin, so the fish +# drop-in has to put that directory on PATH. It is easy to get wrong in a way +# nothing reports: fish_add_path takes one directory per argument, does not +# split on ":", and drops a path that does not exist without a word. A +# colon-joined string therefore adds nothing at all and the shell comes up +# looking healthy with no hyde-shell in it. + +# shellcheck source=tests/lib/common.sh +. "$(dirname -- "$0")/lib/common.sh" + +drop_in="$REPO_ROOT/Configs/.config/fish/conf.d/hyde.fish" + +[ -f "$drop_in" ] || { + fail "the fish drop-in is missing" + finish +} + +# Resolved here rather than called by name below: the run is started with a +# PATH of its own, and a fish installed anywhere but there would not be found. +fish_bin=$(command -v fish 2>/dev/null) +if [ -z "$fish_bin" ]; then + skip "fish is not installed" + finish +fi + +work_dir=$(mktemp -d) +trap 'rm -rf "$work_dir"' EXIT + +home="$work_dir/home" +mkdir -p "$home/.local/bin" "$work_dir/data" "$work_dir/config" + +# An isolated home keeps the universal variables fish_add_path writes out of +# the one running the suite. Past the line under test the drop-in calls a +# function defined elsewhere in the shipped config, which is not sourced here; +# fish reports that on stderr and carries on, so stderr is discarded rather +# than the rest of the configuration being pulled in to silence it. +path_entries=$( + env -i HOME="$home" \ + XDG_DATA_HOME="$work_dir/data" \ + XDG_CONFIG_HOME="$work_dir/config" \ + PATH=/usr/bin:/bin \ + TERM=dumb \ + "$fish_bin" -c "source '$drop_in' 2>/dev/null; for entry in \$PATH; echo \$entry; end" 2>/dev/null +) + +printf '%s\n' "$path_entries" | grep -qxF "$home/.local/bin" || + fail "the drop-in did not put ~/.local/bin on PATH, so no HyDE command resolves in fish" + +# The failure this case exists for leaves a single entry holding the whole +# joined string, which is a path in name only. Only the entries this drop-in +# contributes are judged; what the machine's own fish configuration puts on +# PATH is not this file's business. +stray=$(printf '%s\n' "$path_entries" | grep "^$home" | grep ':') +[ -n "$stray" ] && + fail "PATH holds a colon-joined entry, which is one path that exists nowhere: $stray" + +printf ' %d PATH entry(ies) checked\n' "$(printf '%s\n' "$path_entries" | grep -c .)" + +finish diff --git a/tests/test_hyprlang_leftovers.sh b/tests/test_hyprlang_leftovers.sh new file mode 100755 index 0000000000..3d8dd4cdbb --- /dev/null +++ b/tests/test_hyprlang_leftovers.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# The hyprlang configuration the Lua release deleted is still on every upgraded +# machine, because deployment overwrites files and never removes the ones that +# disappeared upstream. It is not inert: Hyprland falls back to hyprland.conf +# when no Lua config is found, and that file sources the rest, so a session can +# land on the pre-Lua configuration entirely. +# +# The migration moves it aside, and only when the Lua entry point is in place — +# taking the fallback away while nothing else can be found would leave Hyprland +# to generate a default config, which is worse than the leftovers. + +. "$(dirname -- "$0")/lib/common.sh" + +migration="$REPO_ROOT/Scripts/migrations/v26.8.2.sh" + +if [ ! -f "$migration" ]; then + fail "no migration retires the hyprlang configuration" + finish +fi + +work_dir=$(mktemp -d) +trap 'rm -rf "$work_dir"' EXIT + +home_dir="$work_dir/home" +config_home="$work_dir/config" +data_home="$work_dir/data" +state_home="$work_dir/state" + +leftovers_config="animations.conf hyprland.conf keybindings.conf monitors.conf nvidia.conf shaders.conf userprefs.conf windowrules.conf workflows.conf" +leftovers_data="hypr/defaults.conf hypr/dynamic.conf hypr/env.conf hypr/finale.conf hypr/hyprland.conf hypr/migration.conf hypr/startup.conf hypr/variables.conf hypr/windowrules.conf hyde/hyprland.conf hyde/keybindings.conf" +# Still hyprlang, still shipped: these must survive untouched. +kept_config="hypridle.conf hyprlock.conf hyprsunset.conf" + +seed() { + rm -rf "$home_dir" "$config_home" "$data_home" "$state_home" + mkdir -p "$home_dir" "$config_home/hypr" "$data_home/hypr" "$data_home/hyde" "$state_home" + + for rel in $leftovers_config $kept_config; do + printf 'old %s\n' "$rel" >"$config_home/hypr/$rel" + done + mkdir -p "$config_home/hypr/animations" "$config_home/hypr/workflows" "$config_home/hypr/shaders" + printf 'old animation\n' >"$config_home/hypr/animations/theme.conf" + printf 'old workflow\n' >"$config_home/hypr/workflows/default.conf" + printf 'still shipped\n' >"$config_home/hypr/shaders/wallbash.frag" + + for rel in $leftovers_data; do + mkdir -p "$(dirname "$data_home/$rel")" + printf 'old %s\n' "$rel" >"$data_home/$rel" + done + mkdir -p "$data_home/hyde/templates/hypr" + printf 'old template\n' >"$data_home/hyde/templates/hypr/keybindings.conf" + printf 'still shipped\n' >"$data_home/hypr/hyprlock.conf" + + # The gate: the entry point exists and the user's config loads it. + printf 'hyde = hyde or {}\n' >"$data_home/hypr/hyde.lua" + printf 'if not hyde then\nend\n' >"$config_home/hypr/hyprland.lua" +} + +run_migration() { + ( + HOME="$home_dir" XDG_CONFIG_HOME="$config_home" XDG_DATA_HOME="$data_home" \ + XDG_STATE_HOME="$state_home" sh "$migration" "$work_dir/out.log" 2>&1 +} + +backup="$state_home/hyde/migration/v26.8.2" + +# The whole chain is retired, the files still in use are not, and every moved +# file is recoverable. +seed +run_migration +status=$? + +[ "$status" -eq 0 ] || fail "the migration failed on a machine it should act on: $(cat "$work_dir/out.log")" + +for rel in $leftovers_config; do + [ -e "$config_home/hypr/$rel" ] && fail "$rel was left in place" + [ -f "$backup/config/hypr/$rel" ] || fail "$rel was not kept in the backup" +done +for rel in $leftovers_data; do + [ -e "$data_home/$rel" ] && fail "$rel was left in place" + [ -f "$backup/data/$rel" ] || fail "$rel was not kept in the backup" +done +[ -e "$config_home/hypr/animations" ] && fail "the animations directory was left in place" +[ -e "$config_home/hypr/workflows" ] && fail "the workflows directory was left in place" +[ -e "$data_home/hyde/templates/hypr" ] && fail "the hyprlang templates were left in place" + +for rel in $kept_config; do + [ -f "$config_home/hypr/$rel" ] || fail "$rel is still hyprlang and still shipped, but was moved" +done +[ -f "$config_home/hypr/shaders/wallbash.frag" ] || fail "the shader directory was moved" +[ -f "$data_home/hypr/hyprlock.conf" ] || fail "the shipped hyprlock config was moved" +[ -f "$config_home/hypr/hyprland.lua" ] || fail "the Lua config was moved" +[ -f "$data_home/hypr/hyde.lua" ] || fail "the entry point was moved" + +# Nothing to do on a second pass. +run_migration +[ "$?" -eq 0 ] || fail "a second run failed" + +# No entry point: the fallback has to stay, or the machine is left with nothing +# to load at all. +seed +rm -f "$data_home/hypr/hyde.lua" +run_migration +[ "$?" -eq 0 ] || fail "the migration failed instead of skipping without an entry point" +[ -f "$config_home/hypr/hyprland.conf" ] || + fail "the fallback config was moved with no entry point to replace it" + +# An unreadable entry point is no entry point: the loader opens it. +seed +chmod 000 "$data_home/hypr/hyde.lua" +run_migration +chmod 644 "$data_home/hypr/hyde.lua" +[ -f "$config_home/hypr/hyprland.conf" ] || + fail "the fallback config was moved while the entry point could not be read" + +# The user's config does not load HyDE yet: same reasoning. +seed +printf 'hl.config({})\n' >"$config_home/hypr/hyprland.lua" +run_migration +[ -f "$config_home/hypr/hyprland.conf" ] || + fail "the fallback config was moved before the user's config could load HyDE" + +# A retired path left as a dangling symlink still has to go, or it stays +# forever and the migration reports success. +seed +rm -f "$config_home/hypr/hyprland.conf" +ln -s "$work_dir/nothing-here.conf" "$config_home/hypr/hyprland.conf" +run_migration +[ -e "$config_home/hypr/hyprland.conf" ] || [ -L "$config_home/hypr/hyprland.conf" ] && + fail "a dangling symlink at a retired path was left behind" + +finish diff --git a/tests/test_install_env.sh b/tests/test_install_env.sh new file mode 100755 index 0000000000..0c23807ffc --- /dev/null +++ b/tests/test_install_env.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# The Python environment has to be refreshed before anything reaches deez. +# +# The dot deployment and both dependency checks run out of that environment, +# and the revisions they run are the ones this checkout's lock pins. A run that +# skips the step works with whatever was installed the last time it did not, so +# a corrected pin never reaches the machine. Ordering is the whole point: the +# dependency checks reach deez well before the deployment does. + +. "$(dirname -- "$0")/lib/common.sh" + +grep -q '^setup_python_env()' "$REPO_ROOT/Scripts/global_fn.sh" || + fail "global_fn.sh does not define the Python environment step" + +grep -qE '^[[:space:]]*setup_python_env' "$REPO_ROOT/Scripts/install_pre.sh" || + fail "install_pre.sh no longer runs the Python environment step" + +grep -q 'python_env\.py' "$REPO_ROOT/Scripts/install_pre.sh" && + fail "install_pre.sh sets up the environment itself instead of using the shared step" + +work_dir=$(mktemp -d) +trap 'rm -rf "$work_dir"' EXIT + +clone_dir="$work_dir/clone" +home_dir="$work_dir/home" +mkdir -p "$clone_dir" "$home_dir/.local/lib/hyde/wallpaper" "$home_dir/.local/state/hyde/python_env/bin" +cp -a "$REPO_ROOT/Scripts" "$clone_dir/Scripts" + +# Every hand-off is a stub that records the fact, so a case can only fail on +# the flow under test and nothing reaches the machine running the suite. +ran_log="$work_dir/ran.log" +for stub in install_pre install_aur install_pst restore_thm restore_svc; do + printf '#!/usr/bin/env sh\nprintf "%%s\\n" "%s" >>"%s"\n' "$stub" "$ran_log" \ + >"$clone_dir/Scripts/$stub.sh" + chmod +x "$clone_dir/Scripts/$stub.sh" +done +rm -f "$clone_dir/Scripts/migrations"/*.sh + +mkdir -p "$clone_dir/Configs/.local/lib/hyde/pyutils" +printf 'import sys\nsys.exit(0)\n' >"$clone_dir/Configs/.local/lib/hyde/pyutils/lua_env.py" + +# The environment step and deez both record what they were asked to do, in the +# same log, so their order is part of what the case checks. +write_python_stub() { + cat >"$clone_dir/Configs/.local/lib/hyde/pyutils/python_env.py" <>"%s"\n' "$ran_log" \ + >"$home_dir/.local/state/hyde/python_env/bin/deez" +chmod +x "$home_dir/.local/state/hyde/python_env/bin/deez" + +for helper in "wallpaper/cache.sh" "theme.switch.sh" "waybar.py"; do + printf '#!/usr/bin/env sh\nexit 0\n' >"$home_dir/.local/lib/hyde/$helper" + chmod +x "$home_dir/.local/lib/hyde/$helper" +done + +run_installer() { + : >"$ran_log" + rm -rf "$work_dir/state" + ( + env -u HYPRLAND_INSTANCE_SIGNATURE \ + HOME="$home_dir" \ + XDG_STATE_HOME="$work_dir/state" \ + XDG_CACHE_HOME="$work_dir/cache" \ + CLONE_DIR="$clone_dir" \ + "$clone_dir/Scripts/install.sh" "$@" <<<"n" + ) >"$work_dir/out.log" 2>&1 +} + +ran() { grep -qxF "$1" "$ran_log" 2>/dev/null; } +line_of() { grep -nxF "$1" "$ran_log" 2>/dev/null | head -n 1 | cut -d: -f1; } + +# A restore on its own refreshes the environment, and does it before the first +# thing that runs out of it. +run_installer -r +ran "python_env create" || fail "a restore did not create the Python environment" +ran "python_env sync" || fail "a restore did not sync the Python environment" +ran install_pre && fail "a restore ran the pre-install script" + +first_deez=$(line_of "deez deps") +sync_at=$(line_of "python_env sync") +if [ -z "$first_deez" ] || [ -z "$sync_at" ]; then + fail "a restore did not reach both steps, the ordering could not be checked" +elif [ "$sync_at" -gt "$first_deez" ]; then + fail "the environment was synced after deez had already run" +fi + +# An install on its own reaches deez for the dependency check, so it needs the +# environment just as much. +run_installer -i +ran "python_env sync" || fail "an install did not refresh the Python environment" +ran install_pre && fail "an install on its own ran the pre-install script" + +# The pre-install operation and a combined run keep going through +# install_pre.sh, unchanged from before. +run_installer -p +ran install_pre || fail "the pre-install operation no longer runs install_pre.sh" + +run_installer -i -r +ran install_pre || fail "a combined install and restore no longer runs install_pre.sh" + +# A failed create has to stop the run: syncing into an environment that was +# never built would report success over a machine that has nothing installed. +write_python_stub 1 +run_installer -r +status=$? +write_python_stub 0 + +[ "$status" -ne 0 ] || fail "the run reported success after the environment failed to build" +ran "python_env sync" && fail "the environment was synced after it failed to build" +ran "deez deps" && fail "deez ran after the environment failed to build" + +# Dry run reports and touches nothing. +run_installer -r -t +ran "python_env create" && fail "the environment was built under dry-run" + +finish diff --git a/tests/test_install_restore.sh b/tests/test_install_restore.sh new file mode 100755 index 0000000000..04d2e983e6 --- /dev/null +++ b/tests/test_install_restore.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# A dot that fails to deploy must not cost the user the rest of the restore. +# +# The theme, the wallpaper cache, the migrations and the services are what put +# a partly deployed tree back into shape, so they are exactly the steps that +# have to keep running when a deployment reports failures. The run still has to +# end non-zero and say so, or a half-migrated install looks like a clean one. + +. "$(dirname -- "$0")/lib/common.sh" + +work_dir=$(mktemp -d) +trap 'rm -rf "$work_dir"' EXIT + +clone_dir="$work_dir/clone" +home_dir="$work_dir/home" +mkdir -p "$clone_dir" "$home_dir" +cp -a "$REPO_ROOT/Scripts" "$clone_dir/Scripts" + +ran_log="$work_dir/ran.log" +deez_log="$work_dir/deez.log" + +# Everything the restore hands off to is a stub that records the fact, so a +# case can only fail on the flow under test and nothing reaches the machine +# running the suite. +for stub in install_pre install_aur install_pst restore_thm restore_svc; do + printf '#!/usr/bin/env sh\nprintf "%%s\\n" "%s" >>"%s"\n' "$stub" "$ran_log" \ + >"$clone_dir/Scripts/$stub.sh" + chmod +x "$clone_dir/Scripts/$stub.sh" +done +rm -f "$clone_dir/Scripts/migrations"/*.sh +printf '#!/usr/bin/env sh\nprintf "%%s\\n" "migration" >>"%s"\n' "$ran_log" \ + >"$clone_dir/Scripts/migrations/v99.9.9.sh" +chmod +x "$clone_dir/Scripts/migrations/v99.9.9.sh" + +mkdir -p "$clone_dir/Configs/.local/lib/hyde/pyutils" +printf 'import sys\nsys.exit(0)\n' >"$clone_dir/Configs/.local/lib/hyde/pyutils/lua_env.py" +# The restore refreshes the Python environment before it reaches deez, so the +# script it calls has to answer here too. Whether it does is checked by +# test_install_env; this case only needs it out of the way. +printf 'import sys\nsys.exit(0)\n' >"$clone_dir/Configs/.local/lib/hyde/pyutils/python_env.py" + +mkdir -p "$home_dir/.local/state/hyde/python_env/bin" "$home_dir/.local/lib/hyde/wallpaper" +for helper in "wallpaper/cache.sh" "theme.switch.sh" "waybar.py"; do + printf '#!/usr/bin/env sh\nprintf "%%s\\n" "%s" >>"%s"\n' "$(basename "$helper")" "$ran_log" \ + >"$home_dir/.local/lib/hyde/$helper" + chmod +x "$home_dir/.local/lib/hyde/$helper" +done + +deez_exe="$home_dir/.local/state/hyde/python_env/bin/deez" +# The environment step syncs through the interpreter in that environment. +ln -sf "$(command -v python3)" "$home_dir/.local/state/hyde/python_env/bin/python" + +# The stub records every invocation and can be told to fail the core deploy, +# which is the call that used to end the run. +write_deez_stub() { + { + printf '#!/usr/bin/env sh\n' + printf 'printf "%%s\\n" "$*" >>"%s"\n' "$deez_log" + if [ "$1" = "fail-core" ]; then + printf 'case "$*" in\n' + printf ' *dots-groups/core.toml*dots*) exit 1 ;;\n' + printf 'esac\n' + fi + printf 'exit 0\n' + } >"$deez_exe" + chmod +x "$deez_exe" +} + +run_restore() { + : >"$ran_log" + : >"$deez_log" + # A fresh state directory per run: the migration runner records what it + # applied, so a shared one would make the second run look like it skipped + # the step it was told to repeat. + rm -rf "$work_dir/state" + ( + # The run ends by asking about a reboot. Answering keeps the question + # from reading EOF, which would end the script on its own. + env -u HYPRLAND_INSTANCE_SIGNATURE \ + HOME="$home_dir" \ + XDG_STATE_HOME="$work_dir/state" \ + XDG_CACHE_HOME="$work_dir/cache" \ + CLONE_DIR="$clone_dir" \ + "$clone_dir/Scripts/install.sh" -r -s <<<"n" + ) >"$work_dir/out.log" 2>&1 +} + +ran() { grep -qxF "$1" "$ran_log" 2>/dev/null; } + +# A clean deployment: everything runs and the run succeeds. +write_deez_stub ok +run_restore +status=$? + +[ "$status" -eq 0 ] || fail "a clean restore exited with $status: $(tail -n 5 "$work_dir/out.log")" +grep -q 'dots-groups/core.toml' "$deez_log" || fail "a clean restore never deployed the core dots" +ran restore_thm || fail "a clean restore did not apply the theme" +ran migration || fail "a clean restore did not run the migrations" +ran restore_svc || fail "a clean restore did not enable the services" +ran cache.sh || fail "a clean restore did not rebuild the wallpaper cache" + +# The core deployment fails: the remaining steps still run, and the run ends +# non-zero saying what happened. +write_deez_stub fail-core +run_restore +status=$? + +[ "$status" -ne 0 ] || fail "a restore whose core deployment failed reported success" +grep -q 'dots-groups/extra.toml' "$deez_log" || + fail "a failed core deployment stopped the extra dots from being deployed" +ran restore_thm || fail "a failed deployment stopped the theme from being applied" +ran migration || fail "a failed deployment stopped the migrations from running" +ran restore_svc || fail "a failed deployment stopped the services from being enabled" +ran cache.sh || fail "a failed deployment stopped the wallpaper cache from being rebuilt" +grep -q 'Some dots were not deployed' "$work_dir/out.log" || + fail "a failed deployment did not say so at the end of the run" +grep -q 'COMPLETED' "$work_dir/out.log" && + fail "a failed deployment still reported the run as completed" + +finish diff --git a/tests/test_lua_entry_point.sh b/tests/test_lua_entry_point.sh new file mode 100755 index 0000000000..67aa67f7ba --- /dev/null +++ b/tests/test_lua_entry_point.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# The file Hyprland actually loads has to load HyDE. +# +# Hyprland resolves "$XDG_CONFIG_HOME/hypr/hyprland.lua" ahead of hyprland.conf +# and ignores the latter once the former exists. HyDE deploys that path as the +# user's override layer, which hyde.lua pulls in last, and keeps its own entry +# point in the data directory where Hyprland never looks. Without the loader a +# session started with no HYPRLAND_CONFIG gets a valid empty configuration: no +# error, no bar, no binds. + +. "$(dirname -- "$0")/lib/common.sh" + +template="$REPO_ROOT/Configs/.config/hypr/hyprland.lua" +migration="$REPO_ROOT/Scripts/migrations/v26.8.1.sh" + +grep -q '^if not hyde then$' "$template" || + fail "the shipped hyprland.lua does not load HyDE when it is the entry point" + +grep -q 'hypr/hyde.lua' "$template" || + fail "the shipped hyprland.lua does not name HyDE's entry point" + +[ -f "$migration" ] || { + fail "no migration adds the loader to the copies already deployed" + finish +} + +work_dir=$(mktemp -d) +trap 'rm -rf "$work_dir"' EXIT + +home_dir="$work_dir/home" +config_home="$work_dir/config" +state_home="$work_dir/state" +mkdir -p "$home_dir" "$config_home/hypr" "$state_home" + +target="$config_home/hypr/hyprland.lua" +user_line='hl.config({ general = { gaps_in = 7 } })' + +run_migration() { + ( + HOME="$home_dir" XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" \ + sh "$migration" "$work_dir/out.log" 2>&1 +} + +# A file without the loader gets it, keeps what the user wrote, and the copy +# from before the change is recoverable. +printf '%s\n' "$user_line" >"$target" +run_migration +status=$? + +[ "$status" -eq 0 ] || fail "the migration failed on a file that needed the loader: $(cat "$work_dir/out.log")" +grep -q '^if not hyde then$' "$target" || fail "the migration did not add the loader" +grep -qxF "$user_line" "$target" || fail "the migration dropped what the user had written" +[ "$(head -n 1 "$target")" != "$user_line" ] || fail "the loader was not added before the user's own config" +grep -qxF "$user_line" "$state_home/hyde/migration/v26.8.1/hyprland.lua" || + fail "the migration kept no recoverable copy of the original" + +# Running it again changes nothing. +before=$(cat "$target") +run_migration +[ "$(cat "$target")" = "$before" ] || fail "a second run of the migration changed the file again" +[ "$(grep -c '^if not hyde then$' "$target")" -eq 1 ] || fail "a second run added the loader twice" + +# The file mode survives the rewrite: a config the user tightened must not come +# back readable by everyone because the new file was written under the umask. +rm -f "$target" +printf '%s\n' "$user_line" >"$target" +chmod 600 "$target" +run_migration +[ "$(stat -c '%a' "$target")" = "600" ] || + fail "the migration changed the file mode to $(stat -c '%a' "$target")" + +# A config kept under a dotfile manager is a symlink into that tree. The +# rewrite has to follow it, or the machine silently comes off the managed copy. +rm -f "$target" +managed_dir="$work_dir/dotfiles" +mkdir -p "$managed_dir" +managed="$managed_dir/hyprland.lua" +printf '%s\n' "$user_line" >"$managed" +ln -s "$managed" "$target" +run_migration + +[ -L "$target" ] || fail "the migration replaced the symlink with a regular file" +grep -q '^if not hyde then$' "$managed" || + fail "the migration did not add the loader to the file the symlink points at" + +# A link pointing at nothing has to be reported rather than counted as "no file +# here": the runner records a zero exit as applied, and repairing the link +# afterwards would leave the loader missing for good. +rm -f "$target" +ln -s "$work_dir/nothing-here.lua" "$target" +run_migration +[ "$?" -ne 0 ] || fail "a config symlink pointing at nothing was treated as applied" + +# No file, nothing to do. +rm -f "$target" +run_migration +[ "$?" -eq 0 ] || fail "the migration failed when there was no file to change" +[ -f "$target" ] && fail "the migration created a config that was not there" + +# The loader itself: it runs when HyDE has not been loaded, and stays out of +# the way when HyDE is the one loading this file. +command -v lua >/dev/null 2>&1 || { + skip "lua is not installed, the loader was not executed" + finish +} + +mkdir -p "$work_dir/data/hypr" +printf 'hyde = hyde or {}\nloaded = (loaded or 0) + 1\n' >"$work_dir/data/hypr/hyde.lua" +printf '%s\n' "$user_line" >"$target" +run_migration + +entry_runs=$(XDG_DATA_HOME="$work_dir/data" lua -e " + local ok, err = pcall(dofile, '$target') + if not ok and not tostring(err):match('hl') then print('error: ' .. tostring(err)) end + print(loaded or 0) +" 2>/dev/null | tail -n 1) +[ "$entry_runs" = "1" ] || fail "the loader did not reach HyDE when the file was the entry point, got '$entry_runs'" + +layer_runs=$(XDG_DATA_HOME="$work_dir/data" lua -e " + hyde = {} + local ok, err = pcall(dofile, '$target') + if not ok and not tostring(err):match('hl') then print('error: ' .. tostring(err)) end + print(loaded or 0) +" 2>/dev/null | tail -n 1) +[ "$layer_runs" = "0" ] || fail "the loader ran again while HyDE was loading this file, got '$layer_runs'" + +finish diff --git a/tests/test_lua_env.sh b/tests/test_lua_env.sh new file mode 100755 index 0000000000..1f1b026022 --- /dev/null +++ b/tests/test_lua_env.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env sh +# The Lua environment is bootstrapped from a list of rocks, and one of them — +# lgi — is compiled against the GObject introspection headers. A machine +# without them cannot build it, and that used to end the whole installation at +# the Lua step, before a single dotfile was deployed. +# +# An optional rock that fails is reported and skipped; a required one still +# stops the run. + +# shellcheck source=tests/lib/common.sh +. "$(dirname -- "$0")/lib/common.sh" + +if ! command -v python3 >/dev/null 2>&1; then + skip "python3 is not installed" + finish +fi + +module="$REPO_ROOT/Configs/.local/lib/hyde/pyutils/lua_env.py" +[ -f "$module" ] || { + fail "lua_env.py is missing" + finish +} + +work_dir=$(mktemp -d) +trap 'rm -rf "$work_dir"' EXIT + +cp "$module" "$work_dir/lua_env.py" + +# Stands in for luarocks: every install of the package named below fails, the +# rest succeeds. `list` answers with nothing, so the snapshot stays empty. +cat > "$work_dir/luarocks" <<'STUB' +#!/usr/bin/env sh +for arg in "$@"; do + case $arg in + list) exit 0 ;; + *unbuildable*) exit 1 ;; + esac +done +exit 0 +STUB +chmod +x "$work_dir/luarocks" + +printf '#!/usr/bin/env sh\nexit 0\n' > "$work_dir/lua" +chmod +x "$work_dir/lua" + +# Runs one command against a bootstrap list written for the case at hand. +run_with() { + printf '%s\n' "$2" > "$work_dir/lua_env.json" + XDG_STATE_HOME="$work_dir/state" \ + LUA="$work_dir/lua" \ + LUAROCKS="$work_dir/luarocks" \ + python3 "$work_dir/lua_env.py" "$1" >"$work_dir/out" 2>&1 +} + +# The package name alone proves nothing — the failing command line carries it +# too. What has to appear is the handler's own account of the skip, so these +# look for the words only it writes. +expect_skip_report() { + grep -q 'it is optional, continuing without it' "$work_dir/out" || + fail "$1 did not report the skipped optional package" + grep -q 'optional package(s) skipped' "$work_dir/out" || + fail "$1 did not summarise what was skipped" + grep -q 'hyde-shell luainit' "$work_dir/out" || + fail "$1 did not name the command that retries it" +} + +# A non-zero status on its own would also be satisfied by a broken stub or a +# misspelled subcommand, so the reason has to be the package that failed. +expect_required_failure() { + grep -q 'CalledProcessError' "$work_dir/out" || + fail "$1 ended for a reason other than the failed package: $(cat "$work_dir/out")" +} + +run_with create '{"bootstrap_install": [{"name": "dkjson", "version": "2.11-1"}, {"name": "unbuildable", "optional": true}]}' || + fail "an optional package that failed to build ended the run: $(cat "$work_dir/out")" +expect_skip_report create + +run_with create '{"bootstrap_install": [{"name": "unbuildable"}]}' && + fail "a required package that failed to build did not end the run" +expect_required_failure create + +# `hyde-shell luainit` reinstalls through sync rather than create, and falls +# back to a full rebuild when it fails. Without the same tolerance there, an +# unbuildable optional rock would tear the rocks tree down on every routine +# refresh of the environment. +run_with sync '{"bootstrap_install": [{"name": "dkjson", "version": "2.11-1"}, {"name": "unbuildable", "optional": true}]}' || + fail "sync ended the run over an optional package: $(cat "$work_dir/out")" +expect_skip_report sync + +run_with sync '{"bootstrap_install": [{"name": "unbuildable"}]}' && + fail "sync did not end the run over a required package" +expect_required_failure sync + +# The shipped list has to mark lgi optional, otherwise the guard above protects +# nothing on a real installation. +python3 - "$REPO_ROOT/Configs/.local/lib/hyde/pyutils/lua_env.json" <<'CHECK' || fail "the shipped bootstrap list does not mark lgi optional" +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + config = json.load(handle) + +entries = config.get("bootstrap_install", []) +lgi = [e for e in entries if isinstance(e, dict) and "lgi" in e.get("name", "")] +sys.exit(0 if lgi and lgi[0].get("optional") else 1) +CHECK + +printf " %d bootstrap case(s) checked\n" 5 + +finish diff --git a/tests/test_paths.sh b/tests/test_paths.sh index 24453f828b..f2c651f028 100755 --- a/tests/test_paths.sh +++ b/tests/test_paths.sh @@ -22,15 +22,18 @@ 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. +# Prints the resolved value of one field, or "error" when loading blew up. A +# module that fails to load exits non-zero as well: a case that expects a path +# not to be chosen would otherwise be satisfied by the module never running. 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)) + os.exit(1) end + + io.write(tostring(hyde.path.$1)) " 2>&1 } @@ -69,8 +72,10 @@ runtime=$(HOME="$work_dir/home" XDG_RUNTIME_DIR="$work_dir/run" resolve runtime) [ "$runtime" = "$work_dir/run" ] || fail "a set XDG_RUNTIME_DIR was not honoured: $runtime" -# The directory probe shells out, so a home directory is allowed to contain a -# quote: it has to be found, and it must not be able to run anything. +# A home directory is allowed to contain a quote: it has to be found, and it +# must not be able to run anything. The probe no longer goes through a shell, +# which is what makes the second half of that hold; the case stays as the guard +# that says so. quoted_home="$work_dir/qu'ote" mkdir -p "$quoted_home/.local/lib" lib=$(HOME="$quoted_home" resolve lib) @@ -82,6 +87,56 @@ HOME="$work_dir/x'; touch \"$marker\"; echo '" resolve lib >/dev/null 2>&1 [ -e "$marker" ] && fail "a crafted HOME executed a command through the directory probe" +# A candidate that is not a directory has to be rejected, and a FIFO is the one +# that can do more than that: opening it with no writer on the other end blocks +# until one turns up, and this resolver runs before the session has a window on +# screen. It must answer, and it must answer no. +if command -v mkfifo >/dev/null 2>&1 && command -v timeout >/dev/null 2>&1; then + fifo_home="$work_dir/fifo" + mkdir -p "$fifo_home/.local" + mkfifo "$fifo_home/.local/lib" + + lib=$(HOME="$fifo_home" timeout 5 lua -e " + local ok, err = pcall(dofile, [[$path_module]]) + if not ok then + io.write('error: ', tostring(err)) + os.exit(1) + end + + io.write(tostring(hyde.path.lib)) + " 2>&1) + case $? in + 0) ;; + 124) fail "a FIFO in place of a candidate directory hung the resolver" ;; + *) fail "the resolver raised on a FIFO candidate instead of skipping it: $lib" ;; + esac + [ "$lib" = "$fifo_home/.local/lib" ] && + fail "a FIFO was resolved as a directory: $lib" +else + skip "mkfifo or timeout is not available, FIFO case not run" +fi + +# A regular file where a directory is expected is the same question without the +# hazard, and the answer has to be the same. +file_home="$work_dir/file" +mkdir -p "$file_home/.local" +: > "$file_home/.local/lib" +lib=$(HOME="$file_home" resolve lib) || + fail "the resolver raised on a regular file candidate: $lib" +[ "$lib" = "$file_home/.local/lib" ] && + fail "a regular file was resolved as a directory: $lib" + +# A directory that grants search but not read still serves files by name, which +# is all package.path asks of it, so it counts. Running as root makes the +# distinction disappear, and the case then only asserts the ordinary answer. +xonly_home="$work_dir/xonly" +mkdir -p "$xonly_home/.local/lib" +chmod 111 "$xonly_home/.local/lib" +lib=$(HOME="$xonly_home" resolve lib) +chmod 755 "$xonly_home/.local/lib" +[ "$lib" = "$xonly_home/.local/lib" ] || + fail "an unreadable candidate directory was skipped: $lib" + # 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" diff --git a/tests/test_pypr_wrapper.sh b/tests/test_pypr_wrapper.sh new file mode 100755 index 0000000000..acbe932ef6 --- /dev/null +++ b/tests/test_pypr_wrapper.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# The Pyprland wrapper must preserve command arguments when socket helper +# programs are unavailable and it falls back to the Pyprland CLI. + +# shellcheck source=tests/lib/common.sh +. "$(dirname -- "$0")/lib/common.sh" + +hyde_shell="$REPO_ROOT/Configs/.local/bin/hyde-shell" +fixture=$(mktemp -d) +server_pid= + +cleanup() { + if [[ -n "$server_pid" ]]; then + kill "$server_pid" 2>/dev/null || true + wait "$server_pid" 2>/dev/null || true + fi + rm -rf "$fixture" +} +trap cleanup EXIT HUP INT TERM + +mkdir -p \ + "$fixture/bin" \ + "$fixture/config/pypr" \ + "$fixture/runtime/hypr/test-instance" \ + "$fixture/state/hyde/python_env/bin" +: >"$fixture/config/pypr/config.toml" +: >"$fixture/state/hyde/python_env/bin/activate" + +for helper in nc socat ncat; do + cat >"$fixture/bin/$helper" <<'EOF' +#!/usr/bin/env sh +exit 1 +EOF +done + +cat >"$fixture/bin/pgrep" <<'EOF' +#!/usr/bin/env sh +exit 0 +EOF + +cat >"$fixture/bin/pypr" <<'EOF' +#!/usr/bin/env sh +printf '<%s>\n' "$@" +EOF +chmod +x "$fixture/bin/"* + +socket_path="$fixture/runtime/hypr/test-instance/.pyprland.sock" +python3 - "$socket_path" <<'PY' & +import socket +import sys +import time + +server = socket.socket(socket.AF_UNIX) +server.bind(sys.argv[1]) +time.sleep(30) +PY +server_pid=$! + +for _ in {1..100}; do + [[ -S "$socket_path" ]] && break + sleep 0.01 +done + +if [[ ! -S "$socket_path" ]]; then + fail "test Pyprland socket was not created" + finish +fi + +output=$( + PATH="$fixture/bin:/usr/bin:/bin" \ + XDG_CONFIG_HOME="$fixture/config" \ + XDG_RUNTIME_DIR="$fixture/runtime" \ + XDG_STATE_HOME="$fixture/state" \ + HYPRLAND_INSTANCE_SIGNATURE=test-instance \ + "$hyde_shell" pypr toggle console 2>"$fixture/hyde-shell.stderr" +) +status=$? + +if [[ "$status" -ne 0 ]]; then + fail "Pyprland CLI fallback exited with $status: $(<"$fixture/hyde-shell.stderr")" +fi + +if [[ "$output" != $'\n' ]]; then + fail "Pyprland CLI fallback did not preserve command arguments: $output" +fi + +finish