#!/usr/bin/env bash set -euo pipefail # Constants & Paths INSTALLER_VERSION="1.0.0" INSTALL_DIR="$HOME/.local/share/nirimod" BIN_DIR="$HOME/.local/bin" DESKTOP_FILE_DIR="$HOME/.local/share/applications" ICON_DIR="$HOME/.local/share/icons/hicolor/scalable/apps" REPO_URL="https://github.com/srinivasr/nirimod" DISTRO="" DISTRO_PRETTY="" DISTRO_LIKE="" PM="" IMAGE_BUILT_OS=0 # Colors RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' BOLD='\033[1m' NC='\033[0m' # Helpers info() { echo -e "${BLUE} ➜ ${NC}$*"; } success() { echo -e "${GREEN} ✓ ${NC}$*"; } warn() { echo -e "${YELLOW} ⚠ ${NC}$*"; } error() { echo -e "${RED} ✗ ${NC}$*" >&2; } step() { echo -e "\n${BOLD}${CYAN}══ $* ══${NC}"; } pause() { echo -e "\n${BLUE}Press any key to continue...${NC}" read -n 1 -s -r < /dev/tty || true } ask() { # ask → returns 0 for yes, 1 for no local prompt="$1" default="${2:-y}" local yn_hint [[ "$default" == "y" ]] && yn_hint="[Y/n]" || yn_hint="[y/N]" read -p "$(echo -e "${YELLOW} ? ${NC}${prompt} ${yn_hint}: ")" reply < /dev/tty || true reply="${reply:-$default}" [[ "$reply" =~ ^[Yy]$ ]] } print_banner() { clear echo -e "${BLUE}${BOLD}NiriMod Installer v${INSTALLER_VERSION}${NC}" echo -e "${CYAN}GUI Configuration Manager for the Niri Wayland Compositor${NC}\n" } # OS Detection detect_distro() { DISTRO="" DISTRO_PRETTY="" DISTRO_LIKE="" PM="" # detected package manager IMAGE_BUILT_OS=0 if [ -f /etc/os-release ]; then # shellcheck source=/dev/null . /etc/os-release DISTRO="${ID:-}" DISTRO_PRETTY="${PRETTY_NAME:-$ID}" DISTRO_LIKE="${ID_LIKE:-}" fi detect_image_built_os # Normalize distro id using ID_LIKE fallback case "$DISTRO" in arch|manjaro|endeavouros|garuda|artix|parabola) PM="pacman" ;; fedora|rhel|centos|rocky|almalinux) PM="dnf" ;; opensuse*|sles) PM="zypper" ;; ubuntu|debian|linuxmint|pop|elementary|zorin|kali|mx|mxlinux) PM="apt" ;; gentoo) PM="emerge" ;; *) # Try ID_LIKE if [[ "$DISTRO_LIKE" == *"arch"* ]]; then PM="pacman" elif [[ "$DISTRO_LIKE" == *"fedora"* ]] || [[ "$DISTRO_LIKE" == *"rhel"* ]]; then PM="dnf" elif [[ "$DISTRO_LIKE" == *"suse"* ]]; then PM="zypper" elif [[ "$DISTRO_LIKE" == *"debian"* ]] || [[ "$DISTRO_LIKE" == *"ubuntu"* ]]; then PM="apt" elif [[ "$DISTRO_LIKE" == *"gentoo"* ]]; then PM="emerge" fi ;; esac # Verify the detected package manager actually exists. On image-built Fedora, # keep the Fedora package family even if dnf is absent; dependency checks use rpm # and missing packages are reported without attempting a dnf install. if [ -n "$PM" ] && [ "$IMAGE_BUILT_OS" -ne 1 ] && ! command -v "$PM" &>/dev/null; then PM="" fi # Last resort: probe which package manager is installed if [ -z "$PM" ]; then if command -v pacman &>/dev/null; then PM="pacman" elif command -v dnf &>/dev/null; then PM="dnf" elif command -v zypper &>/dev/null; then PM="zypper" elif command -v apt-get &>/dev/null; then PM="apt" elif command -v emerge &>/dev/null; then PM="emerge" fi fi if [ -z "$PM" ]; then error "Could not detect a supported package manager." error "Supported: pacman (Arch), dnf (Fedora/RHEL), zypper (openSUSE), apt (Debian/Ubuntu), emerge (Gentoo)" error "If you are on an unsupported distro, re-run with --skip-deps and install dependencies manually." exit 1 fi info "Detected: ${DISTRO_PRETTY} (package manager: ${PM})" if [ "$IMAGE_BUILT_OS" -eq 1 ]; then info "Detected image-built/atomic OS; system dependencies must come from the image." fi } # Package Installation install_pkgs() { local pkgs=("$@") info "Installing: ${pkgs[*]}" case "$PM" in pacman) sudo pacman -S --needed --noconfirm "${pkgs[@]}" ;; dnf) sudo dnf install -y "${pkgs[@]}" ;; zypper) sudo zypper install -y "${pkgs[@]}" ;; apt) sudo apt-get update -qq && sudo apt-get install -y "${pkgs[@]}" ;; emerge) echo "" echo -e " ${YELLOW}⚠ Gentoo:${NC} packages compile from source and may take a few minutes." echo -e " The cairo USE flag will be set for dev-python/pygobject (needed for the keyboard view)." echo "" if ask "Proceed with emerge?" y; then local use_file="/etc/portage/package.use/nirimod" if ! grep -q "dev-python/pygobject" "$use_file" 2>/dev/null; then echo "dev-python/pygobject cairo" | sudo tee -a "$use_file" > /dev/null fi sudo emerge --newuse --ask=n "${pkgs[@]}" || { error "emerge failed. Try running manually:" for pkg in "${pkgs[@]}"; do echo -e " ${CYAN}sudo emerge $pkg${NC}" done exit 1 } else warn "Install these manually then re-run: bash install.sh --install --skip-deps" for pkg in "${pkgs[@]}"; do echo -e " ${CYAN}sudo emerge $pkg${NC}" done exit 1 fi ;; esac } pkg_installed() { # Returns 0 if the package is installed, 1 otherwise local pkg="$1" case "$PM" in pacman) pacman -Qi "$pkg" &>/dev/null ;; dnf) rpm -q "$pkg" &>/dev/null || rpm -q --whatprovides "$pkg" &>/dev/null ;; zypper) rpm -q "$pkg" &>/dev/null || rpm -q --whatprovides "$pkg" &>/dev/null ;; apt) dpkg-query -W -f='${Status}' "$pkg" 2>/dev/null | grep -q "install ok installed" ;; emerge) if command -v qlist &>/dev/null; then qlist -I "$pkg" &>/dev/null elif command -v equery &>/dev/null; then equery -q list "$pkg" &>/dev/null else return 1 fi ;; esac } cmd_exists() { command -v "$1" &>/dev/null; } detect_image_built_os() { IMAGE_BUILT_OS=0 if [ -e /run/ostree-booted ]; then IMAGE_BUILT_OS=1 fi } needs_uv_preload_cleanup() { [[ "${LD_PRELOAD:-}" == *"libhardened_malloc.so"* ]] || [[ "${LD_PRELOAD:-}" == *"libno_rlimit_as.so"* ]] } filtered_ld_preload() { local entry local kept=() for entry in ${LD_PRELOAD//:/ }; do case "$entry" in *libhardened_malloc.so*|*libno_rlimit_as.so*) ;; *) kept+=("$entry") ;; esac done printf '%s' "${kept[*]}" } run_with_filtered_preload() { if needs_uv_preload_cleanup; then local preload preload="$(filtered_ld_preload)" if [ -n "$preload" ]; then LD_PRELOAD="$preload" "$@" else env -u LD_PRELOAD "$@" fi return fi "$@" } run_uv() { run_with_filtered_preload uv "$@" } resolve_deps() { MISSING=() # Baseline tools if ! cmd_exists git; then case "$PM" in pacman) MISSING+=("git") ;; dnf) MISSING+=("git") ;; zypper) MISSING+=("git") ;; apt) MISSING+=("git") ;; emerge) MISSING+=("dev-vcs/git") ;; esac fi if ! cmd_exists curl; then case "$PM" in pacman) MISSING+=("curl") ;; dnf) MISSING+=("curl") ;; zypper) MISSING+=("curl") ;; apt) MISSING+=("curl") ;; emerge) MISSING+=("net-misc/curl") ;; esac fi if ! cmd_exists python3; then case "$PM" in pacman) MISSING+=("python") ;; dnf) MISSING+=("python3") ;; zypper) MISSING+=("python3") ;; apt) MISSING+=("python3") ;; emerge) MISSING+=("dev-lang/python") ;; esac fi # GTK4 case "$PM" in pacman) pkg_installed gtk4 || MISSING+=("gtk4") ;; dnf) pkg_installed gtk4 || MISSING+=("gtk4") ;; zypper) pkg_installed libgtk-4-1 || MISSING+=("libgtk-4-1") ;; apt) pkg_installed libgtk-4-1 || MISSING+=("libgtk-4-1") ;; emerge) pkg_installed gui-libs/gtk || MISSING+=("gui-libs/gtk") ;; esac # libadwaita case "$PM" in pacman) pkg_installed libadwaita || MISSING+=("libadwaita") ;; dnf) pkg_installed libadwaita || MISSING+=("libadwaita") ;; zypper) pkg_installed libadwaita-1-0 || MISSING+=("libadwaita-1-0") ;; apt) pkg_installed libadwaita-1-0 || MISSING+=("libadwaita-1-0") ;; emerge) pkg_installed gui-libs/libadwaita || MISSING+=("gui-libs/libadwaita") ;; esac # PyGObject / GObject Introspection case "$PM" in pacman) pkg_installed python-gobject || MISSING+=("python-gobject") ;; dnf) pkg_installed python3-gobject || \ pkg_installed python3-gobject-base || \ MISSING+=("python3-gobject") ;; zypper) pkg_installed python3-gobject || MISSING+=("python3-gobject") ;; apt) pkg_installed python3-gi || MISSING+=("python3-gi") pkg_installed python3-gi-cairo || MISSING+=("python3-gi-cairo") ;; emerge) pkg_installed dev-python/pygobject || MISSING+=("dev-python/pygobject") pkg_installed dev-python/pycairo || MISSING+=("dev-python/pycairo") pkg_installed x11-libs/libxkbcommon || MISSING+=("x11-libs/libxkbcommon") pkg_installed x11-misc/xkeyboard-config || MISSING+=("x11-misc/xkeyboard-config") ;; esac # GObject typelibs (needed at runtime for gi.require_version) case "$PM" in dnf) pkg_installed gtk4 || MISSING+=("gtk4") pkg_installed libadwaita || MISSING+=("libadwaita") ;; zypper) pkg_installed typelib-1_0-Gtk-4_0 || MISSING+=("typelib-1_0-Gtk-4_0") pkg_installed typelib-1_0-Adw-1 || MISSING+=("typelib-1_0-Adw-1") ;; apt) pkg_installed gir1.2-gtk-4.0 || MISSING+=("gir1.2-gtk-4.0") pkg_installed gir1.2-adw-1 || MISSING+=("gir1.2-adw-1") ;; esac # Deduplicate if [ ${#MISSING[@]} -gt 0 ]; then # Remove duplicate entries mapfile -t MISSING < <(printf '%s\n' "${MISSING[@]}" | sort -u) fi } # Full Dependency Check check_dependencies() { step "Checking System Dependencies" detect_image_built_os if [ "${SKIP_DEPS:-0}" -eq 1 ]; then warn "Skipping system package manager checks (--skip-deps)." warn "Please ensure git, curl, python3, gtk4, libadwaita, and pygobject are installed manually." else detect_distro resolve_deps if [ ${#MISSING[@]} -gt 0 ]; then warn "The following packages are missing:" for pkg in "${MISSING[@]}"; do echo -e " ${RED}•${NC} $pkg" done echo "" if [ "${IMAGE_BUILT_OS:-0}" -eq 1 ]; then error "This looks like an image-built/atomic system, so the installer will not run sudo ${PM} install." error "Add the missing packages to your image recipe or base image, rebuild, then re-run this installer." warn "If these dependencies are provided outside the system package database, re-run with --skip-deps." exit 1 fi if ask "Install missing packages via sudo?"; then install_pkgs "${MISSING[@]}" success "System packages installed." else error "Cannot proceed without required system packages." exit 1 fi else if [ "${PM:-}" = "emerge" ]; then success "All system packages are already installed." echo -e " ${YELLOW}Note:${NC} If the keyboard view is blank, you may need to rebuild pygobject with the cairo USE flag:" echo -e " ${CYAN}echo 'dev-python/pygobject cairo' | sudo tee -a /etc/portage/package.use/nirimod && sudo emerge --newuse dev-python/pygobject dev-python/pycairo${NC}" echo "" else success "All system packages are already installed." fi fi fi # Niri compositor check (optional warning) if ! cmd_exists niri; then warn "The 'niri' compositor was not found on PATH." warn "NiriMod requires niri to be running. Install it separately if needed." warn " Arch: sudo pacman -S niri" warn " Fedora: sudo dnf install niri" warn " Gentoo: sudo emerge gui-wm/niri" echo "" fi # uv step "Checking uv (Python Environment Manager)" if ! cmd_exists uv; then warn "'uv' is not installed. It is required to manage NiriMod's Python environment." if ask "Install 'uv' via the official installer (astral.sh)?"; then info "Downloading and running the uv installer..." run_with_filtered_preload bash -c 'set -euo pipefail; curl -LsSf https://astral.sh/uv/install.sh | sh' # Make cargo/uv available in current session export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH" if [ -f "$HOME/.cargo/env" ]; then # shellcheck source=/dev/null source "$HOME/.cargo/env" fi if ! cmd_exists uv; then error "'uv' was installed but is not on PATH. Please restart your shell and re-run this installer." error "Or run: export PATH=\"\$HOME/.local/bin:\$HOME/.cargo/bin:\$PATH\"" exit 1 fi success "'uv' installed successfully: $(run_uv --version)" else error "Cannot proceed without 'uv'." exit 1 fi else success "'uv' is available: $(run_uv --version)" fi } # Download / Update Source download_source() { step "Fetching Source Code" if [ -d "$INSTALL_DIR/.git" ]; then info "Updating existing installation at $INSTALL_DIR ..." git -C "$INSTALL_DIR" fetch --quiet origin main && \ git -C "$INSTALL_DIR" reset --hard origin/main --quiet \ || warn "Could not fetch latest changes (maybe no network). Continuing with existing source." else info "Cloning repository to $INSTALL_DIR ..." git clone "$REPO_URL" "$INSTALL_DIR" fi success "Source code is ready." } # Build & Wire Up install_app() { step "Setting Up Python Environment" cd "$INSTALL_DIR" info "Creating virtual environment with system site-packages..." rm -rf .venv # Ensure clean state run_uv venv --system-site-packages --python python3 run_uv sync --no-dev # Verification check if ! run_uv run python -c "import gi" &>/dev/null; then warn "Virtual environment installed, but 'gi' (PyGObject) is still not found." warn "This typically happens if the system bindings are missing or Python version mismatch exists." # Try to diagnose local host_python_ver host_python_ver=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')") info "Host Python: $host_python_ver" if ! python3 -c "import gi" &>/dev/null; then error "PyGObject is NOT installed on your host system." error "Please install it via your package manager first (e.g., python3-gi or python-gobject)." exit 1 else warn "PyGObject is available on host but NOT in the venv. This is unexpected." warn "Attempting a fallback venv creation..." rm -rf .venv run_uv venv --system-site-packages run_uv sync --no-dev fi fi success "Python environment ready and verified." # Launcher script step "Creating Launcher" mkdir -p "$BIN_DIR" # Generate launcher script cat > "$BIN_DIR/nirimod" << EOF #!/usr/bin/env bash # NiriMod launcher — auto-generated by install.sh INSTALL_DIR="${INSTALL_DIR}" if [ ! -d "\$INSTALL_DIR" ]; then echo "NiriMod is not installed at \$INSTALL_DIR. Please re-run the installer." >&2 exit 1 fi export PATH="\$HOME/.local/bin:\$HOME/.cargo/bin:\$PATH" export PYTHONPATH="\$INSTALL_DIR" cd "\$INSTALL_DIR" $(declare -f needs_uv_preload_cleanup) $(declare -f filtered_ld_preload) $(declare -f run_with_filtered_preload) run_with_filtered_preload uv run python3 -m nirimod "\$@" EOF chmod +x "$BIN_DIR/nirimod" success "Launcher created: $BIN_DIR/nirimod" # Desktop entry step "Installing Desktop Entry" mkdir -p "$DESKTOP_FILE_DIR" mkdir -p "$ICON_DIR" # Copy icon if it exists in the repo if [ -f "$INSTALL_DIR/data/nirimod.svg" ]; then cp "$INSTALL_DIR/data/nirimod.svg" "$ICON_DIR/nirimod.svg" ICON_NAME="nirimod" elif [ -f "$INSTALL_DIR/data/nirimod.png" ]; then cp "$INSTALL_DIR/data/nirimod.png" "$HOME/.local/share/icons/hicolor/256x256/apps/nirimod.png" ICON_NAME="nirimod" else ICON_NAME="preferences-system" fi cat > "$DESKTOP_FILE_DIR/io.github.nirimod.desktop" << EOF [Desktop Entry] Version=1.0 Name=NiriMod GenericName=Compositor Settings Comment=GUI Configuration Manager for the Niri Wayland Compositor Exec=${BIN_DIR}/nirimod Icon=${ICON_NAME} Terminal=false Type=Application Categories=Utility;Settings;DesktopSettings; Keywords=compositor;windowmanager;wayland;niri;settings;config; StartupNotify=true StartupWMClass=nirimod EOF # Refresh desktop database if available if cmd_exists update-desktop-database; then update-desktop-database "$DESKTOP_FILE_DIR" 2>/dev/null || true fi if cmd_exists gtk-update-icon-cache; then gtk-update-icon-cache -f -t "$HOME/.local/share/icons/hicolor" 2>/dev/null || true fi success "Desktop entry installed." if [[ ":$PATH:" != *":$BIN_DIR:"* ]]; then echo "" warn "$BIN_DIR isn't in your PATH." if ask "Add it to your shell profile automatically?"; then for rc in "$HOME/.bashrc" "$HOME/.zshrc"; do if [ -f "$rc" ]; then if ! grep -q 'export PATH=.*\.local/bin' "$rc"; then echo -e '\n# nirimod' >> "$rc" echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$rc" success "Patched $rc" fi fi done else warn "You can run it directly: ~/.local/bin/nirimod" fi fi echo "" success "${BOLD}NiriMod ${INSTALLER_VERSION} installed successfully!${NC}" info "Launch from your app menu, or run: ${CYAN}~/.local/bin/nirimod${NC}" } # Uninstall uninstall() { step "Uninstalling NiriMod" warn "This will remove:" echo " • $INSTALL_DIR" echo " • $BIN_DIR/nirimod" echo " • $DESKTOP_FILE_DIR/io.github.nirimod.desktop" echo "" if ! ask "Are you sure you want to uninstall NiriMod?"; then info "Uninstall cancelled." return fi rm -rf "$INSTALL_DIR" rm -f "$BIN_DIR/nirimod" rm -f "$DESKTOP_FILE_DIR/io.github.nirimod.desktop" rm -f "$ICON_DIR/nirimod.svg" if cmd_exists update-desktop-database; then update-desktop-database "$DESKTOP_FILE_DIR" 2>/dev/null || true fi success "NiriMod has been uninstalled." pause exit 0 } # Menu main_menu() { while true; do print_banner echo -e " Please select an option:\n" echo -e " ${GREEN}1${NC}) Install / Update NiriMod" echo -e " ${GREEN}2${NC}) Uninstall NiriMod" echo -e " ${GREEN}q${NC}) Quit" echo "" read -p "$(echo -e " ${BOLD}Enter your choice:${NC} ")" choice < /dev/tty || true case "$choice" in 1) print_banner check_dependencies download_source install_app pause exit 0 ;; 2) print_banner uninstall ;; q|Q) echo -e "\n${BLUE} Goodbye!${NC}\n" exit 0 ;; *) error "Invalid option. Please choose 1, 2, or q." sleep 1 ;; esac done } # Entry Point # Flags: # --install Download from GitHub and install (non-interactive) # --uninstall Remove NiriMod (non-interactive) # --skip-deps Skip system package manager checks (useful for Gentoo/unsupported distros) MODE="" SKIP_DEPS=0 for arg in "$@"; do case "$arg" in --install) MODE="install" ;; --uninstall) MODE="uninstall" ;; --skip-deps) SKIP_DEPS=1 ;; *) error "Unknown option: $arg" echo "Usage: $0 [--install | --uninstall] [--skip-deps]" exit 1 ;; esac done if [ "$MODE" = "install" ]; then print_banner check_dependencies download_source install_app exit 0 elif [ "$MODE" = "uninstall" ]; then print_banner uninstall exit 0 else main_menu fi