From 7c3761fc1a54cd24159b6365a446647d509161b5 Mon Sep 17 00:00:00 2001 From: DreamCoder08 Date: Mon, 26 Oct 2026 13:59:54 -0500 Subject: [PATCH] feat(shell): install conservative durable nmtui routing --- CHANGELOG.md | 1 + docs/configuration/ml4w.md | 19 ++- lib/nmtui_route.sh | 87 ++++++++++++ scripts/setup-hyprland.sh | 41 +++--- tests/ml4w/nmtui_route.bats | 262 ++++++++++++++++++++++++++++++++++++ 5 files changed, 389 insertions(+), 21 deletions(-) create mode 100644 lib/nmtui_route.sh create mode 100644 tests/ml4w/nmtui_route.bats diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a061643..7eae4069 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Conservative setup integration for the ML4W network launcher, preserving custom scripts, existing backups, and write-free dry runs. - Process-local `dreamcoder-nmtui` launcher with terminal-native inverse selection, Fish/Zsh routing, and a Waybar default entry point; explicit Newt style overrides remain supported. - PEP 621 packaging: `pyproject.toml` with setuptools build backend, `__version__` in package. - `dreamcoder-theme` CLI entry point (`dreamcoder-theme sync|doctor|paths`). diff --git a/docs/configuration/ml4w.md b/docs/configuration/ml4w.md index dc370a97..c28b7a19 100644 --- a/docs/configuration/ml4w.md +++ b/docs/configuration/ml4w.md @@ -191,11 +191,23 @@ After every ML4W upgrade: ## What setup-hyprland.sh does -1. **Symlinks** wlogout + swaync `colors.css` → waybar (single theme toggle point) +1. **Symlinks** wlogout + swaync `colors.css` → `../waybar/colors.css` + (inside `~/.config`, a single theme toggle point). Regular files are backed + up before linking; existing incorrect links are repaired and current links + remain unchanged. `--dry-run` previews the same target without writing. 2. **Generates** `custom.lua` from JSON profile via `generate-custom-lua.sh` 3. **Installs** toggle script (`dreamcoder-toggle-theme.sh`) to `~/.config/hypr/scripts/` -4. **Applies** ML4W hooks (wallpaper, theme regeneration) -5. **Reloads** Hyprland +4. **Routes** the optional `~/.config/ml4w/scripts/ml4w-network` menu through + `~/.local/bin/dreamcoder-nmtui`, installed first. Only the two bare `nmtui` + calls in the recognized NetworkManager start/check script are replaced; + comments and formatting remain intact. Custom scripts, symlinks and conflicting + launchers are skipped with a warning. The original route is backed up to + `ml4w-network.bak` only when patched; existing backups are preserved. + Re-running setup repairs a missing launcher without rewriting an already + patched route. Re-run setup after an ML4W upgrade to restore the route. + `--dry-run` previews this installation without writes or network actions. +5. **Applies** ML4W hooks (wallpaper, theme regeneration) +6. **Reloads** Hyprland ## Theme Toggle @@ -245,6 +257,7 @@ tests/ml4w/ ├── generate_custom_lua.bats # generator, incl. the dreamcoder-colors loader ├── keybindings_variant.bats # dreamcoder.lua vs ML4W 2.16.1 and the profile ├── ml4w_managed.bats # ML4W ownership + colour-file predicates +├── nmtui_route.bats # safe optional network-menu route installation ├── setup_hyprland.bats # orchestrator ├── profile_validation.bats # JSON profiles └── waybar_override.bats # Waybar accent override diff --git a/lib/nmtui_route.sh b/lib/nmtui_route.sh new file mode 100644 index 00000000..36786209 --- /dev/null +++ b/lib/nmtui_route.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# Optional ML4W network route. Sourced: leave the caller's shell options alone. + +_nmtui_route() { + # A strict known-body check protects arbitrary personal network scripts. Work + # in bytes so comments, indentation, line endings and final newlines survive. + python3 - "$1" "$2" <<'PY' +import pathlib +import re +import sys + +path = pathlib.Path(sys.argv[1]) +data = path.read_bytes() +launcher = b'"${HOME}/.local/bin/dreamcoder-nmtui"' +lines = [line.strip() for line in data.splitlines() + if line.strip() and not line.lstrip().startswith(b'#')] +expected = [b'if systemctl is-active --quiet NetworkManager; then', b'nmtui', + b'else', b'sudo systemctl start NetworkManager', b'if [ $? -eq 0 ]; then', + b'nmtui', b'else', b'echo "NetworkManager could not start"', b'fi', b'fi'] +normalized = [b'nmtui' if line == launcher else line for line in lines] +if normalized != expected: + print('unknown') +elif sys.argv[2] == 'write': + path.write_bytes(re.sub(rb'(?m)^([ \t]*)nmtui([ \t]*\r?)$', + lambda m: m[1] + launcher + m[2], data)) +else: + print('current' if lines.count(launcher) == 2 else 'pending') +PY +} + +_nmtui_route_prerequisites() { + local route="$1" source_file="$2" + if [[ ! -f "${route}" && ! -L "${route}" ]]; then + warn "ML4W network script missing, skipped: ${route}" + return 1 + fi + if [[ -L "${route}" || ! -f "${route}" ]]; then + warn "Unrecognized ML4W network script, skipped: ${route}" + return 1 + fi + if ! command -v python3 >/dev/null || [[ ! -f "${source_file}" ]]; then + warn "nmtui route prerequisites missing, skipped: ${route}" + return 1 + fi +} + +install_nmtui_route() { + # setup-hyprland has already checked ML4W ownership. Use its logging helpers + # and the same compare-before-copy / no-personal-symlink policy as Waybar. + local source_file="$1/DreamcoderShell/.local/bin/dreamcoder-nmtui" + local route="${HOME}/.config/ml4w/scripts/ml4w-network" + local launcher="${HOME}/.local/bin/dreamcoder-nmtui" state + _nmtui_route_prerequisites "${route}" "${source_file}" || return 0 + state="$(_nmtui_route "${route}" probe)" || return 1 + if [[ "${state}" == unknown ]]; then + warn "Unrecognized ML4W network script, skipped: ${route}" + return 0 + fi + if [[ -L "${launcher}" ]] || { [[ -e "${launcher}" ]] && + { [[ ! -f "${launcher}" ]] || ! cmp -s "${source_file}" "${launcher}"; }; }; then + warn "Conflicting nmtui launcher, skipped: ${launcher}" + return 0 + fi + if [[ "${state}" == current && -x "${launcher}" ]]; then + info "ML4W network route already current: ${route}" + return 0 + fi + if [[ "${DRY_RUN}" == true ]]; then + step "Would install ML4W network route: ${route}" + return 0 + fi + # Never point the network menu at a launcher that has not installed safely. + if [[ ! -f "${launcher}" ]]; then + mkdir -p "$(dirname "${launcher}")" || return 1 + cp "${source_file}" "${launcher}" || return 1 + fi + if [[ ! -x "${launcher}" ]]; then + chmod +x "${launcher}" || return 1 + fi + if [[ "${state}" != current ]]; then + if [[ ! -e "${route}.bak" && ! -L "${route}.bak" ]]; then + cp -p "${route}" "${route}.bak" || return 1 + fi + _nmtui_route "${route}" write || return 1 + fi + info "ML4W network route installed: ${route}" +} diff --git a/scripts/setup-hyprland.sh b/scripts/setup-hyprland.sh index 71d8f90f..a1463924 100755 --- a/scripts/setup-hyprland.sh +++ b/scripts/setup-hyprland.sh @@ -16,7 +16,7 @@ # 2. Symlinks swaync/colors.css → waybar/colors.css # 3. Generates ~/.config/hypr/custom.lua from machine profile # 4. Installs dreamcoder-toggle-theme.sh to ~/.config/hypr/scripts/ -# 5. Applies wallpaper hooks (calls apply-ml4w-hooks.sh) +# 5. Installs the optional ML4W network route and applies wallpaper hooks # 6. Reloads Hyprland configuration # 7. Logs a summary of the integration # @@ -31,6 +31,7 @@ set -euo pipefail DREAMCODER_DOTS_DIR="$(cd "$(dirname "$0")/.." && pwd)" [[ -f "${DREAMCODER_DOTS_DIR}/lib/ml4w.sh" ]] && source "${DREAMCODER_DOTS_DIR}/lib/ml4w.sh" [[ -f "${DREAMCODER_DOTS_DIR}/lib/waybar.sh" ]] && source "${DREAMCODER_DOTS_DIR}/lib/waybar.sh" +[[ -f "${DREAMCODER_DOTS_DIR}/lib/nmtui_route.sh" ]] && source "${DREAMCODER_DOTS_DIR}/lib/nmtui_route.sh" ASSETS_DIR="${DREAMCODER_DOTS_DIR}/ml4w_assets" GENERATOR="${DREAMCODER_DOTS_DIR}/scripts/generate-custom-lua.sh" ML4W_HOOKS="${DREAMCODER_DOTS_DIR}/scripts/apply-ml4w-hooks.sh" @@ -109,7 +110,7 @@ else fi # ── pre-flight validation ────────────────────────────────────────────────── -if $DRY_RUN; then +if [[ "${DRY_RUN}" == true ]]; then step "Dry-run mode — no files will be modified" echo "" fi @@ -161,11 +162,11 @@ if command -v check-json >/dev/null; then SCHEMA_VALID=true fi elif command -v python3 >/dev/null && python3 -c "import jsonschema" 2>/dev/null; then - if python3 -c " + if python3 - "${SCHEMA_FILE}" "${PROFILE_FILE}" <<'PY' 2>/dev/null import json, sys -with open('${SCHEMA_FILE}') as f: +with open(sys.argv[1]) as f: schema = json.load(f) -with open('${PROFILE_FILE}') as f: +with open(sys.argv[2]) as f: profile = json.load(f) import jsonschema try: @@ -174,12 +175,13 @@ try: except jsonschema.ValidationError as e: print(e.message, file=sys.stderr) sys.exit(1) -" 2>/dev/null; then +PY + then SCHEMA_VALID=true fi fi -if $SCHEMA_VALID; then +if [[ "${SCHEMA_VALID}" == true ]]; then ok "Profile matches schema" ((++PASS)) else @@ -209,9 +211,9 @@ echo " Pre-flight: ${PASS} passed" # ── 1. Symlink wlogout → waybar colors ──────────────────────────────────── echo "" echo "═══ Step 1: Symlink Wlogout colors → Waybar ═══" -if $DRY_RUN; then - step "Would symlink: ${WLOGOUT_DIR}/colors.css → ../../waybar/colors.css" - step "Would symlink: ${SWAYNC_DIR}/colors.css → ../../waybar/colors.css" +if [[ "${DRY_RUN}" == true ]]; then + step "Would symlink: ${WLOGOUT_DIR}/colors.css → ../waybar/colors.css" + step "Would symlink: ${SWAYNC_DIR}/colors.css → ../waybar/colors.css" else for target_dir in "${WLOGOUT_DIR}" "${SWAYNC_DIR}"; do local_colors="${target_dir}/colors.css" @@ -219,7 +221,7 @@ else if [[ -L "${local_colors}" ]]; then current_target=$(readlink "${local_colors}") - if [[ "${current_target}" == "../../waybar/colors.css" ]]; then + if [[ "${current_target}" == "../waybar/colors.css" ]]; then ok "Already linked: ${local_colors} → ${current_target}" continue fi @@ -232,7 +234,7 @@ else fi rm -f "${local_colors}" - ln -sf "../../waybar/colors.css" "${local_colors}" + ln -sf "../waybar/colors.css" "${local_colors}" info "Linked: ${local_colors} → $(readlink "${local_colors}")" done fi @@ -240,7 +242,7 @@ fi # ── 2. Generate custom.lua from profile ─────────────────────────────────── echo "" echo "═══ Step 2: Generate custom.lua from profile ═══" -if $DRY_RUN; then +if [[ "${DRY_RUN}" == true ]]; then bash "${GENERATOR}" --profile "${PROFILE_NAME}" --dry-run 2>&1 else if [[ ! -f "${GENERATOR}" ]]; then @@ -273,7 +275,7 @@ KEYBIND_SELECTOR_TARGET="${HYPR_DIR}/conf/keybinding.lua" KEYBIND_VARIANT_TARGET="${HYPR_DIR}/conf/keybindings/dreamcoder.lua" echo "" echo "═══ Step 3: Install keybindings variant (dreamcoder.lua) ═══" -if $DRY_RUN; then +if [[ "${DRY_RUN}" == true ]]; then step "Would install keybindings selector + variant" elif [[ -f "${KEYBIND_VARIANT}" ]] && grep -q "CONTRACT: This file only defines keybindings NOT managed" "${KEYBIND_VARIANT}" 2>/dev/null; then mkdir -p "$(dirname "${KEYBIND_SELECTOR_TARGET}")" "$(dirname "${KEYBIND_VARIANT_TARGET}")" @@ -286,7 +288,7 @@ fi # ML4W's launch.sh prefers style-custom.css in the selected style theme dir. # Optional: a machine without the ML4W selector must not fail setup. -if $DRY_RUN; then +if [[ "${DRY_RUN}" == true ]]; then step "Would install Waybar accent override (style-custom.css)" else install_waybar_override "${DREAMCODER_DOTS_DIR}" || true @@ -295,7 +297,7 @@ fi # ── 4. Install dreamcoder-toggle-theme.sh ───────────────────────────────── echo "" echo "═══ Step 4: Install dreamcoder-toggle-theme.sh ═══" -if $DRY_RUN; then +if [[ "${DRY_RUN}" == true ]]; then step "Would install: ${TOGGLE_TARGET}" else mkdir -p "${HYPR_SCRIPTS}" @@ -309,10 +311,13 @@ else fi fi +# The network menu uses the terminal-aware launcher; custom routes are skipped. +install_nmtui_route "${DREAMCODER_DOTS_DIR}" + # ── 5. Apply ML4W wallpaper hooks ───────────────────────────────────────── echo "" echo "═══ Step 5: Apply ML4W wallpaper hooks ═══" -if $DRY_RUN; then +if [[ "${DRY_RUN}" == true ]]; then step "Would run: ${ML4W_HOOKS}" else if [[ -f "${ML4W_HOOKS}" ]]; then @@ -326,7 +331,7 @@ fi # ── 6. Reload Hyprland ──────────────────────────────────────────────────── echo "" echo "═══ Step 6: Reload Hyprland ═══" -if $DRY_RUN; then +if [[ "${DRY_RUN}" == true ]]; then step "Would run: hyprctl reload" else if command -v hyprctl >/dev/null; then diff --git a/tests/ml4w/nmtui_route.bats b/tests/ml4w/nmtui_route.bats new file mode 100644 index 00000000..b49c877c --- /dev/null +++ b/tests/ml4w/nmtui_route.bats @@ -0,0 +1,262 @@ +# Public setup entry, isolated from the live desktop and networking services. +load '../helpers/setup' +bats_require_minimum_version 1.5.0 + +setup() { + TEST_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)" + export TEST_TEMP_HOME="$(mktemp -d)" + # Keep installed Python test dependencies visible under the isolated HOME. + export PYTHONUSERBASE="$(python3 -m site --user-base)" + export HOME="${TEST_TEMP_HOME}/home" + export XDG_CACHE_HOME="${HOME}/.cache" + export ROUTE="${HOME}/.config/ml4w/scripts/ml4w-network" + export LAUNCHER="${HOME}/.local/bin/dreamcoder-nmtui" + export REPO="${TEST_TEMP_HOME}/repo" + mkdir -p "${REPO}/scripts" "${REPO}/lib" "${HOME}/.config/hypr/conf" \ + "${HOME}/.config/waybar" "${HOME}/.config/wlogout" "${HOME}/.config/swaync" \ + "$(dirname "${ROUTE}")" "${TEST_TEMP_HOME}/bin" + cp "${TEST_DIR}/scripts/setup-hyprland.sh" "${REPO}/scripts/" + cp -R "${TEST_DIR}/lib/." "${REPO}/lib/" + cp -R "${TEST_DIR}/DreamcoderProfiles" "${TEST_DIR}/ml4w_assets" "${TEST_DIR}/DreamcoderShell" "${REPO}/" + touch "${HOME}/.config/hypr/conf/keybinding.lua" "${HOME}/.config/waybar/launch.sh" + printf '#!/bin/bash\nexit 0\n' >"${REPO}/scripts/apply-ml4w-hooks.sh" + cat >"${REPO}/scripts/generate-custom-lua.sh" <<'SCRIPT' +#!/bin/bash +[[ " $* " == *' --dry-run '* ]] && exit 0 +printf '%s\n' '-- Valid isolated generator fixture' >"${HOME}/.config/hypr/custom.lua" +SCRIPT + for command in hyprctl sudo systemctl nmtui; do + printf '#!/bin/bash\nprintf "%%s\\n" "%s $*" >>"${TEST_TEMP_HOME}/calls"\necho ok\n' "${command}" >"${TEST_TEMP_HOME}/bin/${command}" + chmod +x "${TEST_TEMP_HOME}/bin/${command}" + done + printf '#!/bin/bash\nexit 0\n' >"${TEST_TEMP_HOME}/bin/check-json" + chmod +x "${TEST_TEMP_HOME}/bin/check-json" + export PATH="${TEST_TEMP_HOME}/bin:${PATH}" + cat >"${ROUTE}" <<'SCRIPT' +#!/bin/bash +# Keep this customization and whitespace exactly. +if systemctl is-active --quiet NetworkManager; then + nmtui +else + sudo systemctl start NetworkManager + if [ $? -eq 0 ]; then + nmtui + else + echo "NetworkManager could not start" + fi +fi +SCRIPT + chmod +x "${ROUTE}" + cp "${ROUTE}" "${TEST_TEMP_HOME}/original" +} + +apply_setup() { + run --separate-stderr bash "${REPO}/scripts/setup-hyprland.sh" --profile default "$@" + [[ "${status}" -eq 0 ]] +} + +snapshot() { + # Bytes, links, permissions and timestamps, including command counters. + python3 - "${@:-${HOME}}" "${TEST_TEMP_HOME}/calls" <<'PY' +import hashlib, os, pathlib, sys +for root in sys.argv[1:]: + paths = [pathlib.Path(root)] + if paths[0].is_dir(): + paths += sorted(paths[0].rglob('*')) + for p in paths: + if not p.exists() and not p.is_symlink(): + continue + s = p.lstat() + value = os.readlink(p) if p.is_symlink() else hashlib.sha256(p.read_bytes()).hexdigest() if p.is_file() else '' + print(str(p), s.st_mode, s.st_mtime_ns, value) +PY +} + +use_python_schema_paths() { + # Hide the optional validator so the public command exercises its fallback. + mv "${TEST_TEMP_HOME}/bin/check-json" "${TEST_TEMP_HOME}/check-json-disabled" + local repo_name="$1" + mv "${REPO}" "${TEST_TEMP_HOME}/${repo_name}" + export REPO="${TEST_TEMP_HOME}/${repo_name}" + mv "${HOME}" "${TEST_TEMP_HOME}/home's desktop" + export HOME="${TEST_TEMP_HOME}/home's desktop" + export ROUTE="${HOME}/.config/ml4w/scripts/ml4w-network" + export LAUNCHER="${HOME}/.local/bin/dreamcoder-nmtui" + # Suppress the existing read-only probe in snapshots of dry-run/rejection. + printf '#!/bin/bash\nexit 0\n' >"${TEST_TEMP_HOME}/bin/hyprctl" +} + +@test "setup schema: apostrophes in repository and HOME remain literal paths" { + use_python_schema_paths "repo's checkout" + before="$(snapshot)" + apply_setup --dry-run + [[ "${output}" == *'Profile matches schema'* ]] + [[ "${output}" == *'Pre-flight: 10 passed'* ]] + [[ -z "${stderr}" ]] + [[ "$(snapshot)" == "${before}" ]] +} + +@test "setup schema: Python-shaped path cannot execute code and rejected input is inert" { + use_python_schema_paths "repo'+str(__import__('os').system('touch injected'))+'" + cd "${TEST_TEMP_HOME}" + before="$(snapshot "${HOME}" "${REPO}")" + apply_setup --dry-run + [[ "${output}" == *'Profile matches schema'* ]] + [[ "${output}" == *'Pre-flight: 10 passed'* ]] + [[ -z "${stderr}" && ! -e injected ]] + [[ "$(snapshot "${HOME}" "${REPO}")" == "${before}" ]] + run --separate-stderr bash "${REPO}/scripts/setup-hyprland.sh" --unknown + [[ "${status}" -eq 1 && -z "${output}" ]] + [[ "${stderr}" == '✖ Unknown option: --unknown' ]] + [[ ! -e injected ]] + [[ "$(snapshot "${HOME}" "${REPO}")" == "${before}" ]] +} + +@test "setup colors: preview, install and repeat resolve both links to Waybar" { + printf 'waybar colors\n' >"${HOME}/.config/waybar/colors.css" + printf 'original logout colors\n' >"${HOME}/.config/wlogout/colors.css" + ln -s '../../waybar/colors.css' "${HOME}/.config/swaync/colors.css" + # Respond successfully to reload without recording a dry-run probe as a write. + printf '#!/bin/bash\necho ok\n' >"${TEST_TEMP_HOME}/bin/hyprctl" + before="$(snapshot)" + apply_setup --dry-run + [[ -z "${stderr}" ]] + [[ "${output}" == *"${HOME}/.config/wlogout/colors.css → ../waybar/colors.css"* ]] + [[ "${output}" == *"${HOME}/.config/swaync/colors.css → ../waybar/colors.css"* ]] + [[ "$(snapshot)" == "${before}" ]] + apply_setup + [[ -z "${stderr}" ]] + for directory in wlogout swaync; do + link="${HOME}/.config/${directory}/colors.css" + [[ -L "${link}" && "$(readlink "${link}")" == '../waybar/colors.css' ]] + [[ "$(readlink -f "${link}")" == "${HOME}/.config/waybar/colors.css" ]] + [[ "$(<"${link}")" == 'waybar colors' ]] + done + [[ "$(<"${HOME}/.config/wlogout/colors.css.bak")" == 'original logout colors' ]] + [[ ! -e "${HOME}/.config/swaync/colors.css.bak" ]] + before="$(snapshot "${HOME}/.config/wlogout" "${HOME}/.config/swaync")" + apply_setup + [[ -z "${stderr}" ]] + [[ "${output}" == *"Already linked:"* ]] + [[ "$(snapshot "${HOME}/.config/wlogout" "${HOME}/.config/swaync")" == "${before}" ]] +} + +@test "nmtui route: setup installs launcher before replacing only the two invocations" { + apply_setup + [[ "${output}" == *"ML4W network route installed:"* ]] + cmp "${LAUNCHER}" "${REPO}/DreamcoderShell/.local/bin/dreamcoder-nmtui" + [[ -x "${LAUNCHER}" && -x "${ROUTE}" ]] + cmp "${ROUTE}.bak" "${TEST_TEMP_HOME}/original" + python3 - "${TEST_TEMP_HOME}/original" "${ROUTE}" <<'PY' +import pathlib, sys +original, actual = (pathlib.Path(p).read_bytes() for p in sys.argv[1:]) +assert actual == original.replace(b' nmtui', b' "${HOME}/.local/bin/dreamcoder-nmtui"') +PY + [[ "$(cat "${TEST_TEMP_HOME}/calls")" == 'hyprctl monitors -j +hyprctl reload' ]] +} + +@test "nmtui route: dry-run is byte and counter identical" { + # monitors is an existing read-only setup probe; don't count that query. + printf '#!/bin/bash\nexit 0\n' >"${TEST_TEMP_HOME}/bin/hyprctl" + before="$(snapshot)" + apply_setup --dry-run + [[ "${output}" == *"Would install ML4W network route:"* ]] + [[ "$(snapshot)" == "${before}" ]] +} + +@test "nmtui route: repeated and already patched installs preserve bytes and old backup" { + apply_setup + printf 'older backup\n' >"${ROUTE}.bak" + before="$(snapshot "${ROUTE}" "${ROUTE}.bak" "${LAUNCHER}" | grep -v '/calls ')" + apply_setup + [[ "${output}" == *"ML4W network route already current:"* ]] + [[ "$(snapshot "${ROUTE}" "${ROUTE}.bak" "${LAUNCHER}" | grep -v '/calls ')" == "${before}" ]] + # Other setup actions intentionally run again; compare only owned route files. + [[ "$(cat "${ROUTE}.bak")" == 'older backup' ]] + [[ "$(grep -c 'dreamcoder-nmtui' "${ROUTE}")" -eq 2 ]] + cmp "${LAUNCHER}" "${REPO}/DreamcoderShell/.local/bin/dreamcoder-nmtui" +} + +@test "nmtui route: unknown script and missing optional script are skipped" { + printf '#!/bin/bash\nnmtui --custom\n' >"${ROUTE}" + cp "${ROUTE}" "${TEST_TEMP_HOME}/unknown" + apply_setup + [[ "${stderr}" == *"Unrecognized ML4W network script, skipped:"* ]] + cmp "${ROUTE}" "${TEST_TEMP_HOME}/unknown" + [[ ! -e "${LAUNCHER}" && ! -e "${ROUTE}.bak" ]] + mv "${ROUTE}" "${TEST_TEMP_HOME}/absent" + apply_setup + [[ "${stderr}" == *"ML4W network script missing, skipped:"* ]] + [[ ! -e "${ROUTE}" && ! -e "${LAUNCHER}" ]] +} + +@test "nmtui route: unrelated launcher is never overwritten or routed" { + mkdir -p "$(dirname "${LAUNCHER}")" + printf '#!/bin/bash\necho custom\n' >"${LAUNCHER}" + cp "${LAUNCHER}" "${TEST_TEMP_HOME}/custom" + apply_setup + [[ "${stderr}" == *"Conflicting nmtui launcher, skipped:"* ]] + cmp "${LAUNCHER}" "${TEST_TEMP_HOME}/custom" + cmp "${ROUTE}" "${TEST_TEMP_HOME}/original" + [[ ! -e "${ROUTE}.bak" ]] +} + +@test "nmtui route: custom control flow is preserved even with two bare invocations" { + printf '\nlogger custom-network-action\n' >>"${ROUTE}" + cp "${ROUTE}" "${TEST_TEMP_HOME}/custom-route" + apply_setup + [[ "${stderr}" == *"Unrecognized ML4W network script, skipped:"* ]] + cmp "${ROUTE}" "${TEST_TEMP_HOME}/custom-route" + [[ ! -e "${LAUNCHER}" && ! -e "${ROUTE}.bak" ]] +} + +@test "nmtui route: personal route and launcher symlinks are never followed" { + mv "${ROUTE}" "${TEST_TEMP_HOME}/linked-route" + ln -s "${TEST_TEMP_HOME}/linked-route" "${ROUTE}" + apply_setup + [[ "${stderr}" == *"Unrecognized ML4W network script, skipped:"* ]] + [[ -L "${ROUTE}" && ! -e "${LAUNCHER}" && ! -e "${ROUTE}.bak" ]] + cmp "${ROUTE}" "${TEST_TEMP_HOME}/original" + # Restore the regular fixture without removing the personal link. + mv "${ROUTE}" "${TEST_TEMP_HOME}/personal-route-link" + cp "${TEST_TEMP_HOME}/original" "${ROUTE}" + mkdir -p "$(dirname "${LAUNCHER}")" + ln -s "${TEST_TEMP_HOME}/missing-launcher" "${LAUNCHER}" + apply_setup + [[ "${stderr}" == *"Conflicting nmtui launcher, skipped:"* ]] + [[ -L "${LAUNCHER}" && ! -e "${TEST_TEMP_HOME}/missing-launcher" && ! -e "${ROUTE}.bak" ]] + cmp "${ROUTE}" "${TEST_TEMP_HOME}/original" +} + +@test "nmtui route: already patched script repairs missing launcher without a backup" { + python3 - "${ROUTE}" <<'PY' +import pathlib, sys +p = pathlib.Path(sys.argv[1]) +p.write_bytes(p.read_bytes().replace(b' nmtui', b' "${HOME}/.local/bin/dreamcoder-nmtui"')) +PY + cp -p "${ROUTE}" "${TEST_TEMP_HOME}/patched" + apply_setup + [[ -z "${stderr}" ]] + cmp "${ROUTE}" "${TEST_TEMP_HOME}/patched" + cmp "${LAUNCHER}" "${REPO}/DreamcoderShell/.local/bin/dreamcoder-nmtui" + [[ -x "${LAUNCHER}" && ! -e "${ROUTE}.bak" ]] +} + +@test "nmtui route: failed launcher installation cannot patch or back up the route" { + cat >"${TEST_TEMP_HOME}/bin/cp" <<'SCRIPT' +#!/bin/bash +if [[ "${*: -1}" == "${LAUNCHER}" ]]; then + echo 'isolated launcher install failure' >&2 + exit 1 +fi +exec /usr/bin/cp "$@" +SCRIPT + chmod +x "${TEST_TEMP_HOME}/bin/cp" + run --separate-stderr bash "${REPO}/scripts/setup-hyprland.sh" --profile default + [[ "${status}" -eq 1 ]] + [[ "${stderr}" == 'isolated launcher install failure' ]] + cmp "${ROUTE}" "${TEST_TEMP_HOME}/original" + [[ ! -e "${LAUNCHER}" && ! -e "${ROUTE}.bak" ]] + [[ "$(cat "${TEST_TEMP_HOME}/calls")" == 'hyprctl monitors -j' ]] +}