foxygit / dotfiles Log in
commit 17660f28d0f60bbfcb2b251161d6f60f988524ee
Author:     mrfox <jens.se@icloud.com>
AuthorDate: Fri Sep 18 21:49:12 2026 +0200
Commit:     mrfox <jens.se@icloud.com>
CommitDate: Fri Sep 18 21:49:12 2026 +0200

    install: Omarchy-style UI with logo, progress bar and WiFi setup

    Rework install.sh into step functions run behind a centered logo and a
    weighted progress bar, with all yes/no questions asked up front and full
    output going to /tmp/dotfiles-install.log. Add an interactive WiFi guide
    (iwd + dhcpcd, adapter and network picker) that runs before the install
    so apt has network. apt-get update failures (e.g. a broken third-party
    repo) no longer abort the run. ./install.sh --demo previews the UI.

    Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---
 README.md  |   24 +-
 install.sh | 1129 ++++++++++++++++++++++++++++++++++++++++++++----------------
 2 files changed, 855 insertions(+), 298 deletions(-)

diff --git a/README.md b/README.md
index 91a1328..446313a 100644
--- a/README.md
+++ b/README.md
@@ -88,8 +88,28 @@ patches applied), `sxbar`, `dmenu`, `st` (with all local patches applied) and
 `~/.local/bin`, sets up Geany with a Gruvbox theme (see [Geany](#geany) below),
 and offers to write `~/.xinitrc` for you. A couple of steps are interactive
 (writing `~/.xinitrc`, enabling the lid-watch daemon, setting
-`HandleLidSwitch=ignore`, installing the Bluetooth no-autosuspend udev rule)
-so you can opt out if you already have your own setup for those.
+`HandleLidSwitch=ignore`, installing the Bluetooth no-autosuspend udev rule,
+Rust/`bluetui`, autostarting X on TTY1) so you can opt out if you already have
+your own setup for those.
+
+The first question is an optional interactive WiFi setup (`iwd` + `dhcpcd`),
+done *before* the install so `apt` has network: it installs the two packages if
+missing, starts `iwd`, lets you pick the adapter (only asked when there's more
+than one), scans and lets you pick a network (or enter a hidden one), connects
+with `iwctl`, and enables `dhcpcd@<adapter>.service` so DHCP only runs on the
+adapter you chose. If `dhcpcd` had to be installed, the generic
+`dhcpcd.service` (all interfaces) that Debian enables is turned off again so it
+doesn't grab the wired interface. 802.1x (enterprise) networks aren't handled —
+configure those manually with `iwctl`.
+
+The installer has an Omarchy-style UI: a centered logo, all the yes/no questions
+asked up front (one keypress each), then an unattended run with a progress bar,
+the current step and the tail of the log. Full output (apt, make, git, ...) goes
+to `/tmp/dotfiles-install.log`; if a step fails the log tail is shown and the
+script stops. `./install.sh --demo` plays the UI with fake steps and touches
+nothing, useful for previewing or tweaking the look. Without a terminal
+(piped/CI) it falls back to plain `==> step` lines and answers "no" to every
+question.

 `HandleLidSwitch=ignore` only takes effect on the next reboot — the script
 deliberately doesn't restart `systemd-logind` live, since on this machine's
diff --git a/install.sh b/install.sh
index 1ad92fa..96e1b88 100755
--- a/install.sh
+++ b/install.sh
@@ -1,4 +1,11 @@
 #!/bin/bash
+# Installerar hela miljön (sxwm, sxbar, st, konfig, skript ...).
+#
+#   ./install.sh          kör installationen
+#   ./install.sh --demo   visar bara gränssnittet med låtsassteg (rör ingenting)
+#
+# Alla frågor ställs först; sedan körs installationen utan avbrott med en
+# progressbar. Detaljerad utdata (apt, make, git ...) hamnar i $LOG_FILE.
 set -e

 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -6,130 +13,482 @@ SXWM_REPO="https://github.com/uint23/sxwm.git"
 SXBAR_REPO="https://git.kristoffersson.info/repos/sxbar.git"
 ST_REPO="https://git.suckless.org/st"
 BUILD_DIR="/tmp/sxwm-sxbar-build"
+LOG_FILE="/tmp/dotfiles-install.log"
+
+DEMO=""
+[ "${1:-}" = "--demo" ] && DEMO=1
+
+# UTF-8 så att ${#str} räknar tecken (inte byte) vid centrering av loggan.
+export LC_ALL=C.UTF-8
+export DEBIAN_FRONTEND=noninteractive
+# ip/rfkill ligger i sbin, som inte alltid finns i PATH för vanliga användare.
+export PATH="$PATH:/usr/sbin:/sbin"
+# Steg körs utan tangentbord -- fail hellre än att hänga på ett dolt lösenordsfrågor.
+export GIT_TERMINAL_PROMPT=0
+
+# ════════════════════════════════════════════════════════════════════════
+#  Gränssnitt
+# ════════════════════════════════════════════════════════════════════════
+
+[ -t 1 ] && [ -t 0 ] && INTERACTIVE=1 || INTERACTIVE=0
+
+# Gruvbox-palett (Linux-konsolen approximerar truecolor till närmaste 16 färger).
+fg() { printf '\e[38;2;%s;%s;%sm' "$1" "$2" "$3"; }
+RESET=$'\e[0m'
+BOLD=$'\e[1m'
+ORANGE=$(fg 254 128 25)
+YELLOW=$(fg 250 189 47)
+GREEN=$(fg 184 187 38)
+RED=$(fg 251 73 52)
+FG=$(fg 235 219 178)
+GRAY=$(fg 146 131 116)
+DARK=$(fg 80 73 69)
+
+# Ingen terminal (pipe/logg) -- inga färgkoder.
+if [ "$INTERACTIVE" = 0 ]; then
+    RESET=""; BOLD=""; ORANGE=""; YELLOW=""; GREEN=""; RED=""; FG=""; GRAY=""; DARK=""
+fi
+
+# Konsolfonten på TTY saknar braille och bockar -- använd ASCII där.
+if [ "$TERM" = "linux" ]; then
+    SPINNER=('-' '\' '|' '/')
+    SYM_OK="+"; SYM_FAIL="x"; SYM_ASK=">"
+else
+    SPINNER=(⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏)
+    SYM_OK="✓"; SYM_FAIL="✗"; SYM_ASK="❯"
+fi

-echo "==> Installerar beroenden..."
-sudo apt-get update -qq
-sudo apt-get install -y \
-    git \
-    make \
-    gcc \
-    build-essential \
-    pkg-config \
-    libx11-dev \
-    libxinerama-dev \
-    libxrandr-dev \
-    libxcursor-dev \
-    libxft-dev \
-    libfontconfig1-dev \
-    libimlib2-dev \
-    xorg \
-    xinit \
-    firefox-esr \
-    pipewire-audio \
-    alsa-utils \
-    bluez \
-    iwd \
-    xinput \
-    patch \
-    wget \
-    curl \
-    unzip \
-    fontconfig \
-    wmctrl \
-    xdotool \
-    x11-utils \
-    pipx \
-    feh \
-    dmenu \
-    imagemagick \
-    bc \
-    geany \
-    gocryptfs \
-    fuse3 \
-
-mkdir -p "$BUILD_DIR"
-cd "$BUILD_DIR"
-
-echo "==> Klonar sxwm..."
-rm -rf sxwm
-git clone --depth=1 "$SXWM_REPO" sxwm
-
-echo "==> Applicerar sxwm-patchar..."
-cd sxwm
-patch -p1 < "$SCRIPT_DIR/cursor-dpi-fix.patch"
-# Lägger till stöd för _NET_ACTIVE_WINDOW (behövs för sxbars taskbar-modul
-# -- utan denna gör klick i taskbaren ingenting, sxwm har ingen annan
-# extern mekanism för att fokusera ett specifikt fönster)
-patch -p1 < "$SCRIPT_DIR/net-active-window.patch"
-# Fixar _NET_WM_STRUT_PARTIAL-hantering vid monitorer av olika storlek --
-# reservationer band tidigare bara till varje monitors x/y-spann, inte
-# till panelfönstrets egen monitor, vilket kunde göra en monitors
-# reserverade yta större än dess egen höjd/bredd (fönster öppnades då
-# långt utanför synligt område, bara ram synlig)
-patch -p1 < "$SCRIPT_DIR/multi-monitor-struts.patch"
-# Windows tappar rätt monitor-koppling vid hotplug (t.ex. lid-display-watch
-# som slår av/på eDP) -- client->mon är bara ett index i mons[], och blir
-# meningslöst så fort monitor-antalet/ordningen ändras, vilket kan flytta
-# fönster till fel skärm eller klämma in dem för litet. Räknar om mon-
-# tillhörighet från varje fönsters faktiska position vid varje hotplug.
-patch -p1 < "$SCRIPT_DIR/monitor-hotplug-remap.patch"
-# apply_fullscreen() placerade fönstret på c->mon, som bara sätts en gång vid
-# map-tid (till skärmen muspekaren råkade stå på) och aldrig hölls i synk med
-# fönstrets verkliga position -- ett fönster flyttat av ett externt verktyg
-# (xdotool, screensaver-launch) till en annan skärm innan det gjordes
-# fullskärm hamnade då fullskärm på FEL skärm. Räknar om målskärmen från
-# fönstrets faktiska geometri istället.
-patch -p1 < "$SCRIPT_DIR/fullscreen-monitor-fix.patch"
-# En bottom/right-panel (t.ex. sxbars secondary_bar) kunde hamna dold bakom
-# vanliga fönster -- update_struts() räknar reserve_bottom/reserve_right som
-# avstånd från *motsatt* skärmkant och behövde då hela skärmens bredd/höjd,
-# men läste den ur scr_width/scr_height som bara uppdateras i update_mons()
-# (start + monitor-hotplug). Blir de cachade värdena inaktuella räknas bara
-# bottom/right fel (top/left beror aldrig på skärmstorleken, bara på
-# monitorns egen x/y, och påverkades inte). Läser bredd/höjd direkt från
-# X-servern i update_struts() istället för att lita på cachen.
-patch -p1 < "$SCRIPT_DIR/strut-fresh-screen-size.patch"
-
-echo "==> Bygger och installerar sxwm..."
-make
-sudo make install
-cd "$BUILD_DIR"
-
-echo "==> Klonar sxbar..."
-rm -rf sxbar
-git clone --depth=1 "$SXBAR_REPO" sxbar
-
-echo "==> Bygger och installerar sxbar..."
-cd sxbar
-make
-sudo make install
-cd "$BUILD_DIR"
-
-echo "==> Klonar st..."
-rm -rf st
-git clone --depth=1 "$ST_REPO" st
-
-echo "==> Applicerar st-patchar (delkey, scrollback, clipboard, boxdraw_v2, vertcenter, alpha, blinking_cursor, bold-is-not-bright, anysize, histsize-5000, kitty-graphics-protocol)..."
-cd st
-for p in "$SCRIPT_DIR"/st-patches/*.patch; do
-    patch -p1 < "$p"
+LOGO=(
+"███    ███ ██████  ███████  ██████  ██   ██ "
+"████  ████ ██   ██ ██      ██    ██  ██ ██  "
+"██ ████ ██ ██████  █████   ██    ██   ███   "
+"██  ██  ██ ██   ██ ██      ██    ██  ██ ██  "
+"██      ██ ██   ██ ██       ██████  ██   ██ "
+)
+TAGLINE="sxwm  ·  sxbar  ·  st  ·  dotfiles"
+
+LOGO_W=0
+for l in "${LOGO[@]}"; do
+    [ "${#l}" -gt "$LOGO_W" ] && LOGO_W=${#l}
 done
-cp "$SCRIPT_DIR/config/st/config.def.h" config.def.h

-echo "==> Bygger och installerar st..."
-make
-sudo make install
+# Räknar om allt som beror på terminalens storlek. Körs vid start och vid
+# varje storleksändring (sxwm tilear om st-fönstret efter att det startat).
+compute_layout() {
+    COLS=$(tput cols 2>/dev/null || echo 80)
+    ROWS=$(tput lines 2>/dev/null || echo 24)
+    SHOW_LOGO=0
+    [ "$COLS" -ge $((LOGO_W + 4)) ] && [ "$ROWS" -ge 22 ] && SHOW_LOGO=1
+    BLOCK_W=$(( COLS - 4 ))
+    [ "$BLOCK_W" -gt 64 ] && BLOCK_W=64
+    MARGIN=$(( (COLS - BLOCK_W) / 2 ))
+    MARGIN_PAD=$(printf '%*s' "$MARGIN" '')
+}
+compute_layout
+RESIZED=0
+trap 'RESIZED=1' WINCH
+
+# put_center RAD "färgad text" synlig-längd
+put_center() {
+    local col=$(( (COLS - $3) / 2 + 1 ))
+    [ "$col" -lt 1 ] && col=1
+    printf '\e[%d;%dH\e[2K%s' "$1" "$col" "$2"
+}
+
+# Skriver loggan (rad 'top' och neråt) med orange->gul toning. Sätter
+# STATUS_ROW till första lediga rad under headern.
+draw_header() {
+    local top=$1 i r g b n=${#LOGO[@]} row=$1
+    printf '\e[2J\e[H'
+    if [ "$SHOW_LOGO" = 1 ]; then
+        for i in "${!LOGO[@]}"; do
+            r=$(( 254 + (250 - 254) * i / (n - 1) ))
+            g=$(( 128 + (189 - 128) * i / (n - 1) ))
+            b=$((  25 + ( 47 -  25) * i / (n - 1) ))
+            put_center "$row" "$(fg $r $g $b)${LOGO[$i]}${RESET}" "$LOGO_W"
+            row=$((row + 1))
+        done
+        row=$((row + 1))
+        put_center "$row" "${GRAY}${TAGLINE}${RESET}" "${#TAGLINE}"
+        row=$((row + 2))
+    else
+        put_center "$row" "${BOLD}${ORANGE}dotfiles${RESET} ${GRAY}install${RESET}" 16
+        row=$((row + 2))
+    fi
+    STATUS_ROW=$row
+    printf '\e[%d;1H' "$row"
+}
+
+# ask VARIABEL "Titel" "Beskrivning" -- ja/nej med ett tangenttryck (default nej).
+ask() {
+    local var=$1 title=$2 desc=$3 key="" ans=0 line
+    printf '%s%s%s%s %s%s%s %s[j/N]%s ' "$MARGIN_PAD" "$ORANGE" "$SYM_ASK" "$RESET" \
+        "$BOLD" "$title" "$RESET" "$GRAY" "$RESET"
+    if [ "$INTERACTIVE" = 1 ]; then
+        read -rsn1 key </dev/tty || key=""
+    fi
+    case "$key" in
+        j|J|y|Y) ans=1; printf '%sJa%s\n' "$GREEN" "$RESET" ;;
+        *)       printf '%sNej%s\n' "$GRAY" "$RESET" ;;
+    esac
+    while IFS= read -r line; do
+        printf '%s  %s%s%s\n' "$MARGIN_PAD" "$GRAY" "$line" "$RESET"
+    done < <(fold -s -w $((BLOCK_W - 2)) <<<"$desc")
+    printf -v "$var" '%s' "$ans"
+}
+
+# ════════════════════════════════════════════════════════════════════════
+#  WiFi-guide (iwd + dhcpcd) -- interaktiv, körs före själva installationen
+#  eftersom apt behöver nät. Vid --demo används låtsasadaptrar/-nätverk.
+# ════════════════════════════════════════════════════════════════════════
+
+strip_ansi() { sed -e 's/\x1b\[[0-9;?]*[A-Za-z]//g'; }
+
+# wifi_do "beskrivning" kommando... -- kör kommandot (utdata till loggen) och
+# ersätter ".. beskrivning" med bock/kryss på samma rad. Returnerar dess status.
+wifi_do() {
+    local desc=$1 rc=0 sym; shift
+    printf '%s  %s..%s %s' "$MARGIN_PAD" "$YELLOW" "$RESET" "$desc"
+    if [ -n "$DEMO" ]; then
+        sleep 0.6
+        # låtsasdata behövs för menyn, så skanningen "körs" även i demo
+        if [ "$1" = wifi_scan ]; then wifi_scan "$2"; fi
+    elif ! "$@" >>"$LOG_FILE" 2>&1 </dev/null; then
+        rc=1
+    fi
+    if [ "$rc" = 0 ]; then sym="${GREEN}${SYM_OK}"; else sym="${RED}${SYM_FAIL}"; fi
+    printf '\r\e[2K%s  %s%s %s\n' "$MARGIN_PAD" "$sym" "$RESET" "$desc"
+    return "$rc"
+}
+
+wifi_say() { printf '%s  %s%s%s\n' "$MARGIN_PAD" "$GRAY" "$1" "$RESET"; }
+
+# wifi_prompt "text" -> svar i REPLY (radinmatning, synlig markör)
+wifi_prompt() {
+    printf '\n%s%s%s%s %s%s%s ' "$MARGIN_PAD" "$ORANGE" "$SYM_ASK" "$RESET" "$BOLD" "$1" "$RESET"
+    printf '\e[?25h'
+    read -r REPLY </dev/tty || REPLY=""
+    printf '\e[?25l'
+}
+
+wifi_ifaces() {
+    if [ -n "$DEMO" ]; then printf '%s\n' wlan0 wlp2s0; return 0; fi
+    local d
+    for d in /sys/class/net/*; do
+        if [ -d "$d/wireless" ]; then basename "$d"; fi
+    done
+    return 0
+}
+
+wifi_mac() {
+    if [ -n "$DEMO" ]; then echo "aa:bb:cc:dd:ee:0${#1}"; return 0; fi
+    cat "/sys/class/net/$1/address" 2>/dev/null || true
+}
+
+# Skannar och fyller NET_NAMES/NET_SEC/NET_SIG (i nuvarande skal, inte subshell).
+wifi_scan() {
+    local iface=$1 line
+    NET_NAMES=(); NET_SEC=(); NET_SIG=()
+    if [ -n "$DEMO" ]; then
+        NET_NAMES=("Emmeli" "Telia-4F2A" "Grannens WiFi" "Cafe Guest")
+        NET_SEC=(psk psk psk open); NET_SIG=('****' '***' '**' '*')
+        return 0
+    fi
+    sudo iwctl station "$iface" scan || true
+    sleep 3
+    while IFS= read -r line; do
+        if [[ $line =~ ^[[:space:]]*([>][[:space:]]+)?(.*[^[:space:]])[[:space:]]{2,}(open|psk|8021x)[[:space:]]+(\**) ]]; then
+            NET_NAMES+=("${BASH_REMATCH[2]}")
+            NET_SEC+=("${BASH_REMATCH[3]}")
+            NET_SIG+=("${BASH_REMATCH[4]}")
+        fi
+    done < <(sudo iwctl station "$iface" get-networks 2>/dev/null | strip_ansi)
+    return 0
+}
+
+# wifi_connect IFACE SSID [LÖSENORD] [dolt]
+wifi_connect() {
+    local iface=$1 ssid=$2 pass=${3:-} hidden=${4:-} cmd=connect
+    [ -n "$hidden" ] && cmd=connect-hidden
+    if [ -n "$pass" ]; then
+        sudo iwctl --passphrase "$pass" station "$iface" "$cmd" "$ssid"
+    else
+        sudo iwctl station "$iface" "$cmd" "$ssid"
+    fi
+}
+
+wifi_wait_connected() {
+    local n
+    for n in 1 2 3 4 5 6 7 8 9 10; do
+        if sudo iwctl station "$1" show | strip_ansi | grep -qE 'State[[:space:]]+connected'; then
+            return 0
+        fi
+        sleep 1
+    done
+    return 1
+}
+
+wifi_wait_ip() {
+    local n
+    for n in $(seq 1 25); do
+        if ip -4 -o addr show dev "$1" | grep -q inet; then return 0; fi
+        sleep 1
+    done
+    return 1
+}
+
+WIFI_SUMMARY=""
+
+wifi_setup() {
+    local iface="" ifaces=() ssid pass sec hidden k n ip had_dhcpcd=0
+
+    draw_header 2
+    printf '\n%s%sWiFi%s %s(iwd + dhcpcd)%s\n\n' "$MARGIN_PAD" "$BOLD$ORANGE" "$RESET" "$GRAY" "$RESET"
+
+    if [ -z "$DEMO" ]; then
+        dpkg -s dhcpcd >/dev/null 2>&1 && had_dhcpcd=1
+        if ! dpkg -s iwd dhcpcd >/dev/null 2>&1; then
+            if ! wifi_do "Installerar iwd och dhcpcd" sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y iwd dhcpcd; then
+                wifi_say "Kunde inte installera paketen (ingen nätverksanslutning än?). Hoppar över WiFi."
+                sleep 3; return 1
+            fi
+        fi
+        if systemctl is-active --quiet NetworkManager; then
+            wifi_say "Obs: NetworkManager körs och kan krocka med iwd/dhcpcd."
+        fi
+    fi
+    wifi_do "Startar iwd" sudo systemctl enable --now iwd || true
+    if command -v rfkill >/dev/null 2>&1; then wifi_do "Låser upp radion (rfkill)" sudo rfkill unblock wifi || true; fi
+    sleep 1
+
+    # --- Välj adapter ---
+    mapfile -t ifaces < <(wifi_ifaces)
+    if [ "${#ifaces[@]}" -eq 0 ]; then
+        wifi_say "Hittade ingen WiFi-adapter. Hoppar över WiFi."
+        sleep 3; return 1
+    elif [ "${#ifaces[@]}" -eq 1 ]; then
+        iface=${ifaces[0]}
+        wifi_say "Adapter: $iface (den enda hittade)"
+    else
+        printf '\n%s%sVälj WiFi-adapter%s\n' "$MARGIN_PAD" "$BOLD" "$RESET"
+        for k in "${!ifaces[@]}"; do
+            printf '%s  %s%d)%s %-18s %s%s%s\n' "$MARGIN_PAD" "$ORANGE" $(( k + 1 )) "$RESET" \
+                "${ifaces[$k]}" "$GRAY" "$(wifi_mac "${ifaces[$k]}")" "$RESET"
+        done
+        while :; do
+            wifi_prompt "Adapter [1-${#ifaces[@]}], s = hoppa över:"
+            case "$REPLY" in
+                s|S) return 1 ;;
+                ''|*[!0-9]*) ;;
+                *) if [ "$REPLY" -ge 1 ] && [ "$REPLY" -le "${#ifaces[@]}" ]; then
+                       iface=${ifaces[$(( REPLY - 1 ))]}; break
+                   fi ;;
+            esac
+        done
+        printf '\n'
+    fi
+
+    # --- Välj nätverk och anslut (loopar tills det lyckas eller man hoppar över) ---
+    while :; do
+        wifi_do "Söker efter nätverk på $iface" wifi_scan "$iface" || true
+        printf '\n%s%sNätverk%s\n' "$MARGIN_PAD" "$BOLD" "$RESET"
+        n=${#NET_NAMES[@]}; [ "$n" -gt 15 ] && n=15
+        for (( k = 0; k < n; k++ )); do
+            printf '%s  %s%2d)%s %-30s %s%-5s %s%s\n' "$MARGIN_PAD" "$ORANGE" $(( k + 1 )) "$RESET" \
+                "${NET_NAMES[$k]:0:30}" "$GRAY" "${NET_SEC[$k]}" "${NET_SIG[$k]}" "$RESET"
+        done
+        [ "$n" -eq 0 ] && wifi_say "Inga nätverk hittades."
+        wifi_prompt "Nätverk [1-$n], r = skanna om, m = dolt nätverk, s = hoppa över:"
+
+        hidden=""; ssid=""; sec=psk
+        case "$REPLY" in
+            s|S) return 1 ;;
+            r|R|'') continue ;;
+            m|M) printf '%s  SSID: ' "$MARGIN_PAD"; printf '\e[?25h'
+                 read -r ssid </dev/tty || ssid=""; printf '\e[?25l'
+                 [ -z "$ssid" ] && continue
+                 hidden=1 ;;
+            *[!0-9]*) continue ;;
+            *) if [ "$REPLY" -ge 1 ] && [ "$REPLY" -le "$n" ]; then
+                   ssid=${NET_NAMES[$(( REPLY - 1 ))]}; sec=${NET_SEC[$(( REPLY - 1 ))]}
+               else continue; fi ;;
+        esac
+
+        if [ "$sec" = 8021x ]; then
+            wifi_say "\"$ssid\" använder 802.1x (företagsnät) -- konfigurera det manuellt med iwctl."
+            continue
+        fi
+
+        pass=""
+        if [ "$sec" != open ]; then
+            printf '\n%s  Lösenord för %s%s%s: ' "$MARGIN_PAD" "$BOLD" "$ssid" "$RESET"
+            printf '\e[?25h'
+            read -rs pass </dev/tty || pass=""
+            printf '\e[?25l\n'
+            # dolt öppet nätverk: tomt lösenord är tillåtet
+            [ -z "$pass" ] && [ -z "$hidden" ] && continue
+        fi
+
+        if wifi_do "Ansluter till $ssid" wifi_connect "$iface" "$ssid" "$pass" "$hidden" \
+            && wifi_do "Väntar på association" wifi_wait_connected "$iface"; then
+            break
+        fi
+        wifi_say "Anslutningen misslyckades (fel lösenord eller för svag signal?). Försöker igen."
+        pass=""
+    done
+
+    # --- dhcpcd bara på vald adapter ---
+    if [ -z "$DEMO" ] && systemctl is-active --quiet dhcpcd.service; then
+        wifi_say "dhcpcd körs redan på alla adaptrar -- lämnar det orört."
+    else
+        # Debian aktiverar dhcpcd.service (alla adaptrar) vid installation; det
+        # skulle ta över även kabelnätet, så stäng av det om vi nyss installerade det.
+        if [ "$had_dhcpcd" = 0 ]; then
+            wifi_do "Stänger av dhcpcd på övriga adaptrar" sudo systemctl disable --now dhcpcd.service || true
+        fi
+        wifi_do "Startar dhcpcd på $iface" sudo systemctl enable --now "dhcpcd@${iface}.service" || true
+    fi
+
+    if wifi_do "Väntar på IP-adress" wifi_wait_ip "$iface"; then
+        if [ -n "$DEMO" ]; then ip="192.168.1.42/24"
+        else ip=$(ip -4 -o addr show dev "$iface" | awk '{print $4}' | head -1); fi
+        WIFI_SUMMARY="WiFi: ${ssid} på ${iface} (${ip})"
+        wifi_do "Kontrollerar namnuppslagning" getent hosts deb.debian.org || wifi_say "DNS svarar inte än -- kolla /etc/resolv.conf efter installationen."
+    else
+        WIFI_SUMMARY="WiFi: ansluten till ${ssid} på ${iface}, men ingen IP-adress fick ännu (se 'journalctl -u dhcpcd@${iface}')"
+        wifi_say "Ingen IP-adress fick. Installationen fortsätter ändå."
+    fi
+    sleep 2
+    return 0
+}
+
+# ════════════════════════════════════════════════════════════════════════
+#  Steg
+# ════════════════════════════════════════════════════════════════════════
+
+step_deps() {
+    # apt update returnerar fel om ett tredjepartsförråd (t.ex. VS Code) har
+    # trasig signatur -- det ska inte stoppa hela installationen.
+    sudo env DEBIAN_FRONTEND=noninteractive apt-get update -qq \
+        || echo "VARNING: apt-get update misslyckades (trasigt förråd?), fortsätter"
+    sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y \
+        git \
+        make \
+        gcc \
+        build-essential \
+        pkg-config \
+        libx11-dev \
+        libxinerama-dev \
+        libxrandr-dev \
+        libxcursor-dev \
+        libxft-dev \
+        libfontconfig1-dev \
+        libimlib2-dev \
+        xorg \
+        xinit \
+        firefox-esr \
+        pipewire-audio \
+        alsa-utils \
+        bluez \
+        iwd \
+        xinput \
+        patch \
+        wget \
+        curl \
+        unzip \
+        fontconfig \
+        wmctrl \
+        xdotool \
+        x11-utils \
+        pipx \
+        feh \
+        dmenu \
+        imagemagick \
+        bc \
+        geany \
+        gocryptfs \
+        fuse3
+}

-echo "==> Installerar icat (visa bilder i st via kitty graphics-protokollet)..."
-mkdir -p "$HOME/.local/bin"
-cp icat-mini.sh "$HOME/.local/bin/icat"
-chmod +x "$HOME/.local/bin/icat"
-echo "    icat-mini.sh -> ~/.local/bin/icat"
+step_sxwm() {
+    mkdir -p "$BUILD_DIR"
+    cd "$BUILD_DIR"
+    rm -rf sxwm
+    git clone --depth=1 "$SXWM_REPO" sxwm
+
+    cd sxwm
+    patch -p1 < "$SCRIPT_DIR/cursor-dpi-fix.patch"
+    # Lägger till stöd för _NET_ACTIVE_WINDOW (behövs för sxbars taskbar-modul
+    # -- utan denna gör klick i taskbaren ingenting, sxwm har ingen annan
+    # extern mekanism för att fokusera ett specifikt fönster)
+    patch -p1 < "$SCRIPT_DIR/net-active-window.patch"
+    # Fixar _NET_WM_STRUT_PARTIAL-hantering vid monitorer av olika storlek --
+    # reservationer band tidigare bara till varje monitors x/y-spann, inte
+    # till panelfönstrets egen monitor, vilket kunde göra en monitors
+    # reserverade yta större än dess egen höjd/bredd (fönster öppnades då
+    # långt utanför synligt område, bara ram synlig)
+    patch -p1 < "$SCRIPT_DIR/multi-monitor-struts.patch"
+    # Windows tappar rätt monitor-koppling vid hotplug (t.ex. lid-display-watch
+    # som slår av/på eDP) -- client->mon är bara ett index i mons[], och blir
+    # meningslöst så fort monitor-antalet/ordningen ändras, vilket kan flytta
+    # fönster till fel skärm eller klämma in dem för litet. Räknar om mon-
+    # tillhörighet från varje fönsters faktiska position vid varje hotplug.
+    patch -p1 < "$SCRIPT_DIR/monitor-hotplug-remap.patch"
+    # apply_fullscreen() placerade fönstret på c->mon, som bara sätts en gång vid
+    # map-tid (till skärmen muspekaren råkade stå på) och aldrig hölls i synk med
+    # fönstrets verkliga position -- ett fönster flyttat av ett externt verktyg
+    # (xdotool, screensaver-launch) till en annan skärm innan det gjordes
+    # fullskärm hamnade då fullskärm på FEL skärm. Räknar om målskärmen från
+    # fönstrets faktiska geometri istället.
+    patch -p1 < "$SCRIPT_DIR/fullscreen-monitor-fix.patch"
+    # En bottom/right-panel (t.ex. sxbars secondary_bar) kunde hamna dold bakom
+    # vanliga fönster -- update_struts() räknar reserve_bottom/reserve_right som
+    # avstånd från *motsatt* skärmkant och behövde då hela skärmens bredd/höjd,
+    # men läste den ur scr_width/scr_height som bara uppdateras i update_mons()
+    # (start + monitor-hotplug). Blir de cachade värdena inaktuella räknas bara
+    # bottom/right fel (top/left beror aldrig på skärmstorleken, bara på
+    # monitorns egen x/y, och påverkades inte). Läser bredd/höjd direkt från
+    # X-servern i update_struts() istället för att lita på cachen.
+    patch -p1 < "$SCRIPT_DIR/strut-fresh-screen-size.patch"
+
+    make
+    sudo make install
+}

-cd "$BUILD_DIR"
+step_sxbar() {
+    mkdir -p "$BUILD_DIR"
+    cd "$BUILD_DIR"
+    rm -rf sxbar
+    git clone --depth=1 "$SXBAR_REPO" sxbar
+    cd sxbar
+    make
+    sudo make install
+}

-echo "==> Kopierar konfigfiler..."
-mkdir -p "$HOME/.config/sxbar"
+step_st() {
+    mkdir -p "$BUILD_DIR"
+    cd "$BUILD_DIR"
+    rm -rf st
+    git clone --depth=1 "$ST_REPO" st
+
+    # delkey, scrollback, clipboard, boxdraw_v2, vertcenter, alpha,
+    # blinking_cursor, bold-is-not-bright, anysize, histsize-5000,
+    # kitty-graphics-protocol
+    cd st
+    for p in "$SCRIPT_DIR"/st-patches/*.patch; do
+        patch -p1 < "$p"
+    done
+    cp "$SCRIPT_DIR/config/st/config.def.h" config.def.h
+
+    make
+    sudo make install
+
+    # icat: visa bilder i st via kitty graphics-protokollet
+    mkdir -p "$HOME/.local/bin"
+    cp icat-mini.sh "$HOME/.local/bin/icat"
+    chmod +x "$HOME/.local/bin/icat"
+    echo "icat-mini.sh -> ~/.local/bin/icat"
+}

 copy_config() {
     local src="$SCRIPT_DIR/config/$1"
@@ -137,51 +496,52 @@ copy_config() {
     mkdir -p "$(dirname "$dst")"
     if [ -f "$dst" ]; then
         cp "$dst" "$dst.bak"
-        echo "    $2 -> backup sparad: ~/.config/$2.bak"
+        echo "$2 -> backup sparad: ~/.config/$2.bak"
     fi
     cp "$src" "$dst"
-    echo "    $2 -> ~/.config/$2"
-}
-
-copy_config sxwmrc sxwmrc
-copy_config sxbarc sxbar/sxbarc
-copy_config geany/geany.conf geany/geany.conf
-copy_config geany/colorschemes/gruvbox.conf geany/colorschemes/gruvbox.conf
-
-echo "==> Installerar Gruvbox GTK-tema (för Geanys gränssnitt, inte bara editorn)..."
-mkdir -p "$HOME/.local/share/themes/Gruvbox-Dark/gtk-3.0"
-cp "$SCRIPT_DIR/themes/Gruvbox-Dark/gtk-3.0/gtk.css" "$HOME/.local/share/themes/Gruvbox-Dark/gtk-3.0/gtk.css"
-cp "$SCRIPT_DIR/themes/Gruvbox-Dark/index.theme" "$HOME/.local/share/themes/Gruvbox-Dark/index.theme"
-echo "    Gruvbox-Dark -> ~/.local/share/themes/Gruvbox-Dark"
-
-echo "==> Installerar geany-wrapper..."
-# Sätter GTK_THEME=Gruvbox-Dark oavsett hur Geany startas. Ett bash-alias
-# räcker inte -- dmenu/sxwm kör aldrig ~/.bashrc, men ärver PATH från
-# ~/.xinitrc där ~/.local/bin ligger först, så denna skuggar /usr/bin/geany.
-cp "$SCRIPT_DIR/bin/geany" "$HOME/.local/bin/geany"
-chmod +x "$HOME/.local/bin/geany"
-echo "    geany -> ~/.local/bin/geany"
-
-echo "==> Installerar Geany .desktop-override (samma tema från app-startare)..."
-mkdir -p "$HOME/.local/share/applications"
-cp "$SCRIPT_DIR/applications/geany.desktop" "$HOME/.local/share/applications/geany.desktop"
-echo "    geany.desktop -> ~/.local/share/applications/geany.desktop"
-
-echo "==> Kopierar skrivbordsbakgrund..."
-mkdir -p "$HOME/BG"
-cp -n "$SCRIPT_DIR/BG/"* "$HOME/BG/"
-echo "    BG/ -> ~/BG/"
-
-echo ""
-echo "==> Konfigurering av ~/.xinitrc"
-echo ""
-
-read -p "Vill du skriva över ~/.xinitrc? (j/n) " -n 1 -r
-echo
-if [[ $REPLY =~ ^[Jj]$ ]]; then
+    echo "$2 -> ~/.config/$2"
+}
+
+step_configs() {
+    mkdir -p "$HOME/.config/sxbar"
+    copy_config sxwmrc sxwmrc
+    copy_config sxbarc sxbar/sxbarc
+    copy_config geany/geany.conf geany/geany.conf
+    copy_config geany/colorschemes/gruvbox.conf geany/colorschemes/gruvbox.conf
+}
+
+step_theme() {
+    # Gruvbox GTK-tema (för Geanys gränssnitt, inte bara editorn)
+    mkdir -p "$HOME/.local/share/themes/Gruvbox-Dark/gtk-3.0"
+    cp "$SCRIPT_DIR/themes/Gruvbox-Dark/gtk-3.0/gtk.css" "$HOME/.local/share/themes/Gruvbox-Dark/gtk-3.0/gtk.css"
+    cp "$SCRIPT_DIR/themes/Gruvbox-Dark/index.theme" "$HOME/.local/share/themes/Gruvbox-Dark/index.theme"
+    echo "Gruvbox-Dark -> ~/.local/share/themes/Gruvbox-Dark"
+
+    # geany-wrapper: sätter GTK_THEME=Gruvbox-Dark oavsett hur Geany startas.
+    # Ett bash-alias räcker inte -- dmenu/sxwm kör aldrig ~/.bashrc, men ärver
+    # PATH från ~/.xinitrc där ~/.local/bin ligger först, så denna skuggar
+    # /usr/bin/geany.
+    mkdir -p "$HOME/.local/bin"
+    cp "$SCRIPT_DIR/bin/geany" "$HOME/.local/bin/geany"
+    chmod +x "$HOME/.local/bin/geany"
+    echo "geany -> ~/.local/bin/geany"
+
+    # .desktop-override: samma tema från app-startare
+    mkdir -p "$HOME/.local/share/applications"
+    cp "$SCRIPT_DIR/applications/geany.desktop" "$HOME/.local/share/applications/geany.desktop"
+    echo "geany.desktop -> ~/.local/share/applications/geany.desktop"
+}
+
+step_wallpaper() {
+    mkdir -p "$HOME/BG"
+    cp -n "$SCRIPT_DIR/BG/"* "$HOME/BG/"
+    echo "BG/ -> ~/BG/"
+}
+
+step_xinitrc() {
     if [ -f "$HOME/.xinitrc" ]; then
         cp "$HOME/.xinitrc" "$HOME/.xinitrc.bak"
-        echo "    Backup skapad: ~/.xinitrc.bak"
+        echo "Backup skapad: ~/.xinitrc.bak"
     fi
     cat > "$HOME/.xinitrc" << 'EOF'
 #!/bin/bash
@@ -203,194 +563,371 @@ setxkbmap se
 exec sxwm
 EOF
     chmod +x "$HOME/.xinitrc"
-    echo "    ~/.xinitrc skapad"
-else
-    echo "Lägg manuellt till i ~/.xinitrc:"
-    echo ""
-    echo "  xset s 300 300"
-    echo "  xset dpms 300 300 300"
-    echo "  setxkbmap se"
-    echo "  exec sxwm"
-fi
+    echo "~/.xinitrc skapad"
+}

-echo "==> Installerar lidsuspend..."
-mkdir -p "$HOME/.local/bin"
-cp "$SCRIPT_DIR/bin/lidsuspend" "$HOME/.local/bin/lidsuspend"
-chmod +x "$HOME/.local/bin/lidsuspend"
-echo "    lidsuspend -> ~/.local/bin/lidsuspend"
-
-echo "==> Installerar mouse-settings..."
-mkdir -p "$HOME/.local/bin"
-cp "$SCRIPT_DIR/bin/mouse-settings" "$HOME/.local/bin/mouse-settings"
-chmod +x "$HOME/.local/bin/mouse-settings"
-echo "    mouse-settings -> ~/.local/bin/mouse-settings"
-
-echo "==> Installerar status..."
-mkdir -p "$HOME/.local/bin"
-cp "$SCRIPT_DIR/bin/status" "$HOME/.local/bin/status"
-chmod +x "$HOME/.local/bin/status"
-echo "    status -> ~/.local/bin/status"
-
-echo "==> Installerar gcfs (minimal gocryptfs-valvhanterare)..."
-mkdir -p "$HOME/.local/bin"
-cp "$SCRIPT_DIR/bin/gcfs" "$HOME/.local/bin/gcfs"
-chmod +x "$HOME/.local/bin/gcfs"
-# Lägg bara exempel-configen om ingen redan finns -- gcfs skapar en identisk
-# vid första körning, och en riktig vaults.conf ska aldrig skrivas över.
-if [ ! -f "$HOME/.config/gcfs/vaults.conf" ]; then
-    mkdir -p "$HOME/.config/gcfs"
-    cp "$SCRIPT_DIR/config/gcfs/vaults.conf" "$HOME/.config/gcfs/vaults.conf"
-    echo "    gcfs -> ~/.local/bin/gcfs, vaults.conf -> ~/.config/gcfs/"
-else
-    echo "    gcfs -> ~/.local/bin/gcfs (behöll befintlig ~/.config/gcfs/vaults.conf)"
-fi
+step_scripts() {
+    mkdir -p "$HOME/.local/bin"
+    local s
+    for s in lidsuspend mouse-settings status gcfs; do
+        cp "$SCRIPT_DIR/bin/$s" "$HOME/.local/bin/$s"
+        chmod +x "$HOME/.local/bin/$s"
+        echo "$s -> ~/.local/bin/$s"
+    done
+    # gcfs: lägg bara exempel-configen om ingen redan finns -- gcfs skapar en
+    # identisk vid första körning, och en riktig vaults.conf ska aldrig
+    # skrivas över.
+    if [ ! -f "$HOME/.config/gcfs/vaults.conf" ]; then
+        mkdir -p "$HOME/.config/gcfs"
+        cp "$SCRIPT_DIR/config/gcfs/vaults.conf" "$HOME/.config/gcfs/vaults.conf"
+        echo "vaults.conf -> ~/.config/gcfs/"
+    else
+        echo "behöll befintlig ~/.config/gcfs/vaults.conf"
+    fi
+}
+
+step_screensaver() {
+    pipx install terminaltexteffects
+    pipx ensurepath
+
+    mkdir -p "$HOME/.local/bin"
+    cp "$SCRIPT_DIR/bin/screensaver" "$SCRIPT_DIR/bin/screensaverd" "$SCRIPT_DIR/bin/screensaver-launch" "$SCRIPT_DIR/bin/screensaver-toggle" "$SCRIPT_DIR/bin/screensaver.txt" "$HOME/.local/bin/"
+    chmod +x "$HOME/.local/bin/screensaver" "$HOME/.local/bin/screensaverd" "$HOME/.local/bin/screensaver-launch" "$HOME/.local/bin/screensaver-toggle"
+    echo "screensaver, screensaverd, screensaver-launch, screensaver-toggle, screensaver.txt -> ~/.local/bin/"
+}

-echo "==> Installerar tte (terminaltexteffects) via pipx..."
-pipx install terminaltexteffects
-pipx ensurepath
-
-echo "==> Installerar screensaver..."
-mkdir -p "$HOME/.local/bin"
-cp "$SCRIPT_DIR/bin/screensaver" "$SCRIPT_DIR/bin/screensaverd" "$SCRIPT_DIR/bin/screensaver-launch" "$SCRIPT_DIR/bin/screensaver-toggle" "$SCRIPT_DIR/bin/screensaver.txt" "$HOME/.local/bin/"
-chmod +x "$HOME/.local/bin/screensaver" "$HOME/.local/bin/screensaverd" "$HOME/.local/bin/screensaver-launch" "$HOME/.local/bin/screensaver-toggle"
-echo "    screensaver, screensaverd, screensaver-launch, screensaver-toggle, screensaver.txt -> ~/.local/bin/"
-
-echo ""
-echo "==> Automatisk avstängning av interna skärmen vid stängt lock"
-echo ""
-read -p "Installera lid-display-watch (stänger av interna skärmen när locket stängs, men bara om en extern skärm är ansluten)? (j/n) " -n 1 -r
-echo
-if [[ $REPLY =~ ^[Jj]$ ]]; then
+step_lidwatch() {
     cp "$SCRIPT_DIR/bin/internal-display" "$HOME/.local/bin/internal-display"
     chmod +x "$HOME/.local/bin/internal-display"
     cp "$SCRIPT_DIR/bin/lid-display-watch" "$HOME/.local/bin/lid-display-watch"
     chmod +x "$HOME/.local/bin/lid-display-watch"
-    echo "    internal-display, lid-display-watch -> ~/.local/bin/"
+    echo "internal-display, lid-display-watch -> ~/.local/bin/"

     if ! grep -qF 'exec : "lid-display-watch"' "$HOME/.config/sxwmrc" 2>/dev/null; then
         echo 'exec : "lid-display-watch"' >> "$HOME/.config/sxwmrc"
-        echo "    lid-display-watch tillagd i ~/.config/sxwmrc"
+        echo "lid-display-watch tillagd i ~/.config/sxwmrc"
     fi
-else
-    echo "    Hoppar över lid-display-watch"
-fi
+}

-echo ""
-echo "==> Stänga av locket ska inte göra något (ingen suspend/hibernate)"
-echo ""
-read -p "Sätta HandleLidSwitch=ignore i /etc/systemd/logind.conf? (j/n) " -n 1 -r
-echo
-if [[ $REPLY =~ ^[Jj]$ ]]; then
-    LOGIND_CONF="/etc/systemd/logind.conf"
+step_lidswitch() {
+    local LOGIND_CONF="/etc/systemd/logind.conf"
     if grep -q '^HandleLidSwitch=igonre$' "$LOGIND_CONF" 2>/dev/null; then
         # Stavfel som gjort att systemd-logind ignorerat raden och fallit
         # tillbaka på det inbyggda default-värdet ("suspend") istället
         sudo sed -i 's/^HandleLidSwitch=igonre$/HandleLidSwitch=ignore/' "$LOGIND_CONF"
-        echo "    HandleLidSwitch=igonre -> ignore (rättade stavfel)"
+        echo "HandleLidSwitch=igonre -> ignore (rättade stavfel)"
     elif grep -q '^HandleLidSwitch=' "$LOGIND_CONF" 2>/dev/null; then
-        echo "    HandleLidSwitch redan satt, rör inte: $(grep '^HandleLidSwitch=' "$LOGIND_CONF")"
+        echo "HandleLidSwitch redan satt, rör inte: $(grep '^HandleLidSwitch=' "$LOGIND_CONF")"
     else
         echo 'HandleLidSwitch=ignore' | sudo tee -a "$LOGIND_CONF" > /dev/null
-        echo "    HandleLidSwitch=ignore tillagd"
+        echo "HandleLidSwitch=ignore tillagd"
     fi
-    echo "    Kräver omstart för att aktiveras. Kör INTE 'systemctl restart"
-    echo "    systemd-logind' live -- på den här maskinen (X startas manuellt"
-    echo "    via startx/.xinitrc, ingen display manager) har det hängt hela"
-    echo "    skrivbordet och krävt en hård omstart."
-else
-    echo "    Hoppar över HandleLidSwitch"
-fi
+    # Kräver omstart för att aktiveras. Kör INTE 'systemctl restart
+    # systemd-logind' live -- på den här maskinen (X startas manuellt via
+    # startx/.xinitrc, ingen display manager) har det hängt hela skrivbordet
+    # och krävt en hård omstart.
+}

-echo ""
-echo "==> Förhindra att Bluetooth tappar anslutningar vid systeminaktivitet"
-echo ""
-read -p "Installera udev-regel som stänger av USB-autosuspend för Bluetooth-adaptern? (j/n) " -n 1 -r
-echo
-if [[ $REPLY =~ ^[Jj]$ ]]; then
+step_btudev() {
     # Specifik för den här laptopens BT-chip (Qualcomm Atheros 0cf3:e500).
     # Annan hårdvara: hitta idVendor/idProduct via `lsusb`, matcha mot
     # /sys/class/bluetooth/hci0 -> realpath för att hitta rätt usb-enhet.
     sudo cp "$SCRIPT_DIR/udev/71-bluetooth-no-autosuspend.rules" /etc/udev/rules.d/71-bluetooth-no-autosuspend.rules
     sudo udevadm control --reload-rules
     sudo udevadm trigger --attr-match=idVendor=0cf3 --attr-match=idProduct=e500
-    echo "    71-bluetooth-no-autosuspend.rules -> /etc/udev/rules.d/"
-else
-    echo "    Hoppar över Bluetooth-udev-regeln"
-fi
+    echo "71-bluetooth-no-autosuspend.rules -> /etc/udev/rules.d/"
+}

-echo "==> Konfigurerar skärmsläckning på TTY1 (innan inloggning/efter utloggning)..."
-GRUB_FILE="/etc/default/grub"
-if grep -q 'consoleblank=' "$GRUB_FILE" 2>/dev/null; then
-    echo "    consoleblank redan konfigurerad i $GRUB_FILE, hoppar över"
-else
-    sudo sed -i '/^GRUB_CMDLINE_LINUX_DEFAULT=/ s/"$/ consoleblank=300"/' "$GRUB_FILE"
-    sudo update-grub
-    echo "    consoleblank=300 tillagt (5 min), kräver omstart för att aktiveras"
-fi
+step_consoleblank() {
+    local GRUB_FILE="/etc/default/grub"
+    if grep -q 'consoleblank=' "$GRUB_FILE" 2>/dev/null; then
+        echo "consoleblank redan konfigurerad i $GRUB_FILE, hoppar över"
+    else
+        sudo sed -i '/^GRUB_CMDLINE_LINUX_DEFAULT=/ s/"$/ consoleblank=300"/' "$GRUB_FILE"
+        sudo update-grub
+        echo "consoleblank=300 tillagt (5 min), kräver omstart för att aktiveras"
+    fi
+}

-echo "==> Stänger av PC-speaker beep..."
-echo "blacklist pcspkr" | sudo tee /etc/modprobe.d/nobeep.conf > /dev/null
-sudo rmmod pcspkr 2>/dev/null || true
+step_nobeep() {
+    echo "blacklist pcspkr" | sudo tee /etc/modprobe.d/nobeep.conf > /dev/null
+    sudo rmmod pcspkr 2>/dev/null || true
+}

-echo ""
-read -p "Installera Rust (rustup) och bygga/installera bluetui (bluetooth-hantering)? (j/n) " -n 1 -r
-echo
-if [[ $REPLY =~ ^[Jj]$ ]]; then
-    echo "==> Tar bort ev. gammal rustc/cargo från apt (Debian har ofta för gammal version)..."
+step_rust() {
+    # Ta bort ev. gammal rustc/cargo från apt (Debian har ofta för gammal version)
+    local RUST_APT_PKGS
     RUST_APT_PKGS=$(dpkg-query -W -f='${Package}\n' 'rustc' 'cargo' 'libstd-rust-dev' 'rust-llvm' 'libstd-rust-*' 2>/dev/null || true)
     if [ -n "$RUST_APT_PKGS" ]; then
-        sudo apt-get purge -y $RUST_APT_PKGS
-        sudo apt-get autoremove --purge -y
+        sudo env DEBIAN_FRONTEND=noninteractive apt-get purge -y $RUST_APT_PKGS
+        sudo env DEBIAN_FRONTEND=noninteractive apt-get autoremove --purge -y
     fi

-    echo "==> Installerar rustup..."
     if ! command -v rustup >/dev/null 2>&1 && [ ! -x "$HOME/.cargo/bin/rustup" ]; then
         curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
     fi
     # shellcheck disable=SC1091
     source "$HOME/.cargo/env"

-    echo "==> Bygger och installerar bluetui..."
+    mkdir -p "$BUILD_DIR"
     rm -rf "$BUILD_DIR/bluetui"
     git clone --depth=1 https://github.com/pythops/bluetui "$BUILD_DIR/bluetui"
     cd "$BUILD_DIR/bluetui"
     cargo build --release
     sudo cp target/release/bluetui /usr/local/bin/
-    cd "$BUILD_DIR"
-else
-    echo "    Hoppar över Rust och bluetui"
-fi
+}
+
+step_font() {
+    local FONT_DIR="$HOME/.local/share/fonts/JetBrainsMono"
+    local FONT_URL="https://github.com/ryanoasis/nerd-fonts/releases/latest/download/JetBrainsMono.zip"
+    mkdir -p "$FONT_DIR"
+    wget -q -O /tmp/JetBrainsMono.zip "$FONT_URL"
+    unzip -o /tmp/JetBrainsMono.zip -d "$FONT_DIR" '*.ttf'
+    rm /tmp/JetBrainsMono.zip
+    fc-cache -f
+    echo "JetBrainsMono Nerd Font installerad"
+}

-echo "==> Installerar JetBrainsMono Nerd Font..."
-FONT_DIR="$HOME/.local/share/fonts/JetBrainsMono"
-mkdir -p "$FONT_DIR"
-FONT_URL="https://github.com/ryanoasis/nerd-fonts/releases/latest/download/JetBrainsMono.zip"
-wget -q --show-progress -O /tmp/JetBrainsMono.zip "$FONT_URL"
-unzip -o /tmp/JetBrainsMono.zip -d "$FONT_DIR" '*.ttf'
-rm /tmp/JetBrainsMono.zip
-fc-cache -f
-echo "    JetBrainsMono Nerd Font installerad"
-
-echo ""
-read -p "Lägga till automatisk start av X (sxwm) vid inloggning på TTY1? (j/n) " -n 1 -r
-echo
-if [[ $REPLY =~ ^[Jj]$ ]]; then
-    echo "==> Konfigurerar autostart av X på TTY1..."
-    BASH_PROFILE="$HOME/.bash_profile"
-    AUTOSTART='if [ -z "$DISPLAY" ] && [ "$(tty)" = "/dev/tty1" ]; then exec startx; fi'
+step_autostart() {
+    local BASH_PROFILE="$HOME/.bash_profile"
+    local AUTOSTART='if [ -z "$DISPLAY" ] && [ "$(tty)" = "/dev/tty1" ]; then exec startx; fi'
     if grep -qF 'exec startx' "$BASH_PROFILE" 2>/dev/null; then
-        echo "    ~/.bash_profile redan konfigurerad, hoppar över"
+        echo "~/.bash_profile redan konfigurerad, hoppar över"
     else
         echo "" >> "$BASH_PROFILE"
         echo "$AUTOSTART" >> "$BASH_PROFILE"
-        echo "    ~/.bash_profile uppdaterad"
+        echo "~/.bash_profile uppdaterad"
     fi
+}
+
+step_pipewire() {
+    systemctl --user enable --now pipewire pipewire-pulse wireplumber
+}
+
+# Låtsassteg för --demo: skriver lite "loggutdata" och väntar i proportion
+# till stegets vikt. DEMO_FAIL=<del av stegnamn> låter ett steg misslyckas.
+demo_step() {
+    local name=$1 weight=$2 n
+    for n in 1 2 3 4 5 6; do
+        echo "demo: $name ($n/6) ..."
+        sleep "0.$((weight > 9 ? 9 : weight))"
+    done
+    if [ -n "${DEMO_FAIL:-}" ] && [[ "$name" == *"$DEMO_FAIL"* ]]; then
+        echo "demo: simulerat fel i '$name'"
+        return 1
+    fi
+}
+
+# ════════════════════════════════════════════════════════════════════════
+#  Steghantering
+# ════════════════════════════════════════════════════════════════════════
+
+STEP_NAMES=(); STEP_FNS=(); STEP_W=()
+TOTAL_W=0
+# add_step VIKT "Namn" funktion -- vikten styr hur mycket steget flyttar baren.
+add_step() {
+    STEP_W+=("$1"); STEP_NAMES+=("$2"); STEP_FNS+=("$3")
+    TOTAL_W=$((TOTAL_W + $1))
+}
+
+KEEPALIVE_PID=""
+cleanup() {
+    local rc=$?
+    [ -n "$KEEPALIVE_PID" ] && kill "$KEEPALIVE_PID" 2>/dev/null
+    kill $(jobs -p) 2>/dev/null || true
+    printf '\e[?25h%s\n' "$RESET"
+    exit "$rc"
+}
+trap cleanup EXIT
+trap 'exit 130' INT TERM
+
+# Senaste raderna ur loggen, utan färgkoder/CR och blanka rader.
+log_tail() {
+    tail -c 4000 "$LOG_FILE" 2>/dev/null | tr '\r\t' '\n ' \
+        | sed -e 's/\x1b\[[0-9;?]*[A-Za-z]//g' | grep -v '^[[:space:]]*$' | tail -n 3
+}
+
+fmt_time() { printf '%02d:%02d' $(( $1 / 60 )) $(( $1 % 60 )); }
+
+# render PROCENT "steg-namn" steg-nr spinner-tick "lograder"
+render() {
+    local pct=$1 label=$2 idx=$3 tick=$4 logs=$5
+    local bar_w=$(( COLS - 16 )) filled f e row=$STATUS_ROW line
+    [ "$bar_w" -gt 46 ] && bar_w=46
+    filled=$(( pct * bar_w / 100 ))
+    printf -v f '%*s' "$filled" ''; f=${f// /█}
+    printf -v e '%*s' $(( bar_w - filled )) ''; e=${e// /░}
+    put_center "$row" "${ORANGE}${f}${DARK}${e}${RESET}  ${FG}$(printf '%3d%%' "$pct")${RESET}" $(( bar_w + 6 ))
+
+    local spin=${SPINNER[$(( tick % ${#SPINNER[@]} ))]}
+    local meta="  ${idx}/${#STEP_FNS[@]} · $(fmt_time "$SECONDS")"
+    put_center $(( row + 2 )) "${YELLOW}${spin}${RESET} ${BOLD}${FG}${label}${RESET}${GRAY}${meta}${RESET}" $(( 2 + ${#label} + ${#meta} ))
+
+    row=$(( row + 4 ))
+    local i=0
+    while IFS= read -r line; do
+        printf '\e[%d;%dH\e[2K%s%s%s' "$row" $(( MARGIN + 1 )) "$DARK" "${line:0:$BLOCK_W}" "$RESET"
+        row=$(( row + 1 )); i=$(( i + 1 ))
+    done <<<"$logs"
+    while [ "$i" -lt 3 ]; do
+        printf '\e[%d;1H\e[2K' "$row"; row=$(( row + 1 )); i=$(( i + 1 ))
+    done
+}
+
+fail_screen() {
+    local name=$1
+    printf '\e[%d;1H\e[J' "$STATUS_ROW"
+    printf '%s%s%s %sSteget misslyckades:%s %s\n\n' "$MARGIN_PAD" "$RED" "$SYM_FAIL" "$BOLD" "$RESET" "$name"
+    tail -n 12 "$LOG_FILE" | sed -e "s/^/${MARGIN_PAD}${GRAY}/" -e "s/\$/${RESET}/"
+    printf '\n%sFullständig logg: %s%s\n' "$MARGIN_PAD" "$LOG_FILE" "$RESET"
+}
+
+run_steps() {
+    local i fn name weight pid rc tick done_w=0 pct creep
+    local total=${#STEP_FNS[@]}
+    for i in "${!STEP_FNS[@]}"; do
+        fn=${STEP_FNS[$i]}; name=${STEP_NAMES[$i]}; weight=${STEP_W[$i]}
+        printf '\n=== [%d/%d] %s ===\n' $(( i + 1 )) "$total" "$name" >>"$LOG_FILE"
+
+        # Körs inte i ett ||/if-sammanhang, så `set -e` gäller inuti steget.
+        if [ -n "$DEMO" ]; then
+            ( demo_step "$name" "$weight" ) >>"$LOG_FILE" 2>&1 </dev/null &
+        else
+            ( set -e; "$fn" ) >>"$LOG_FILE" 2>&1 </dev/null &
+        fi
+        pid=$!
+
+        tick=0; rc=0
+        if [ "$PLAIN" = 1 ]; then
+            echo "==> $name"
+            wait "$pid" || rc=$?
+        else
+            local logs=""
+            while kill -0 "$pid" 2>/dev/null; do
+                if [ "$RESIZED" = 1 ]; then
+                    RESIZED=0; compute_layout; draw_run_header
+                fi
+                # Baren kryper asymptotiskt mot 90 % av stegets vikt så att den
+                # rör sig även under långa steg (apt, cargo) utan att ljuga.
+                creep=$(( 900 * tick / (tick + 200) ))
+                pct=$(( (done_w * 1000 + weight * creep) / (TOTAL_W * 10) ))
+                [ $(( tick % 3 )) -eq 0 ] && logs=$(log_tail)
+                render "$pct" "$name" $(( i + 1 )) "$tick" "$logs"
+                tick=$(( tick + 1 ))
+                sleep 0.1
+            done
+            wait "$pid" || rc=$?
+        fi
+
+        if [ "$rc" -ne 0 ]; then
+            if [ "$PLAIN" = 1 ]; then
+                echo "Steget misslyckades: $name (se $LOG_FILE)"
+            else
+                fail_screen "$name"
+            fi
+            exit 1
+        fi
+        done_w=$(( done_w + weight ))
+    done
+    [ "$PLAIN" = 1 ] || render 100 "Klart" "$total" 0 ""
+}
+
+# ════════════════════════════════════════════════════════════════════════
+#  Main
+# ════════════════════════════════════════════════════════════════════════
+
+PLAIN=0
+[ "$INTERACTIVE" = 1 ] || PLAIN=1
+
+: > "$LOG_FILE"
+
+if [ "$PLAIN" = 0 ]; then
+    printf '\e[?25l'
+    draw_header 2
+    printf '\n'
+    printf '%s%sKonfigurera installationen%s %s(ett tangenttryck per fråga)%s\n\n' \
+        "$MARGIN_PAD" "$BOLD$ORANGE" "$RESET" "$GRAY" "$RESET"
 else
-    echo "    Hoppar över autostart av X"
+    echo "Kör utan terminal -- alla val faller tillbaka på 'nej'."
 fi

-echo "==> Aktiverar PipeWire..."
-systemctl --user enable --now pipewire pipewire-pulse wireplumber
+ask A_WIFI "Konfigurera WiFi (iwd + dhcpcd)?" \
+    "Väljer adapter och nätverk interaktivt, ansluter med iwctl och kör dhcpcd på vald adapter. Görs före installationen så att apt har nät."
+ask A_XINITRC "Skriva över ~/.xinitrc?" \
+    "Backup sparas som ~/.xinitrc.bak. Sätter PATH, skärmsläckning, svenskt tangentbord och startar sxwm."
+ask A_LIDWATCH "Installera lid-display-watch?" \
+    "Stänger av interna skärmen när locket stängs, men bara om en extern skärm är ansluten."
+ask A_LIDSWITCH "Sätta HandleLidSwitch=ignore?" \
+    "Ändrar /etc/systemd/logind.conf så att stängt lock inte suspend:ar. Kräver omstart."
+ask A_BTUDEV "Stänga av USB-autosuspend för Bluetooth?" \
+    "udev-regel som hindrar Bluetooth-adaptern (0cf3:e500) från att tappa anslutningar."
+ask A_RUST "Installera Rust och bygga bluetui?" \
+    "Tar bort apt-versionen av rustc/cargo, installerar rustup och bygger bluetui (tar en stund)."
+ask A_AUTOSTART "Starta X automatiskt på TTY1?" \
+    "Lägger till startx i ~/.bash_profile så att sxwm startar vid inloggning."
+
+# Sudo-lösenord en gång i förväg; hålls sedan levande under hela körningen så
+# att steg som körs i bakgrunden aldrig fastnar på en dold lösenordsfråga.
+if [ -z "$DEMO" ]; then
+    printf '\n%s%s%s%s %sBehöver sudo för att installera paket%s\n' "$MARGIN_PAD" "$ORANGE" "$SYM_ASK" "$RESET" "$BOLD" "$RESET"
+    printf '\e[?25h'
+    sudo -v
+    [ "$PLAIN" = 1 ] || printf '\e[?25l'
+    ( while true; do sudo -n true; sleep 50; kill -0 "$$" 2>/dev/null || exit; done ) 2>/dev/null &
+    KEEPALIVE_PID=$!
+fi
+
+# Interaktiv WiFi-guide i samma flöde, före stegen (apt behöver nät).
+if [ "$A_WIFI" = 1 ] && [ "$PLAIN" = 0 ]; then
+    wifi_setup || true
+fi

-echo ""
-echo "Installation slutförd!"
+add_step 10 "Installerar beroenden"        step_deps
+add_step  3 "Bygger sxwm"                  step_sxwm
+add_step  2 "Bygger sxbar"                 step_sxbar
+add_step  4 "Bygger st"                    step_st
+add_step  1 "Kopierar konfigfiler"         step_configs
+add_step  1 "Installerar Geany & Gruvbox"  step_theme
+add_step  1 "Kopierar bakgrunder"          step_wallpaper
+[ "$A_XINITRC"   = 1 ] && add_step 1 "Skriver ~/.xinitrc"        step_xinitrc
+add_step  1 "Installerar skript"           step_scripts
+add_step  3 "Installerar skärmsläckare"    step_screensaver
+[ "$A_LIDWATCH"  = 1 ] && add_step 1 "Installerar lid-display-watch" step_lidwatch
+[ "$A_LIDSWITCH" = 1 ] && add_step 1 "Konfigurerar lock-hantering"   step_lidswitch
+[ "$A_BTUDEV"    = 1 ] && add_step 1 "Konfigurerar Bluetooth (udev)" step_btudev
+add_step  1 "Konfigurerar skärmsläckning på TTY"  step_consoleblank
+add_step  1 "Stänger av PC-speaker"        step_nobeep
+[ "$A_RUST"      = 1 ] && add_step 12 "Installerar Rust och bluetui" step_rust
+add_step  2 "Installerar JetBrainsMono Nerd Font"  step_font
+[ "$A_AUTOSTART" = 1 ] && add_step 1 "Konfigurerar autostart av X"   step_autostart
+add_step  1 "Aktiverar PipeWire"           step_pipewire
+
+draw_run_header() {
+    local top=$(( (ROWS - 16) / 2 ))
+    [ "$top" -lt 1 ] && top=1
+    draw_header "$top"
+}
+
+if [ "$PLAIN" = 0 ]; then
+    compute_layout
+    draw_run_header
+fi
+
+SECONDS=0
+run_steps
+
+if [ "$PLAIN" = 0 ]; then
+    printf '\e[%d;1H\e[J' $(( STATUS_ROW + 2 ))
+    done_msg="Installation slutförd ($(fmt_time "$SECONDS"))"
+    put_center $(( STATUS_ROW + 2 )) "${GREEN}${SYM_OK}${RESET} ${BOLD}${FG}${done_msg}${RESET}" $(( 2 + ${#done_msg} ))
+    printf '\e[%d;1H' $(( STATUS_ROW + 4 ))
+fi
+printf '\n'
+printf '%s%sStarta om datorn så att konsol-blankning och lock-inställningar tar effekt.%s\n' "$MARGIN_PAD" "$GRAY" "$RESET"
+if [ -n "$WIFI_SUMMARY" ]; then
+    printf '%s%s%s%s\n' "$MARGIN_PAD" "$GRAY" "$WIFI_SUMMARY" "$RESET"
+fi
+if [ "$A_XINITRC" != 1 ]; then
+    printf '%s%sLägg manuellt till i ~/.xinitrc: xset s 300 300; xset dpms 300 300 300; setxkbmap se; exec sxwm%s\n' "$MARGIN_PAD" "$GRAY" "$RESET"
+fi
+printf '%s%sLogg: %s%s\n' "$MARGIN_PAD" "$GRAY" "$LOG_FILE" "$RESET"